diff --git a/composer.json b/composer.json index 9c143d0..1de5e06 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "php": ">=8.0", "phpnomad/auth": "^1.0", "phpnomad/asset": "^1.0", - "phpnomad/db": "dev-codex/operation-local-handler-bridge as 2.2.x-dev", + "phpnomad/db": "dev-codex/operation-bridge-retirement-convergence#76d7161702a4368748b6865cd05001dce0b157cc as 2.2.x-dev", "phpnomad/datastore": "^2.0", "phpnomad/event": "^1.0", "phpnomad/email": "^1.0", diff --git a/composer.lock b/composer.lock index 1384208..cee129a 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": "37faa428f4f7ff1eeb826752e48d0bff", + "content-hash": "b0f5e170a11e05021d7925a420b8c3ca", "packages": [ { "name": "phpnomad/asset", @@ -270,11 +270,11 @@ }, { "name": "phpnomad/db", - "version": "dev-codex/operation-local-handler-bridge", + "version": "dev-codex/operation-bridge-retirement-convergence", "source": { "type": "git", "url": "https://github.com/phpnomad/db.git", - "reference": "4b0b7709e5945a4290b04c2e3562ef919923ed08" + "reference": "76d7161702a4368748b6865cd05001dce0b157cc" }, "require": { "phpnomad/cache": "^1.0", @@ -313,7 +313,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", @@ -6125,7 +6125,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/Strategies/TableUpdateStrategy.php b/lib/Strategies/TableUpdateStrategy.php index 5623c67..e484327 100644 --- a/lib/Strategies/TableUpdateStrategy.php +++ b/lib/Strategies/TableUpdateStrategy.php @@ -6,12 +6,13 @@ use PHPNomad\Database\Factories\Column; use PHPNomad\Database\Factories\Index; use PHPNomad\Database\Interfaces\Table; +use PHPNomad\Database\Interfaces\TableColumnRetirementStrategy as CoreTableColumnRetirementStrategy; use PHPNomad\Database\Interfaces\TableUpdateStrategy as CoreTableUpdateStrategy; use PHPNomad\Datastore\Exceptions\DatastoreErrorException; use PHPNomad\Integrations\WordPress\Traits\CanModifyWordPressDatabase; use PHPNomad\Utils\Helpers\Arr; -class TableUpdateStrategy implements CoreTableUpdateStrategy +class TableUpdateStrategy implements CoreTableUpdateStrategy, CoreTableColumnRetirementStrategy { use CanModifyWordPressDatabase; @@ -36,6 +37,177 @@ 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); + + global $wpdb; + $drops = array_map( + fn(string $columnName): string => 'DROP COLUMN ' . $wpdb->prepare('%i', $columnName), + array_values($targets) + ); + $this->wpdbQuery( + 'ALTER TABLE ' . $wpdb->prepare('%i', $table->getName()) . ' ' . implode(', ', $drops) + ); + } 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 + { + global $wpdb; + $rows = $wpdb->get_results($wpdb->prepare( + 'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS ' + . 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = %s', + $tableName, + $columnName + ), ARRAY_A); + $this->assertMetadataSucceeded($rows); + + foreach ($rows as $row) { + if (!is_array($row) || !isset($row['COLUMN_NAME']) || !is_string($row['COLUMN_NAME'])) { + throw new \UnexpectedValueException('Column metadata did not contain a valid name.'); + } + + return $row['COLUMN_NAME']; + } + + return null; + } + + private function identifiersEqual(string $left, string $right): bool + { + global $wpdb; + $rows = $wpdb->get_results($wpdb->prepare( + '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 + ), ARRAY_A); + $this->assertMetadataSucceeded($rows); + $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 + { + global $wpdb; + $statistics = $wpdb->get_results($wpdb->prepare( + 'SELECT INDEX_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS ' + . 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $tableName + ), ARRAY_A); + $this->assertMetadataSucceeded($statistics); + + foreach ($statistics as $statistic) { + if (!is_array($statistic) || !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 = $wpdb->get_results($wpdb->prepare( + '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 + ), ARRAY_A); + $this->assertMetadataSucceeded($foreignKeys); + + foreach ($foreignKeys as $foreignKey) { + if (!is_array($foreignKey)) { + throw new \UnexpectedValueException('Foreign-key metadata contained a malformed row.'); + } + $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.'); + } + } + } + + private function assertMetadataSucceeded($results): void + { + global $wpdb; + if (!is_array($results) || $wpdb->last_error !== '') { + throw new DatastoreErrorException('Failed to inspect table metadata.'); + } + } + protected function convertColumnToSql(Column $column): string { // Get the column name and type @@ -59,9 +231,10 @@ protected function getCurrentColumns(string $tableName): array global $wpdb; $query = 'SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_NAME = %s'; + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s'; $results = $wpdb->get_results($wpdb->prepare($query, $tableName), ARRAY_A); + $this->assertMetadataSucceeded($results); $columns = []; foreach ($results as $row) { @@ -121,15 +294,6 @@ protected function buildSyncColumnsQuery(Table $table): ?string } } - $newColumnNames = Arr::pluck($newColumns, 'name'); - - // Drop columns that no longer exist in the new definition - foreach ($currentColumns as $currentColumnName => $currentColumnData) { - if (!in_array($currentColumnName, $newColumnNames)) { - $queries[] = 'DROP COLUMN ' . $wpdb->prepare('%i', $currentColumnName); - } - } - $args = Arr::process($queries) ->whereNotEmpty() ->setSeparator(",\n ") diff --git a/lib/Strategies/WordPressInitializer.php b/lib/Strategies/WordPressInitializer.php index 6af8235..7c6bb46 100644 --- a/lib/Strategies/WordPressInitializer.php +++ b/lib/Strategies/WordPressInitializer.php @@ -37,6 +37,7 @@ use PHPNomad\Database\Interfaces\TableDeleteStrategy as CoreTableDeleteStrategyAlias; use PHPNomad\Database\Interfaces\TableExistsStrategy as CoreTableExistsStrategyAlias; use PHPNomad\Database\Interfaces\TableUpdateStrategy as CoreTableUpdateStrategy; +use PHPNomad\Database\Interfaces\TableColumnRetirementStrategy as CoreTableColumnRetirementStrategy; use PHPNomad\Datastore\Events\RecordCreated; use PHPNomad\Datastore\Events\RecordDeleted; use PHPNomad\Di\Interfaces\CanSetContainer; @@ -103,7 +104,10 @@ public function getClassDefinitions(): array WordPressOperationDatabaseProviderFactory::class => CoreOperationDatabaseProviderFactory::class, DefaultCacheTtlProvider::class => HasDefaultTtl::class, TableCreateStrategy::class => CoreTableCreateStrategyAlias::class, - TableUpdateStrategy::class => CoreTableUpdateStrategy::class, + TableUpdateStrategy::class => [ + CoreTableUpdateStrategy::class, + CoreTableColumnRetirementStrategy::class, + ], TableDeleteStrategy::class => CoreTableDeleteStrategyAlias::class, TableExistsStrategy::class => CoreTableExistsStrategyAlias::class, TranslationStrategy::class => CoreTranslationStrategyAlias::class, diff --git a/tests/Integration/Database/RealWpdbTableColumnRetirementContractTest.php b/tests/Integration/Database/RealWpdbTableColumnRetirementContractTest.php new file mode 100644 index 0000000..5417763 --- /dev/null +++ b/tests/Integration/Database/RealWpdbTableColumnRetirementContractTest.php @@ -0,0 +1,435 @@ +suppress_errors(true); + $GLOBALS['wpdb'] = self::$wpdb; + + if (!self::$wpdb->ready) { + throw new \RuntimeException('wpdb could not connect: ' . self::$wpdb->last_error); + } + } + + protected function setUp(): void + { + $this->container = new Container(); + (new Bootstrapper($this->container, new WordPressInitializer()))->load(); + $this->strategy = $this->container->get(RetirementStrategy::class); + $this->table = new ContractTable( + self::TABLE, + 'retirement', + [new Column('id', 'INT', null, 'PRIMARY KEY'), new Column('modernValue', 'INT')], + ['id'] + ); + $this->shadowSchema = 'nomad_wpdb_retirement_shadow_' . getmypid(); + $this->resetFixtures(); + } + + protected function tearDown(): void + { + self::rawQuery('DROP TABLE IF EXISTS ' . self::CHILD_TABLE); + self::rawQuery('DROP TABLE IF EXISTS ' . self::TABLE); + self::rawQuery('DROP TABLE IF EXISTS ' . self::quoteIdentifier(self::QUOTED_TABLE)); + self::rawQuery('DROP TABLE IF EXISTS ' . self::ACCENT_TABLE); + self::rawQuery('DROP DATABASE IF EXISTS ' . self::quoteIdentifier($this->shadowSchema)); + } + + public static function tearDownAfterClass(): void + { + unset($GLOBALS['wpdb']); + } + + public function testSyncIsAdditiveAndColumnExistenceIsScopedToTheActiveSchema(): void + { + self::rawQuery('CREATE DATABASE ' . self::quoteIdentifier($this->shadowSchema)); + self::rawQuery( + 'CREATE TABLE ' . self::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'], + self::columns() + ); + self::assertSame( + [['id' => '1', 'legacyValue' => '41', 'unrelatedUnknown' => 'keep']], + self::rawSelect('SELECT id, legacyValue, unrelatedUnknown FROM ' . self::TABLE) + ); + } + + 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'], self::columns()); + self::assertSame( + [['id' => '1', 'unrelatedUnknown' => 'keep']], + self::rawSelect('SELECT id, unrelatedUnknown FROM ' . self::TABLE) + ); + } + + public function testCaseVariantPresentAndAbsentNamesRetireOnlyThePresentIntersection(): void + { + $this->strategy->retireColumns($this->table, 'LEGACYVALUE', 'missingLegacy'); + + self::assertSame( + ['id', 'unrelatedUnknown', 'legacy value', 'odd`name', 'select', 'legacy-name', 'légacy值'], + self::columns() + ); + self::assertSame( + [['id' => '1', 'unrelatedUnknown' => 'keep']], + self::rawSelect('SELECT id, unrelatedUnknown FROM ' . self::TABLE) + ); + } + + public function testMetadataResolutionIsCaseInsensitiveButAccentSensitive(): void + { + self::rawQuery( + 'CREATE TABLE ' . self::ACCENT_TABLE + . ' (id INT PRIMARY KEY, legacy INT NULL, `légacy` INT NULL, unrelatedUnknown VARCHAR(32) NULL) ' + . 'ENGINE=InnoDB' + ); + self::rawQuery( + "INSERT INTO " . self::ACCENT_TABLE + . " (id, legacy, `légacy`, unrelatedUnknown) VALUES (1, 41, 42, 'keep')" + ); + $table = new ContractTable( + self::ACCENT_TABLE, + 'retirement', + [new Column('id', 'INT', null, 'PRIMARY KEY'), new Column('legacy', 'INT')], + ['id'] + ); + + 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'], self::columns(self::ACCENT_TABLE)); + self::assertSame( + [['id' => '1', 'legacy' => '41', 'unrelatedUnknown' => 'keep']], + self::rawSelect('SELECT id, legacy, unrelatedUnknown FROM ' . self::ACCENT_TABLE) + ); + } + + /** @dataProvider absentDeclaredCaseVariants */ + public function testAbsentDeclaredCaseVariantRejectsWholeBatchBeforeMutation( + string $declaredName, + string $requestedName + ): void { + $failure = null; + try { + $declaredTable = new ContractTable( + self::TABLE, + 'retirement', + [new Column('id', 'INT'), new Column($declaredName, 'INT')], + ['id'] + ); + $this->strategy->retireColumns($declaredTable, 'legacyValue', $requestedName); + } catch (\InvalidArgumentException $expected) { + $failure = $expected; + } + + self::assertSame( + ['id', 'legacyValue', 'unrelatedUnknown', 'legacy value', 'odd`name', 'select', 'legacy-name', 'légacy值'], + self::columns() + ); + self::assertSame( + [['id' => '1', 'legacyValue' => '41', 'unrelatedUnknown' => 'keep']], + self::rawSelect('SELECT id, legacyValue, unrelatedUnknown FROM ' . self::TABLE) + ); + 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值'], + self::columns() + ); + self::assertSame( + [['id' => '1', 'legacyValue' => '41', 'unrelatedUnknown' => 'keep']], + self::rawSelect('SELECT id, legacyValue, unrelatedUnknown FROM ' . self::TABLE) + ); + self::assertInstanceOf(\InvalidArgumentException::class, $failure); + } + + public function invalidBatchMembers(): array + { + return [[''], ["legacy\0Value"]]; + } + + public function testRetirementQuotesThePersistedTableIdentifier(): void + { + $quotedTable = self::quoteIdentifier(self::QUOTED_TABLE); + self::rawQuery( + 'CREATE TABLE ' . $quotedTable + . ' (id INT PRIMARY KEY, `legacy value` INT NULL, unrelatedUnknown VARCHAR(32) NULL) ENGINE=InnoDB' + ); + self::rawQuery( + "INSERT INTO " . $quotedTable . " (id, `legacy value`, unrelatedUnknown) VALUES (1, 42, 'keep')" + ); + $table = new ContractTable( + self::QUOTED_TABLE, + 'retirement', + [new Column('id', 'INT', null, 'PRIMARY KEY')], + ['id'] + ); + + $this->strategy->retireColumns($table, 'legacy value'); + + self::assertSame(['id', 'unrelatedUnknown'], self::columns(self::QUOTED_TABLE)); + self::assertSame( + [['id' => '1', 'unrelatedUnknown' => 'keep']], + self::rawSelect('SELECT id, unrelatedUnknown FROM ' . $quotedTable) + ); + } + + public function testWholeBatchPreflightRejectsADeclaredCaseVariantBeforeDdl(): void + { + try { + $declaredTable = new ContractTable( + self::TABLE, + 'retirement', + [new Column('id', 'INT'), new Column('unrelatedUnknown', 'VARCHAR', [32])], + ['id'] + ); + $this->strategy->retireColumns($declaredTable, '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值'], + self::columns() + ); + } + } + + public function testWholeBatchPreflightUsesDatabaseCaseSemanticsForUnicodeNames(): void + { + try { + $declaredTable = new ContractTable( + self::TABLE, + 'retirement', + [new Column('id', 'INT'), new Column('légacy值', 'INT')], + ['id'] + ); + $this->strategy->retireColumns($declaredTable, '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值'], + self::columns() + ); + } + } + + public function testPlainIndexedColumnIsRefusedWithoutSchemaChanges(): void + { + self::rawQuery('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', self::columns()); + self::assertSame( + 'legacy_value_index', + self::rawSelect( + "SELECT INDEX_NAME FROM INFORMATION_SCHEMA.STATISTICS " + . "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . self::TABLE . "' " + . "AND COLUMN_NAME = 'legacyValue'" + )[0]['INDEX_NAME'] + ); + } + } + + public function testFunctionalIndexDependencyIsRefusedWhenStatisticsColumnNameIsNull(): void + { + self::rawQuery( + 'ALTER TABLE ' . self::TABLE . ' ADD INDEX legacy_value_expression ((legacyValue + 1))' + ); + + self::assertNull(self::rawSelect( + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS " + . "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . self::TABLE . "' " + . "AND INDEX_NAME = 'legacy_value_expression'" + )[0]['COLUMN_NAME']); + + try { + $this->strategy->retireColumns($this->table, 'legacyValue'); + self::fail('Retirement must not overlook a functional-index dependency.'); + } catch (\InvalidArgumentException $expected) { + self::assertContains('legacyValue', self::columns()); + self::assertSame( + 'legacy_value_expression', + self::rawSelect( + "SELECT INDEX_NAME FROM INFORMATION_SCHEMA.STATISTICS " + . "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . self::TABLE . "' " + . "AND INDEX_NAME = 'legacy_value_expression'" + )[0]['INDEX_NAME'] + ); + } + } + + public function testInboundForeignKeyColumnIsRefusedWithoutSchemaChanges(): void + { + self::rawQuery('ALTER TABLE ' . self::TABLE . ' ADD UNIQUE INDEX legacy_value_unique (legacyValue)'); + self::rawQuery( + 'CREATE TABLE ' . self::CHILD_TABLE + . ' (id INT PRIMARY KEY, parentLegacyValue INT NULL, ' + . 'CONSTRAINT wpdb_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', self::columns()); + self::assertSame( + 'wpdb_retirement_parent_fk', + self::rawSelect( + "SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS " + . "WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = '" . self::CHILD_TABLE . "'" + )[0]['CONSTRAINT_NAME'] + ); + } + } + + private function resetFixtures(): void + { + self::rawQuery('DROP TABLE IF EXISTS ' . self::CHILD_TABLE); + self::rawQuery('DROP TABLE IF EXISTS ' . self::TABLE); + self::rawQuery('DROP TABLE IF EXISTS ' . self::quoteIdentifier(self::QUOTED_TABLE)); + self::rawQuery('DROP TABLE IF EXISTS ' . self::ACCENT_TABLE); + self::rawQuery('DROP DATABASE IF EXISTS ' . self::quoteIdentifier($this->shadowSchema)); + self::rawQuery( + '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' + ); + self::rawQuery( + "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 static function columns(string $tableName = self::TABLE): array + { + $quotedName = str_replace("'", "''", $tableName); + + return array_column(self::rawSelect( + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " + . "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . $quotedName . "' ORDER BY ORDINAL_POSITION" + ), 'COLUMN_NAME'); + } + + private static function rawQuery(string $sql): void + { + $result = self::$wpdb->query($sql); + + if ($result === false || self::$wpdb->last_error !== '') { + throw new \RuntimeException('wpdb query failed: ' . self::$wpdb->last_error . ' SQL=' . $sql); + } + } + + /** @return list> */ + private static function rawSelect(string $sql): array + { + $result = self::$wpdb->get_results($sql, ARRAY_A); + + if (!is_array($result) || self::$wpdb->last_error !== '') { + throw new \RuntimeException('wpdb SELECT failed: ' . self::$wpdb->last_error . ' SQL=' . $sql); + } + + return $result; + } + + private static function quoteIdentifier(string $identifier): string + { + return '`' . str_replace('`', '``', $identifier) . '`'; + } + + private static function environment(string $name, string $default): string + { + $value = getenv($name); + + return is_string($value) && $value !== '' ? $value : $default; + } +} diff --git a/tests/Unit/Strategies/TableColumnRetirementStrategyTest.php b/tests/Unit/Strategies/TableColumnRetirementStrategyTest.php new file mode 100644 index 0000000..5d61e27 --- /dev/null +++ b/tests/Unit/Strategies/TableColumnRetirementStrategyTest.php @@ -0,0 +1,398 @@ +database = new RetirementWpdb(); + $GLOBALS['wpdb'] = $this->database; + } + + protected function tearDown(): void + { + unset($GLOBALS['wpdb']); + parent::tearDown(); + } + + public function testStrategyExposesTheOptionalCapability(): void + { + self::assertInstanceOf(RetirementContract::class, new TableUpdateStrategy()); + } + + public function testDefaultSyncIsAdditiveAndPreservesUnknownColumns(): void + { + $this->database->columns = ['id', 'unrelatedUnknown']; + + (new TableUpdateStrategy())->syncColumns($this->table(['id'])); + + self::assertSame([], $this->database->alterQueries()); + } + + public function testColumnExistsUsesTheActiveSchemaMetadata(): void + { + $this->database->columns = ['legacyValue']; + + self::assertTrue((new TableUpdateStrategy())->columnExists($this->table(), 'legacyValue')); + self::assertStringContainsString('TABLE_SCHEMA = DATABASE()', $this->database->queries[0]); + } + + public function testMissingColumnIsReportedAsAbsent(): void + { + $this->database->columns = ['id']; + + self::assertFalse((new TableUpdateStrategy())->columnExists($this->table(), 'legacyValue')); + } + + public function testMetadataFailureIsNotClassifiedAsAbsence(): void + { + $this->database->columns = ['legacyValue']; + $this->database->failMetadata = true; + + $this->expectException(TableUpdateFailedException::class); + (new TableUpdateStrategy())->columnExists($this->table(), 'legacyValue'); + } + + public function testMalformedMetadataResultFailsClosedBeforeDdl(): void + { + $this->database->columns = ['legacyValue']; + $this->database->overrideMetadataResult = true; + $this->database->metadataResult = [null]; + + try { + (new TableUpdateStrategy())->retireColumns($this->table(), 'legacyValue'); + self::fail('Malformed metadata must fail closed.'); + } catch (TableUpdateFailedException $expected) { + self::assertSame([], $this->database->alterQueries()); + } + } + + /** @dataProvider malformedMetadataResults */ + public function testMalformedIdentifierComparisonFailsClosedBeforeDdl($result): void + { + $this->database->columns = ['id', 'legacyValue']; + $this->database->metadataOverrides['identifier'] = $result; + + try { + (new TableUpdateStrategy())->retireColumns($this->table(), 'legacyValue'); + self::fail('Malformed identifier-comparison metadata must fail closed.'); + } catch (TableUpdateFailedException $expected) { + self::assertSame([], $this->database->alterQueries()); + } + } + + /** @dataProvider malformedMetadataResults */ + public function testMalformedStatisticsMetadataFailsClosedBeforeDdl($result): void + { + $this->database->columns = ['id', 'legacyValue']; + $this->database->metadataOverrides['statistics'] = $result; + + try { + (new TableUpdateStrategy())->retireColumns($this->table(), 'legacyValue'); + self::fail('Malformed index metadata must fail closed.'); + } catch (TableUpdateFailedException $expected) { + self::assertSame([], $this->database->alterQueries()); + } + } + + /** @dataProvider malformedMetadataResults */ + public function testMalformedForeignKeyMetadataFailsClosedBeforeDdl($result): void + { + $this->database->columns = ['id', 'legacyValue']; + $this->database->metadataOverrides['foreign_key'] = $result; + + try { + (new TableUpdateStrategy())->retireColumns($this->table(), 'legacyValue'); + self::fail('Malformed foreign-key metadata must fail closed.'); + } catch (TableUpdateFailedException $expected) { + self::assertSame([], $this->database->alterQueries()); + } + } + + public function malformedMetadataResults(): array + { + return [ + 'false result' => [false], + 'null result' => [null], + 'malformed row' => [[null]], + ]; + } + + public function testRetirementDropsOnlyTheNamedColumn(): void + { + $this->database->columns = ['id', 'legacyValue', 'unrelatedUnknown']; + (new TableUpdateStrategy())->retireColumns($this->table(), 'legacyValue'); + + self::assertCount(1, $this->database->alterQueries()); + self::assertStringContainsString('DROP COLUMN `legacyValue`', $this->database->alterQueries()[0]); + self::assertStringNotContainsString('unrelatedUnknown', $this->database->alterQueries()[0]); + } + + public function testAbsentNamedColumnIsAnIdempotentNoOp(): void + { + $this->database->columns = ['id', 'unrelatedUnknown']; + $strategy = new TableUpdateStrategy(); + + $strategy->retireColumns($this->table(), 'legacyValue'); + $strategy->retireColumns($this->table(), 'legacyValue'); + + self::assertSame([], $this->database->alterQueries()); + } + + public function testEmptyRetirementRequestIsRejectedBeforeDdl(): void + { + try { + (new TableUpdateStrategy())->retireColumns($this->table()); + self::fail('An empty retirement request must be rejected.'); + } catch (\InvalidArgumentException $e) { + self::assertSame([], $this->database->alterQueries()); + } + } + + /** @dataProvider invalidColumnNames */ + public function testEmptyOrNulColumnNameIsRejectedBeforeDdl(string $name): void + { + try { + (new TableUpdateStrategy())->retireColumns($this->table(), 'legacyValue', $name); + self::fail('The invalid name must be rejected.'); + } catch (\InvalidArgumentException $e) { + self::assertSame([], $this->database->alterQueries()); + } + } + + public function invalidColumnNames(): array + { + return [[''], ["legacy\0Value"]]; + } + + public function testCaseVariantOfDeclaredColumnIsRejectedBeforeDdl(): void + { + $this->database->columns = ['id']; + + try { + (new TableUpdateStrategy())->retireColumns($this->table(), 'ID'); + self::fail('MySQL column identifiers are case-insensitive.'); + } catch (\InvalidArgumentException $e) { + self::assertSame([], $this->database->alterQueries()); + } + } + + public function testOneDeclaredNameRejectsTheWholeBatchBeforeDdl(): void + { + $this->database->columns = ['id', 'legacyValue']; + + try { + (new TableUpdateStrategy())->retireColumns($this->table(), 'legacyValue', 'ID'); + self::fail('A mixed valid/invalid batch must not partially execute.'); + } catch (\InvalidArgumentException $e) { + self::assertSame([], $this->database->alterQueries()); + } + } + + public function testQuotedBackendIdentifiersAreAccepted(): void + { + $this->database->columns = ['id', 'legacy value', 'odd`name']; + (new TableUpdateStrategy())->retireColumns($this->table(), 'legacy value', 'odd`name'); + + self::assertStringContainsString('DROP COLUMN `legacy value`', $this->database->alterQueries()[0]); + self::assertStringContainsString('DROP COLUMN `odd``name`', $this->database->alterQueries()[0]); + self::assertStringStartsWith('ALTER TABLE `sample-table` ', $this->database->alterQueries()[0]); + } + + public function testIndexedColumnIsRejectedWithoutDdl(): void + { + $this->database->columns = ['id', 'legacyValue']; + $this->database->indexedColumns = ['legacyValue']; + + try { + (new TableUpdateStrategy())->retireColumns($this->table(), 'legacyValue'); + self::fail('Index retirement is outside this capability.'); + } catch (\InvalidArgumentException $e) { + self::assertSame([], $this->database->alterQueries()); + } + } + + public function testForeignKeyColumnIsRejectedWithoutDdl(): void + { + $this->database->columns = ['id', 'legacyValue']; + $this->database->foreignKeyColumns = ['legacyValue']; + + try { + (new TableUpdateStrategy())->retireColumns($this->table(), 'legacyValue'); + self::fail('Foreign-key retirement is outside this capability.'); + } catch (\InvalidArgumentException $e) { + self::assertSame([], $this->database->alterQueries()); + } + } + + public function testDdlFailureIsWrapped(): void + { + $this->database->columns = ['id', 'legacyValue']; + $this->database->failAlter = true; + + $this->expectException(TableUpdateFailedException::class); + (new TableUpdateStrategy())->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; + } +} + +/** Minimal wpdb boundary fixture: metadata in, recorded DDL out. */ +final class RetirementWpdb +{ + public string $last_error = ''; + /** @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 = []; + + public function prepare(string $query, ...$args): string + { + if (count($args) === 1 && is_array($args[0])) { + $args = $args[0]; + } + + foreach ($args as $arg) { + $position = strpos($query, '%i'); + 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 get_results(string $query, string $output) + { + $this->queries[] = $query; + if ($this->failMetadata) { + $this->last_error = 'metadata failed'; + return []; + } + 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 []; + } + + public function query(string $query) + { + $this->queries[] = $query; + if (stripos($query, 'ALTER TABLE') !== false && $this->failAlter) { + $this->last_error = 'DDL failed'; + return false; + } + + $this->last_error = ''; + return 1; + } + + /** @return list */ + public function alterQueries(): array + { + return array_values(array_filter( + $this->queries, + static fn (string $query): bool => stripos($query, 'ALTER TABLE') !== false + )); + } +}