diff --git a/README.md b/README.md index 7c15f64..65bac5e 100644 --- a/README.md +++ b/README.md @@ -374,7 +374,7 @@ CREATE TABLE event_outbox ( publish_at DATETIME(6) NOT NULL, INDEX idx_event_outbox_status_publish (status, publish_at), INDEX idx_event_outbox_created_at (created_at) -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Audit trail: one row per event status transition (pending → processing → processed) CREATE TABLE event_outbox_status ( @@ -383,7 +383,7 @@ CREATE TABLE event_outbox_status ( error_message TEXT, created_at DATETIME(6) NOT NULL, INDEX idx_event_outbox_status_event_created (event_id, created_at DESC) -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` **Option 2 — call the shipped `Schema::create()` helper at boot.** Convenient for prototypes, single-app deployments, or projects without their own migration tool. The helper is idempotent (`CREATE … IF NOT EXISTS` on SQLite, `information_schema` check on MySQL), so it's safe to call on every boot — but be aware: if the host application also runs migrations, this can race with them. Prefer Option 1 in that case. diff --git a/migrations/mysql/0001_create_event_outbox.sql b/migrations/mysql/0001_create_event_outbox.sql index e541aca..9fa503c 100644 --- a/migrations/mysql/0001_create_event_outbox.sql +++ b/migrations/mysql/0001_create_event_outbox.sql @@ -8,7 +8,7 @@ CREATE TABLE event_outbox ( INDEX idx_event_outbox_status_publish (status, publish_at), INDEX idx_event_outbox_created_at (created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE event_outbox_status ( event_id VARCHAR(36) NOT NULL, @@ -17,4 +17,4 @@ CREATE TABLE event_outbox_status ( created_at DATETIME(6) NOT NULL, INDEX idx_event_outbox_status_event_created (event_id, created_at DESC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/migrations/mysql/0001_create_event_outbox_redelivery.sql b/migrations/mysql/0001_create_event_outbox_redelivery.sql index b033181..ea09e3a 100644 --- a/migrations/mysql/0001_create_event_outbox_redelivery.sql +++ b/migrations/mysql/0001_create_event_outbox_redelivery.sql @@ -11,4 +11,4 @@ CREATE TABLE event_outbox_redelivery ( PRIMARY KEY (event_id, listener), INDEX idx_redelivery_due (status, next_retry_at), INDEX idx_redelivery_event_id (event_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/Infrastructure/Schema/MysqlEventStoreSchema.php b/src/Infrastructure/Schema/MysqlEventStoreSchema.php index 603e04d..8ea65b4 100644 --- a/src/Infrastructure/Schema/MysqlEventStoreSchema.php +++ b/src/Infrastructure/Schema/MysqlEventStoreSchema.php @@ -24,7 +24,7 @@ public static function create(PDO $connection): void INDEX idx_event_outbox_status_publish (status, publish_at), INDEX idx_event_outbox_created_at (created_at) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; SQL, ); @@ -39,7 +39,7 @@ public static function create(PDO $connection): void created_at DATETIME(6) NOT NULL, INDEX idx_event_outbox_status_event_created (event_id, created_at DESC) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; SQL, ); } diff --git a/src/Infrastructure/Schema/MysqlRedeliverySchema.php b/src/Infrastructure/Schema/MysqlRedeliverySchema.php index d241daa..d7c14a2 100644 --- a/src/Infrastructure/Schema/MysqlRedeliverySchema.php +++ b/src/Infrastructure/Schema/MysqlRedeliverySchema.php @@ -25,7 +25,7 @@ public static function create(PDO $connection): void PRIMARY KEY (event_id, listener), INDEX idx_redelivery_due (status, next_retry_at), INDEX idx_redelivery_event_id (event_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; SQL, ); } diff --git a/tests/Feature/MysqlSchemaCollationTest.php b/tests/Feature/MysqlSchemaCollationTest.php new file mode 100644 index 0000000..e258d6a --- /dev/null +++ b/tests/Feature/MysqlSchemaCollationTest.php @@ -0,0 +1,118 @@ + ['id'], + 'event_outbox_status' => ['event_id'], + 'event_outbox_redelivery' => ['event_id', 'listener'], + ]; + + private PDO $pdo; + + protected function setUp(): void + { + $dsn = getenv('EVENTS_MYSQL_DSN'); + + if ($dsn === false || $dsn === '') { + self::markTestSkipped('Set EVENTS_MYSQL_DSN to a MySQL DSN to exercise this test.'); + } + + $user = getenv('EVENTS_MYSQL_USER') ?: null; + $password = getenv('EVENTS_MYSQL_PASSWORD') ?: null; + + try { + $this->pdo = new PDO($dsn, $user, $password); + } catch (PDOException $e) { + self::markTestSkipped('MySQL unavailable: ' . $e->getMessage()); + } + + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->dropTables(); + } + + protected function tearDown(): void + { + if (isset($this->pdo)) { + $this->dropTables(); + } + } + + public function test_migration_templates_pin_join_keys_to_utf8mb4_unicode_ci(): void + { + $this->pdo->exec((string) file_get_contents(__DIR__ . '/../../migrations/mysql/0001_create_event_outbox.sql')); + $this->pdo->exec((string) file_get_contents(__DIR__ . '/../../migrations/mysql/0001_create_event_outbox_redelivery.sql')); + + $this->assertJoinKeyCollations(); + } + + public function test_boot_time_schema_helpers_pin_join_keys_to_utf8mb4_unicode_ci(): void + { + MysqlEventStoreSchema::create($this->pdo); + MysqlRedeliverySchema::create($this->pdo); + + $this->assertJoinKeyCollations(); + } + + private function assertJoinKeyCollations(): void + { + foreach (self::JOIN_KEY_COLUMNS as $table => $columns) { + foreach ($columns as $column) { + self::assertSame( + self::REQUIRED_COLLATION, + $this->collationOf($table, $column), + "{$table}.{$column} must use " . self::REQUIRED_COLLATION, + ); + } + } + } + + private function collationOf(string $table, string $column): string + { + $stmt = $this->pdo->prepare( + <<execute(['table' => $table, 'column' => $column]); + + $collation = $stmt->fetchColumn(); + + self::assertIsString($collation, "Column {$table}.{$column} not found"); + + return $collation; + } + + private function dropTables(): void + { + foreach (array_keys(self::JOIN_KEY_COLUMNS) as $table) { + $this->pdo->exec("DROP TABLE IF EXISTS {$table}"); + } + } +}