diff --git a/tests/Integration/Examples/ExampleSchemaCleanupTest.php b/tests/Integration/Examples/ExampleSchemaCleanupTest.php index 19f5ded..97188f7 100644 --- a/tests/Integration/Examples/ExampleSchemaCleanupTest.php +++ b/tests/Integration/Examples/ExampleSchemaCleanupTest.php @@ -14,6 +14,12 @@ * fails part-way. This forces that failure: the second CREATE TABLE collides * with a pre-existing table, and the test proves the first table was dropped, * the pre-existing table was not touched, and the original error propagated. + * + * Schema policy of the colliding table (PACKAGE_BUILDING_STANDARD section 6): + * test-only, InnoDB, a single `id` column that is the PRIMARY KEY and the only + * access path (it is only ever created and dropped, never queried, so no other + * index is needed). No soft delete, no display order, no uniqueness beyond the + * primary key, and no FK or join to host tables. */ final class ExampleSchemaCleanupTest extends TestCase { @@ -25,9 +31,14 @@ public function testPartialSetupFailureDropsOnlyTablesTheExampleCreated(): void $items = 'maa_example_items_' . $suffix; $pdo->exec('DROP TABLE IF EXISTS ' . $items); - $pdo->exec('CREATE TABLE ' . $items . ' (id INT NOT NULL PRIMARY KEY) ENGINE=InnoDB'); + $pdo->exec('CREATE TABLE ' . $items . ' (' + . "id INT NOT NULL COMMENT 'Surrogate key of the pre-existing colliding probe table. No FK.'," + . ' PRIMARY KEY (id)' + . ") ENGINE=InnoDB COMMENT='Test-only table that makes the example second CREATE TABLE collide; must survive the cleanup.'"); try { + $this->assertCollisionTableIsDocumented($pdo, $items); + $result = $this->runExampleScript( <<<'PHP' try { @@ -54,6 +65,29 @@ static function (): void { } } + private function assertCollisionTableIsDocumented(PDO $pdo, string $table): void + { + $statement = $pdo->prepare( + 'SELECT TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', + ); + $statement->execute([$table]); + $tableComment = $statement->fetchColumn(); + self::assertIsString($tableComment); + self::assertNotSame('', trim($tableComment), 'the collision table needs a table COMMENT'); + + $statement = $pdo->prepare( + "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND TRIM(COLUMN_COMMENT) = ''", + ); + $statement->execute([$table]); + self::assertSame(0, (int) $statement->fetchColumn(), 'every column of the collision table needs a COMMENT'); + + $statement = $pdo->prepare( + "SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = 'PRIMARY'", + ); + $statement->execute([$table]); + self::assertSame('id', $statement->fetchColumn(), 'the collision table needs PRIMARY KEY (id)'); + } + /** * Regression for a failure found while hardening the examples: when a * scenario fails while a second connection is still inside a transaction diff --git a/tests/Integration/Support/LazySavepointSchemaComplianceTest.php b/tests/Integration/Support/LazySavepointSchemaComplianceTest.php new file mode 100644 index 0000000..5e51e2b --- /dev/null +++ b/tests/Integration/Support/LazySavepointSchemaComplianceTest.php @@ -0,0 +1,126 @@ +pdo = (new MySqlConnectionFactory())->create(); + $this->schema = new LazySavepointSchema($this->pdo); + $this->schema->initialize(); + } + + protected function tearDown(): void + { + $this->schema->drop(); + } + + /** + * @return array}>}> + */ + public static function tables(): array + { + $none = []; + + return [ + 'plain' => [LazySavepointSchema::PLAIN, $none], + 'unique' => [LazySavepointSchema::UNIQUE, ['u_updated' => ['unique' => true, 'columns' => ['updated_at']]]], + 'audit' => [LazySavepointSchema::AUDIT, $none], + 'marker' => [LazySavepointSchema::MARKER, $none], + 'fill' => [LazySavepointSchema::FILL, $none], + ]; + } + + /** + * @param array}> $expectedSecondary + */ + #[DataProvider('tables')] + public function testTableHasIdPrimaryKeyTableAndColumnCommentsAndOnlyTheDocumentedSecondaryIndexes( + string $table, + array $expectedSecondary, + ): void { + $tableComment = $this->scalar( + 'SELECT TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', + [$table], + ); + self::assertNotSame('', trim($tableComment), $table . ' needs a table COMMENT'); + + $uncommented = $this->column( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND TRIM(COLUMN_COMMENT) = '' ORDER BY ORDINAL_POSITION", + [$table], + ); + self::assertSame([], $uncommented, $table . ' has columns without a COMMENT'); + + $from = ' FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX'; + $names = $this->column('SELECT INDEX_NAME' . $from, [$table]); + $nonUnique = $this->column('SELECT CAST(NON_UNIQUE AS CHAR)' . $from, [$table]); + $columns = $this->column('SELECT COLUMN_NAME' . $from, [$table]); + self::assertCount(count($names), $nonUnique); + self::assertCount(count($names), $columns); + + /** @var array}> $indexes */ + $indexes = []; + + foreach ($names as $position => $name) { + $indexes[$name] ??= ['unique' => $nonUnique[$position] === '0', 'columns' => []]; + $indexes[$name]['columns'][] = $columns[$position]; + } + + self::assertSame(['unique' => true, 'columns' => ['id']], $indexes['PRIMARY'] ?? null, $table . ' needs PRIMARY KEY (id)'); + unset($indexes['PRIMARY']); + self::assertSame($expectedSecondary, $indexes, $table . ': secondary indexes differ from the documented set'); + } + + /** + * @param list $params + */ + private function scalar(string $sql, array $params): string + { + $statement = $this->pdo->prepare($sql); + $statement->execute($params); + $value = $statement->fetchColumn(); + self::assertIsString($value); + + return $value; + } + + /** + * @param list $params + * + * @return list + */ + private function column(string $sql, array $params): array + { + $statement = $this->pdo->prepare($sql); + $statement->execute($params); + $values = []; + + while (($value = $statement->fetchColumn()) !== false) { + self::assertIsString($value); + $values[] = $value; + } + + return $values; + } +} diff --git a/tests/Support/MySql/LazySavepointSchema.php b/tests/Support/MySql/LazySavepointSchema.php index cb785b2..f4020c1 100644 --- a/tests/Support/MySql/LazySavepointSchema.php +++ b/tests/Support/MySql/LazySavepointSchema.php @@ -7,11 +7,31 @@ use PDO; /** - * Test-only schema for the moveWithinScope() operation-local savepoint tests: - * two ordering tables (plain, and with UNIQUE (updated_at) so that the target - * write can fail with 1062 after the range shift), an audit table that a - * trigger touches to provoke lock waits and deadlocks, a marker table for - * the caller's earlier work and a filler table that makes a partner transaction heavy. + * Test-only schema for the moveWithinScope() operation-local savepoint tests + * (PACKAGE_BUILDING_STANDARD section 6; policies below stand in for an SQL header). + * + * Tables (all InnoDB, utf8mb4, PRIMARY KEY (id), no foreign keys or joins to host tables): + * - PLAIN: global (NULL-scope) ordering table. Display order is `display_order`, + * mutated by the shift. `deleted_at` is the soft-delete column: the + * tests' ScopedOrderingConfig keeps its default `deleted_at`, so the + * soft-delete filter is active, but no test ever soft-deletes a row + * (every row stays NULL = active); `updated_at` is the optional target + * mutation timestamp; `label` is free text. Uniqueness: none besides + * the primary key. + * - UNIQUE: same columns as PLAIN plus UNIQUE KEY `u_updated` (`updated_at`), so + * that the target write can fail with 1062 after the range shift. + * - AUDIT: two counter rows; a trigger on the ordering tables updates row 2 to + * provoke lock waits (1205) and deadlocks (1213). + * - MARKER: records the caller's earlier work inside its transaction. + * - FILL: filler rows that make the partner transaction heavy enough to lose + * the deadlock victim choice. + * + * Indexes are deliberate: there is NO index on `display_order` (nor a scope + * column). The scope lock is `SELECT ... ORDER BY display_order, id FOR UPDATE` + * and the tests assert that it keeps every row and the insert gap (id 6) locked; + * an extra index would change the access path and therefore which records and + * gaps are locked, so the table stays PRIMARY KEY plus the documented UNIQUE. + * LazySavepointSchemaComplianceTest pins this index set. */ final readonly class LazySavepointSchema { @@ -38,13 +58,13 @@ public function initialize(): void foreach ([self::PLAIN => '', self::UNIQUE => ', UNIQUE KEY `u_updated` (`updated_at`)'] as $table => $extra) { $this->pdo->exec( 'CREATE TABLE `' . $table . '` (' - . ' `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,' - . ' `display_order` INT NOT NULL,' - . ' `deleted_at` DATETIME NULL DEFAULT NULL,' - . ' `updated_at` DATETIME NULL DEFAULT NULL,' - . ' `label` VARCHAR(191) NOT NULL,' + . " `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Fixture row identity.'," + . " `display_order` INT NOT NULL COMMENT 'Mutable ordering position shifted by moveWithinScope(); deliberately unindexed.'," + . " `deleted_at` DATETIME NULL DEFAULT NULL COMMENT 'Optional soft-delete timestamp; NULL means active.'," + . " `updated_at` DATETIME NULL DEFAULT NULL COMMENT 'Optional target mutation timestamp; UNIQUE only in the UNIQUE variant.'," + . " `label` VARCHAR(191) NOT NULL COMMENT 'Fixture row label.'," . ' PRIMARY KEY (`id`)' . $extra - . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + . ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Test-only ordering fixture for the lazy savepoint scenarios.'" ); for ($i = 1; $i <= 5; $i++) { @@ -55,10 +75,22 @@ public function initialize(): void } } - $this->pdo->exec('CREATE TABLE `' . self::AUDIT . '` (`id` INT NOT NULL PRIMARY KEY, `n` INT NOT NULL) ENGINE=InnoDB'); + $this->pdo->exec('CREATE TABLE `' . self::AUDIT . '` (' + . " `id` INT NOT NULL COMMENT 'Fixed audit row identity (1 and 2); no auto increment.'," + . " `n` INT NOT NULL COMMENT 'Counter touched by the trigger to provoke lock waits and deadlocks.'," + . ' PRIMARY KEY (`id`)' + . ") ENGINE=InnoDB COMMENT='Test-only lock-contention counter rows.'"); $this->pdo->exec('INSERT INTO `' . self::AUDIT . '` (`id`, `n`) VALUES (1, 0), (2, 0)'); - $this->pdo->exec('CREATE TABLE `' . self::MARKER . '` (`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `note` VARCHAR(64) NOT NULL) ENGINE=InnoDB'); - $this->pdo->exec('CREATE TABLE `' . self::FILL . '` (`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `payload` VARCHAR(32) NOT NULL) ENGINE=InnoDB'); + $this->pdo->exec('CREATE TABLE `' . self::MARKER . '` (' + . " `id` INT NOT NULL AUTO_INCREMENT COMMENT 'Marker row identity.'," + . " `note` VARCHAR(64) NOT NULL COMMENT 'Caller earlier-work marker text.'," + . ' PRIMARY KEY (`id`)' + . ") ENGINE=InnoDB COMMENT='Test-only marker rows proving the caller transaction kept earlier work.'"); + $this->pdo->exec('CREATE TABLE `' . self::FILL . '` (' + . " `id` INT NOT NULL AUTO_INCREMENT COMMENT 'Filler row identity.'," + . " `payload` VARCHAR(32) NOT NULL COMMENT 'Filler payload that makes the partner transaction heavy.'," + . ' PRIMARY KEY (`id`)' + . ") ENGINE=InnoDB COMMENT='Test-only filler rows for the deadlock partner transaction.'"); } public function drop(): void