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
36 changes: 35 additions & 1 deletion tests/Integration/Examples/ExampleSchemaCleanupTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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 {
Expand All @@ -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
Expand Down
126 changes: 126 additions & 0 deletions tests/Integration/Support/LazySavepointSchemaComplianceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

namespace Maatify\Persistence\Tests\Integration\Support;

use Maatify\Persistence\Tests\Support\MySql\LazySavepointSchema;
use Maatify\Persistence\Tests\Support\MySql\MySqlConnectionFactory;
use PDO;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* Reads the schema the lazy-savepoint tests actually run against from
* information_schema (PACKAGE_BUILDING_STANDARD section 6) and pins the declared
* structure: PRIMARY KEY (id), table and column COMMENTs, and the exact set of
* secondary index declarations. It guards index declarations only; it does not
* freeze optimizer plans or EXPLAIN output and does not execute any locking
* scenario. The lock, 1062/1205/1213 and savepoint behaviour is proved by
* ScopedOrderingManagerLazySavepointTest.
*/
final class LazySavepointSchemaComplianceTest extends TestCase
{
private PDO $pdo;
private LazySavepointSchema $schema;

protected function setUp(): void
{
$this->pdo = (new MySqlConnectionFactory())->create();
$this->schema = new LazySavepointSchema($this->pdo);
$this->schema->initialize();
}

protected function tearDown(): void
{
$this->schema->drop();
}

/**
* @return array<string, array{0: string, 1: array<string, array{unique: bool, columns: list<string>}>}>
*/
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<string, array{unique: bool, columns: list<string>}> $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<string, array{unique: bool, columns: list<string>}> $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<string> $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<string> $params
*
* @return list<string>
*/
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;
}
}
60 changes: 46 additions & 14 deletions tests/Support/MySql/LazySavepointSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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++) {
Expand All @@ -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
Expand Down