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
50 changes: 45 additions & 5 deletions class/xion/SchemaDiffer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
* - Adds emitted as SQL:
* - new tables (full `CREATE TABLE` via {@see SchemaCompiler})
* - new columns (`ALTER TABLE … ADD COLUMN …`)
* - new SQLite indexes (deferred to follow-up — current scope is
* MySQL-first; SQLite index re-creation is operator-side)
* - new indexes (`CREATE INDEX … ON … (…)`) — MySQL + SQLite (#421)
* - Drops, renames, type changes, constraint changes: **warning only**.
* The operator hand-writes those — destructive ops should never be
* produced by an automated tool without data semantics.
Expand All @@ -36,8 +35,11 @@ final class SchemaDiffer
* `$liveTables` shape:
*
* [
* 'users' => ['columns' => ['id' => [...], ...]],
* 'todos' => ['columns' => [...]],
* 'users' => [
* 'columns' => ['id' => [...], ...],
* 'indexes' => ['users_user_id_unique' => ['user_id']],
* ],
* 'todos' => ['columns' => [...], 'indexes' => [...]],
* ]
*
* `$definitionTables` shape: `SchemaDefinition::tables()` output.
Expand All @@ -49,6 +51,7 @@ final class SchemaDiffer
* @return array{
* newTables: array<string,string>,
* newColumns: array<int,array{table:string,column:string,sql:string}>,
* newIndexes: array<int,array{table:string,index:string,sql:string}>,
* warnings: array<int,string>,
* inSync: bool
* }
Expand All @@ -57,6 +60,7 @@ public static function diff(array $liveTables, array $definitionTables, string $
{
$newTables = [];
$newColumns = [];
$newIndexes = [];
$warnings = [];

foreach ($definitionTables as $tableName => $tableSpec) {
Expand Down Expand Up @@ -84,6 +88,26 @@ public static function diff(array $liveTables, array $definitionTables, string $
);
}
}
$liveIndexes = $liveTables[$tableName]['indexes'] ?? [];
$defIndexes = $tableSpec['indexes'] ?? [];
foreach ($defIndexes as $indexName => $indexCols) {
if (!isset($liveIndexes[$indexName])) {
$newIndexes[] = [
'table' => $tableName,
'index' => $indexName,
'sql' => self::createIndexSql($tableName, $indexName, $indexCols),
];
}
}
foreach (array_keys($liveIndexes) as $liveIndex) {
if (!isset($defIndexes[$liveIndex])) {
$warnings[] = sprintf(
'index `%s` on `%s` exists in the live database but not in SchemaDefinition — drop SQL must be hand-written (ADR-0009 destructive-op rule).',
$liveIndex,
$tableName
);
}
}
}
foreach (array_keys($liveTables) as $liveTable) {
if (!isset($definitionTables[$liveTable])) {
Expand All @@ -97,8 +121,9 @@ public static function diff(array $liveTables, array $definitionTables, string $
return [
'newTables' => $newTables,
'newColumns' => $newColumns,
'newIndexes' => $newIndexes,
'warnings' => $warnings,
'inSync' => $newTables === [] && $newColumns === [],
'inSync' => $newTables === [] && $newColumns === [] && $newIndexes === [],
];
}

Expand All @@ -123,4 +148,19 @@ private static function addColumnSql(string $table, string $column, array $colSp
: SchemaCompiler::mysqlColumn($column, $colSpec);
return 'ALTER TABLE ' . $table . ' ADD COLUMN ' . $columnDdl . ';';
}

/**
* `CREATE INDEX` syntax is shared verbatim between MySQL and SQLite,
* so the same emitter works for both drivers. Unique-constraint
* indexes from the `unique` key of {@see SchemaDefinition::tables()}
* are part of the original `CREATE TABLE` and are not re-emitted
* here — only entries from the `indexes` key are subject to the
* diff path.
*
* @param array<int,string> $columns
*/
private static function createIndexSql(string $table, string $index, array $columns): string
{
return 'CREATE INDEX ' . $index . ' ON ' . $table . ' (' . implode(', ', $columns) . ');';
}
}
53 changes: 51 additions & 2 deletions cli/schemaDiff.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@
fwrite(STDOUT, "\n-- new column: {$entry['table']}.{$entry['column']}\n");
fwrite(STDOUT, $entry['sql'] . "\n");
}
foreach ($diff['newIndexes'] as $entry) {
fwrite(STDOUT, "\n-- new index: {$entry['index']} on {$entry['table']}\n");
fwrite(STDOUT, $entry['sql'] . "\n");
}

exit(0);

Expand Down Expand Up @@ -204,6 +208,20 @@ function introspectMysql(PDO $pdo): array
'SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS '
. 'WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table'
);
// STATISTICS lists every non-PK index. PRIMARY is excluded so the
// diff does not chase a phantom "missing index" for the PK that
// `CREATE TABLE` already declared. UNIQUE indexes (NON_UNIQUE=0)
// are likewise excluded — they belong to `unique` constraints in
// `SchemaDefinition` and live alongside the table definition,
// not in the `indexes` map the diff path watches. ADR-0009 keeps
// constraint changes in the warning-only path.
$indexStmt = $pdo->prepare(
'SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX '
. 'FROM INFORMATION_SCHEMA.STATISTICS '
. 'WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table '
. 'AND INDEX_NAME != "PRIMARY" AND NON_UNIQUE = 1 '
. 'ORDER BY INDEX_NAME, SEQ_IN_INDEX'
);

