diff --git a/composer.json b/composer.json index 71a7680..98402f7 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ } ], "require": { - "phpnomad/db": "dev-codex/operation-local-handler-bridge#4b0b7709e5945a4290b04c2e3562ef919923ed08 as 2.2.x-dev", + "phpnomad/db": "dev-codex/operation-bridge-retirement-convergence#76d7161702a4368748b6865cd05001dce0b157cc as 2.2.x-dev", "phpnomad/loader": "^1.0 || ^2.0", "phpnomad/logger": "^1.0", "ext-pdo": "*" diff --git a/composer.lock b/composer.lock index 30e2e67..2f02e19 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "65f1a0ce072b6923bec7c83fa5a20df0", + "content-hash": "3a6297decce77bfac8777dd7758d1e3d", "packages": [ { "name": "phpnomad/cache", @@ -93,11 +93,11 @@ }, { "name": "phpnomad/db", - "version": "dev-codex/operation-local-handler-bridge", + "version": "dev-codex/operation-bridge-retirement-convergence", "source": { "type": "git", "url": "git@github.com:phpnomad/db.git", - "reference": "4b0b7709e5945a4290b04c2e3562ef919923ed08" + "reference": "76d7161702a4368748b6865cd05001dce0b157cc" }, "require": { "phpnomad/cache": "^1.0", @@ -136,7 +136,7 @@ } ], "homepage": "https://github.com/phpnomad/core", - "time": "2026-09-20T23:21:17+00:00" + "time": "2026-09-20T19:09:57+00:00" }, { "name": "phpnomad/di", @@ -5365,7 +5365,7 @@ "aliases": [ { "package": "phpnomad/db", - "version": "dev-codex/operation-local-handler-bridge", + "version": "dev-codex/operation-bridge-retirement-convergence", "alias": "2.2.x-dev", "alias_normalized": "2.2.9999999.9999999-dev" } diff --git a/lib/Connections/PdoConnection.php b/lib/Connections/PdoConnection.php index 76927b8..de58634 100644 --- a/lib/Connections/PdoConnection.php +++ b/lib/Connections/PdoConnection.php @@ -8,10 +8,12 @@ * Lazily-opened PDO connection for the PDO database strategies. * * Accepts the same configuration shape consumers passed to SafeMySQL - * (host / user / pass / db / port / charset), so migrating off the - * abandoned colshrapnel/safemysql backend is a binding swap. An existing + * (host / user / pass / db / port / charset), so switching from the + * SafeMySQL backend is a binding swap. An existing * PDO instance can also be wrapped directly via fromPdo() — useful for * tests and for applications that manage their own connection. + * + * @phpstan-consistent-constructor */ class PdoConnection { @@ -31,7 +33,6 @@ public function __construct(array $config = []) public static function fromPdo(PDO $pdo): static { // Late static binding preserves the public factory contract for consumer subclasses. - // @phpstan-ignore new.static $connection = new static(); $connection->pdo = $pdo; diff --git a/lib/MySqlInitializer.php b/lib/MySqlInitializer.php index d8914cb..259c86f 100644 --- a/lib/MySqlInitializer.php +++ b/lib/MySqlInitializer.php @@ -10,6 +10,7 @@ use PHPNomad\Database\Interfaces\TableCreateStrategy as CoreTableCreateStrategy; use PHPNomad\Database\Interfaces\TableDeleteStrategy as CoreTableDeleteStrategy; use PHPNomad\Database\Interfaces\TableExistsStrategy as CoreTableExistsStrategy; +use PHPNomad\Database\Interfaces\TableColumnRetirementStrategy as CoreTableColumnRetirementStrategy; use PHPNomad\Database\Interfaces\TableUpdateStrategy as CoreTableUpdateStrategy; use PHPNomad\Loader\Interfaces\HasClassDefinitions; use PHPNomad\MySql\Integration\Adapters\DatabaseDateAdapter; @@ -31,7 +32,10 @@ public function getClassDefinitions(): array TableCreateStrategy::class => CoreTableCreateStrategy::class, TableDeleteStrategy::class => CoreTableDeleteStrategy::class, TableExistsStrategy::class => CoreTableExistsStrategy::class, - TableUpdateStrategy::class => CoreTableUpdateStrategy::class, + TableUpdateStrategy::class => [ + CoreTableUpdateStrategy::class, + CoreTableColumnRetirementStrategy::class, + ], QueryStrategy::class => CoreQueryStrategy::class, DatabaseDateAdapter::class => [ CanConvertDatabaseStringToDateTime::class, @@ -39,4 +43,4 @@ public function getClassDefinitions(): array ] ]; } -} \ No newline at end of file +} diff --git a/lib/Strategies/PdoDatabaseStrategy.php b/lib/Strategies/PdoDatabaseStrategy.php index 735efc1..9a9d833 100644 --- a/lib/Strategies/PdoDatabaseStrategy.php +++ b/lib/Strategies/PdoDatabaseStrategy.php @@ -60,6 +60,7 @@ public function parse(string $query, ...$args): string } } + /** @var string $part */ $result .= match ($part) { '?n' => $this->escapeIdentifier((string) $value), '?s' => $this->escapeString($value), diff --git a/lib/Strategies/TableUpdateStrategy.php b/lib/Strategies/TableUpdateStrategy.php index 47669eb..1c4de55 100644 --- a/lib/Strategies/TableUpdateStrategy.php +++ b/lib/Strategies/TableUpdateStrategy.php @@ -5,11 +5,12 @@ use PHPNomad\Database\Exceptions\TableUpdateFailedException; use PHPNomad\Database\Factories\Column; use PHPNomad\Database\Interfaces\Table; +use PHPNomad\Database\Interfaces\TableColumnRetirementStrategy as CoreTableColumnRetirementStrategy; use PHPNomad\Database\Interfaces\TableUpdateStrategy as CoreTableUpdateStrategy; use PHPNomad\Utils\Helpers\Arr; use PHPNomad\MySql\Integration\Interfaces\DatabaseStrategy; -class TableUpdateStrategy implements CoreTableUpdateStrategy +class TableUpdateStrategy implements CoreTableUpdateStrategy, CoreTableColumnRetirementStrategy { protected DatabaseStrategy $db; @@ -49,6 +50,174 @@ public function syncColumns(Table $table): void } } + public function columnExists(Table $table, string $columnName): bool + { + $this->assertValidColumnName($columnName); + + try { + return $this->findCurrentColumnName($table->getName(), $columnName) !== null; + } catch (\InvalidArgumentException $e) { + throw $e; + } catch (\Exception $e) { + throw new TableUpdateFailedException($e); + } + } + + public function retireColumns(Table $table, string ...$columnNames): void + { + if ($columnNames === []) { + throw new \InvalidArgumentException('At least one column must be named for retirement.'); + } + + foreach ($columnNames as $columnName) { + $this->assertValidColumnName($columnName); + } + + try { + $targets = []; + foreach ($columnNames as $columnName) { + $currentName = $this->findCurrentColumnName($table->getName(), $columnName); + if ($currentName !== null) { + $targets[$currentName] = $currentName; + } + } + + foreach ($table->getColumns() as $column) { + foreach ($columnNames as $columnName) { + if ($this->identifiersEqual($column->getName(), $columnName)) { + throw new \InvalidArgumentException('A declared column cannot be retired.'); + } + } + } + + if ($targets === []) { + return; + } + + $this->assertNoColumnDependencies($table->getName(), $targets); + + $drops = array_map( + fn (string $columnName): string => 'DROP COLUMN ' . $this->db->parse('?n', $columnName), + array_values($targets) + ); + $query = $this->db->parse('ALTER TABLE ?n ', $table->getName()) . implode(', ', $drops); + $this->db->query($query); + } catch (\InvalidArgumentException $e) { + throw $e; + } catch (\Exception $e) { + throw new TableUpdateFailedException($e); + } + } + + private function assertValidColumnName(string $columnName): void + { + if ($columnName === '' || str_contains($columnName, "\0")) { + throw new \InvalidArgumentException('Column names must be non-empty and cannot contain NUL.'); + } + } + + private function findCurrentColumnName(string $tableName, string $columnName): ?string + { + $rows = $this->metadataRows($this->db->query($this->db->parse( + 'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS ' + . 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?s AND COLUMN_NAME = ?s', + $tableName, + $columnName + ))); + + foreach ($rows as $row) { + $persistedName = $row['COLUMN_NAME'] ?? null; + if (!is_string($persistedName)) { + throw new \UnexpectedValueException('Column metadata did not contain a valid name.'); + } + + return $persistedName; + } + + return null; + } + + private function identifiersEqual(string $left, string $right): bool + { + $rows = $this->metadataRows($this->db->query($this->db->parse( + 'SELECT candidate = ?s AS identifiers_equal FROM (' + . 'SELECT COLUMN_NAME AS candidate FROM INFORMATION_SCHEMA.COLUMNS WHERE 1 = 0 ' + . 'UNION ALL SELECT ?s) AS identifier_semantics', + $right, + $left + ))); + $value = $rows[0]['identifiers_equal'] ?? null; + + if ($value !== 0 && $value !== 1 && $value !== '0' && $value !== '1') { + throw new \UnexpectedValueException('Failed to compare column identifiers.'); + } + + return (string) $value === '1'; + } + + /** @param array $targets persisted name => persisted name */ + private function assertNoColumnDependencies(string $tableName, array $targets): void + { + $statistics = $this->metadataRows($this->db->query($this->db->parse( + 'SELECT INDEX_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS ' + . 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?s', + $tableName + ))); + + foreach ($statistics as $statistic) { + if (!array_key_exists('COLUMN_NAME', $statistic)) { + throw new \UnexpectedValueException('Index metadata did not contain a column identity.'); + } + $columnName = $statistic['COLUMN_NAME'] ?? null; + if ($columnName !== null && !is_string($columnName)) { + throw new \UnexpectedValueException('Index metadata contained a malformed column identity.'); + } + if (is_string($columnName) && isset($targets[$columnName])) { + throw new \InvalidArgumentException('An indexed column cannot be retired implicitly.'); + } + if ($columnName === null) { + throw new \InvalidArgumentException('An unresolved functional index prevents column retirement.'); + } + } + + $foreignKeys = $this->metadataRows($this->db->query($this->db->parse( + 'SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME ' + . 'FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() ' + . 'AND (TABLE_NAME = ?s OR (REFERENCED_TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME = ?s))', + $tableName, + $tableName + ))); + + foreach ($foreignKeys as $foreignKey) { + $localColumn = $foreignKey['COLUMN_NAME'] ?? null; + $referencedColumn = $foreignKey['REFERENCED_COLUMN_NAME'] ?? null; + if (!is_string($localColumn) + || ($referencedColumn !== null && !is_string($referencedColumn))) { + throw new \UnexpectedValueException('Foreign-key metadata contained a malformed column identity.'); + } + if ((is_string($localColumn) && isset($targets[$localColumn])) + || (is_string($referencedColumn) && isset($targets[$referencedColumn]))) { + throw new \InvalidArgumentException('A foreign-key column cannot be retired implicitly.'); + } + } + } + + /** @return list> */ + private function metadataRows($result): array + { + if (!is_array($result)) { + throw new \UnexpectedValueException('Metadata query did not return rows.'); + } + + foreach ($result as $row) { + if (!is_array($row)) { + throw new \UnexpectedValueException('Metadata query returned a malformed row.'); + } + } + + return array_values($result); + } + /** * Attributes that declare a key rather than describe the column. * @@ -98,7 +267,7 @@ protected function convertColumnToSql(Column $column, bool $includeKeys = true): if (!$includeKeys) { $columnAttributes = Arr::filter( $columnAttributes, - static fn($attribute): bool => !in_array( + static fn ($attribute): bool => !in_array( strtoupper(trim((string) $attribute)), static::KEY_ATTRIBUTES, true diff --git a/tests/Integration/RealMySqlTableColumnRetirementContractTest.php b/tests/Integration/RealMySqlTableColumnRetirementContractTest.php new file mode 100644 index 0000000..27266da --- /dev/null +++ b/tests/Integration/RealMySqlTableColumnRetirementContractTest.php @@ -0,0 +1,446 @@ +markTestSkipped('An explicit isolated MySQL test DSN is required.'); + } + + try { + $this->pdo = new PDO( + $dsn, + getenv('TEST_MYSQL_USER') ?: 'root', + getenv('TEST_MYSQL_PASS') ?: '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_STRINGIFY_FETCHES => true, + ] + ); + } catch (PDOException $failure) { + $this->markTestSkipped('The assigned MySQL resource is unavailable: ' . $failure->getCode()); + } + + $connection = PdoConnection::fromPdo($this->pdo); + $this->container = new Container(); + $this->container->bindSingletonFromFactory( + PdoConnection::class, + static fn (): PdoConnection => $connection + ); + $this->container->bindSingleton(PdoDatabaseStrategy::class, DatabaseStrategy::class); + (new Bootstrapper($this->container, new MySqlInitializer()))->load(); + $this->strategy = $this->container->get(RetirementStrategy::class); + $this->shadowSchema = 'nomad_retirement_shadow_' . getmypid(); + $this->resetFixtures(); + } + + protected function tearDown(): void + { + if (isset($this->pdo)) { + $this->pdo->exec('DROP TABLE IF EXISTS ' . self::CHILD_TABLE); + $this->pdo->exec('DROP TABLE IF EXISTS ' . self::TABLE); + $this->pdo->exec('DROP TABLE IF EXISTS ' . $this->quoteIdentifier(self::QUOTED_TABLE)); + $this->pdo->exec('DROP TABLE IF EXISTS ' . self::ACCENT_TABLE); + $this->pdo->exec('DROP DATABASE IF EXISTS ' . $this->quoteIdentifier($this->shadowSchema)); + } + + parent::tearDown(); + } + + public function testSyncIsAdditiveAndColumnExistenceIsScopedToTheActiveSchema(): void + { + $this->pdo->exec('CREATE DATABASE ' . $this->quoteIdentifier($this->shadowSchema)); + $this->pdo->exec( + 'CREATE TABLE ' . $this->quoteIdentifier($this->shadowSchema) . '.' . self::TABLE + . ' (crossSchemaOnly INT NULL) ENGINE=InnoDB' + ); + + self::assertTrue($this->strategy->columnExists($this->table(), 'legacyValue')); + self::assertTrue($this->strategy->columnExists($this->table(), 'LEGACYVALUE')); + self::assertTrue($this->strategy->columnExists($this->table(), 'LÉGACY值')); + self::assertFalse($this->strategy->columnExists($this->table(), 'crossSchemaOnly')); + + $this->strategy->syncColumns($this->table()); + + self::assertSame( + ['id', 'legacyValue', 'unrelatedUnknown', 'legacy value', 'odd`name', 'select', 'legacy-name', 'légacy值', 'modernValue'], + $this->columns() + ); + self::assertSame( + [['id' => '1', 'legacyValue' => '41', 'unrelatedUnknown' => 'keep']], + $this->pdo->query( + 'SELECT id, legacyValue, unrelatedUnknown FROM ' . self::TABLE + )->fetchAll() + ); + } + + public function testBootstrapperResolvesOneStrategyForBaseAndRetirementContracts(): void + { + self::assertSame($this->strategy, $this->container->get(UpdateStrategy::class)); + } + + public function testRetirementDeduplicatesBackendEquivalentNamesAndIsIdempotent(): void + { + $retired = [ + 'legacyValue', + 'LEGACYVALUE', + 'legacy value', + 'odd`name', + 'select', + 'legacy-name', + 'légacy值', + 'LÉGACY值', + ]; + $this->strategy->retireColumns($this->table(), ...$retired); + $this->strategy->retireColumns($this->table(), ...$retired); + + self::assertSame(['id', 'unrelatedUnknown'], $this->columns()); + self::assertSame( + [['id' => '1', 'unrelatedUnknown' => 'keep']], + $this->pdo->query('SELECT id, unrelatedUnknown FROM ' . self::TABLE)->fetchAll() + ); + } + + public function testCaseVariantPresentAndAbsentNamesRetireOnlyThePresentIntersection(): void + { + $this->strategy->retireColumns($this->table(), 'LEGACYVALUE', 'missingLegacy'); + + self::assertSame( + ['id', 'unrelatedUnknown', 'legacy value', 'odd`name', 'select', 'legacy-name', 'légacy值'], + $this->columns() + ); + self::assertSame( + [['id' => '1', 'unrelatedUnknown' => 'keep']], + $this->pdo->query('SELECT id, unrelatedUnknown FROM ' . self::TABLE)->fetchAll() + ); + } + + public function testMetadataResolutionIsCaseInsensitiveButAccentSensitive(): void + { + $this->pdo->exec( + 'CREATE TABLE ' . self::ACCENT_TABLE + . ' (id INT PRIMARY KEY, legacy INT NULL, `légacy` INT NULL, unrelatedUnknown VARCHAR(32) NULL) ' + . 'ENGINE=InnoDB' + ); + $this->pdo->exec( + "INSERT INTO " . self::ACCENT_TABLE + . " (id, legacy, `légacy`, unrelatedUnknown) VALUES (1, 41, 42, 'keep')" + ); + $table = $this->table(['id', 'legacy'], self::ACCENT_TABLE); + + self::assertTrue($this->strategy->columnExists($table, 'LEGACY')); + self::assertTrue($this->strategy->columnExists($table, 'LÉGACY')); + $this->strategy->retireColumns($table, 'LÉGACY'); + + self::assertSame(['id', 'legacy', 'unrelatedUnknown'], $this->columns(self::ACCENT_TABLE)); + self::assertSame( + [['id' => '1', 'legacy' => '41', 'unrelatedUnknown' => 'keep']], + $this->pdo->query( + 'SELECT id, legacy, unrelatedUnknown FROM ' . self::ACCENT_TABLE + )->fetchAll() + ); + } + + /** @dataProvider absentDeclaredCaseVariants */ + public function testAbsentDeclaredCaseVariantRejectsWholeBatchBeforeMutation( + string $declaredName, + string $requestedName + ): void { + $failure = null; + try { + $this->strategy->retireColumns( + $this->table(['id', $declaredName]), + 'legacyValue', + $requestedName + ); + } catch (\InvalidArgumentException $expected) { + $failure = $expected; + } + + self::assertSame( + ['id', 'legacyValue', 'unrelatedUnknown', 'legacy value', 'odd`name', 'select', 'legacy-name', 'légacy值'], + $this->columns() + ); + self::assertSame( + [['id' => '1', 'legacyValue' => '41', 'unrelatedUnknown' => 'keep']], + $this->pdo->query('SELECT id, legacyValue, unrelatedUnknown FROM ' . self::TABLE)->fetchAll() + ); + self::assertInstanceOf(\InvalidArgumentException::class, $failure); + } + + public function absentDeclaredCaseVariants(): array + { + return [ + 'ASCII case variant' => ['modernValue', 'MODERNVALUE'], + 'Unicode case variant' => ['módernValue', 'MÓDERNVALUE'], + ]; + } + + /** @dataProvider invalidBatchMembers */ + public function testInvalidBatchMemberPreventsAnyPersistedMutation(string $invalidName): void + { + $failure = null; + try { + $this->strategy->retireColumns($this->table(), 'legacyValue', $invalidName); + } catch (\InvalidArgumentException $expected) { + $failure = $expected; + } + + self::assertSame( + ['id', 'legacyValue', 'unrelatedUnknown', 'legacy value', 'odd`name', 'select', 'legacy-name', 'légacy值'], + $this->columns() + ); + self::assertSame( + [['id' => '1', 'legacyValue' => '41', 'unrelatedUnknown' => 'keep']], + $this->pdo->query('SELECT id, legacyValue, unrelatedUnknown FROM ' . self::TABLE)->fetchAll() + ); + self::assertInstanceOf(\InvalidArgumentException::class, $failure); + } + + public function invalidBatchMembers(): array + { + return [[''], ["legacy\0Value"]]; + } + + public function testRetirementQuotesThePersistedTableIdentifier(): void + { + $quotedTable = $this->quoteIdentifier(self::QUOTED_TABLE); + $this->pdo->exec( + 'CREATE TABLE ' . $quotedTable + . ' (id INT PRIMARY KEY, `legacy value` INT NULL, unrelatedUnknown VARCHAR(32) NULL) ENGINE=InnoDB' + ); + $this->pdo->exec( + "INSERT INTO " . $quotedTable . " (id, `legacy value`, unrelatedUnknown) VALUES (1, 42, 'keep')" + ); + + $this->strategy->retireColumns($this->table(['id'], self::QUOTED_TABLE), 'legacy value'); + + self::assertSame(['id', 'unrelatedUnknown'], $this->columns(self::QUOTED_TABLE)); + self::assertSame( + [['id' => '1', 'unrelatedUnknown' => 'keep']], + $this->pdo->query('SELECT id, unrelatedUnknown FROM ' . $quotedTable)->fetchAll() + ); + } + + public function testWholeBatchPreflightRejectsADeclaredCaseVariantBeforeDdl(): void + { + try { + $this->strategy->retireColumns( + $this->table(['id', 'unrelatedUnknown']), + 'legacyValue', + 'UNRELATEDUNKNOWN' + ); + self::fail('A case-variant declared column must reject the whole batch.'); + } catch (\InvalidArgumentException $expected) { + self::assertSame( + ['id', 'legacyValue', 'unrelatedUnknown', 'legacy value', 'odd`name', 'select', 'legacy-name', 'légacy值'], + $this->columns() + ); + } + } + + public function testWholeBatchPreflightUsesDatabaseCaseSemanticsForUnicodeNames(): void + { + try { + $this->strategy->retireColumns( + $this->table(['id', 'légacy值']), + 'legacyValue', + 'LÉGACY值' + ); + self::fail('A Unicode case-variant declared column must reject the whole batch.'); + } catch (\InvalidArgumentException $expected) { + self::assertSame( + ['id', 'legacyValue', 'unrelatedUnknown', 'legacy value', 'odd`name', 'select', 'legacy-name', 'légacy值'], + $this->columns() + ); + } + } + + public function testPlainIndexedColumnIsRefusedWithoutSchemaChanges(): void + { + $this->pdo->exec('ALTER TABLE ' . self::TABLE . ' ADD INDEX legacy_value_index (legacyValue)'); + + try { + $this->strategy->retireColumns($this->table(), 'legacyValue'); + self::fail('Retirement must not remove a plain index implicitly.'); + } catch (\InvalidArgumentException $expected) { + self::assertContains('legacyValue', $this->columns()); + self::assertSame('legacy_value_index', $this->pdo->query( + "SELECT INDEX_NAME FROM INFORMATION_SCHEMA.STATISTICS " + . "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . self::TABLE . "' " + . "AND COLUMN_NAME = 'legacyValue'" + )->fetchColumn()); + } + } + + public function testFunctionalIndexDependencyIsRefusedWhenStatisticsColumnNameIsNull(): void + { + $this->pdo->exec( + 'ALTER TABLE ' . self::TABLE . ' ADD INDEX legacy_value_expression ((legacyValue + 1))' + ); + + self::assertSame(null, $this->pdo->query( + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS " + . "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . self::TABLE . "' " + . "AND INDEX_NAME = 'legacy_value_expression'" + )->fetchColumn()); + + try { + $this->strategy->retireColumns($this->table(), 'legacyValue'); + self::fail('Retirement must not overlook a functional-index dependency.'); + } catch (\InvalidArgumentException $expected) { + self::assertContains('legacyValue', $this->columns()); + self::assertSame('legacy_value_expression', $this->pdo->query( + "SELECT INDEX_NAME FROM INFORMATION_SCHEMA.STATISTICS " + . "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . self::TABLE . "' " + . "AND INDEX_NAME = 'legacy_value_expression'" + )->fetchColumn()); + } + } + + public function testInboundForeignKeyColumnIsRefusedWithoutSchemaChanges(): void + { + $this->pdo->exec('ALTER TABLE ' . self::TABLE . ' ADD UNIQUE INDEX legacy_value_unique (legacyValue)'); + $this->pdo->exec( + 'CREATE TABLE ' . self::CHILD_TABLE + . ' (id INT PRIMARY KEY, parentLegacyValue INT NULL, ' + . 'CONSTRAINT retirement_parent_fk FOREIGN KEY (parentLegacyValue) REFERENCES ' + . self::TABLE . ' (legacyValue)) ENGINE=InnoDB' + ); + + try { + $this->strategy->retireColumns($this->table(), 'legacyValue'); + self::fail('Retirement must not remove an inbound foreign key implicitly.'); + } catch (\InvalidArgumentException $expected) { + self::assertContains('legacyValue', $this->columns()); + self::assertSame('retirement_parent_fk', $this->pdo->query( + "SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS " + . "WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = '" . self::CHILD_TABLE . "'" + )->fetchColumn()); + } + } + + private function resetFixtures(): void + { + $this->pdo->exec('DROP TABLE IF EXISTS ' . self::CHILD_TABLE); + $this->pdo->exec('DROP TABLE IF EXISTS ' . self::TABLE); + $this->pdo->exec('DROP TABLE IF EXISTS ' . $this->quoteIdentifier(self::QUOTED_TABLE)); + $this->pdo->exec('DROP TABLE IF EXISTS ' . self::ACCENT_TABLE); + $this->pdo->exec('DROP DATABASE IF EXISTS ' . $this->quoteIdentifier($this->shadowSchema)); + $this->pdo->exec( + 'CREATE TABLE ' . self::TABLE . ' (' + . 'id INT PRIMARY KEY, legacyValue INT NULL, unrelatedUnknown VARCHAR(32) NULL, ' + . '`legacy value` INT NULL, `odd``name` INT NULL, `select` INT NULL, ' + . '`legacy-name` INT NULL, `légacy值` INT NULL) ENGINE=InnoDB' + ); + $this->pdo->exec( + "INSERT INTO " . self::TABLE + . " (id, legacyValue, unrelatedUnknown, `legacy value`, `odd``name`, `select`, `legacy-name`, `légacy值`) " + . "VALUES (1, 41, 'keep', 42, 43, 44, 45, 46)" + ); + } + + /** @return list */ + private function columns(string $tableName = self::TABLE): array + { + $statement = $this->pdo->prepare( + 'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS ' + . 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION' + ); + $statement->execute([$tableName]); + + return array_column($statement->fetchAll(), 'COLUMN_NAME'); + } + + /** @param list $declaredNames */ + private function table(array $declaredNames = ['id', 'modernValue'], string $tableName = self::TABLE): Table + { + return new class ($declaredNames, $tableName) implements Table { + public function __construct(private array $declaredNames, private string $tableName) + { + } + public function getName(): string + { + return $this->tableName; + } + public function getAlias(): string + { + return 'retirement'; + } + public function getTableVersion(): string + { + return '1'; + } + public function getColumns(): array + { + return array_map( + static fn (string $name): Column => new Column($name, 'INT'), + $this->declaredNames + ); + } + public function getIndices(): array + { + return []; + } + public function getCharset(): ?string + { + return 'utf8mb4'; + } + public function getCollation(): ?string + { + return 'utf8mb4_unicode_ci'; + } + public function getFieldsForIdentity(): array + { + return ['id']; + } + public function getUnprefixedName(): string + { + return $this->getName(); + } + public function getSingularUnprefixedName(): string + { + return 'retirement'; + } + }; + } + + private function quoteIdentifier(string $identifier): string + { + return '`' . str_replace('`', '``', $identifier) . '`'; + } +} diff --git a/tests/Unit/Strategies/TableColumnRetirementStrategyTest.php b/tests/Unit/Strategies/TableColumnRetirementStrategyTest.php new file mode 100644 index 0000000..8c7dcdf --- /dev/null +++ b/tests/Unit/Strategies/TableColumnRetirementStrategyTest.php @@ -0,0 +1,377 @@ +columnExists($this->table(), 'legacyValue')); + self::assertStringContainsString('TABLE_SCHEMA = DATABASE()', $db->queries[0]); + } + + public function testMissingColumnIsReportedAsAbsent(): void + { + $strategy = new TableUpdateStrategy(new RetirementRecordingDatabase(['id'])); + + self::assertFalse($strategy->columnExists($this->table(), 'legacyValue')); + } + + public function testMetadataFailureIsNotClassifiedAsAbsence(): void + { + $db = new RetirementRecordingDatabase(['legacyValue']); + $db->failMetadata = true; + + $this->expectException(TableUpdateFailedException::class); + (new TableUpdateStrategy($db))->columnExists($this->table(), 'legacyValue'); + } + + /** @dataProvider malformedMetadataResults */ + public function testMalformedMetadataResultFailsClosedBeforeDdl($result): void + { + $db = new RetirementRecordingDatabase(['legacyValue']); + $db->overrideMetadataResult = true; + $db->metadataResult = $result; + + try { + (new TableUpdateStrategy($db))->retireColumns($this->table(), 'legacyValue'); + self::fail('Malformed metadata must fail closed.'); + } catch (TableUpdateFailedException $expected) { + self::assertSame([], $db->alterQueries()); + } + } + + public function malformedMetadataResults(): array + { + return [ + 'false' => [false], + 'null' => [null], + 'malformed row' => [[null]], + ]; + } + + /** @dataProvider malformedMetadataResults */ + public function testMalformedIdentifierComparisonFailsClosedBeforeDdl($result): void + { + $db = new RetirementRecordingDatabase(['id', 'legacyValue']); + $db->metadataOverrides['identifier'] = $result; + + try { + (new TableUpdateStrategy($db))->retireColumns($this->table(), 'legacyValue'); + self::fail('Malformed identifier-comparison metadata must fail closed.'); + } catch (TableUpdateFailedException $expected) { + self::assertSame([], $db->alterQueries()); + } + } + + /** @dataProvider malformedMetadataResults */ + public function testMalformedStatisticsMetadataFailsClosedBeforeDdl($result): void + { + $db = new RetirementRecordingDatabase(['id', 'legacyValue']); + $db->metadataOverrides['statistics'] = $result; + + try { + (new TableUpdateStrategy($db))->retireColumns($this->table(), 'legacyValue'); + self::fail('Malformed index metadata must fail closed.'); + } catch (TableUpdateFailedException $expected) { + self::assertSame([], $db->alterQueries()); + } + } + + /** @dataProvider malformedMetadataResults */ + public function testMalformedForeignKeyMetadataFailsClosedBeforeDdl($result): void + { + $db = new RetirementRecordingDatabase(['id', 'legacyValue']); + $db->metadataOverrides['foreign_key'] = $result; + + try { + (new TableUpdateStrategy($db))->retireColumns($this->table(), 'legacyValue'); + self::fail('Malformed foreign-key metadata must fail closed.'); + } catch (TableUpdateFailedException $expected) { + self::assertSame([], $db->alterQueries()); + } + } + + public function testRetirementDropsOnlyTheNamedColumn(): void + { + $db = new RetirementRecordingDatabase(['id', 'legacyValue', 'unrelatedUnknown']); + (new TableUpdateStrategy($db))->retireColumns($this->table(), 'legacyValue'); + + self::assertCount(1, $db->alterQueries()); + self::assertStringContainsString('DROP COLUMN `legacyValue`', $db->alterQueries()[0]); + self::assertStringNotContainsString('unrelatedUnknown', $db->alterQueries()[0]); + } + + public function testAbsentNamedColumnIsAnIdempotentNoOp(): void + { + $db = new RetirementRecordingDatabase(['id', 'unrelatedUnknown']); + $strategy = new TableUpdateStrategy($db); + + $strategy->retireColumns($this->table(), 'legacyValue'); + $strategy->retireColumns($this->table(), 'legacyValue'); + + self::assertSame([], $db->alterQueries()); + } + + public function testEmptyRetirementRequestIsRejectedBeforeDdl(): void + { + $db = new RetirementRecordingDatabase(['legacyValue']); + + try { + (new TableUpdateStrategy($db))->retireColumns($this->table()); + self::fail('An empty retirement request must be rejected.'); + } catch (\InvalidArgumentException $e) { + self::assertSame([], $db->alterQueries()); + } + } + + /** @dataProvider invalidColumnNames */ + public function testEmptyOrNulColumnNameIsRejectedBeforeDdl(string $name): void + { + $db = new RetirementRecordingDatabase(['legacyValue']); + + try { + (new TableUpdateStrategy($db))->retireColumns($this->table(), 'legacyValue', $name); + self::fail('The invalid name must be rejected.'); + } catch (\InvalidArgumentException $e) { + self::assertSame([], $db->alterQueries()); + } + } + + public function invalidColumnNames(): array + { + return [[''], ["legacy\0Value"]]; + } + + public function testCaseVariantOfDeclaredColumnIsRejectedBeforeDdl(): void + { + $db = new RetirementRecordingDatabase(['id']); + + try { + (new TableUpdateStrategy($db))->retireColumns($this->table(), 'ID'); + self::fail('MySQL column identifiers are case-insensitive.'); + } catch (\InvalidArgumentException $e) { + self::assertSame([], $db->alterQueries()); + } + } + + public function testOneDeclaredNameRejectsTheWholeBatchBeforeDdl(): void + { + $db = new RetirementRecordingDatabase(['id', 'legacyValue']); + + try { + (new TableUpdateStrategy($db))->retireColumns($this->table(), 'legacyValue', 'ID'); + self::fail('A mixed valid/invalid batch must not partially execute.'); + } catch (\InvalidArgumentException $e) { + self::assertSame([], $db->alterQueries()); + } + } + + public function testQuotedBackendIdentifiersAreAccepted(): void + { + $db = new RetirementRecordingDatabase(['id', 'legacy value', 'odd`name']); + (new TableUpdateStrategy($db))->retireColumns($this->table(), 'legacy value', 'odd`name'); + + self::assertStringContainsString('DROP COLUMN `legacy value`', $db->alterQueries()[0]); + self::assertStringContainsString('DROP COLUMN `odd``name`', $db->alterQueries()[0]); + self::assertStringStartsWith('ALTER TABLE `sample-table` ', $db->alterQueries()[0]); + } + + public function testIndexedColumnIsRejectedWithoutDdl(): void + { + $db = new RetirementRecordingDatabase(['id', 'legacyValue']); + $db->indexedColumns = ['legacyValue']; + + try { + (new TableUpdateStrategy($db))->retireColumns($this->table(), 'legacyValue'); + self::fail('Index retirement is outside this capability.'); + } catch (\InvalidArgumentException $e) { + self::assertSame([], $db->alterQueries()); + } + } + + public function testForeignKeyColumnIsRejectedWithoutDdl(): void + { + $db = new RetirementRecordingDatabase(['id', 'legacyValue']); + $db->foreignKeyColumns = ['legacyValue']; + + try { + (new TableUpdateStrategy($db))->retireColumns($this->table(), 'legacyValue'); + self::fail('Foreign-key retirement is outside this capability.'); + } catch (\InvalidArgumentException $e) { + self::assertSame([], $db->alterQueries()); + } + } + + public function testDdlFailureIsWrapped(): void + { + $db = new RetirementRecordingDatabase(['id', 'legacyValue']); + $db->failAlter = true; + + $this->expectException(TableUpdateFailedException::class); + (new TableUpdateStrategy($db))->retireColumns($this->table(), 'legacyValue'); + } + + /** @param list $declaredNames */ + private function table(array $declaredNames = ['id']): Table + { + $table = Mockery::mock(Table::class); + $table->shouldReceive('getName')->andReturn('sample-table'); + $table->shouldReceive('getColumns')->andReturn(array_map( + static fn (string $name): Column => new Column($name, 'BIGINT'), + $declaredNames + )); + + return $table; + } +} + +/** Query-boundary fixture: metadata in, recorded DDL out. */ +final class RetirementRecordingDatabase implements DatabaseStrategy +{ + /** @var list */ + public array $queries = []; + /** @var list */ + public array $columns; + /** @var list */ + public array $indexedColumns = []; + /** @var list */ + public array $foreignKeyColumns = []; + public bool $failMetadata = false; + public bool $failAlter = false; + public bool $overrideMetadataResult = false; + public $metadataResult; + /** @var array */ + public array $metadataOverrides = []; + + /** @param list $columns */ + public function __construct(array $columns = []) + { + $this->columns = $columns; + } + + public function parse(string $query, ...$args): string + { + foreach ($args as $arg) { + $position = strpos($query, '?n'); + if ($position !== false) { + $quoted = '`' . str_replace('`', '``', (string) $arg) . '`'; + $query = substr_replace($query, $quoted, $position, 2); + continue; + } + + $position = strpos($query, '?s'); + if ($position !== false) { + $quoted = "'" . str_replace("'", "''", (string) $arg) . "'"; + $query = substr_replace($query, $quoted, $position, 2); + } + } + + return $query; + } + + public function query(string $query) + { + $this->queries[] = $query; + + if (stripos($query, 'ALTER TABLE') !== false) { + if ($this->failAlter) { + throw new DatastoreErrorException('DDL failed'); + } + return []; + } + if ($this->failMetadata) { + throw new DatastoreErrorException('Metadata failed'); + } + if (stripos($query, 'AS identifiers_equal') !== false + && array_key_exists('identifier', $this->metadataOverrides)) { + return $this->metadataOverrides['identifier']; + } + if (stripos($query, 'INFORMATION_SCHEMA.STATISTICS') !== false + && array_key_exists('statistics', $this->metadataOverrides)) { + return $this->metadataOverrides['statistics']; + } + if (stripos($query, 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE') !== false + && array_key_exists('foreign_key', $this->metadataOverrides)) { + return $this->metadataOverrides['foreign_key']; + } + if ($this->overrideMetadataResult && stripos($query, 'INFORMATION_SCHEMA.') !== false) { + return $this->metadataResult; + } + if (stripos($query, 'AS identifiers_equal') !== false) { + if (preg_match( + "/SELECT candidate = '((?:''|[^'])*)'.*UNION ALL SELECT '((?:''|[^'])*)'/is", + $query, + $matches + ) !== 1) { + throw new \RuntimeException('Identifier comparison query was not understood by the fixture.'); + } + $right = str_replace("''", "'", $matches[1]); + $left = str_replace("''", "'", $matches[2]); + + return [['identifiers_equal' => strcasecmp($left, $right) === 0 ? '1' : '0']]; + } + if (stripos($query, 'INFORMATION_SCHEMA.COLUMNS') !== false) { + $columns = $this->columns; + if (preg_match("/AND COLUMN_NAME = '((?:''|[^'])*)'/i", $query, $matches) === 1) { + $requestedName = str_replace("''", "'", $matches[1]); + $columns = array_values(array_filter( + $columns, + static fn (string $name): bool => strcasecmp($name, $requestedName) === 0 + )); + } + + return array_map(static fn (string $name): array => [ + 'COLUMN_NAME' => $name, + 'COLUMN_TYPE' => 'bigint', + 'IS_NULLABLE' => 'YES', + 'COLUMN_DEFAULT' => null, + 'EXTRA' => '', + ], $columns); + } + if (stripos($query, 'INFORMATION_SCHEMA.STATISTICS') !== false) { + return array_map(static fn (string $name): array => ['COLUMN_NAME' => $name], $this->indexedColumns); + } + if (stripos($query, 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE') !== false) { + return array_map(static fn (string $name): array => ['COLUMN_NAME' => $name], $this->foreignKeyColumns); + } + + return []; + } + + /** @return list */ + public function alterQueries(): array + { + return array_values(array_filter( + $this->queries, + static fn (string $query): bool => stripos($query, 'ALTER TABLE') !== false + )); + } +}