diff --git a/class/xion/SchemaDiffer.php b/class/xion/SchemaDiffer.php index 048a098..0aed9ff 100644 --- a/class/xion/SchemaDiffer.php +++ b/class/xion/SchemaDiffer.php @@ -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. @@ -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. @@ -49,6 +51,7 @@ final class SchemaDiffer * @return array{ * newTables: array, * newColumns: array, + * newIndexes: array, * warnings: array, * inSync: bool * } @@ -57,6 +60,7 @@ public static function diff(array $liveTables, array $definitionTables, string $ { $newTables = []; $newColumns = []; + $newIndexes = []; $warnings = []; foreach ($definitionTables as $tableName => $tableSpec) { @@ -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])) { @@ -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 === [], ]; } @@ -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 $columns + */ + private static function createIndexSql(string $table, string $index, array $columns): string + { + return 'CREATE INDEX ' . $index . ' ON ' . $table . ' (' . implode(', ', $columns) . ');'; + } } diff --git a/cli/schemaDiff.php b/cli/schemaDiff.php index 5b196a2..b8105e0 100644 --- a/cli/schemaDiff.php +++ b/cli/schemaDiff.php @@ -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); @@ -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) { @@ -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; } @@ -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; } diff --git a/tests/Unit/Xion/SchemaDifferTest.php b/tests/Unit/Xion/SchemaDifferTest.php index 6ccad12..5ce1bb8 100644 --- a/tests/Unit/Xion/SchemaDifferTest.php +++ b/tests/Unit/Xion/SchemaDifferTest.php @@ -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']); + } }