$result = [];
foreach ($tables as $table) {
Expand All @@ -213,7 +231,14 @@ function introspectMysql(PDO $pdo): array
$name = (string)$row['COLUMN_NAME'];
$columns[$name] = ['data_type' => (string)$row['DATA_TYPE']];
}
$result[$table] = ['columns' => $columns];
$indexStmt->execute([':schema' => $database, ':table' => $table]);
$indexes = [];
foreach ($indexStmt->fetchAll() as $row) {
$idxName = (string)$row['INDEX_NAME'];
$indexes[$idxName] = $indexes[$idxName] ?? [];
$indexes[$idxName][] = (string)$row['COLUMN_NAME'];
}
$result[$table] = ['columns' => $columns, 'indexes' => $indexes];
}
return $result;
}
Expand All @@ -235,7 +260,31 @@ function introspectSqlite(PDO $pdo): array
$name = (string)$row['name'];
$columns[$name] = ['data_type' => (string)$row['type']];
}
$result[$table] = ['columns' => $columns];
// `PRAGMA index_list` returns user-defined indexes plus those
// SQLite synthesizes for UNIQUE / PRIMARY KEY constraints.
// The synthesized ones are prefixed `sqlite_autoindex_`.
// UNIQUE-named indexes (PRAGMA's `unique` column = 1) belong
// to constraint declarations and are tracked by `unique` in
// SchemaDefinition, not by `indexes` — so they are excluded
// from the diff path. ADR-0009 keeps constraint changes in
// the warning-only path.
$indexListStmt = $pdo->query('PRAGMA index_list(' . $table . ')');
$indexes = [];
foreach ($indexListStmt->fetchAll() as $indexRow) {
$idxName = (string)$indexRow['name'];
if (str_starts_with($idxName, 'sqlite_autoindex_')) {
continue;
}
if ((int)$indexRow['unique'] === 1) {
continue;
}
$infoStmt = $pdo->query('PRAGMA index_info(' . $idxName . ')');
$indexes[$idxName] = [];
foreach ($infoStmt->fetchAll() as $infoRow) {
$indexes[$idxName][] = (string)$infoRow['name'];
}
}
$result[$table] = ['columns' => $columns, 'indexes' => $indexes];
}
return $result;
}
100 changes: 100 additions & 0 deletions tests/Unit/Xion/SchemaDifferTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,104 @@ public function testInSyncFlagIsFalseWhenAnyAddIsPresent(): void
$diff = SchemaDiffer::diff($live, $tables, SchemaDiffer::DRIVER_MYSQL);
self::assertFalse($diff['inSync']);
}

public function testNewIndexEmitsCreateIndex(): void
{
// The table itself is in sync — the only change is a new
// secondary index added to SchemaDefinition (#421).
$tables = [
'todos' => [
'columns' => ['id' => ['type' => 'pk-bigint'], 'user_id' => ['type' => 'bigint']],
'indexes' => ['todos_user_id_index' => ['user_id']],
],
];
$live = [
'todos' => [
'columns' => ['id' => ['data_type' => 'bigint'], 'user_id' => ['data_type' => 'bigint']],
'indexes' => [],
],
];
$diff = SchemaDiffer::diff($live, $tables, SchemaDiffer::DRIVER_MYSQL);

self::assertFalse($diff['inSync']);
self::assertCount(1, $diff['newIndexes']);
self::assertSame('todos', $diff['newIndexes'][0]['table']);
self::assertSame('todos_user_id_index', $diff['newIndexes'][0]['index']);
self::assertSame(
'CREATE INDEX todos_user_id_index ON todos (user_id);',
$diff['newIndexes'][0]['sql']
);
}

public function testCompositeIndexEmitsBothColumns(): void
{
$tables = [
'todos' => [
'columns' => [
'id' => ['type' => 'pk-bigint'],
'user_id' => ['type' => 'bigint'],
'is_completed' => ['type' => 'bool', 'default' => 0],
],
'indexes' => ['todos_user_completed' => ['user_id', 'is_completed']],
],
];
$live = [
'todos' => [
'columns' => [
'id' => ['data_type' => 'bigint'],
'user_id' => ['data_type' => 'bigint'],
'is_completed' => ['data_type' => 'tinyint'],
],
'indexes' => [],
],
];
$diff = SchemaDiffer::diff($live, $tables, SchemaDiffer::DRIVER_MYSQL);

self::assertSame(
'CREATE INDEX todos_user_completed ON todos (user_id, is_completed);',
$diff['newIndexes'][0]['sql']
);
}

public function testExtraLiveIndexEmitsWarningNotSql(): void
{
$tables = [
'todos' => [
'columns' => ['id' => ['type' => 'pk-bigint']],
'indexes' => [],
],
];
$live = [
'todos' => [
'columns' => ['id' => ['data_type' => 'bigint']],
'indexes' => ['orphan_index' => ['id']],
],
];
$diff = SchemaDiffer::diff($live, $tables, SchemaDiffer::DRIVER_MYSQL);

self::assertTrue($diff['inSync']);
self::assertSame([], $diff['newIndexes']);
self::assertCount(1, $diff['warnings']);
self::assertStringContainsString('index `orphan_index` on `todos`', $diff['warnings'][0]);
}

public function testIndexInSyncDoesNotEmitDuplicate(): void
{
$tables = [
'todos' => [
'columns' => ['id' => ['type' => 'pk-bigint'], 'user_id' => ['type' => 'bigint']],
'indexes' => ['todos_user_id_index' => ['user_id']],
],
];
$live = [
'todos' => [
'columns' => ['id' => ['data_type' => 'bigint'], 'user_id' => ['data_type' => 'bigint']],
'indexes' => ['todos_user_id_index' => ['user_id']],
],
];
$diff = SchemaDiffer::diff($live, $tables, SchemaDiffer::DRIVER_MYSQL);

self::assertTrue($diff['inSync']);
self::assertSame([], $diff['newIndexes']);
}
}
Loading