Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions migrations/mysql/0001_create_event_outbox.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
2 changes: 1 addition & 1 deletion migrations/mysql/0001_create_event_outbox_redelivery.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
4 changes: 2 additions & 2 deletions src/Infrastructure/Schema/MysqlEventStoreSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

Expand All @@ -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,
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Infrastructure/Schema/MysqlRedeliverySchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down
118 changes: 118 additions & 0 deletions tests/Feature/MysqlSchemaCollationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

namespace Test\Vesper\Tool\Event\Feature;

use PDO;
use PDOException;
use PHPUnit\Framework\TestCase;
use Vesper\Tool\Event\Infrastructure\Schema\MysqlEventStoreSchema;
use Vesper\Tool\Event\Infrastructure\Schema\MysqlRedeliverySchema;

/**
* Regression test: SqlRedeliveryStore::fetchNextDueRow joins event_outbox_redelivery to
* event_outbox on the event id and listener columns. If those columns inherit different
* collations from the schema/server default at table-creation time, MySQL 8 raises
* SQLSTATE 1267 "Illegal mix of collations" when the join runs. The schema templates and
* boot-time helpers pin the join keys to utf8mb4_unicode_ci so the join always works
* regardless of the surrounding database default.
*
* Requires a MySQL DSN in the EVENTS_MYSQL_DSN env var (with optional EVENTS_MYSQL_USER /
* EVENTS_MYSQL_PASSWORD). Skipped when unavailable.
*/
class MysqlSchemaCollationTest extends TestCase
{
private const REQUIRED_COLLATION = 'utf8mb4_unicode_ci';

private const JOIN_KEY_COLUMNS = [
'event_outbox' => ['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(
<<<SQL
SELECT collation_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table
AND column_name = :column
SQL,
);
$stmt->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}");
}
}
}
Loading