From cf9cefa123713056d063bfeddce4c99f7f8be788 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 12:55:34 -0400 Subject: [PATCH 1/4] Define optional named-column retirement contract --- .../UnsupportedColumnRetirementException.php | 10 +++ .../TableColumnRetirementStrategy.php | 31 +++++++ .../TableColumnRetirementSurfaceTest.php | 84 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 lib/Exceptions/UnsupportedColumnRetirementException.php create mode 100644 lib/Interfaces/TableColumnRetirementStrategy.php create mode 100644 tests/Unit/Contracts/TableColumnRetirementSurfaceTest.php diff --git a/lib/Exceptions/UnsupportedColumnRetirementException.php b/lib/Exceptions/UnsupportedColumnRetirementException.php new file mode 100644 index 0000000..f1d3c21 --- /dev/null +++ b/lib/Exceptions/UnsupportedColumnRetirementException.php @@ -0,0 +1,10 @@ +methodNames(TableColumnRetirementStrategy::class) + ); + } + + public function testColumnExistsSignatureCarriesTableAndName(): void + { + $method = (new ReflectionClass(TableColumnRetirementStrategy::class))->getMethod('columnExists'); + + self::assertSame('bool', (string) $method->getReturnType()); + self::assertSame( + ['table', 'columnName'], + array_map(static fn ($parameter): string => $parameter->getName(), $method->getParameters()) + ); + } + + public function testRetireColumnsSignatureIsVariadicAndReturnsVoid(): void + { + $method = (new ReflectionClass(TableColumnRetirementStrategy::class))->getMethod('retireColumns'); + $parameters = $method->getParameters(); + + self::assertSame('void', (string) $method->getReturnType()); + self::assertSame(['table', 'columnNames'], array_map( + static fn ($parameter): string => $parameter->getName(), + $parameters + )); + self::assertTrue($parameters[1]->isVariadic()); + } + + public function testUnsupportedFailureIsARecoverableDatastoreFailure(): void + { + self::assertInstanceOf( + DatastoreErrorException::class, + new UnsupportedColumnRetirementException('No named-column retirement adapter is active.') + ); + } + + /** @param class-string $type */ + private function methodNames(string $type): array + { + $names = array_map( + static fn ($method): string => $method->getName(), + (new ReflectionClass($type))->getMethods() + ); + sort($names); + + return $names; + } +} + +/** A pre-capability adapter remains a valid implementation. */ +final class LegacyTableUpdateStrategy implements TableUpdateStrategy +{ + public function syncColumns(Table $table): void + { + } +} From e4a4e7bc1509c67254e68ee5697af0a405b5e46f Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 13:17:50 -0400 Subject: [PATCH 2/4] Document retirement safety contract --- .../TableColumnRetirementStrategy.php | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/Interfaces/TableColumnRetirementStrategy.php b/lib/Interfaces/TableColumnRetirementStrategy.php index 2a51a1d..4473961 100644 --- a/lib/Interfaces/TableColumnRetirementStrategy.php +++ b/lib/Interfaces/TableColumnRetirementStrategy.php @@ -4,13 +4,22 @@ use PHPNomad\Database\Exceptions\TableUpdateFailedException; -/** Optional, explicitly named retirement of legacy table columns. */ +/** + * Optional, explicitly named retirement of legacy table columns. + * + * Implementations use their backend's identifier and case semantics. They + * accept identifiers the backend can safely quote, reject empty or NUL-bearing + * names, and treat metadata/query failures as failures rather than absence. + */ interface TableColumnRetirementStrategy extends TableUpdateStrategy { /** * Determine whether a named column exists in the table's active schema. * - * Metadata failures must propagate; they are not absence. + * Metadata lookup is scoped to the active schema. Identifier comparison + * follows backend semantics (for example, MySQL column names compare + * case-insensitively). Metadata failures must propagate; they are not + * absence. * * @throws TableUpdateFailedException */ @@ -19,9 +28,13 @@ public function columnExists(Table $table, string $columnName): bool; /** * Retire only the explicitly named columns. * - * The whole request must be validated before mutation. A requested column - * that is absent is an idempotent no-op. A requested column that remains - * declared by the supplied table is invalid. + * The request must contain at least one name, and the whole request must be + * validated before any mutation. A requested column that is absent is an + * idempotent no-op. A requested column that remains declared by the supplied + * table, using backend case semantics, is invalid. Implementations must not + * implicitly remove indexes or foreign keys: a requested column with either + * dependency is refused before DDL. Only requested, currently present, and + * fully preflighted columns may be retired; unrelated columns are preserved. * * @param non-empty-string ...$columnNames * @throws \InvalidArgumentException From 15ce66106881a30741ffc9246d142e75f063dcd5 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 13:36:30 -0400 Subject: [PATCH 3/4] Specify backend-equivalent request deduplication --- lib/Interfaces/TableColumnRetirementStrategy.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/Interfaces/TableColumnRetirementStrategy.php b/lib/Interfaces/TableColumnRetirementStrategy.php index 4473961..939bb44 100644 --- a/lib/Interfaces/TableColumnRetirementStrategy.php +++ b/lib/Interfaces/TableColumnRetirementStrategy.php @@ -30,8 +30,10 @@ public function columnExists(Table $table, string $columnName): bool; * * The request must contain at least one name, and the whole request must be * validated before any mutation. A requested column that is absent is an - * idempotent no-op. A requested column that remains declared by the supplied - * table, using backend case semantics, is invalid. Implementations must not + * idempotent no-op. Multiple requested spellings that identify the same + * persisted column under backend case semantics are deduplicated to one + * retirement. A requested column that remains declared by the supplied + * table, using those same semantics, is invalid. Implementations must not * implicitly remove indexes or foreign keys: a requested column with either * dependency is refused before DDL. Only requested, currently present, and * fully preflighted columns may be retired; unrelated columns are preserved. From 76d7161702a4368748b6865cd05001dce0b157cc Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 15:09:57 -0400 Subject: [PATCH 4/4] Document contract method name return type --- tests/Unit/Contracts/TableColumnRetirementSurfaceTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Contracts/TableColumnRetirementSurfaceTest.php b/tests/Unit/Contracts/TableColumnRetirementSurfaceTest.php index 79e5299..0917be1 100644 --- a/tests/Unit/Contracts/TableColumnRetirementSurfaceTest.php +++ b/tests/Unit/Contracts/TableColumnRetirementSurfaceTest.php @@ -62,7 +62,10 @@ public function testUnsupportedFailureIsARecoverableDatastoreFailure(): void ); } - /** @param class-string $type */ + /** + * @param class-string $type + * @return list + */ private function methodNames(string $type): array { $names = array_map(