From 62f21a550653113bcdcfd394ba7168931b81f98f Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 15 Sep 2026 09:11:00 -0600 Subject: [PATCH 01/53] Add `@see` docblocks for internal tables --- src/Database/Table/Tables/LockTable.php | 3 +++ src/Database/Table/Tables/MigrationTable.php | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/Database/Table/Tables/LockTable.php b/src/Database/Table/Tables/LockTable.php index a48611a..f87648c 100644 --- a/src/Database/Table/Tables/LockTable.php +++ b/src/Database/Table/Tables/LockTable.php @@ -3,10 +3,13 @@ namespace StellarWP\Foundation\Database\Table\Tables; use StellarWP\Foundation\Database\Contracts\TableGateway; +use StellarWP\Foundation\Database\Migration\StoreSchema; use StellarWP\Foundation\Database\Table\Table; /** * Defines the database-backed lock table used during migration runs. + * + * @see StoreSchema::initializeLock() */ final readonly class LockTable extends Table { diff --git a/src/Database/Table/Tables/MigrationTable.php b/src/Database/Table/Tables/MigrationTable.php index 7a4f0d3..a2fd2d8 100644 --- a/src/Database/Table/Tables/MigrationTable.php +++ b/src/Database/Table/Tables/MigrationTable.php @@ -3,10 +3,13 @@ namespace StellarWP\Foundation\Database\Table\Tables; use StellarWP\Foundation\Database\Contracts\TableGateway; +use StellarWP\Foundation\Database\Migration\StoreSchema; use StellarWP\Foundation\Database\Table\Table; /** * Defines the migration ledger table used to record completed migrations. + * + * @see StoreSchema::initializeLedger() */ final readonly class MigrationTable extends Table { From 0658a9cf14d65bad89ad75aacfa37c09cedab92b Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 22 Sep 2026 17:33:51 -0600 Subject: [PATCH 02/53] Replace WordPress database backend with managed Doctrine DBAL --- composer.json | 2 + .../Factories/MigrationFileFactory.php | 14 +- .../Commands/Make/Database/TableCommand.php | 2 +- src/Database/Cli/Migrate.php | 245 +-- src/Database/Connection/AdvisoryLock.php | 137 ++ .../Connection/BorrowedConnection.php | 96 ++ src/Database/Connection/BorrowedStatement.php | 36 + .../Connection/WordPressConnection.php | 182 ++ src/Database/Connection/WordPressDriver.php | 36 + .../Connection/WordPressMiddleware.php | 29 + src/Database/Connection/WordPressSession.php | 224 +++ .../Contracts/CharsetCollationProvider.php | 14 - src/Database/Contracts/Database.php | 13 - src/Database/Contracts/Migration.php | 28 - src/Database/Contracts/QueryExecutor.php | 18 - src/Database/Contracts/QueryGateway.php | 14 - src/Database/Contracts/QueryReader.php | 46 - src/Database/Contracts/Schema.php | 62 - src/Database/Contracts/SchemaInspector.php | 32 - src/Database/Contracts/SqlDialect.php | 19 - src/Database/Contracts/TableGateway.php | 10 - src/Database/Contracts/TableNameResolver.php | 2 + src/Database/Contracts/TableWriter.php | 53 - src/Database/Database.php | 369 ---- src/Database/DatabaseProvider.php | 214 +-- .../Exceptions/CommitOutcomeUnknown.php | 12 + .../Exceptions/MigrationAlreadyRunning.php | 12 + .../Exceptions/MigrationInterrupted.php | 12 + src/Database/Exceptions/QueryException.php | 49 - src/Database/Exceptions/TransactionFailed.php | 12 + src/Database/Lock/DatabaseLock.php | 124 +- src/Database/Migration/Collection.php | 106 -- .../Contracts/DescribesMigration.php | 14 + .../Migration/Contracts/MigratesData.php | 18 + .../Migration/Contracts/Migration.php | 32 + .../Migration/Contracts/Repository.php | 60 - .../Exceptions/DuplicateMigration.php | 15 - .../Exceptions/IncompatibleSchema.php | 26 + .../Exceptions/InvalidRollbackBatch.php | 19 - .../Migration/Exceptions/LedgerFailure.php | 13 +- .../Migration/Exceptions/MigrationFailed.php | 20 - .../Exceptions/MigrationLockFailed.php | 32 - .../Exceptions/UnavailableMigration.php | 25 - .../Exceptions/UninitializedStore.php | 15 - .../Migration/Factories/LeaseFactory.php | 29 - .../Migration/Factories/SessionFactory.php | 30 - src/Database/Migration/History.php | 99 ++ src/Database/Migration/Lease.php | 66 - .../Migration/MigrationCollection.php | 75 + src/Database/Migration/Migrator.php | 373 +++-- src/Database/Migration/Repository.php | 151 -- src/Database/Migration/Schema/Blueprint.php | 105 ++ .../Migration/Schema/ColumnDefinition.php | 236 +++ .../Migration/Schema/SchemaPlanner.php | 514 ++++++ src/Database/Migration/Schema/SchemaState.php | 31 + .../Migration/Schema/TableBlueprint.php | 304 ++++ .../Schema/ValueObjects/SchemaPlan.php | 26 + src/Database/Migration/Session.php | 68 - src/Database/Migration/Store.php | 188 --- src/Database/Migration/StoreSchema.php | 110 -- src/Database/Migration/ValueObjects/Id.php | 6 +- .../ValueObjects/MigrationStatus.php | 31 + .../Migration/ValueObjects/Record.php | 19 - .../Migration/ValueObjects/Result.php | 30 - .../Migration/ValueObjects/Status.php | 89 - src/Database/Migration/ValueObjects/Step.php | 23 + src/Database/Query/Query.php | 86 - src/Database/Query/QueryBuilder.php | 311 ---- src/Database/README.md | 6 +- src/Database/Schema.php | 104 -- .../Schema/Contracts/SchemaExecutor.php | 20 - src/Database/Schema/DbDelta.php | 43 - src/Database/Schema/Editor.php | 282 ---- .../Schema/PhysicalIndexCollection.php | 167 -- src/Database/Schema/Reconciler.php | 699 -------- .../Schema/ValueObjects/IndexState.php | 70 - src/Database/Table/Blueprint.php | 512 ------ src/Database/Table/Column.php | 260 --- src/Database/Table/ColumnDefinition.php | 163 -- src/Database/Table/CreateTable.php | 44 - src/Database/Table/Index.php | 90 - src/Database/Table/IndexType.php | 16 - src/Database/Table/Table.php | 121 +- src/Database/Table/TableNameResolver.php | 42 + src/Database/Table/Tables/LockTable.php | 13 +- src/Database/Table/Tables/MigrationTable.php | 11 +- .../Table/ValueObjects/ColumnComment.php | 35 - .../Table/ValueObjects/CurrentTimestamp.php | 12 - src/Database/Traits/QuotesSqlIdentifiers.php | 18 - src/Database/composer.json | 4 +- src/Database/stubs/alter-table-migration.stub | 8 +- .../stubs/create-table-migration.stub | 10 +- src/Database/stubs/migration.stub | 4 +- src/Database/stubs/provider.stub | 8 - .../Database/AddRecoveryStatusMigration.php | 48 - .../Database/CommentReconciliationTable.php | 38 - .../Fixtures/Database/CommentedTable.php | 35 - .../Database/Consumer/Entry_Table.php | 18 + .../Database/ContextChangingMigration.php | 31 - .../Fixtures/Database/DatabaseTestCase.php | 108 ++ .../Database/DateTimePrecisionTable.php | 27 - .../Declarative/AddEntryArchivedFlag.php | 32 + .../Database/Declarative/AddEntryNote.php | 36 + .../Declarative/BackfillEntryStatus.php | 35 + .../Database/Declarative/CreateEntries.php | 41 + .../Database/Declarative/CreateEntryTags.php | 33 + .../Database/Declarative/DropEntryTags.php | 32 + .../Database/Declarative/EntriesTable.php | 20 + .../Database/Declarative/IndexEntryNote.php | 31 + .../Declarative/RecreateEntryTags.php | 34 + .../Declarative/ReduceCreatedAtPrecision.php | 31 + .../Declarative/ReplaceNoteLookup.php | 31 + .../Database/Declarative/TagsTable.php | 20 + .../Declarative/TrackEntryUpdates.php | 31 + .../Database/Declarative/WidenEntryName.php | 31 + .../Fixtures/Database/ExternalTable.php | 15 - .../Fixtures/Database/FailingMigration.php | 37 - .../Fixtures/Database/FakeDatabase.php | 165 -- .../Fixtures/Database/FakeQueryGateway.php | 42 - .../Fixtures/Database/GeneratedStyleTable.php | 15 - .../Database/Import/DatasetImporter.php | 38 + .../Database/Import/EntryRepository.php | 80 + .../Fixtures/Database/InMemoryRepository.php | 65 - .../Database/IndexReconciliationTable.php | 43 - .../Database/LostAdvisoryLockReply.php | 26 + .../Database/ManagedTimestampTable.php | 32 - .../Database/NativeConnectionFactory.php | 49 + .../Fixtures/Database/NoopMigration.php | 24 - .../Fixtures/Database/RecordingSchema.php | 59 - .../Database/RecordingSchemaExecutor.php | 17 - .../Fixtures/Database/RecoveryTable.php | 18 - .../Database/SchemaReconciliationTable.php | 34 - .../Fixtures/Database/TestDatabaseScope.php | 32 - .../Fixtures/Database/TestDatabaseTable.php | 48 - .../Fixtures/Database/TestMigration.php | 26 - tests/Support/Fixtures/Database/TestTable.php | 25 - .../register-wpcli-migrate-command.php | 87 +- .../Cli/Commands/Make/DatabaseCommandTest.php | 54 +- tests/Unit/Database/Cli/MigrateTest.php | 256 --- .../Exceptions/QueryExceptionTest.php | 34 - tests/Unit/Database/Lock/DatabaseLockTest.php | 289 ---- .../Database/Migration/CollectionTest.php | 101 -- .../Migration/ColumnDefinitionTest.php | 76 + .../Migration/Factories/LeaseFactoryTest.php | 30 - .../Factories/SessionFactoryTest.php | 42 - tests/Unit/Database/Migration/LeaseTest.php | 83 - .../Migration/MigrationCollectionTest.php | 66 + .../Migration/MigratorExecutionTest.php | 1087 ------------ .../Unit/Database/Migration/MigratorTest.php | 290 ---- .../Database/Migration/RepositoryTest.php | 108 -- tests/Unit/Database/Migration/SessionTest.php | 103 -- .../Database/Migration/TableBlueprintTest.php | 39 + .../Migration/ValueObjects/ResultTest.php | 19 - .../Migration/ValueObjects/StatusTest.php | 60 - .../Unit/Database/Query/QueryBuilderTest.php | 201 --- tests/Unit/Database/Query/QueryTest.php | 45 - tests/Unit/Database/Schema/DbDeltaTest.php | 21 - tests/Unit/Database/Schema/EditorTest.php | 216 --- .../Schema/PhysicalIndexCollectionTest.php | 135 -- tests/Unit/Database/Schema/ReconcilerTest.php | 534 ------ .../Schema/ValueObjects/IndexStateTest.php | 19 - tests/Unit/Database/SchemaTest.php | 134 -- tests/Unit/Database/Table/BlueprintTest.php | 473 ------ .../Database/Table/ColumnDefinitionTest.php | 104 -- tests/Unit/Database/Table/ColumnTest.php | 246 --- tests/Unit/Database/Table/CreateTableTest.php | 51 - tests/Unit/Database/Table/IndexTest.php | 66 - .../Database/Table/TableNameResolverTest.php | 41 + tests/Unit/Database/Table/TableTest.php | 77 - .../Database/Table/Tables/LockTableTest.php | 76 - .../Table/Tables/MigrationTableTest.php | 73 - .../Table/ValueObjects/ColumnCommentTest.php | 34 - .../Database/AdvisoryMigrationTest.php | 391 +++++ .../integration/Database/DatabaseLockTest.php | 76 + .../Database/DatabaseProviderTest.php | 345 ---- .../DeclarativeMigratorEdgeCaseTest.php | 235 +++ .../Database/DeclarativeMigratorTest.php | 354 ++++ tests/integration/Database/ImporterTest.php | 32 + .../Database/MigrationRecoveryTest.php | 118 -- .../Database/NativeBehaviorTest.php | 58 + tests/integration/Database/TableTest.php | 87 + .../integration/Database/TransactionTest.php | 453 +++++ .../Database/Cli/DatabaseMigrateCest.php | 101 +- .../Database/DatabaseIntegrationTest.php | 1480 ----------------- tests/wpunit/Database/Schema/DbDeltaTest.php | 69 - 185 files changed, 5633 insertions(+), 13444 deletions(-) create mode 100644 src/Database/Connection/AdvisoryLock.php create mode 100644 src/Database/Connection/BorrowedConnection.php create mode 100644 src/Database/Connection/BorrowedStatement.php create mode 100644 src/Database/Connection/WordPressConnection.php create mode 100644 src/Database/Connection/WordPressDriver.php create mode 100644 src/Database/Connection/WordPressMiddleware.php create mode 100644 src/Database/Connection/WordPressSession.php delete mode 100644 src/Database/Contracts/CharsetCollationProvider.php delete mode 100644 src/Database/Contracts/Database.php delete mode 100644 src/Database/Contracts/Migration.php delete mode 100644 src/Database/Contracts/QueryExecutor.php delete mode 100644 src/Database/Contracts/QueryGateway.php delete mode 100644 src/Database/Contracts/QueryReader.php delete mode 100644 src/Database/Contracts/Schema.php delete mode 100644 src/Database/Contracts/SchemaInspector.php delete mode 100644 src/Database/Contracts/SqlDialect.php delete mode 100644 src/Database/Contracts/TableGateway.php delete mode 100644 src/Database/Contracts/TableWriter.php delete mode 100644 src/Database/Database.php create mode 100644 src/Database/Exceptions/CommitOutcomeUnknown.php create mode 100644 src/Database/Exceptions/MigrationAlreadyRunning.php create mode 100644 src/Database/Exceptions/MigrationInterrupted.php delete mode 100644 src/Database/Exceptions/QueryException.php create mode 100644 src/Database/Exceptions/TransactionFailed.php delete mode 100644 src/Database/Migration/Collection.php create mode 100644 src/Database/Migration/Contracts/DescribesMigration.php create mode 100644 src/Database/Migration/Contracts/MigratesData.php create mode 100644 src/Database/Migration/Contracts/Migration.php delete mode 100644 src/Database/Migration/Contracts/Repository.php delete mode 100644 src/Database/Migration/Exceptions/DuplicateMigration.php create mode 100644 src/Database/Migration/Exceptions/IncompatibleSchema.php delete mode 100644 src/Database/Migration/Exceptions/InvalidRollbackBatch.php delete mode 100644 src/Database/Migration/Exceptions/MigrationFailed.php delete mode 100644 src/Database/Migration/Exceptions/MigrationLockFailed.php delete mode 100644 src/Database/Migration/Exceptions/UnavailableMigration.php delete mode 100644 src/Database/Migration/Exceptions/UninitializedStore.php delete mode 100644 src/Database/Migration/Factories/LeaseFactory.php delete mode 100644 src/Database/Migration/Factories/SessionFactory.php create mode 100644 src/Database/Migration/History.php delete mode 100644 src/Database/Migration/Lease.php create mode 100644 src/Database/Migration/MigrationCollection.php delete mode 100644 src/Database/Migration/Repository.php create mode 100644 src/Database/Migration/Schema/Blueprint.php create mode 100644 src/Database/Migration/Schema/ColumnDefinition.php create mode 100644 src/Database/Migration/Schema/SchemaPlanner.php create mode 100644 src/Database/Migration/Schema/SchemaState.php create mode 100644 src/Database/Migration/Schema/TableBlueprint.php create mode 100644 src/Database/Migration/Schema/ValueObjects/SchemaPlan.php delete mode 100644 src/Database/Migration/Session.php delete mode 100644 src/Database/Migration/Store.php delete mode 100644 src/Database/Migration/StoreSchema.php create mode 100644 src/Database/Migration/ValueObjects/MigrationStatus.php delete mode 100644 src/Database/Migration/ValueObjects/Record.php delete mode 100644 src/Database/Migration/ValueObjects/Result.php delete mode 100644 src/Database/Migration/ValueObjects/Status.php create mode 100644 src/Database/Migration/ValueObjects/Step.php delete mode 100644 src/Database/Query/Query.php delete mode 100644 src/Database/Query/QueryBuilder.php delete mode 100644 src/Database/Schema.php delete mode 100644 src/Database/Schema/Contracts/SchemaExecutor.php delete mode 100644 src/Database/Schema/DbDelta.php delete mode 100644 src/Database/Schema/Editor.php delete mode 100644 src/Database/Schema/PhysicalIndexCollection.php delete mode 100644 src/Database/Schema/Reconciler.php delete mode 100644 src/Database/Schema/ValueObjects/IndexState.php delete mode 100644 src/Database/Table/Blueprint.php delete mode 100644 src/Database/Table/Column.php delete mode 100644 src/Database/Table/ColumnDefinition.php delete mode 100644 src/Database/Table/CreateTable.php delete mode 100644 src/Database/Table/Index.php delete mode 100644 src/Database/Table/IndexType.php create mode 100644 src/Database/Table/TableNameResolver.php delete mode 100644 src/Database/Table/ValueObjects/ColumnComment.php delete mode 100644 src/Database/Table/ValueObjects/CurrentTimestamp.php delete mode 100644 src/Database/Traits/QuotesSqlIdentifiers.php delete mode 100644 tests/Support/Fixtures/Database/AddRecoveryStatusMigration.php delete mode 100644 tests/Support/Fixtures/Database/CommentReconciliationTable.php delete mode 100644 tests/Support/Fixtures/Database/CommentedTable.php create mode 100644 tests/Support/Fixtures/Database/Consumer/Entry_Table.php delete mode 100644 tests/Support/Fixtures/Database/ContextChangingMigration.php create mode 100644 tests/Support/Fixtures/Database/DatabaseTestCase.php delete mode 100644 tests/Support/Fixtures/Database/DateTimePrecisionTable.php create mode 100644 tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php create mode 100644 tests/Support/Fixtures/Database/Declarative/AddEntryNote.php create mode 100644 tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php create mode 100644 tests/Support/Fixtures/Database/Declarative/CreateEntries.php create mode 100644 tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php create mode 100644 tests/Support/Fixtures/Database/Declarative/DropEntryTags.php create mode 100644 tests/Support/Fixtures/Database/Declarative/EntriesTable.php create mode 100644 tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php create mode 100644 tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php create mode 100644 tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php create mode 100644 tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php create mode 100644 tests/Support/Fixtures/Database/Declarative/TagsTable.php create mode 100644 tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php create mode 100644 tests/Support/Fixtures/Database/Declarative/WidenEntryName.php delete mode 100644 tests/Support/Fixtures/Database/ExternalTable.php delete mode 100644 tests/Support/Fixtures/Database/FailingMigration.php delete mode 100644 tests/Support/Fixtures/Database/FakeDatabase.php delete mode 100644 tests/Support/Fixtures/Database/FakeQueryGateway.php delete mode 100644 tests/Support/Fixtures/Database/GeneratedStyleTable.php create mode 100644 tests/Support/Fixtures/Database/Import/DatasetImporter.php create mode 100644 tests/Support/Fixtures/Database/Import/EntryRepository.php delete mode 100644 tests/Support/Fixtures/Database/InMemoryRepository.php delete mode 100644 tests/Support/Fixtures/Database/IndexReconciliationTable.php create mode 100644 tests/Support/Fixtures/Database/LostAdvisoryLockReply.php delete mode 100644 tests/Support/Fixtures/Database/ManagedTimestampTable.php create mode 100644 tests/Support/Fixtures/Database/NativeConnectionFactory.php delete mode 100644 tests/Support/Fixtures/Database/NoopMigration.php delete mode 100644 tests/Support/Fixtures/Database/RecordingSchema.php delete mode 100644 tests/Support/Fixtures/Database/RecordingSchemaExecutor.php delete mode 100644 tests/Support/Fixtures/Database/RecoveryTable.php delete mode 100644 tests/Support/Fixtures/Database/SchemaReconciliationTable.php delete mode 100644 tests/Support/Fixtures/Database/TestDatabaseScope.php delete mode 100644 tests/Support/Fixtures/Database/TestDatabaseTable.php delete mode 100644 tests/Support/Fixtures/Database/TestMigration.php delete mode 100644 tests/Support/Fixtures/Database/TestTable.php delete mode 100644 tests/Unit/Database/Cli/MigrateTest.php delete mode 100644 tests/Unit/Database/Exceptions/QueryExceptionTest.php delete mode 100644 tests/Unit/Database/Lock/DatabaseLockTest.php delete mode 100644 tests/Unit/Database/Migration/CollectionTest.php create mode 100644 tests/Unit/Database/Migration/ColumnDefinitionTest.php delete mode 100644 tests/Unit/Database/Migration/Factories/LeaseFactoryTest.php delete mode 100644 tests/Unit/Database/Migration/Factories/SessionFactoryTest.php delete mode 100644 tests/Unit/Database/Migration/LeaseTest.php create mode 100644 tests/Unit/Database/Migration/MigrationCollectionTest.php delete mode 100644 tests/Unit/Database/Migration/MigratorExecutionTest.php delete mode 100644 tests/Unit/Database/Migration/MigratorTest.php delete mode 100644 tests/Unit/Database/Migration/RepositoryTest.php delete mode 100644 tests/Unit/Database/Migration/SessionTest.php create mode 100644 tests/Unit/Database/Migration/TableBlueprintTest.php delete mode 100644 tests/Unit/Database/Migration/ValueObjects/ResultTest.php delete mode 100644 tests/Unit/Database/Migration/ValueObjects/StatusTest.php delete mode 100644 tests/Unit/Database/Query/QueryBuilderTest.php delete mode 100644 tests/Unit/Database/Query/QueryTest.php delete mode 100644 tests/Unit/Database/Schema/DbDeltaTest.php delete mode 100644 tests/Unit/Database/Schema/EditorTest.php delete mode 100644 tests/Unit/Database/Schema/PhysicalIndexCollectionTest.php delete mode 100644 tests/Unit/Database/Schema/ReconcilerTest.php delete mode 100644 tests/Unit/Database/Schema/ValueObjects/IndexStateTest.php delete mode 100644 tests/Unit/Database/SchemaTest.php delete mode 100644 tests/Unit/Database/Table/BlueprintTest.php delete mode 100644 tests/Unit/Database/Table/ColumnDefinitionTest.php delete mode 100644 tests/Unit/Database/Table/ColumnTest.php delete mode 100644 tests/Unit/Database/Table/CreateTableTest.php delete mode 100644 tests/Unit/Database/Table/IndexTest.php create mode 100644 tests/Unit/Database/Table/TableNameResolverTest.php delete mode 100644 tests/Unit/Database/Table/TableTest.php delete mode 100644 tests/Unit/Database/Table/Tables/LockTableTest.php delete mode 100644 tests/Unit/Database/Table/Tables/MigrationTableTest.php delete mode 100644 tests/Unit/Database/Table/ValueObjects/ColumnCommentTest.php create mode 100644 tests/integration/Database/AdvisoryMigrationTest.php create mode 100644 tests/integration/Database/DatabaseLockTest.php delete mode 100644 tests/integration/Database/DatabaseProviderTest.php create mode 100644 tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php create mode 100644 tests/integration/Database/DeclarativeMigratorTest.php create mode 100644 tests/integration/Database/ImporterTest.php delete mode 100644 tests/integration/Database/MigrationRecoveryTest.php create mode 100644 tests/integration/Database/NativeBehaviorTest.php create mode 100644 tests/integration/Database/TableTest.php create mode 100644 tests/integration/Database/TransactionTest.php delete mode 100644 tests/wpunit/Database/DatabaseIntegrationTest.php delete mode 100644 tests/wpunit/Database/Schema/DbDeltaTest.php diff --git a/composer.json b/composer.json index c339257..6b70589 100644 --- a/composer.json +++ b/composer.json @@ -12,8 +12,10 @@ "php": ">=8.3", "ext-curl": "*", "ext-exif": "*", + "ext-mysqli": "*", "adbario/php-dot-notation": ">=2.5", "arokettu/random-polyfill": ">=1.0.6 <1.99", + "doctrine/dbal": "^4.4", "lucatume/di52": ">=4.1", "monolog/monolog": "^2.11", "nikic/php-parser": ">=5.0 <6.0", diff --git a/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php b/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php index 5263983..0ea0f57 100644 --- a/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php +++ b/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php @@ -49,8 +49,8 @@ public function generic(string $name, ?string $namespace = null, ?string $path = 'namespace' => $context['namespace'], 'class' => $context['class'], 'id_php' => $this->stubRenderer->phpStringLiteral($context['id']), - 'foundation_database_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), - 'foundation_database_schema' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), + 'foundation_database_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration'), + 'foundation_database_schema' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint'), 'foundation_database_irreversible_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration'), ])); } @@ -71,9 +71,8 @@ public function createTable(string $name, string $tableClass, ?string $namespace 'id_php' => $this->stubRenderer->phpStringLiteral($context['id']), 'table_class' => $table['class'], 'table_namespace' => $table['namespace'], - 'foundation_database_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), - 'foundation_database_schema' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), - 'foundation_database_blueprint' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Table\\Blueprint'), + 'foundation_database_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration'), + 'foundation_database_schema' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint'), ])); } @@ -94,9 +93,8 @@ public function alterTable(string $name, string $tableClass, ?string $namespace 'table_class' => $table['class'], 'table_namespace' => $table['namespace'], 'foundation_database_irreversible_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration'), - 'foundation_database_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), - 'foundation_database_schema' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), - 'foundation_database_blueprint' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Table\\Blueprint'), + 'foundation_database_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration'), + 'foundation_database_schema' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint'), ])); } diff --git a/src/Cli/Commands/Make/Database/TableCommand.php b/src/Cli/Commands/Make/Database/TableCommand.php index 35d5f7a..c6c9aa6 100644 --- a/src/Cli/Commands/Make/Database/TableCommand.php +++ b/src/Cli/Commands/Make/Database/TableCommand.php @@ -106,7 +106,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(''); if ($migration === null) { - $output->writeln('Create a migration that defines this table with Blueprint and Schema::create().'); + $output->writeln('Create a migration that defines this table with Blueprint::create().'); } elseif ($providerPath === null && ! $this->providerExists($input)) { $output->writeln(sprintf( 'Register %s and %s with your database provider.', diff --git a/src/Database/Cli/Migrate.php b/src/Database/Cli/Migrate.php index 21a1cb3..c896d3c 100644 --- a/src/Database/Cli/Migrate.php +++ b/src/Database/Cli/Migrate.php @@ -3,6 +3,8 @@ namespace StellarWP\Foundation\Database\Cli; use StellarWP\Foundation\Database\Migration\Migrator; +use StellarWP\Foundation\Database\Migration\ValueObjects\MigrationStatus; +use StellarWP\Foundation\Database\Migration\ValueObjects\Step; use StellarWP\Foundation\WPCli\Command; use WP_CLI; @@ -10,198 +12,131 @@ use function WP_CLI\Utils\get_flag_value; /** - * WP-CLI command for inspecting and applying Foundation database migrations. + * Inspect, preview, apply, and reverse registered database migrations. */ final class Migrate extends Command { - private const string FLAG_RUN = 'run'; - private const string FLAG_ROLLBACK = 'rollback'; - private const string FLAG_REFRESH = 'refresh'; - private const string FLAG_DROP_STORE = 'drop-store'; - private const string FLAG_INITIALIZE = 'initialize'; - private const string FLAG_YES = 'yes'; - + /** + * Receive the application's migration service. + */ public function __construct( - private readonly Migrator $migrator + private readonly Migrator $migrator, ) { } /** - * Dispatch the selected migration operation or display migration status. + * Execute one migration operation, or display status when no operation is selected. * - * @param list $args - * @param array $assocArgs + * @param list $args + * @param array $assocArgs + * + * @throws \Throwable When migration planning, execution, or storage fails. */ public function runCommand(array $args = [], array $assocArgs = []): int { - $operation = $this->selectedOperation($assocArgs); + $operations = array_values(array_filter(['run', 'rollback', 'refresh'], static fn (string $flag): bool => (bool) get_flag_value($assocArgs, $flag, false))); - if ($operation !== null && $operation !== self::FLAG_INITIALIZE && ! $this->migrator->isInitialized()) { - WP_CLI::error($this->uninitializedMessage()); + if (count($operations) > 1) { + WP_CLI::error('Choose only one of --run, --rollback, or --refresh.'); } + $operation = $operations[0] ?? null; + $dryRun = (bool) get_flag_value($assocArgs, 'dry-run', false); - match ($operation) { - self::FLAG_RUN => $this->runMigrations(), - self::FLAG_ROLLBACK => $this->rollbackMigrations(), - self::FLAG_REFRESH => $this->refreshMigrations($assocArgs), - self::FLAG_DROP_STORE => $this->dropStore($assocArgs), - self::FLAG_INITIALIZE => $this->initializeStore(), - default => $this->showStatus(), - }; - - return self::SUCCESS; - } + if ($dryRun && $operation !== 'run') { + WP_CLI::error('--dry-run requires --run.'); + } - protected function subcommand(): string { - return 'migrate'; - } + if (isset($assocArgs['to']) && ! in_array($operation, ['run', 'rollback'], true)) { + WP_CLI::error('--to requires --run or --rollback.'); + } - protected function description(): string { - return 'List and manage database migrations.'; - } + if (isset($assocArgs['step']) && ($operation !== 'rollback' || isset($assocArgs['to']))) { + WP_CLI::error('--step requires --rollback and cannot be combined with --to.'); + } + $steps = filter_var($assocArgs['step'] ?? 1, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); - protected function arguments(): array { - return [ - [ - 'type' => self::FLAG, - 'name' => self::FLAG_RUN, - 'description' => 'Run pending migrations.', - 'optional' => true, - 'default' => false, - ], - [ - 'type' => self::FLAG, - 'name' => self::FLAG_ROLLBACK, - 'description' => 'Rollback the latest migration batch.', - 'optional' => true, - 'default' => false, - ], - [ - 'type' => self::FLAG, - 'name' => self::FLAG_REFRESH, - 'description' => 'Rollback and rerun all migrations.', - 'optional' => true, - 'default' => false, - ], - [ - 'type' => self::FLAG, - 'name' => self::FLAG_DROP_STORE, - 'description' => 'Drop only the migration ledger.', - 'optional' => true, - 'default' => false, - ], - [ - 'type' => self::FLAG, - 'name' => self::FLAG_INITIALIZE, - 'description' => 'Initialize or reconcile Foundation migration storage.', - 'optional' => true, - 'default' => false, - ], - [ - 'type' => self::FLAG, - 'name' => self::FLAG_YES, - 'description' => 'Skip confirmation prompts for destructive actions.', - 'optional' => true, - 'default' => false, - ], - ]; - } + if ($steps === false) { + WP_CLI::error('--step must be a positive integer.'); - private function showStatus(): void { - if (! $this->migrator->isInitialized()) { - WP_CLI::warning($this->uninitializedMessage()); + return self::ERROR; } - format_items('table', array_map( - static fn ($status): array => [ - 'migration' => $status->migration, - 'status' => $status->state(), - 'batch' => $status->batch ?? '', - 'ran_at' => $status->ranAt?->format('Y-m-d H:i:s') ?? '', - ], - $this->migrator->status() - ), [ - 'migration', - 'status', - 'batch', - 'ran_at', - ]); - } + if ($operation === null) { + $this->showStatus(); - /** - * Select the one migration operation requested by the command flags. - * - * @param array $assocArgs - */ - private function selectedOperation(array $assocArgs): ?string { - $operations = [ - self::FLAG_RUN, - self::FLAG_ROLLBACK, - self::FLAG_REFRESH, - self::FLAG_DROP_STORE, - self::FLAG_INITIALIZE, - ]; - $selected = array_values(array_filter( - $operations, - static fn (string $operation): bool => (bool) get_flag_value($assocArgs, $operation, false) - )); - - if (count($selected) > 1) { - WP_CLI::error(sprintf( - 'Only one migration operation can be used at a time. Received: --%s.', - implode(', --', $selected) - )); + return self::SUCCESS; } + $target = (string) ($assocArgs['to'] ?? Migrator::LATEST); + + if ($operation === 'refresh') { + WP_CLI::confirm('Roll back and rerun all migrations? This can permanently delete application data.', $assocArgs); + $result = $this->migrator->refresh(); + } elseif ($operation === 'rollback') { + $result = isset($assocArgs['to']) ? $this->migrator->migrate($target) : $this->migrator->rollback($steps); + } else { + $result = $dryRun ? $this->migrator->preview($target) : $this->migrator->migrate($target); + } + $this->showSteps($result, $dryRun); - return $selected[0] ?? null; - } - - private function initializeStore(): void { - $this->migrator->initialize(); - WP_CLI::success('Foundation migration storage is initialized.'); + return self::SUCCESS; } /** - * Confirm and remove only the migration ledger. - * - * @param array $assocArgs + * {@inheritDoc} */ - private function dropStore(array $assocArgs): void { - WP_CLI::confirm('Drop only the migration ledger? Application tables and shared lock storage remain, but all migrations will appear pending afterward.', $assocArgs); - $this->migrator->dropStore(); - WP_CLI::success('The migration ledger was dropped. Application tables were not changed, and shared lock storage remains available.'); + protected function subcommand(): string { + return 'migrate'; } /** - * Confirm, roll back, and rerun all configured migrations. - * - * @param array $assocArgs + * {@inheritDoc} */ - private function refreshMigrations(array $assocArgs): void { - WP_CLI::confirm('Are you sure you want to roll back and rerun all Foundation database migrations?', $assocArgs); - $result = $this->migrator->refresh(); - - WP_CLI::success(sprintf('Rolled back %d migrations and ran %d migrations.', count($result->rolledBack), count($result->ran))); + protected function description(): string { + return 'Inspect, preview, apply, and reverse database migrations.'; } - private function rollbackMigrations(): void { - $result = $this->migrator->rollback(); - - WP_CLI::success(sprintf('Rolled back %d migrations.', count($result->rolledBack))); + /** + * {@inheritDoc} + */ + protected function arguments(): array { + return [ + ['type' => self::FLAG, 'name' => 'run', 'description' => 'Apply pending migrations, or reconcile to --to.', 'optional' => true], + ['type' => self::FLAG, 'name' => 'rollback', 'description' => 'Reverse the highest applied ID, or select --step or --to.', 'optional' => true], + ['type' => self::FLAG, 'name' => 'refresh', 'description' => 'Reverse and reapply all migrations.', 'optional' => true], + ['type' => self::FLAG, 'name' => 'dry-run', 'description' => 'Preview --run SQL without executing SQL or data callbacks.', 'optional' => true], + ['type' => self::ASSOCIATIVE, 'name' => 'to', 'description' => 'Target migration ID, 0 for none, or latest for all.', 'optional' => true], + ['type' => self::ASSOCIATIVE, 'name' => 'step', 'description' => 'Positive number of applied IDs to reverse.', 'optional' => true], + ['type' => self::FLAG, 'name' => 'yes', 'description' => 'Confirm refresh without prompting.', 'optional' => true], + ]; } - private function runMigrations(): void { - $result = $this->migrator->run(); - - WP_CLI::success(sprintf('Ran %d migrations.', count($result->ran))); + private function showStatus(): void { + format_items('table', array_map(static fn (MigrationStatus $status): array => [ + 'id' => $status->id, + 'migration' => $status->migration ?? '(missing)', + 'status' => $status->state(), + 'applied_at' => $status->appliedAt ?? '', + 'description' => $status->description, + ], $this->migrator->status()), ['id', 'migration', 'status', 'applied_at', 'description']); } - private function uninitializedMessage(): string { - $command = $this->registeredCommandName(); - - if ($command === null) { - return 'Migration storage is not initialized. Run this command with --initialize first.'; + /** + * @param list $steps + */ + private function showSteps(array $steps, bool $preview): void { + foreach ($steps as $step) { + WP_CLI::line(($step->reverse ? 'Down ' : 'Up ') . $step->id); + + if (! $preview) { + continue; + } + foreach ($step->sql as $sql) { + WP_CLI::line($sql . ';'); + } + + if ($step->hasDataStep) { + WP_CLI::line('Data callback will run after schema changes.'); + } } - - return sprintf('Migration storage is not initialized. Run `wp %s --initialize` first.', $command); + WP_CLI::success(sprintf('%s %d migration steps.', $preview ? 'Previewed' : 'Completed', count($steps))); } } diff --git a/src/Database/Connection/AdvisoryLock.php b/src/Database/Connection/AdvisoryLock.php new file mode 100644 index 0000000..59752b6 --- /dev/null +++ b/src/Database/Connection/AdvisoryLock.php @@ -0,0 +1,137 @@ +driver = new Connection($native); + $this->connectionId = $native->thread_id; + $this->site = $scope->capture(); + $this->prefix = $scope->resolveTableName(''); + $this->name = hash('sha256', $this->driver->query('SELECT DATABASE()')->fetchOne() . "\0" . $resource); + } + + /** + * Acquire without waiting, before even initializing migration history. + * + * @throws MigrationAlreadyRunning When another session owns this resource. + * @throws Throwable When ambient transaction state or the database prevents acquisition. + */ + public function acquire(): void { + if ((int) $this->driver->query('SELECT @@session.autocommit')->fetchOne() !== 1) { + throw new RuntimeException('Migrations require autocommit to be enabled.'); + } + // Reject an ambient transaction without implicitly committing it. + $this->driver->exec('SET TRANSACTION READ WRITE'); + + try { + $result = $this->driver->query("SELECT GET_LOCK('{$this->name}', 0)")->fetchOne(); + } catch (Throwable $failure) { + // The server may have acquired the lock before its acknowledgement was lost. + try { + $this->native->close(); + } catch (Throwable) { + // A terminated session cannot retain an advisory lock. + } + + throw $failure; + } + + if ($result === null || $result === false) { + throw new MigrationInterrupted('The database could not acquire the migration lock.'); + } + + if ((int) $result !== 1) { + throw new MigrationAlreadyRunning('Another session is running these migrations; retry after it completes.'); + } + } + + /** + * Verify ownership before SQL, including after a migration catches an earlier exception. + * + * @throws MigrationInterrupted When work failed or the original scope or lock was lost. + */ + public function check(mixed $current): void { + if ($this->failure !== null) { + throw new MigrationInterrupted('The migration session failed; start a fresh migration run.', 0, $this->failure); + } + + try { + $this->scope->assertCurrent($this->site); + + if ($current !== $this->native || $this->native->thread_id !== $this->connectionId || $this->scope->resolveTableName('') !== $this->prefix) { + throw new RuntimeException('The migration connection or table prefix changed.'); + } + + if ((int) $this->driver->query("SELECT IS_USED_LOCK('{$this->name}')")->fetchOne() !== $this->connectionId) { + throw new RuntimeException('The migration session no longer owns its advisory lock.'); + } + } catch (Throwable $failure) { + $this->fail($failure); + + throw new MigrationInterrupted('Migration ownership could not be confirmed.', 0, $failure); + } + } + + /** + * Retain the first SQL failure across inner transaction cleanup and caught exceptions. + */ + public function fail(Throwable $failure): void { + $this->failure ??= $failure; + } + + /** + * Release on the captured session even after a site switch; discard uncertain sessions. + * + * @throws Throwable When release cannot be acknowledged. + */ + public function release(): void { + try { + if ($this->native->thread_id !== $this->connectionId) { + throw new MigrationInterrupted('The original migration connection was lost.'); + } + + if ((int) $this->driver->query("SELECT RELEASE_LOCK('{$this->name}')")->fetchOne() !== 1) { + throw new MigrationInterrupted('The database did not confirm migration lock release.'); + } + } catch (Throwable $failure) { + try { + $this->native->close(); + } catch (Throwable) { + // A terminated session already released its server-owned locks. + } + + throw $failure; + } + } +} diff --git a/src/Database/Connection/BorrowedConnection.php b/src/Database/Connection/BorrowedConnection.php new file mode 100644 index 0000000..d93eeb6 --- /dev/null +++ b/src/Database/Connection/BorrowedConnection.php @@ -0,0 +1,96 @@ +session->execute($this->native, fn (): Statement => new BorrowedStatement(parent::prepare($sql), $this->native, $this->session)); + } + + /** + * {@inheritDoc} + * + * @throws \Throwable When execution fails or the borrowed session is no longer valid. + */ + public function query(string $sql): Result { + return $this->session->execute($this->native, fn (): Result => parent::query($sql)); + } + + /** + * {@inheritDoc} + * + * @throws \Throwable When execution fails or the borrowed session is no longer valid. + */ + public function exec(string $sql): int|string { + return $this->session->execute($this->native, fn (): int|string => parent::exec($sql)); + } + + /** + * Start the native transaction and require a positive acknowledgement. + * + * @throws RuntimeException When mysqli cannot start the transaction. + */ + public function beginTransaction(): void { + $this->session->execute($this->native, function (): void { + if (! $this->native->begin_transaction()) { + throw new RuntimeException('Unable to start the transaction: ' . $this->native->error); + } + }); + } + + /** + * Commit the native transaction and require a positive acknowledgement. + * + * @throws RuntimeException When mysqli cannot confirm the commit. + */ + public function commit(): void { + $this->session->execute($this->native, function (): void { + if (! $this->native->commit()) { + throw new RuntimeException('The database did not acknowledge the commit.'); + } + }); + } + + /** + * Roll back when DBAL is used outside the managed callback entry point. + * + * @throws RuntimeException When mysqli cannot confirm rollback. + */ + public function rollBack(): void { + if (! $this->native->rollback()) { + throw new RuntimeException('The database did not acknowledge rollback.'); + } + } +} diff --git a/src/Database/Connection/BorrowedStatement.php b/src/Database/Connection/BorrowedStatement.php new file mode 100644 index 0000000..062a8b8 --- /dev/null +++ b/src/Database/Connection/BorrowedStatement.php @@ -0,0 +1,36 @@ +session->execute($this->native, fn (): Result => parent::execute()); + } +} diff --git a/src/Database/Connection/WordPressConnection.php b/src/Database/Connection/WordPressConnection.php new file mode 100644 index 0000000..3769c64 --- /dev/null +++ b/src/Database/Connection/WordPressConnection.php @@ -0,0 +1,182 @@ +session()->native(); + + if ($this->borrowed !== null && $native !== $this->borrowed) { + parent::close(); + } + $driver = parent::connect(); + $this->borrowed = $native; + + return $driver; + } + + /** + * Commit successful work, or preserve the escaping error through cleanup. + * + * @template T + * + * @param Closure(Connection): T $func + * + * @throws Throwable When work fails, ownership changes, or commit is uncertain. + * + * @return T + */ + public function transactional(Closure $func): mixed { + $level = $this->getTransactionNestingLevel() + 1; + $this->beginTransaction(); + + try { + $result = $func($this); + $this->commit(); + + return $result; + } catch (Throwable $failure) { + try { + if ($level === 1) { + $this->session()->rollback(); + parent::close(); + $this->session()->finish(); + } elseif ($this->getTransactionNestingLevel() >= $level) { + $this->rollBack(); + } + } catch (Throwable) { + // Preserve the operation's exception if cleanup also fails. + } + + throw $failure; + } + } + + /** + * Start ownership at Doctrine's native transaction boundary. + * + * @throws Throwable When the session is unavailable or already owned elsewhere. + */ + public function beginTransaction(): void { + $outer = $this->getTransactionNestingLevel() === 0; + $session = $this->session(); + + if ($outer) { + parent::close(); + $session->start(); + } + + try { + parent::beginTransaction(); + } catch (Throwable $failure) { + if ($outer) { + $session->rollback(); + parent::close(); + $session->finish(); + } + + throw $failure; + } + } + + /** + * Confirm ownership before commit and distinguish a missing acknowledgement. + * + * @throws Throwable When work is terminally failed or commit is uncertain. + */ + public function commit(): void { + $session = $this->session(); + $session->check(); + $outer = $this->getTransactionNestingLevel() === 1; + + try { + parent::commit(); + } catch (Throwable $failure) { + if ($outer) { + throw new CommitOutcomeUnknown('Commit was not confirmed; the operation may have completed. Do not retry blindly.', 0, $failure); + } + + throw $failure; + } + + if ($outer) { + parent::close(); + $session->finish(); + } + } + + /** + * Keep failed commits visible to cleanup after DBAL decrements nesting. + */ + public function isTransactionActive(): bool { + return parent::isTransactionActive() || $this->session()->isActive(); + } + + /** + * Roll back the pinned session even when a terminal failure prevents further SQL. + * + * @throws Throwable When explicit rollback fails without an earlier database failure. + */ + public function rollBack(): void { + $session = $this->session(); + + if ($this->getTransactionNestingLevel() > 1 && $session->hasFailed()) { + // A terminal SQL failure invalidates the whole transaction. Preserve its + // failure latch until the outer operation acknowledges the rollback. + $session->rollback(); + parent::close(); + + return; + } + + if ($this->getTransactionNestingLevel() > 1 || ! $session->isActive()) { + parent::rollBack(); + + return; + } + + $failed = $session->hasFailed(); + $rolledBack = $session->rollback(); + parent::close(); + $session->finish(); + + if (! $rolledBack && ! $failed) { + throw new \RuntimeException('The database did not acknowledge rollback.'); + } + } + + private function session(): WordPressSession { + foreach ($this->getConfiguration()->getMiddlewares() as $middleware) { + if ($middleware instanceof WordPressMiddleware) { + return $middleware->session; + } + } + + throw new LogicException('WordPressConnection requires WordPressMiddleware.'); + } +} diff --git a/src/Database/Connection/WordPressDriver.php b/src/Database/Connection/WordPressDriver.php new file mode 100644 index 0000000..ccc7371 --- /dev/null +++ b/src/Database/Connection/WordPressDriver.php @@ -0,0 +1,36 @@ +session->native(); + + return new BorrowedConnection(new Connection($native), $native, $this->session); + } +} diff --git a/src/Database/Connection/WordPressMiddleware.php b/src/Database/Connection/WordPressMiddleware.php new file mode 100644 index 0000000..f58aad2 --- /dev/null +++ b/src/Database/Connection/WordPressMiddleware.php @@ -0,0 +1,29 @@ +session); + } +} diff --git a/src/Database/Connection/WordPressSession.php b/src/Database/Connection/WordPressSession.php new file mode 100644 index 0000000..8a6c502 --- /dev/null +++ b/src/Database/Connection/WordPressSession.php @@ -0,0 +1,224 @@ +check(); + $native = $this->wpdb->__get('dbh'); + + if (! $native instanceof mysqli) { + throw new RuntimeException('WordPress must have an open mysqli connection.'); + } + + return $native; + } + + /** + * Report whether a managed operation owns the session. + */ + public function isActive(): bool { + return $this->owned !== null; + } + + /** + * Own one advisory lock across the complete migration operation, including planning. + * + * @template T + * + * @param Closure(): T $operation + * + * @throws Throwable When acquisition, execution, ownership checks, or release fail. + * + * @return T + */ + public function withAdvisoryLock(string $resource, Closure $operation): mixed { + if ($this->advisoryLock !== null || $this->isActive()) { + throw new RuntimeException('Start migrations outside an existing transaction or migration run.'); + } + $lock = new AdvisoryLock($this->native(), $this->scope, $resource); + $lock->acquire(); + $this->advisoryLock = $lock; + + try { + $result = $operation(); + $this->check(); + } catch (Throwable $failure) { + try { + $lock->release(); + } catch (Throwable) { + // Preserve the migration failure when its cleanup also fails. + } + + throw $failure; + } finally { + $this->advisoryLock = null; + } + $lock->release(); + + return $result; + } + + /** + * Reject ambient work before claiming the session for a managed transaction. + * + * @throws Throwable When the connection is unavailable or already in a transaction. + */ + public function start(): void { + $native = $this->native(); + $driver = new Connection($native); + + if ((int) $driver->query('SELECT @@session.autocommit')->fetchOne() !== 1) { + throw new RuntimeException('Managed transactions require autocommit to be enabled.'); + } + // Both MySQL and MariaDB reject this inside an existing transaction. + // Unlike START TRANSACTION, it cannot implicitly commit the caller's work. + $driver->exec('SET TRANSACTION READ WRITE'); + $this->site = $this->scope->capture(); + $this->prefix = $this->scope->resolveTableName(''); + $this->owned = $native; + } + + /** + * Execute against the expected session and remember caught database failures. + * + * @template T + * + * @param Closure(): T $operation + * + * @throws Throwable When execution fails or ownership changes. + * + * @return T + */ + public function execute(mysqli $native, Closure $operation): mixed { + try { + $this->check(); + + if ($this->wpdb->__get('dbh') !== $native) { + throw new RuntimeException('The WordPress connection changed; start a fresh operation.'); + } + + return $operation(); + } catch (Throwable $failure) { + $this->advisoryLock?->fail($failure); + + if ($this->isActive()) { + $this->failure ??= $failure; + } + + throw $failure; + } + } + + /** + * Confirm that a normal callback return can still be committed. + * + * @throws TransactionFailed When a query failed, even if its exception was caught. + * @throws Throwable When the WordPress site or connection changed. + */ + public function check(): void { + $this->advisoryLock?->check($this->wpdb->__get('dbh')); + + if ($this->failure !== null) { + throw new TransactionFailed('The transaction failed and must be rolled back.', 0, $this->failure); + } + + if ($this->owned === null) { + return; + } + + try { + $this->scope->assertCurrent($this->site); + + if ($this->scope->resolveTableName('') !== $this->prefix) { + throw new RuntimeException('The WordPress table prefix changed during a transaction.'); + } + + if ($this->wpdb->__get('dbh') !== $this->owned) { + throw new RuntimeException('The WordPress connection changed during a transaction.'); + } + } catch (Throwable $failure) { + $this->failure = $failure; + + throw $failure; + } + } + + /** + * Roll back only the owned session; discard it if cleanup cannot be confirmed. + * + * Cleanup must never replace the exception already escaping the operation. + */ + public function rollback(): bool { + if ($this->owned === null) { + return true; + } + + try { + if ($this->owned->rollback()) { + return true; + } + } catch (Throwable) { + // A lost connection is already unusable; a live uncertain one is closed below. + } + + try { + $this->owned->close(); + } catch (Throwable) { + // mysqli also throws when the handle has already been closed. + } + + return false; + } + + /** + * Report whether cleanup is following a terminal infrastructure failure. + */ + public function hasFailed(): bool { + return $this->failure !== null; + } + + /** + * End the invocation's ownership and terminal-failure state. + */ + public function finish(): void { + $this->owned = null; + $this->failure = null; + } +} diff --git a/src/Database/Contracts/CharsetCollationProvider.php b/src/Database/Contracts/CharsetCollationProvider.php deleted file mode 100644 index f3752d0..0000000 --- a/src/Database/Contracts/CharsetCollationProvider.php +++ /dev/null @@ -1,14 +0,0 @@ -|null - */ - public function row(string $sql, mixed ...$bindings): ?array; - - /** - * Execute a query and return every matching row. - * - * @throws DatabaseException When the database returns a row in an invalid shape. - * @throws QueryException When preparation or execution fails. - * - * @return list> - */ - public function rows(string $sql, mixed ...$bindings): array; - - /** - * Execute a query and return the first column from its first row. - * - * @throws QueryException When preparation or execution fails. - */ - public function value(string $sql, mixed ...$bindings): mixed; -} diff --git a/src/Database/Contracts/Schema.php b/src/Database/Contracts/Schema.php deleted file mode 100644 index 7d8b128..0000000 --- a/src/Database/Contracts/Schema.php +++ /dev/null @@ -1,62 +0,0 @@ - $data - * - * @throws DatabaseException When table-name resolution fails. - * @throws QueryException When the insert fails. - */ - public function insert(Table $table, array $data): int; - - /** - * Insert one row and return its generated auto-increment identifier. - * - * @param array $data - * - * @throws DatabaseException When table-name resolution fails. - * @throws QueryException When the insert fails. - */ - public function insertGetId(Table $table, array $data): int; - - /** - * Update rows matching the supplied equality-based conditions. - * - * @param array $data - * @param array $where - * - * @throws DatabaseException When table-name resolution fails. - * @throws QueryException When the update fails. - */ - public function update(Table $table, array $data, array $where): int; - - /** - * Delete rows matching the supplied equality-based conditions. - * - * @param array $where - * - * @throws DatabaseException When table-name resolution fails. - * @throws QueryException When the delete fails. - */ - public function delete(Table $table, array $where): int; -} diff --git a/src/Database/Database.php b/src/Database/Database.php deleted file mode 100644 index 7f42a76..0000000 --- a/src/Database/Database.php +++ /dev/null @@ -1,369 +0,0 @@ -unprefixedName(); - - if ($unprefixedTableName === '' || trim($unprefixedTableName) !== $unprefixedTableName) { - throw new DatabaseException('The unprefixed database table name cannot be blank or contain surrounding whitespace.'); - } - - $tableName = $this->scope->resolveTableName($unprefixedTableName); - - if (preg_match('/\A[A-Za-z0-9_]+\z/', $tableName) !== 1) { - throw new DatabaseException('Database table names may contain only ASCII letters, numbers, and underscores.'); - } - - if (strlen($tableName) > 64) { - throw new DatabaseException(sprintf('Database table name "%s" exceeds MySQL\'s 64-character identifier limit.', $tableName)); - } - - return $tableName; - } - - /** - * Determine whether the table exists in the active WordPress database scope. - * - * @throws DatabaseException When table-name resolution or inspection fails. - */ - public function tableExists(Table $table): bool { - return $this->row( - 'SHOW TABLES LIKE %s', - $this->escLike($this->tableName($table)) - ) !== null; - } - - /** - * Determine whether a named column exists on the table. - * - * @throws DatabaseException When table-name resolution or inspection fails. - */ - public function columnExists(Table $table, string $column): bool { - return $this->row( - 'SHOW COLUMNS FROM %i LIKE %s', - $this->tableName($table), - $this->escLike($column) - ) !== null; - } - - /** - * Determine whether a named index exists on the table. - * - * @throws DatabaseException When table-name resolution or inspection fails. - */ - public function indexExists(Table $table, string $index): bool { - return $this->row( - 'SHOW INDEX FROM %i WHERE Key_name = %s', - $this->tableName($table), - $index - ) !== null; - } - - /** - * Prepare a non-empty SQL template with WordPress placeholder bindings. - * - * @throws QueryException When the template is empty or WordPress cannot prepare it. - */ - public function prepare(string $sql, mixed ...$bindings): string { - if (trim($sql) === '') { - throw new QueryException('SQL statement cannot be empty.', $sql, array_values($bindings)); - } - - if ($bindings === []) { - return $sql; - } - - $bindings = array_values($bindings); - $prepared = $this->prepareDynamicSql($sql, $bindings); - - if (! is_string($prepared) || $prepared === '') { - throw new QueryException('Unable to prepare SQL statement.', $sql, $bindings); - } - - return $prepared; - } - - /** - * Execute a query and return its first row when present. - * - * @throws DatabaseException When WordPress returns a row in an invalid shape. - * @throws QueryException When preparation or execution fails. - * - * @return array|null - */ - public function row(string $sql, mixed ...$bindings): ?array { - $bindings = array_values($bindings); - $query = $this->prepare($sql, ...$bindings); - $result = $this->wpdb->get_row($query, self::ARRAY_A); - $this->throwIfLastError($sql, $bindings); - - if ($result === null) { - return null; - } - - return $this->stringKeyedRow($result); - } - - /** - * Execute a query and return all rows with string keys. - * - * @throws DatabaseException When WordPress returns a row in an invalid shape. - * @throws QueryException When preparation or execution fails. - * - * @return list> - */ - public function rows(string $sql, mixed ...$bindings): array { - $bindings = array_values($bindings); - $query = $this->prepare($sql, ...$bindings); - $results = $this->wpdb->get_results($query, self::ARRAY_A); - $this->throwIfLastError($sql, $bindings); - - if ($results === null) { - throw new QueryException('Unable to retrieve database rows.', $sql, $bindings); - } - - $rows = []; - - foreach ($results as $result) { - $rows[] = $this->stringKeyedRow($result); - } - - return $rows; - } - - /** - * Execute a query and return the first column from its first row. - * - * @throws QueryException When preparation or execution fails. - */ - public function value(string $sql, mixed ...$bindings): mixed { - $bindings = array_values($bindings); - $query = $this->prepare($sql, ...$bindings); - $result = $this->wpdb->get_var($query); - $this->throwIfLastError($sql, $bindings); - - return $result; - } - - /** - * Execute a write or schema statement and return its affected-row count. - * - * @throws QueryException When preparation or execution fails. - */ - public function execute(string $sql, mixed ...$bindings): int { - $bindings = array_values($bindings); - $query = $this->prepare($sql, ...$bindings); - $result = $this->wpdb->query($query); - - if ($result === false) { - throw new QueryException($this->message('Unable to execute SQL statement.'), $sql, $bindings, $this->lastError()); - } - - return (int) $result; - } - - /** - * Insert one row into the supplied table. - * - * @param array $data - * - * @throws DatabaseException When table-name resolution or validation fails. - * @throws QueryException When the insert fails. - */ - public function insert(Table $table, array $data): int { - $tableName = $this->tableName($table); - $result = $this->wpdb->insert($tableName, $data); - - if ($result === false) { - throw new QueryException( - $this->message('Unable to insert database row.'), - sprintf('INSERT INTO %s', $this->quoteIdentifier($tableName)), - [], - $this->lastError() - ); - } - - return (int) $result; - } - - /** - * Insert one row and return the connection's auto-increment identifier. - * - * @param array $data - * - * @throws DatabaseException When table-name resolution or validation fails. - * @throws QueryException When the insert fails. - */ - public function insertGetId(Table $table, array $data): int { - $this->insert($table, $data); - - return (int) $this->wpdb->insert_id; - } - - /** - * Update rows matching equality-based column values. - * - * @param array $data - * @param array $where - * - * @throws DatabaseException When table-name resolution or validation fails. - * @throws QueryException When the update fails. - */ - public function update(Table $table, array $data, array $where): int { - $tableName = $this->tableName($table); - $result = $this->wpdb->update($tableName, $data, $where); - - if ($result === false) { - throw new QueryException( - $this->message('Unable to update database rows.'), - sprintf('UPDATE %s', $this->quoteIdentifier($tableName)), - [], - $this->lastError() - ); - } - - return (int) $result; - } - - /** - * Delete rows matching equality-based column values. - * - * @param array $where - * - * @throws DatabaseException When table-name resolution or validation fails. - * @throws QueryException When the delete fails. - */ - public function delete(Table $table, array $where): int { - $tableName = $this->tableName($table); - $result = $this->wpdb->delete($tableName, $where); - - if ($result === false) { - throw new QueryException( - $this->message('Unable to delete database rows.'), - sprintf('DELETE FROM %s', $this->quoteIdentifier($tableName)), - [], - $this->lastError() - ); - } - - return (int) $result; - } - - /** - * Quote one trusted SQL identifier while escaping embedded backticks. - */ - public function quoteIdentifier(string $identifier): string { - return $this->quoteSqlIdentifier($identifier); - } - - /** - * Escape SQL LIKE wildcard characters without adding a surrounding pattern. - */ - public function escLike(string $value): string { - return $this->wpdb->esc_like($value); - } - - /** - * Return the charset and collation clause configured by WordPress. - */ - public function charsetCollate(): string { - return $this->wpdb->get_charset_collate(); - } - - /** - * Throw the current WordPress database error with its original query context. - * - * @param list $bindings - * - * @throws QueryException When WordPress reports a query error. - */ - private function throwIfLastError(string $sql, array $bindings): void { - $error = $this->lastError(); - - if ($error !== null) { - throw new QueryException($error, $sql, $bindings, $error); - } - } - - /** - * Prefer the current WordPress database error over a generic fallback. - */ - private function message(string $fallback): string { - return $this->lastError() ?? $fallback; - } - - /** - * Return the current WordPress database error when one is present. - */ - private function lastError(): ?string { - $error = $this->wpdb->last_error; - - return $error !== '' ? $error : null; - } - - /** - * Invoke wpdb::prepare() for runtime SQL that cannot satisfy the WordPress - * stubs' literal-string requirement during static analysis. - * - * @param list $bindings - */ - private function prepareDynamicSql(string $sql, array $bindings): mixed { - $method = 'prepare'; - - return call_user_func_array([$this->wpdb, $method], array_merge([$sql], $bindings)); - } - - /** - * Validate that a WordPress row result contains only string keys. - * - * @param array $result - * - * @throws DatabaseException When WordPress returns a non-string row key. - * - * @return array - */ - private function stringKeyedRow(array $result): array { - $row = []; - - foreach ($result as $key => $value) { - if (! is_string($key)) { - throw new DatabaseException('Database row result contained a non-string key.'); - } - - $row[$key] = $value; - } - - return $row; - } -} diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index 8d6cf74..e2bfcfa 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -2,217 +2,99 @@ namespace StellarWP\Foundation\Database; -use InvalidArgumentException; +use Doctrine\DBAL\Configuration; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Schema\DefaultSchemaManagerFactory; use StellarWP\Foundation\Container\Contracts\Provider; use StellarWP\Foundation\Container\Contracts\Resolver as C; use StellarWP\Foundation\Container\Traits\ResolvesFoundationPrefix; use StellarWP\Foundation\Database\Cli\Migrate; -use StellarWP\Foundation\Database\Contracts\CharsetCollationProvider; -use StellarWP\Foundation\Database\Contracts\Database as DatabaseContract; +use StellarWP\Foundation\Database\Connection\WordPressConnection; +use StellarWP\Foundation\Database\Connection\WordPressMiddleware; +use StellarWP\Foundation\Database\Connection\WordPressSession; use StellarWP\Foundation\Database\Contracts\DatabaseScope; -use StellarWP\Foundation\Database\Contracts\QueryExecutor; -use StellarWP\Foundation\Database\Contracts\QueryGateway; -use StellarWP\Foundation\Database\Contracts\QueryReader; -use StellarWP\Foundation\Database\Contracts\Schema as SchemaContract; -use StellarWP\Foundation\Database\Contracts\SchemaInspector; -use StellarWP\Foundation\Database\Contracts\SqlDialect; -use StellarWP\Foundation\Database\Contracts\TableGateway; use StellarWP\Foundation\Database\Contracts\TableNameResolver; -use StellarWP\Foundation\Database\Contracts\TableWriter; use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Lock\DatabaseLock; -use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; -use StellarWP\Foundation\Database\Migration\Contracts\Repository; -use StellarWP\Foundation\Database\Migration\Factories\LeaseFactory; -use StellarWP\Foundation\Database\Migration\Factories\SessionFactory; +use StellarWP\Foundation\Database\Migration\History; +use StellarWP\Foundation\Database\Migration\MigrationCollection; use StellarWP\Foundation\Database\Migration\Migrator; -use StellarWP\Foundation\Database\Migration\Repository as MigrationRecordRepository; -use StellarWP\Foundation\Database\Migration\Store; -use StellarWP\Foundation\Database\Migration\StoreSchema; -use StellarWP\Foundation\Database\Schema\Contracts\SchemaExecutor; -use StellarWP\Foundation\Database\Schema\DbDelta; -use StellarWP\Foundation\Database\Schema\Editor; -use StellarWP\Foundation\Database\Schema\Reconciler; +use StellarWP\Foundation\Database\Migration\Schema\SchemaPlanner; use StellarWP\Foundation\Database\Scope\SiteScope; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; -use StellarWP\Foundation\Lock\Contracts\Lock; use StellarWP\Foundation\WPCli\WPCliProvider; +use wpdb; /** - * Registers Foundation database services for WordPress environments. + * Wire a shared WordPress Doctrine connection, application tables, and migrations. */ final class DatabaseProvider extends Provider { + public const string MIGRATIONS = self::class . '.migrations'; use ResolvesFoundationPrefix; - public const string MIGRATIONS = self::class . '.migrations'; - private const string MIGRATIONS_TABLE = self::class . '.migrations_table'; - private const string LOCKS_TABLE = self::class . '.locks_table'; - private const string LOCK_NAME = self::class . '.lock_name'; - private const string LOCK_TTL = self::class . '.lock_ttl'; - - private bool $registered = false; - /** - * Register database, schema, migration, lock, and WP-CLI services. + * Register lazy database services without executing queries or creating storage. * - * @throws InvalidArgumentException When the configured Foundation prefix is invalid. + * @throws \InvalidArgumentException When the configured resource prefix is invalid. */ public function register(): void { - if ($this->registered) { - return; - } - - $this->registerDatabase(); - $this->registerDatabaseScope(); - $this->registerSchema(); - $this->registerConfiguration(); + $this->registerConnection(); $this->registerTables(); $this->registerMigrations(); - $this->registerLocks(); - $this->registerCliCommands(); - - $this->registered = true; - } - - /** - * Register application-scoped table names, lock name, and lock lifetime. - */ - private function registerConfiguration(): void { - $foundationPrefix = $this->foundationPrefix(); - $databasePrefix = str_replace('-', '_', $foundationPrefix); - $migrationsTable = $this->tableName( - $this->config->get('database.migrations_table'), - $databasePrefix . '_foundation_migrations' - ); - $locksTable = $this->tableName( - $this->config->get('database.locks_table'), - $databasePrefix . '_foundation_locks' - ); - $lockName = $this->config->get('database.lock_name') - ?? $foundationPrefix . '-foundation-database-migrations'; - - $this->container->singleton(self::MIGRATIONS_TABLE, $migrationsTable); - $this->container->singleton(self::LOCKS_TABLE, $locksTable); - $this->container->singleton(self::LOCK_NAME, $lockName); - $this->container->singleton(self::LOCK_TTL, (int) $this->config->get('database.lock_ttl', 300)); + $this->container->singleton(DatabaseLock::class); + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [$c->get(Migrate::class)]); } - /** - * Register the active WordPress connection and developer-facing database API. - */ - private function registerDatabase(): void { - $this->container->singleton(\wpdb::class, static function (): \wpdb { - $wpdb = $GLOBALS['wpdb'] ?? null; + private function registerConnection(): void { + $this->container->singleton(wpdb::class, static function (): wpdb { + $source = $GLOBALS['wpdb'] ?? null; - if (! $wpdb instanceof \wpdb) { + if (! $source instanceof wpdb) { throw new DatabaseException('The global wpdb instance is not available.'); } - return $wpdb; + return $source; + }); + $this->container->singleton(DatabaseScope::class, SiteScope::class); + $this->container->singleton(TableNameResolver::class, Table\TableNameResolver::class); + $this->container->singleton(WordPressSession::class); + $this->container->singleton(Connection::class, static function (C $c): Connection { + $config = new Configuration(); + $config->setSchemaManagerFactory(new DefaultSchemaManagerFactory()); + $config->setMiddlewares([new WordPressMiddleware($c->get(WordPressSession::class))]); + + return DriverManager::getConnection([ + 'driver' => 'mysqli', + 'wrapperClass' => WordPressConnection::class, + ], $config); }); - $this->container->singleton(Database::class); - $this->container->singleton(DatabaseContract::class, static fn (C $c): Database => $c->get(Database::class)); - $this->container->singleton(CharsetCollationProvider::class, static fn (C $c): DatabaseContract => $c->get(DatabaseContract::class)); - $this->container->singleton(QueryExecutor::class, static fn (C $c): DatabaseContract => $c->get(DatabaseContract::class)); - $this->container->singleton(QueryGateway::class, static fn (C $c): DatabaseContract => $c->get(DatabaseContract::class)); - $this->container->singleton(QueryReader::class, static fn (C $c): DatabaseContract => $c->get(DatabaseContract::class)); - $this->container->singleton(SchemaInspector::class, static fn (C $c): DatabaseContract => $c->get(DatabaseContract::class)); - $this->container->singleton(SqlDialect::class, static fn (C $c): DatabaseContract => $c->get(DatabaseContract::class)); - $this->container->singleton(TableNameResolver::class, static fn (C $c): DatabaseContract => $c->get(DatabaseContract::class)); - $this->container->singleton(TableGateway::class, static fn (C $c): DatabaseContract => $c->get(DatabaseContract::class)); - $this->container->singleton(TableWriter::class, static fn (C $c): DatabaseContract => $c->get(DatabaseContract::class)); - } - - /** - * Register schema execution, reconciliation, and inspection services. - */ - private function registerSchema(): void { - $this->container->singleton(DbDelta::class); - $this->container->singleton(SchemaExecutor::class, static fn (C $c): DbDelta => $c->get(DbDelta::class)); - $this->container->singleton(Reconciler::class); - $this->container->singleton(Editor::class); - $this->container->singleton(Schema::class); - $this->container->singleton(SchemaContract::class, static fn (C $c): Schema => $c->get(Schema::class)); - } - - /** - * Register site-scoped WordPress table-name resolution. - */ - private function registerDatabaseScope(): void { - $this->container->singleton(SiteScope::class); - $this->container->singleton(DatabaseScope::class, static fn (C $c): SiteScope => $c->get(SiteScope::class)); } - /** - * Register the migration ledger and lock storage tables. - */ private function registerTables(): void { - $this->container->when(MigrationTable::class) - ->needs('$unprefixedTableName') - ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); - - $this->container->when(LockTable::class) - ->needs('$unprefixedTableName') - ->give(static fn (C $c): string => $c->get(self::LOCKS_TABLE)); - + $prefix = str_replace('-', '_', $this->foundationPrefix()); + $this->container->when(MigrationTable::class)->needs('$unprefixedTableName') + ->give($this->config->get('database.migrations_table', $prefix . '_foundation_migrations')); + $this->container->when(LockTable::class)->needs('$unprefixedTableName') + ->give($this->config->get('database.locks_table', $prefix . '_foundation_locks')); $this->container->singleton(MigrationTable::class); $this->container->singleton(LockTable::class); } - /** - * Register migration contributions, ledger access, and orchestration services. - */ private function registerMigrations(): void { $this->container->mergeArrayVar(self::MIGRATIONS, []); - - $this->container->when(MigrationCollection::class) - ->needs('$migrations') + $this->container->when(MigrationCollection::class)->needs('$migrations') ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); + $this->container->when(SchemaPlanner::class)->needs('$tableOptions')->give(static function (C $c): array { + $source = $c->get(wpdb::class); - $this->container->when(Store::class) - ->needs('$lockName') - ->give(static fn (C $c): string => $c->get(self::LOCK_NAME)); - - $this->container->when(Store::class) - ->needs('$lockTtl') - ->give(static fn (C $c): int => $c->get(self::LOCK_TTL)); - - $this->container->when(Store::class) - ->needs(Lock::class) - ->give(static fn (C $c): DatabaseLock => $c->get(DatabaseLock::class)); - + return array_filter(['engine' => 'InnoDB', 'charset' => $source->charset, 'collation' => $source->collate]); + }); $this->container->singleton(MigrationCollection::class); - $this->container->singleton(MigrationRecordRepository::class); - $this->container->singleton(Repository::class, static fn (C $c): MigrationRecordRepository => $c->get(MigrationRecordRepository::class)); - $this->container->singleton(LeaseFactory::class); - $this->container->singleton(SessionFactory::class); - $this->container->singleton(StoreSchema::class); - $this->container->singleton(Store::class); + $this->container->singleton(History::class); + $this->container->singleton(SchemaPlanner::class); $this->container->singleton(Migrator::class); } - - /** - * Register the WordPress database lock backend used by migrations. - */ - private function registerLocks(): void { - $this->container->singleton(DatabaseLock::class); - } - - /** - * Contribute the migration command for lazy registration during cli_init. - */ - private function registerCliCommands(): void { - $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ - $c->get(Migrate::class), - ]); - } - - /** - * Return a non-empty configured table name or its application-scoped default. - */ - private function tableName(mixed $configured, string $default): string { - return is_string($configured) && $configured !== '' ? $configured : $default; - } } diff --git a/src/Database/Exceptions/CommitOutcomeUnknown.php b/src/Database/Exceptions/CommitOutcomeUnknown.php new file mode 100644 index 0000000..419ba90 --- /dev/null +++ b/src/Database/Exceptions/CommitOutcomeUnknown.php @@ -0,0 +1,12 @@ + $bindings - */ - public function __construct( - string $message, - private readonly string $sql, - private readonly array $bindings = [], - private readonly ?string $databaseError = null, - ?Throwable $previous = null - ) { - parent::__construct($message, 0, $previous); - } - - /** - * Return the SQL template or operation context associated with the failure. - */ - public function sql(): string { - return $this->sql; - } - - /** - * Return the bindings supplied for the failed SQL template. - * - * @return list - */ - public function bindings(): array { - return $this->bindings; - } - - /** - * Return the native database error when WordPress provided one. - */ - public function databaseError(): ?string { - return $this->databaseError; - } -} diff --git a/src/Database/Exceptions/TransactionFailed.php b/src/Database/Exceptions/TransactionFailed.php new file mode 100644 index 0000000..2cf1269 --- /dev/null +++ b/src/Database/Exceptions/TransactionFailed.php @@ -0,0 +1,12 @@ +database->createSchemaManager(); + + if ($manager->tablesExist([$this->table->name()])) { + return; + } + $table = Table::editor()->setUnquotedName($this->table->name())->setOptions(['engine' => 'InnoDB']) + ->setColumns( + Column::editor()->setUnquotedName('name')->setTypeName(Types::BINARY)->setLength(191)->setNotNull(true)->create(), + Column::editor()->setUnquotedName('owner')->setTypeName(Types::BINARY)->setLength(64)->setNotNull(true)->create(), + Column::editor()->setUnquotedName('expires_at')->setTypeName(Types::DATETIME_MUTABLE)->setColumnDefinition('DATETIME(6) NOT NULL')->create(), + Column::editor()->setUnquotedName('created_at')->setTypeName(Types::DATETIME_MUTABLE)->setColumnDefinition('DATETIME(6) NOT NULL')->create(), + Column::editor()->setUnquotedName('updated_at')->setTypeName(Types::DATETIME_MUTABLE)->setColumnDefinition('DATETIME(6) NOT NULL')->create(), + ) + ->setPrimaryKeyConstraint(PrimaryKeyConstraint::editor()->setUnquotedColumnNames('name')->create())->create(); + + try { + $manager->createTable($table); + } catch (\Doctrine\DBAL\Exception\TableExistsException) { + // Another activation created the same configured storage concurrently. + } + } + /** * Acquire an unexpired lock or return null when another owner holds it. * @@ -45,40 +78,33 @@ public function acquire(string $name, int $ttl): ?LockToken { $owner = $this->generateLockOwner(); try { - $table = $this->database->tableName($this->table); + $table = $this->table->quotedName(); - $this->database->execute( - 'INSERT INTO %i (name, owner, expires_at, created_at, updated_at) - VALUES (%s, %s, TIMESTAMPADD(SECOND, %d, UTC_TIMESTAMP(6)), UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) + $this->database->executeStatement( + "INSERT INTO {$table} (name, owner, expires_at, created_at, updated_at) + VALUES (?, ?, TIMESTAMPADD(SECOND, ?, UTC_TIMESTAMP(6)), UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) ON DUPLICATE KEY UPDATE - owner = IF(expires_at <= UTC_TIMESTAMP(6), %s, owner), + owner = IF(expires_at <= UTC_TIMESTAMP(6), ?, owner), updated_at = IF(expires_at <= UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), updated_at), expires_at = IF( expires_at <= UTC_TIMESTAMP(6), - TIMESTAMPADD(SECOND, %d, UTC_TIMESTAMP(6)), + TIMESTAMPADD(SECOND, ?, UTC_TIMESTAMP(6)), expires_at - )', - $table, - $name, - $owner, - $ttl, - $owner, - $ttl + )", + [$name, $owner, $ttl, $owner, $ttl] ); - $row = $this->database->row( - 'SELECT expires_at FROM %i - WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6) - LIMIT 1', - $table, - $name, - $owner + $row = $this->database->fetchAssociative( + "SELECT expires_at FROM {$table} + WHERE name = ? AND owner = ? AND expires_at > UTC_TIMESTAMP(6) + LIMIT 1", + [$name, $owner] ); - } catch (DatabaseException $exception) { + } catch (Throwable $exception) { throw new LockUnavailableException('The database could not determine the lock acquisition result.', 0, $exception); } - if ($row === null) { + if ($row === false) { return null; } @@ -96,13 +122,11 @@ public function acquire(string $name, int $ttl): ?LockToken { */ public function release(LockToken $token): bool { try { - return $this->database->execute( - 'DELETE FROM %i WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6)', - $this->database->tableName($this->table), - $token->name, - $token->owner + return $this->database->executeStatement( + 'DELETE FROM ' . $this->table->quotedName() . ' WHERE name = ? AND owner = ? AND expires_at > UTC_TIMESTAMP(6)', + [$token->name, $token->owner] ) > 0; - } catch (DatabaseException $exception) { + } catch (Throwable $exception) { throw new LockUnavailableException('The database could not determine the lock release result.', 0, $exception); } } @@ -117,28 +141,23 @@ public function refresh(LockToken $token, int $ttl): ?LockToken { $this->assertValidLockTtl($ttl); try { - $table = $this->database->tableName($this->table); - - $this->database->execute( - 'UPDATE %i SET expires_at = TIMESTAMPADD(SECOND, %d, UTC_TIMESTAMP(6)), updated_at = UTC_TIMESTAMP(6) - WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6)', - $table, - $ttl, - $token->name, - $token->owner + $table = $this->table->quotedName(); + + $this->database->executeStatement( + "UPDATE {$table} SET expires_at = TIMESTAMPADD(SECOND, ?, UTC_TIMESTAMP(6)), updated_at = UTC_TIMESTAMP(6) + WHERE name = ? AND owner = ? AND expires_at > UTC_TIMESTAMP(6)", + [$ttl, $token->name, $token->owner] ); - $row = $this->database->row( - 'SELECT expires_at FROM %i WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6) LIMIT 1', - $table, - $token->name, - $token->owner + $row = $this->database->fetchAssociative( + "SELECT expires_at FROM {$table} WHERE name = ? AND owner = ? AND expires_at > UTC_TIMESTAMP(6) LIMIT 1", + [$token->name, $token->owner] ); - } catch (DatabaseException $exception) { + } catch (Throwable $exception) { throw new LockUnavailableException('The database could not determine the lock refresh result.', 0, $exception); } - if ($row === null) { + if ($row === false) { return null; } @@ -155,12 +174,11 @@ public function isAcquired(string $name): bool { $this->assertValidName($name); try { - return $this->database->row( - 'SELECT name FROM %i WHERE name = %s AND expires_at > UTC_TIMESTAMP(6) LIMIT 1', - $this->database->tableName($this->table), - $name - ) !== null; - } catch (DatabaseException $exception) { + return $this->database->fetchAssociative( + 'SELECT name FROM ' . $this->table->quotedName() . ' WHERE name = ? AND expires_at > UTC_TIMESTAMP(6) LIMIT 1', + [$name] + ) !== false; + } catch (Throwable $exception) { throw new LockUnavailableException('The database could not determine whether the lock exists.', 0, $exception); } } diff --git a/src/Database/Migration/Collection.php b/src/Database/Migration/Collection.php deleted file mode 100644 index 000c0bd..0000000 --- a/src/Database/Migration/Collection.php +++ /dev/null @@ -1,106 +0,0 @@ - - */ -final class Collection implements IteratorAggregate -{ - /** - * @var array - */ - private array $migrations = []; - - /** - * Create a globally ordered collection from configured migration contributions. - * - * @param iterable $migrations - * - * @throws DuplicateMigration When a migration identifier is already registered. - * @throws InvalidMigrationId When a migration identifier cannot be stored safely. - */ - public function __construct( - iterable $migrations = [] - ) { - $this->addMigrations($migrations); - } - - /** - * Add migrations and retain ascending byte-exact identifier order. - * - * @throws DuplicateMigration When a migration identifier is already registered. - * @throws InvalidMigrationId When a migration identifier cannot be stored safely. - */ - public function add(Migration ...$migrations): void { - $this->addMigrations($migrations); - } - - /** - * Validate and atomically merge one migration contribution into the ordered collection. - * - * @param iterable $migrations - * - * @throws DuplicateMigration When a migration identifier is already registered. - * @throws InvalidMigrationId When a migration identifier cannot be stored safely. - */ - private function addMigrations(iterable $migrations): void { - $additions = []; - - foreach ($migrations as $migration) { - $id = (new Id($migration->id()))->value; - - if (isset($this->migrations[$id]) || isset($additions[$id])) { - throw DuplicateMigration::forMigration($id); - } - - $additions[$id] = $migration; - } - - foreach ($additions as $id => $migration) { - $this->migrations[$id] = $migration; - } - - uksort( - $this->migrations, - static fn (string $left, string $right): int => strcmp($left, $right) - ); - } - - /** - * Return all migrations keyed by their byte-exact identifier. - * - * @return array - */ - public function all(): array { - return $this->migrations; - } - - /** - * Return all migrations as an ordered list. - * - * @return list - */ - public function values(): array { - return array_values($this->migrations); - } - - /** - * Iterate over migrations keyed by their byte-exact identifier. - * - * @return Traversable - */ - public function getIterator(): Traversable { - return new ArrayIterator($this->migrations); - } -} diff --git a/src/Database/Migration/Contracts/DescribesMigration.php b/src/Database/Migration/Contracts/DescribesMigration.php new file mode 100644 index 0000000..6a54455 --- /dev/null +++ b/src/Database/Migration/Contracts/DescribesMigration.php @@ -0,0 +1,14 @@ + - */ - public function all(): array; - - /** - * Record a successful migration in the supplied batch. - * - * @throws DatabaseException When the migration ledger cannot be written. - * @throws InvalidMigrationId When the migration identifier is invalid. - * @throws LedgerFailure When the insert does not record exactly one migration. - */ - public function recordRun(string $migration, int $batch): void; - - /** - * Return false when no matching ledger row was deleted. - * - * @throws DatabaseException When the migration ledger cannot be written. - * @throws InvalidMigrationId When the migration identifier is invalid. - */ - public function deleteRun(string $migration): bool; - - /** - * Return the latest recorded batch, or null when the ledger is empty. - * - * @throws DatabaseException When the migration ledger cannot be read. - */ - public function latestBatch(): ?int; - - /** - * Return ledger records belonging to one batch in ascending execution order, - * with the oldest record first. - * - * @throws DatabaseException When the migration ledger cannot be read. - * @throws InvalidMigrationId When a stored migration identifier is invalid. - * - * @return list - */ - public function recordsForBatch(int $batch): array; -} diff --git a/src/Database/Migration/Exceptions/DuplicateMigration.php b/src/Database/Migration/Exceptions/DuplicateMigration.php deleted file mode 100644 index 50dd36e..0000000 --- a/src/Database/Migration/Exceptions/DuplicateMigration.php +++ /dev/null @@ -1,15 +0,0 @@ - $migrations - */ - public function __construct( - public readonly array $migrations - ) { - parent::__construct(sprintf( - 'Cannot roll back unavailable migrations: %s.', - implode(', ', $this->migrations) - )); - } -} diff --git a/src/Database/Migration/Exceptions/UninitializedStore.php b/src/Database/Migration/Exceptions/UninitializedStore.php deleted file mode 100644 index 7a663dd..0000000 --- a/src/Database/Migration/Exceptions/UninitializedStore.php +++ /dev/null @@ -1,15 +0,0 @@ -schema, $lease); - } -} diff --git a/src/Database/Migration/History.php b/src/Database/Migration/History.php new file mode 100644 index 0000000..749e780 --- /dev/null +++ b/src/Database/Migration/History.php @@ -0,0 +1,99 @@ +table->name(); + } + + /** + * Create the history table when first running migrations. + * + * @throws Exception When inspection or creation fails. + */ + public function initialize(): void { + $manager = $this->connection->createSchemaManager(); + + if ($manager->tablesExist([$this->table()])) { + return; + } + + $table = Table::editor()->setUnquotedName($this->table()) + ->setOptions(['engine' => 'InnoDB']) + ->setColumns( + Column::editor()->setUnquotedName('version')->setTypeName(Types::BINARY)->setLength(191)->setNotNull(true)->create(), + Column::editor()->setUnquotedName('applied_at')->setTypeName(Types::DATETIME_MUTABLE) + ->setColumnDefinition('DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)')->create(), + ) + ->setPrimaryKeyConstraint(PrimaryKeyConstraint::editor()->setUnquotedColumnNames('version')->create()) + ->create(); + $manager->createTable($table); + } + + /** + * Applied identities keyed by ID with their timestamps, in byte order. + * + * @throws Exception + * + * @return array + */ + public function applied(): array { + if (! $this->connection->createSchemaManager()->tablesExist([$this->table()])) { + return []; + } + $rows = $this->connection->fetchAllKeyValue('SELECT version, applied_at FROM ' . $this->quoted() . ' ORDER BY version'); + + return array_map('strval', $rows); + } + + /** + * Record successful completion after migration work. + * + * @throws Exception When the ledger write fails; the schema change has already committed. + */ + public function record(string $id): void { + $this->connection->insert($this->quoted(), ['version' => $id]); + } + + /** + * Remove history after successful reversal. + * + * @throws Exception When the ledger delete fails; the inverse has already committed. + */ + public function remove(string $id): void { + $this->connection->delete($this->quoted(), ['version' => $id]); + } + + private function quoted(): string { + return $this->table->quotedName(); + } +} diff --git a/src/Database/Migration/Lease.php b/src/Database/Migration/Lease.php deleted file mode 100644 index bdfbe40..0000000 --- a/src/Database/Migration/Lease.php +++ /dev/null @@ -1,66 +0,0 @@ -scope->assertCurrent($this->scopeId); - $refreshed = $this->lock->refresh($this->token, $this->ttl); - $this->scope->assertCurrent($this->scopeId); - - if ($refreshed === null) { - throw MigrationLockFailed::forLostOwnership($this->token->name); - } - - $this->token = $refreshed; - } - - /** - * Release the migration lock using its latest ownership token. - * - * @throws DatabaseContextChanged When the active database scope has changed. - * @throws MigrationLockFailed When lock ownership cannot be confirmed. - * @throws LockUnavailableException When the backend cannot determine the release result. - */ - public function release(): void { - $this->scope->assertCurrent($this->scopeId); - $released = $this->lock->release($this->token); - $this->scope->assertCurrent($this->scopeId); - - if (! $released) { - throw MigrationLockFailed::forUnconfirmedOwnership($this->token->name); - } - } -} diff --git a/src/Database/Migration/MigrationCollection.php b/src/Database/Migration/MigrationCollection.php new file mode 100644 index 0000000..d084aea --- /dev/null +++ b/src/Database/Migration/MigrationCollection.php @@ -0,0 +1,75 @@ + + */ + private array $migrations = []; + + /** + * Validate provider contributions and sort their persistent identities. + * + * @param iterable $migrations + * + * @throws InvalidArgumentException When an identity is duplicated. + * @throws Exceptions\InvalidMigrationId When an identity is invalid. + */ + public function __construct( + iterable $migrations = [], + ) { + foreach ($migrations as $migration) { + $id = (new ValueObjects\Id($migration->id()))->value; + + if (isset($this->migrations[$id])) { + throw new InvalidArgumentException('Duplicate migration identity: ' . $id); + } + $this->migrations[$id] = $migration; + } + ksort($this->migrations, SORT_STRING); + } + + /** + * Return migrations keyed by their persistent identities. + * + * @return array Keyed by ID; PHP may store numeric IDs as integer keys, so use ids() for strings. + */ + public function all(): array { + return $this->migrations; + } + + /** + * Return the globally ordered identities as strings. + * + * @return list + */ + public function ids(): array { + return array_map('strval', array_keys($this->migrations)); + } + + /** + * Check whether an identity was contributed. + */ + public function has(string $id): bool { + return isset($this->migrations[$id]); + } + + /** + * Resolve a registered migration by identity. + */ + public function get(string $id): Migration { + return $this->migrations[$id] ?? throw new InvalidArgumentException('Unknown migration: ' . $id); + } +} diff --git a/src/Database/Migration/Migrator.php b/src/Database/Migration/Migrator.php index f5d404f..ac5ab66 100644 --- a/src/Database/Migration/Migrator.php +++ b/src/Database/Migration/Migrator.php @@ -2,252 +2,277 @@ namespace StellarWP\Foundation\Database\Migration; -use StellarWP\Foundation\Database\Contracts\Migration; -use StellarWP\Foundation\Database\Exceptions\DatabaseException; -use StellarWP\Foundation\Database\Migration\Contracts\Repository; -use StellarWP\Foundation\Database\Migration\Exceptions\InvalidRollbackBatch; -use StellarWP\Foundation\Database\Migration\Exceptions\LedgerFailure; -use StellarWP\Foundation\Database\Migration\Exceptions\MigrationFailed; -use StellarWP\Foundation\Database\Migration\Exceptions\MigrationLockFailed; -use StellarWP\Foundation\Database\Migration\Exceptions\UnavailableMigration; -use StellarWP\Foundation\Database\Migration\Exceptions\UninitializedStore; -use StellarWP\Foundation\Database\Migration\ValueObjects\Record; -use StellarWP\Foundation\Database\Migration\ValueObjects\Result; -use StellarWP\Foundation\Database\Migration\ValueObjects\Status; -use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; +use Closure; +use Doctrine\DBAL\Connection; +use InvalidArgumentException; +use StellarWP\Foundation\Database\Connection\WordPressSession; +use StellarWP\Foundation\Database\Contracts\DatabaseScope; +use StellarWP\Foundation\Database\Exceptions\MigrationInterrupted; +use StellarWP\Foundation\Database\Migration\Contracts\DescribesMigration; +use StellarWP\Foundation\Database\Migration\Contracts\MigratesData; +use StellarWP\Foundation\Database\Migration\Contracts\Migration; +use StellarWP\Foundation\Database\Migration\Schema\SchemaPlanner; +use StellarWP\Foundation\Database\Migration\ValueObjects\MigrationStatus; +use StellarWP\Foundation\Database\Migration\ValueObjects\Step; +use Throwable; /** - * Applies and rolls back configured database migrations through an initialized store. + * Apply or reverse declarative migrations by diffing desired states against the live schema. + * + * For each step the runner replays history in memory to obtain the desired schema before and + * after the migration, introspects the owned tables, and executes only the remaining difference. + * Work a previous attempt already completed produces no SQL; differences the migration never + * declared stop the run. All of this happens under one MySQL advisory lock on the shared session. */ -final readonly class Migrator +final class Migrator { + public const string LATEST = 'latest'; + public const string NONE = '0'; + /** - * Create the migration entry point from configured migrations, ledger, and store services. + * Receive the shared session, history, and planning services. + * + * @internal Constructed by Foundation; applications receive this object through provider wiring or migration callbacks. */ public function __construct( - private Collection $migrations, - private Repository $repository, - private Store $store + private readonly Connection $connection, + private readonly WordPressSession $session, + private readonly DatabaseScope $scope, + private readonly History $history, + private readonly SchemaPlanner $planner, + private readonly MigrationCollection $migrations, ) { } /** - * Initialize or reconcile migration storage before other migration operations run. + * Apply pending migrations up to the target, or reverse applied migrations above it. * - * @throws DatabaseException When migration storage cannot be initialized. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - */ - public function initialize(): void { - $this->store->initialize(); - } - - /** - * Drop the migration ledger while preserving shared lock storage. + * @throws Throwable When planning, DDL, a data step, the ledger, or lock ownership fails. * - * @throws DatabaseException When migration storage cannot be dropped. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - * @throws UninitializedStore When migration storage has not been initialized. + * @return list */ - public function dropStore(): void { - $this->store->drop(); + public function migrate(string $target = self::LATEST): array { + $this->assertTarget($target); + + return $this->locked(fn (): array => $this->run($target, true)); } /** - * Determine whether the complete migration store has been initialized. + * Reverse the highest applied migration IDs, one by default. * - * @throws DatabaseException When migration storage cannot be inspected. + * The target is selected from history read under the same lock that protects execution, so a + * migration another process applies in the meantime cannot widen the reversal. + * + * @return list */ - public function isInitialized(): bool { - return $this->store->isInitialized(); + public function rollback(int $steps = 1): array { + if ($steps < 1) { + throw new InvalidArgumentException('Rollback steps must be positive.'); + } + + return $this->locked(function () use ($steps): array { + $applied = array_map('strval', array_keys($this->history->applied())); + + if ($applied === []) { + return []; + } + $remaining = array_slice($applied, 0, max(0, count($applied) - $steps)); + + return $this->run($remaining === [] ? self::NONE : (string) end($remaining), true, false); + }); } /** - * Run all pending configured migrations. + * Reverse all migrations and reapply them under one uninterrupted migration lock. + * + * @throws Throwable When an inverse, execution, or ownership check fails. * - * @throws DatabaseException When migration storage or schema access fails. - * @throws LedgerFailure When an applied migration cannot be recorded in the ledger. - * @throws MigrationFailed When a migration fails while running. - * @throws MigrationLockFailed When the lock cannot be acquired, renewed, or released. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - * @throws UninitializedStore When migration storage has not been initialized. + * @return list */ - public function run(): Result { - $migrations = $this->migrations->all(); - - return $this->store->withMigrationLock( - fn (Session $session): Result => $this->runPending($migrations, $session) - ); + public function refresh(): array { + return $this->locked(fn (): array => array_merge($this->run(self::NONE, true), $this->run(self::LATEST, true))); } /** - * Roll back the latest recorded migration batch. + * One advisory lock per site ledger: independent plugins and sites never block each other. + * + * @template T * - * @param int|null $expectedLatestBatch The expected latest batch, available as Status::$batch from status(). Pass null to roll back whichever batch is latest. + * @param Closure(): T $operation * - * @throws DatabaseException When migration storage or schema access fails. - * @throws InvalidRollbackBatch When the requested batch does not match the latest recorded batch. - * @throws LedgerFailure When a rolled-back migration ledger record cannot be deleted. - * @throws MigrationFailed When a migration fails while rolling back. - * @throws MigrationLockFailed When the lock cannot be acquired, renewed, or released. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - * @throws UnavailableMigration When a recorded migration implementation is unavailable. - * @throws UninitializedStore When migration storage has not been initialized. + * @return T */ - public function rollback(?int $expectedLatestBatch = null): Result { - return $this->store->withMigrationLock(function (Session $session) use ($expectedLatestBatch): Result { - $configured = $this->migrations->all(); - $latestBatch = $this->repository->latestBatch(); - - if ($expectedLatestBatch !== null && $expectedLatestBatch !== $latestBatch) { - throw new InvalidRollbackBatch($expectedLatestBatch, $latestBatch); - } - - if ($latestBatch === null) { - return new Result(); + private function locked(Closure $operation): mixed { + return $this->session->withAdvisoryLock($this->history->table(), function () use ($operation): mixed { + try { + return $operation(); + } catch (Throwable $failure) { + try { + while ($this->connection->isTransactionActive()) { + $this->connection->rollBack(); + } + } catch (Throwable) { + // Preserve the migration failure when transaction cleanup also fails. + } + + throw $failure; } - - $batch = $latestBatch; - - return $this->rollbackRecords( - $configured, - $this->repository->recordsForBatch($batch), - $session - ); }); } /** - * Roll back and rerun all configured migrations. + * Compute the SQL each step would execute, without executing or recording anything. * - * @throws DatabaseException When migration storage or schema access fails. - * @throws LedgerFailure When an applied migration cannot be recorded in the ledger. - * @throws MigrationFailed When a migration fails while running or rolling back. - * @throws MigrationLockFailed When the lock cannot be acquired, renewed, or released. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - * @throws UnavailableMigration When a recorded migration implementation is unavailable. - * @throws UninitializedStore When migration storage has not been initialized. + * @return list */ - public function refresh(): Result { - return $this->store->withMigrationLock(function (Session $session): Result { - $configured = $this->migrations->all(); - $rollback = $this->rollbackRecords($configured, array_values($this->repository->all()), $session); - $run = $this->runPending($configured, $session); - - return new Result( - ran: $run->ran, - rolledBack: $rollback->rolledBack, - skipped: $run->skipped - ); - }); + public function preview(string $target = self::LATEST): array { + $this->assertTarget($target); + + return $this->run($target, false); } /** - * Return the status of every configured and recorded migration. + * List registered and recorded migrations without mutating storage. * - * @throws DatabaseException When migration storage cannot be inspected. - * - * @return list + * @return list */ public function status(): array { - $configured = $this->migrations->all(); - - if (! $this->store->hasLedger()) { - return array_map( - static fn (Migration $migration): Status => Status::pending($migration->id()), - array_values($configured) + $applied = $this->history->applied(); + $rows = []; + + foreach ($this->migrations->all() as $id => $migration) { + $id = (string) $id; + $rows[$id] = new MigrationStatus( + $id, + self::shortName($migration), + $migration instanceof DescribesMigration ? $migration->describe() : '', + $applied[$id] ?? null, ); } - $records = $this->repository->all(); - $statuses = []; + foreach ($applied as $id => $appliedAt) { + $rows[$id] ??= new MigrationStatus((string) $id, null, '', $appliedAt); + } + ksort($rows, SORT_STRING); - foreach ($configured as $migration) { - $statuses[] = isset($records[$migration->id()]) - ? Status::applied($records[$migration->id()]) - : Status::pending($migration->id()); + return array_values($rows); + } + + /** + * @return list + */ + private function run(string $target, bool $execute, bool $applyPending = true): array { + // Capture the operation's site and prefix; a change mid-run is rejected before any ledger write. + $site = $this->scope->capture(); + $prefix = $this->scope->resolveTableName(''); - unset($records[$migration->id()]); + if ($execute) { + $this->history->initialize(); } + $applied = array_map('strval', array_keys($this->history->applied())); + $steps = []; + $simulated = null; + $simulatedNames = []; + + foreach ($this->plan($target, $applied, $applyPending) as [$id, $reverse]) { + $migration = $this->migrations->get($id); + $afterIds = $reverse ? array_values(array_diff($applied, [$id])) : array_merge($applied, [$id]); + $change = $this->planner->plan($applied, $id, $reverse, $simulated, $simulatedNames); + $sql = $change->sql; + + if ($execute) { + foreach ($sql as $statement) { + $this->connection->executeStatement($statement); + } + $this->session->check(); + + if (! $reverse && $migration instanceof MigratesData) { + $migration->migrate($this->connection); + $this->session->check(); + + if ($this->connection->isTransactionActive()) { + throw new MigrationInterrupted('A migration data callback left a transaction open; complete its transaction before returning.'); + } + } + $this->assertScope($site, $prefix); + + try { + $reverse ? $this->history->remove($id) : $this->history->record($id); + } catch (Throwable $failure) { + throw new Exceptions\LedgerFailure('Migration ' . $id . ' changed the schema but could not update its history; retry the migration.', 0, $failure); + } + } else { + $simulated = $change->schema; + $simulatedNames = array_values(array_unique(array_merge($simulatedNames, array_map('strtolower', $change->tableNames)))); + } - foreach ($records as $record) { - $statuses[] = Status::unavailable($record); + $applied = $afterIds; + $steps[] = new Step($id, self::shortName($migration), $reverse, $sql, ! $reverse && $migration instanceof MigratesData); } - return $statuses; + return $steps; } /** - * Roll back recorded migrations in reverse order after confirming every implementation is available. + * Select forward or reverse work. An explicit target names the version that must remain applied. * - * @param array $migrations - * @param list $records Ledger records in ascending execution order. - * @param Session $session The active migration session that maintains lock ownership. + * @param list $applied * - * @throws LedgerFailure When a rolled-back migration ledger record cannot be deleted. - * @throws MigrationFailed When a migration fails while rolling back. - * @throws MigrationLockFailed When the migration lock cannot be renewed. - * @throws LockUnavailableException When the lock backend cannot determine the refresh result. - * @throws UnavailableMigration When a recorded migration implementation is unavailable. + * @return list */ - private function rollbackRecords(array $migrations, array $records, Session $session): Result { - $records = array_reverse($records); - $unavailable = array_values(array_map( - static fn (Record $record): string => $record->migration, - array_filter($records, static fn (Record $record): bool => ! isset($migrations[$record->migration])) + private function plan(string $target, array $applied, bool $applyPending): array { + $reverse = $target === self::LATEST ? [] : array_values(array_filter( + $applied, + static fn (string $id): bool => $target === self::NONE || strcmp($id, $target) > 0, )); + rsort($reverse, SORT_STRING); + $this->assertKnown($applied); + $plan = array_map(static fn (string $id): array => [$id, true], $reverse); - if ($unavailable !== []) { - throw new UnavailableMigration($unavailable); + if ($target === self::NONE || ! $applyPending) { + return $plan; } - - $rolledBack = []; - - foreach ($records as $record) { - $migration = $migrations[$record->migration]; - $session->revert($migration); - - if (! $this->repository->deleteRun($migration->id())) { - throw LedgerFailure::notDeletedAfterRollback($migration->id()); + foreach ($this->migrations->ids() as $id) { + if (in_array($id, $applied, true) || ($target !== self::LATEST && strcmp($id, $target) > 0)) { + continue; } - - $rolledBack[] = $migration->id(); + $plan[] = [$id, false]; } - return new Result(rolledBack: $rolledBack); + return $plan; } /** - * Run migrations that are absent from the ledger and record them in the next batch. - * - * @param array $migrations - * @param Session $session The active migration session that maintains lock ownership. - * - * @throws DatabaseException When migration ledger access fails. - * @throws LedgerFailure When an applied migration cannot be recorded in the ledger. - * @throws MigrationFailed When a migration fails while running. - * @throws MigrationLockFailed When the migration lock cannot be renewed. - * @throws LockUnavailableException When the lock backend cannot determine the refresh result. + * @param list $ids */ - private function runPending(array $migrations, Session $session): Result { - $records = $this->repository->all(); - $ran = []; - $skipped = []; - $batch = ($this->repository->latestBatch() ?? 0) + 1; - - foreach ($migrations as $migration) { - if (isset($records[$migration->id()])) { - $skipped[] = $migration->id(); - continue; + private function assertKnown(array $ids): void { + foreach ($ids as $id) { + if (! $this->migrations->has($id)) { + throw new MigrationInterrupted('Restore the missing migration before reversing it: ' . $id); } + } + } + + private function assertTarget(string $target): void { + if (! in_array($target, [self::NONE, self::LATEST], true) && ! $this->migrations->has($target)) { + throw new InvalidArgumentException('Unknown migration target: ' . $target); + } + } - $session->apply($migration); + /** + * @throws MigrationInterrupted When the WordPress site or table prefix changed during the run. + */ + private function assertScope(int $site, string $prefix): void { + $this->scope->assertCurrent($site); - $this->repository->recordRun($migration->id(), $batch); - $ran[] = $migration->id(); + if ($this->scope->resolveTableName('') !== $prefix) { + throw new MigrationInterrupted('The WordPress table prefix changed during the migration run.'); } + } + + private static function shortName(Migration $migration): string { + $parts = explode('\\', $migration::class); - return new Result(ran: $ran, skipped: $skipped); + return (string) end($parts); } } diff --git a/src/Database/Migration/Repository.php b/src/Database/Migration/Repository.php deleted file mode 100644 index 2a2af6b..0000000 --- a/src/Database/Migration/Repository.php +++ /dev/null @@ -1,151 +0,0 @@ - - */ - public function all(): array { - $records = []; - - foreach ($this->table->query() - ->select('id', 'migration', 'batch', 'ran_at') - ->orderBy('id') - ->get() as $row) { - $record = $this->recordFromRow($row); - - $records[$record->migration] = $record; - } - - return $records; - } - - /** - * Determine whether a migration identifier has been recorded. - * - * @throws DatabaseException When the migration ledger cannot be read. - * @throws InvalidMigrationId When the migration identifier is invalid. - */ - public function hasRun(string $migration): bool { - $migration = (new Id($migration))->value; - - return $this->table->query() - ->select('id') - ->where('migration', '=', $migration) - ->first() !== null; - } - - /** - * Insert a ledger record for a successful migration. - * - * @throws DatabaseException When the migration ledger cannot be written. - * @throws InvalidMigrationId When the migration identifier is invalid. - * @throws LedgerFailure When the insert does not record exactly one migration. - */ - public function recordRun(string $migration, int $batch): void { - $migration = (new Id($migration))->value; - $ranAt = new DateTimeImmutable('now', new DateTimeZone('UTC')); - - $inserted = $this->table->insert([ - 'migration' => $migration, - 'batch' => $batch, - 'ran_at' => $ranAt->format('Y-m-d H:i:s'), - ]); - - if ($inserted !== 1) { - throw LedgerFailure::notInsertedAfterRun($migration); - } - } - - /** - * Delete a ledger record and report whether a row was removed. - * - * @throws DatabaseException When the migration ledger cannot be written. - * @throws InvalidMigrationId When the migration identifier is invalid. - */ - public function deleteRun(string $migration): bool { - $migration = (new Id($migration))->value; - - return $this->table->delete(['migration' => $migration]) > 0; - } - - /** - * Return the latest recorded batch, or null when the ledger is empty. - * - * @throws DatabaseException When the migration ledger cannot be read. - */ - public function latestBatch(): ?int { - $batch = $this->table->query()->max('batch'); - - if ($batch === null) { - return null; - } - - return (int) $batch; - } - - /** - * Return ledger records belonging to one batch in ascending execution order, - * with the oldest record first. - * - * @throws DatabaseException When the migration ledger cannot be read. - * @throws InvalidMigrationId When a stored migration identifier is invalid. - * - * @return list - */ - public function recordsForBatch(int $batch): array { - return array_map( - fn (array $row): Record => $this->recordFromRow($row), - $this->table->query() - ->select('id', 'migration', 'batch', 'ran_at') - ->where('batch', '=', $batch) - ->orderBy('id') - ->get() - ); - } - - /** - * Convert one database row into a validated migration ledger record. - * - * @param array $row - * - * @throws InvalidMigrationId When the stored migration identifier is invalid. - */ - private function recordFromRow(array $row): Record { - return new Record( - id: (int) $row['id'], - migration: (new Id((string) $row['migration']))->value, - batch: (int) $row['batch'], - ranAt: new DateTimeImmutable((string) $row['ran_at'], new DateTimeZone('UTC')) - ); - } -} diff --git a/src/Database/Migration/Schema/Blueprint.php b/src/Database/Migration/Schema/Blueprint.php new file mode 100644 index 0000000..ca2d3f6 --- /dev/null +++ b/src/Database/Migration/Schema/Blueprint.php @@ -0,0 +1,105 @@ + Deferred mutations of the Doctrine schema, in declaration order. */ + private array $operations = []; + + /** + * @param array $tableOptions Default engine/charset options for created tables. + * + * @internal Constructed by Foundation; applications receive this object through provider wiring or migration callbacks. + */ + public function __construct( + private readonly SchemaState $state, + private readonly TableNameResolver $names, + private readonly array $tableOptions = [], + ) { + } + + /** + * Declare a new table and its complete initial definition. + */ + public function create(Table $table): TableBlueprint { + $blueprint = new TableBlueprint($this->resolve($table)); + $this->operations[] = function () use ($blueprint): void { + $name = $blueprint->name(); + + if ($this->state->schema->hasTable($name)) { + throw \Doctrine\DBAL\Schema\Exception\TableAlreadyExists::new($name); + } + $table = DoctrineTable::editor()->setUnquotedName($name)->setOptions($this->tableOptions); + $this->replaceTable($blueprint->applyTo($table, $this->state)); + }; + + return $blueprint; + } + + /** + * Declare additions, changes, and removals on an existing table. + */ + public function table(Table $table): TableBlueprint { + $blueprint = new TableBlueprint($this->resolve($table)); + $this->operations[] = function () use ($blueprint): void { + $this->replaceTable($blueprint->applyTo($this->state->schema->getTable($blueprint->name())->edit(), $this->state)); + }; + + return $blueprint; + } + + /** + * Declare that the table no longer exists after this migration. + */ + public function drop(Table $table): void { + $name = $this->resolve($table); + $this->operations[] = function () use ($name): void { + $this->state->schema->dropTable($name); + foreach (array_keys($this->state->timestamps) as $key) { + if (str_starts_with($key, strtolower($name) . '.')) { + unset($this->state->timestamps[$key]); + } + } + }; + } + + /** + * Resolve the physical table name for the current site. + * + * @return non-empty-string + */ + public function resolve(Table $table): string { + return $this->names->tableName($table); + } + + /** + * Mutate the underlying Doctrine schema with the collected declarations. + * + * @internal Called by the runner after up()/down() returns. + */ + public function apply(): void { + foreach ($this->operations as $operation) { + $operation(); + } + $this->operations = []; + } + + private function replaceTable(DoctrineTable $table): void { + $name = $table->getObjectName()->toString(); + $tables = array_filter($this->state->schema->getTables(), static fn (DoctrineTable $existing): bool => $existing->getObjectName()->toString() !== $name); + $tables[] = $table; + $this->state->schema = new \Doctrine\DBAL\Schema\Schema(array_values($tables)); + } +} diff --git a/src/Database/Migration/Schema/ColumnDefinition.php b/src/Database/Migration/Schema/ColumnDefinition.php new file mode 100644 index 0000000..637dc82 --- /dev/null +++ b/src/Database/Migration/Schema/ColumnDefinition.php @@ -0,0 +1,236 @@ +nullable = $nullable; + + return $this; + } + + /** + * Require a non-null value. + */ + public function notNull(): self { + return $this->nullable(false); + } + + /** + * Use an unsigned numeric column. + */ + public function unsigned(bool $unsigned = true): self { + $this->unsigned = $unsigned; + + return $this; + } + + /** + * Declare the database default value. + */ + public function default(mixed $default): self { + $this->hasDefault = true; + $this->default = $default; + + return $this; + } + + /** + * Let the database fill the column with the current timestamp on insert. + */ + public function useCurrent(): self { + $this->currentDefault = true; + + return $this; + } + + /** + * Let the database refresh the column with the current timestamp on every update. + */ + public function useCurrentOnUpdate(): self { + $this->currentOnUpdate = true; + + return $this; + } + + /** + * Generate increasing numeric identifiers. + */ + public function autoIncrement(): self { + $this->autoIncrement = true; + + return $this; + } + + /** + * Set the column comment. + */ + public function comment(string $comment): self { + $this->comment = $comment; + + return $this; + } + + /** + * Replace an existing column's complete definition instead of adding a new column. + */ + public function change(): self { + $this->change = true; + + return $this; + } + + /** + * Return the declared column name. + */ + public function name(): string { + return $this->name; + } + + /** + * Add or modify the Doctrine column on the given table. + * + * @internal Called by TableBlueprint while the runner applies declarations. + */ + public function applyTo(TableEditor $table): void { + $column = Column::editor() + ->setUnquotedName($this->name) + ->setTypeName($this->type) + ->setNotNull(! $this->nullable) + ->setUnsigned($this->unsigned) + ->setAutoincrement($this->autoIncrement) + ->setComment($this->comment ?? ''); + + if (isset($this->options['length'])) { + $column->setLength($this->options['length']); + } + + if (isset($this->options['precision'])) { + $column->setPrecision($this->options['precision']); + } + + if (isset($this->options['scale'])) { + $column->setScale($this->options['scale']); + } + + if ($this->hasDefault) { + $column->setDefaultValue($this->normalizedDefault()); + } + + if ($this->currentDefault) { + $column->setDefaultValue($this->currentTimestamp()); + } + + if ($this->isTimestamp()) { + $column->setColumnDefinition($this->timestampDefinition()); + } + + if ($this->change) { + $table->dropColumnByUnquotedName($this->name); + } + $table->addColumn($column->create()); + } + + /** + * Return the timestamp facts carried beside Doctrine's schema representation. + * + * @internal + * + * @return array{precision: int, on_update: bool}|null + */ + public function timestampAttributes(): ?array { + return $this->isTimestamp() ? ['precision' => $this->precision ?? 0, 'on_update' => $this->currentOnUpdate] : null; + } + + /** + * MySQL reports decimal defaults at the column's scale ('0.0000'); declare them the same way so + * the comparator sees no difference on a second run. + */ + private function normalizedDefault(): mixed { + if ($this->type !== Types::DECIMAL || ! is_scalar($this->default) || preg_match('/^(-?\\d+)(?:\\.(\\d*))?$/', (string) $this->default, $parts) !== 1) { + return $this->default; + } + $scale = (int) ($this->options['scale'] ?? 0); + $fraction = $parts[2] ?? ''; + + if (strlen($fraction) > $scale) { + return $this->default; // More precision than the column stores; let the comparator surface it. + } + $fraction = str_pad($fraction, $scale, '0'); + + return $scale === 0 ? $parts[1] : $parts[1] . '.' . $fraction; + } + + private function isTimestamp(): bool { + return $this->type === Types::DATETIME_MUTABLE || $this->type === Types::DATETIME_IMMUTABLE; + } + + private function currentTimestamp(): string { + // Explicit precision 0 and omitted precision are the same column; the database reports both as CURRENT_TIMESTAMP. + return ($this->precision ?? 0) === 0 ? 'CURRENT_TIMESTAMP' : 'CURRENT_TIMESTAMP(' . $this->precision . ')'; + } + + /** + * @return non-empty-string + */ + private function timestampDefinition(): string { + $sql = ($this->precision ?? 0) === 0 ? 'DATETIME' : 'DATETIME(' . $this->precision . ')'; + $sql .= $this->nullable ? ' DEFAULT NULL' : ' NOT NULL'; + + if ($this->currentDefault) { + $sql .= ' DEFAULT ' . $this->currentTimestamp(); + } elseif ($this->hasDefault && $this->default !== null) { + $sql .= " DEFAULT '" . addslashes((string) $this->default) . "'"; + } + + if ($this->currentOnUpdate) { + $sql .= ' ON UPDATE ' . $this->currentTimestamp(); + } + + if ($this->comment !== null) { + $sql .= " COMMENT '" . addslashes($this->comment) . "'"; + } + + return $sql; + } +} diff --git a/src/Database/Migration/Schema/SchemaPlanner.php b/src/Database/Migration/Schema/SchemaPlanner.php new file mode 100644 index 0000000..60629dd --- /dev/null +++ b/src/Database/Migration/Schema/SchemaPlanner.php @@ -0,0 +1,514 @@ + $tableOptions Defaults such as charset/collation applied to created tables. + */ + public function __construct( + private Connection $connection, + private TableNameResolver $names, + private MigrationCollection $migrations, + private array $tableOptions = [], + ) { + } + + /** + * Authorize only the remaining changes declared by one historical migration. + * + * @param list $applied + * @param list $simulatedNames Tables already decided by an earlier preview step. + * + * @throws IncompatibleSchema When live drift would require undeclared changes. + * @throws \Throwable When a declaration or schema inspection fails. + */ + public function plan(array $applied, string $id, bool $reverse, ?SchemaState $simulated = null, array $simulatedNames = []): SchemaPlan { + $before = $this->replay($applied); + $after = $reverse ? $this->reverse($before, $id) : $this->replay([...$applied, $id]); + $expected = $this->compare($before, $after); + $names = $this->tableNames($before, $after); + $actual = $this->introspect($names, $simulated, $simulatedNames); + $desired = $this->preserveUndeclared($actual, $before, $after); + $remaining = $this->compare($actual, $desired); + $this->assertContained($remaining, $expected, $id); + $sql = array_merge( + $this->sql($remaining), + $this->timestampSql($actual, $desired, $remaining, $this->timestampChanges($before, $after), $id), + $this->commentSql($actual, $before, $after, $id), + ); + + return new SchemaPlan($desired, $sql, $names); + } + + /** + * Replay the given IDs' up() declarations in byte order on an empty schema. Pure PHP; no queries. + * + * @param list $ids + */ + private function replay(array $ids): SchemaState { + $schema = new SchemaState(new Schema()); + sort($ids, SORT_STRING); + + foreach ($ids as $id) { + $blueprint = new Blueprint($schema, $this->names, $this->tableOptions); + $this->migrations->get($id)->up($blueprint); + $this->apply($blueprint, $id); + } + + return $schema; + } + + private function apply(Blueprint $blueprint, string $id): void { + try { + $blueprint->apply(); + } catch (SchemaException $failure) { + throw new MigrationInterrupted(sprintf( + 'Migration %s cannot be applied to the schema that recorded history produces (%s). Check the declaration and migration order, or repair the ledger.', + $id, + $failure->getMessage(), + ), 0, $failure); + } + } + + /** + * Apply one migration's down() to a copy of the schema it was applied on. + * + * @throws MigrationInterrupted When the inverse cannot be applied to recorded history. + */ + private function reverse(SchemaState $before, string $id): SchemaState { + $after = clone $before; + $blueprint = new Blueprint($after, $this->names, $this->tableOptions); + $this->migrations->get($id)->down($blueprint); + $this->apply($blueprint, $id); + + return $after; + } + + /** + * Introspect the owned tables that currently exist, overlaying tables a preview has already simulated. + * + * A simulated table that is absent from the overlay was deliberately dropped by an earlier step, + * so it must not be reloaded from the database. + * + * @param list $names + * @param list $simulated Lower-cased names of every table an earlier preview step decided on. + */ + private function introspect(array $names, ?SchemaState $overlay = null, array $simulated = []): SchemaState { + $manager = $this->connection->createSchemaManager(); + $tables = []; + $timestamps = $this->timestampAttributes($names); + + foreach (array_unique($names) as $name) { + if ($overlay?->schema->hasTable($name) ?? false) { + $tables[] = $overlay->schema->getTable($name); + } elseif (in_array(strtolower($name), $simulated, true)) { + continue; // Dropped by an earlier simulated step. + } elseif ($manager->tablesExist([$name])) { + $tables[] = $this->normalize($manager->introspectTable($name)); + } + } + + $facts = []; + foreach ($tables as $table) { + $name = strtolower($table->getObjectName()->toString()); + + if ($overlay?->schema->hasTable($name)) { + foreach ($overlay->timestamps as $key => $attributes) { + if (str_starts_with($key, $name . '.')) { + $facts[$key] = $attributes; + } + } + } else { + foreach ($timestamps[$name] ?? [] as $column => $attributes) { + $facts[$name . '.' . $column] = $attributes; + } + } + } + + return new SchemaState(new Schema($tables), $facts); + } + + /** + * Read what DBAL does not: fractional precision and ON UPDATE for datetime columns. + * + * @param list $names + * + * @return array> Lower-cased table, then column. + */ + private function timestampAttributes(array $names): array { + if ($names === []) { + return []; + } + $rows = $this->connection->fetchAllAssociative( + 'SELECT TABLE_NAME, COLUMN_NAME, DATETIME_PRECISION, EXTRA FROM information_schema.COLUMNS' + . ' WHERE TABLE_SCHEMA = DATABASE() AND DATA_TYPE IN (\'datetime\', \'timestamp\') AND TABLE_NAME IN (' + . implode(', ', array_fill(0, count($names), '?')) . ')', + $names, + ); + $attributes = []; + foreach ($rows as $row) { + $attributes[strtolower((string) $row['TABLE_NAME'])][strtolower((string) $row['COLUMN_NAME'])] = ['precision' => (int) $row['DATETIME_PRECISION'], 'on_update' => stripos((string) $row['EXTRA'], 'on update') !== false]; + } + + return $attributes; + } + + /** + * Align engine-reported values with Foundation's declarations where DBAL does not. + * + * MariaDB reports fractional timestamp defaults as `current_timestamp(6)`; DBAL only recognizes + * the exact `CURRENT_TIMESTAMP`. Declarations always use upper case, so upper-case the report. + */ + private function normalize(Table $table): Table { + $editor = $table->edit(); + foreach ($table->getColumns() as $column) { + $default = $column->getDefault(); + + if (is_string($default) && preg_match('/^current_timestamp(\(\d+\))?$/i', $default) === 1) { + $editor->modifyColumn($column->getObjectName(), static function (\Doctrine\DBAL\Schema\ColumnEditor $column) use ($default): void { + $column->setDefaultValue(strtoupper($default)); + }); + } + } + + return $editor->create(); + } + + /** + * Return a copy of the desired schema that keeps columns and indexes the history never declared. + * + * Unrelated additions made outside migrations are preserved rather than dropped. + */ + private function preserveUndeclared(SchemaState $actual, SchemaState $before, SchemaState $after): SchemaState { + $preserved = clone $after; + $tables = []; + foreach ($preserved->schema->getTables() as $target) { + $name = $target->getObjectName()->toString(); + + if (! $actual->schema->hasTable($name)) { + $tables[] = $target; + continue; + } + $actualTable = $actual->schema->getTable($name); + $beforeTable = $before->schema->hasTable($name) ? $before->schema->getTable($name) : null; + $editor = $target->edit(); + foreach ($actualTable->getColumns() as $column) { + $columnName = $column->getObjectName()->toString(); + + if ($target->hasColumn($columnName) || ($beforeTable?->hasColumn($columnName) ?? false)) { + continue; + } + $editor->addColumn($column); + $key = strtolower($name . '.' . $columnName); + + if (isset($actual->timestamps[$key])) { + $preserved->timestamps[$key] = $actual->timestamps[$key]; + } + } + $primary = $actualTable->getPrimaryKeyConstraint()?->getObjectName()?->toString() ?? 'primary'; + foreach ($actualTable->getIndexes() as $index) { + $indexName = $index->getObjectName()->toString(); + + if (strcasecmp($indexName, $primary) === 0 || $target->hasIndex($indexName) || ($beforeTable?->hasIndex($indexName) ?? false)) { + continue; + } + $editor->addIndex($index); + } + foreach ($actualTable->getForeignKeys() as $foreignKey) { + $key = $foreignKey->getObjectName()?->toString(); + + if ($key !== null && ($target->hasForeignKey($key) || ($beforeTable?->hasForeignKey($key) ?? false))) { + continue; + } + $editor->addForeignKeyConstraint($foreignKey); + } + $tables[] = $editor->create(); + } + $preserved->schema = new Schema($tables); + + return $preserved; + } + + /** + * Authorize SQL for timestamp attributes DBAL cannot compare. + * + * @param list $declaredChanges Lower-cased "table.column" keys. + * + * @return list SQL statements for attributes DBAL does not compare. + */ + private function timestampSql(SchemaState $actual, SchemaState $desired, SchemaDiff $remaining, array $declaredChanges, string $id): array { + $platform = $this->connection->getDatabasePlatform(); + $sql = []; + $covered = $this->columnsRewrittenBy($remaining); + + foreach ($desired->schema->getTables() as $table) { + if (! $actual->schema->hasTable($table->getObjectName()->toString())) { + continue; // CREATE TABLE already carries the full column definitions. + } + $actualTable = $actual->schema->getTable($table->getObjectName()->toString()); + + foreach ($table->getColumns() as $column) { + $key = strtolower($table->getObjectName()->toString() . '.' . $column->getObjectName()->toString()); + + if (in_array($key, $covered, true)) { + continue; // Doctrine's CHANGE/ADD already applies the complete declaration. + } + + if (! $actualTable->hasColumn($column->getObjectName()->toString()) || ! isset($desired->timestamps[$key]) || $desired->timestamps[$key] === ($actual->timestamps[$key] ?? ['precision' => 0, 'on_update' => false])) { + continue; + } + $this->require(in_array($key, $declaredChanges, true), $id, $table->getObjectName()->toString(), 'column ' . $column->getObjectName()->toString() . ' timestamp precision or ON UPDATE differs from its declaration'); + $sql[] = 'ALTER TABLE ' . $platform->quoteIdentifier($table->getObjectName()->toString()) . ' MODIFY ' . $platform->getColumnDeclarationSQL($platform->quoteIdentifier($column->getObjectName()->toString()), $column->toArray()); + } + } + + return $sql; + } + + /** + * Compare table comments, which DBAL does not include in its table differences. + * + * @return list + */ + private function commentSql(SchemaState $actual, SchemaState $before, SchemaState $after, string $id): array { + $sql = []; + $platform = $this->connection->getDatabasePlatform(); + foreach ($after->schema->getTables() as $table) { + $name = $table->getObjectName()->toString(); + + if (! $actual->schema->hasTable($name)) { + continue; + } + $comment = $table->getComment() ?? ''; + + if (($actual->schema->getTable($name)->getComment() ?? '') === $comment) { + continue; + } + $previous = $before->schema->hasTable($name) ? ($before->schema->getTable($name)->getComment() ?? '') : $comment; + $this->require($previous !== $comment, $id, $name, 'table comment differs from its declaration'); + $sql[] = 'ALTER TABLE ' . $platform->quoteSingleIdentifier($name) . ' COMMENT = ' . $platform->quoteStringLiteral($comment); + } + + return $sql; + } + + /** + * Columns whose complete declaration Doctrine's alteration SQL already emits. + * + * @return list Lower-cased "table.column" keys. + */ + private function columnsRewrittenBy(SchemaDiff $diff): array { + $keys = []; + foreach ($diff->getAlteredTables() as $tableDiff) { + $table = $this->tableName($tableDiff); + foreach ($tableDiff->getAddedColumns() as $column) { + $keys[] = strtolower($table . '.' . $column->getObjectName()->toString()); + } + foreach ($tableDiff->getChangedColumns() as $columnDiff) { + $keys[] = strtolower($table . '.' . $columnDiff->getOldColumn()->getObjectName()->toString()); + } + } + + return $keys; + } + + /** + * Timestamp columns whose declared attributes change between two desired states. + * + * @return list Lower-cased "table.column" keys. + */ + private function timestampChanges(SchemaState $before, SchemaState $after): array { + $changes = []; + foreach ($after->timestamps as $key => $attributes) { + if (($before->timestamps[$key] ?? null) !== $attributes) { + $changes[] = $key; + } + } + + return $changes; + } + + /** + * Compare two snapshots using the configured Doctrine platform. + */ + private function compare(SchemaState $from, SchemaState $to): SchemaDiff { + return $this->connection->createSchemaManager()->createComparator((new ComparatorConfig())->withReportModifiedIndexes(false))->compareSchemas($from->schema, $to->schema); + } + + /** + * Generate platform DDL for the supplied differences. + * + * @return list + */ + private function sql(SchemaDiff $diff): array { + return $this->connection->getDatabasePlatform()->getAlterSchemaSQL($diff); + } + + /** + * Names of every table present in either schema. + * + * @return list + */ + private function tableNames(SchemaState ...$schemas): array { + $names = []; + foreach ($schemas as $schema) { + foreach ($schema->schema->getTables() as $table) { + $names[] = $table->getObjectName()->toString(); + } + } + + return array_values(array_unique($names)); + } + + /** + * Fail when the remaining work contains anything the migration did not declare. + * + * Remaining work is the difference between the live schema and the desired state; expected work + * is the difference the migration itself declares. Remaining must be a subset of expected, except + * that a table the migration creates may already exist with a subset of its declared columns. + * + * @throws IncompatibleSchema + */ + private function assertContained(SchemaDiff $remaining, SchemaDiff $expected, string $id): void { + $expectedCreated = $this->names($expected->getCreatedTables()); + $expectedDropped = $this->names($expected->getDroppedTables()); + $expectedAltered = []; + foreach ($expected->getAlteredTables() as $diff) { + $expectedAltered[strtolower($this->tableName($diff))] = $diff; + } + + foreach ($remaining->getCreatedTables() as $table) { + $this->require(in_array(strtolower($table->getObjectName()->toString()), $expectedCreated, true), $id, $table->getObjectName()->toString(), 'table would be created'); + } + + foreach ($remaining->getDroppedTables() as $table) { + $this->require(in_array(strtolower($table->getObjectName()->toString()), $expectedDropped, true), $id, $table->getObjectName()->toString(), 'table would be dropped'); + } + + foreach ($remaining->getAlteredTables() as $diff) { + $name = $this->tableName($diff); + $key = strtolower($name); + + if (isset($expectedAltered[$key])) { + $this->assertAlterationContained($diff, $expectedAltered[$key], $id, $name); + + continue; + } + + if (in_array($key, $expectedCreated, true)) { + // The table already exists; only missing declared columns/indexes may be added. + $this->require($diff->getChangedColumns() === [] && $diff->getDroppedColumns() === [] + && $diff->getDroppedIndexes() === [] && $diff->getRenamedIndexes() === [], $id, $name, 'existing table differs from the declared initial definition'); + + continue; + } + + $this->require(false, $id, $name, $this->describe($diff)); + } + } + + private function assertAlterationContained(TableDiff $remaining, TableDiff $expected, string $id, string $table): void { + $added = $this->names($expected->getAddedColumns()); + $modified = array_map(static fn ($diff): string => strtolower($diff->getOldColumn()->getObjectName()->toString()), $expected->getChangedColumns()); + $dropped = $this->names($expected->getDroppedColumns()); + $indexes = [ + 'added' => $this->names($expected->getAddedIndexes()), + 'dropped' => $this->names($expected->getDroppedIndexes()), + ]; + + foreach ($remaining->getAddedColumns() as $column) { + $this->require(in_array(strtolower($column->getObjectName()->toString()), $added, true), $id, $table, 'column ' . $column->getObjectName()->toString() . ' would be added'); + } + + foreach ($remaining->getChangedColumns() as $diff) { + $name = $diff->getOldColumn()->getObjectName()->toString(); + $this->require(! $diff->hasNameChanged() && in_array(strtolower($name), $modified, true), $id, $table, 'column ' . $name . ' differs from its declaration'); + } + + foreach ($remaining->getDroppedColumns() as $column) { + $this->require(in_array(strtolower($column->getObjectName()->toString()), $dropped, true), $id, $table, 'column ' . $column->getObjectName()->toString() . ' would be dropped'); + } + + $this->require($remaining->getRenamedIndexes() === [], $id, $table, 'a rename would be required'); + $this->requireNoForeignKeyChanges($remaining, $id, $table); + + // A replacement authorizes both a drop and an addition. An interrupted drop leaves only + // the authorized addition; a conflicting addition would require an unauthorized drop. + foreach ($remaining->getAddedIndexes() as $index) { + $name = strtolower($index->getObjectName()->toString()); + $this->require(in_array($name, $indexes['added'], true), $id, $table, 'index ' . $index->getObjectName()->toString() . ' would be added'); + } + + foreach ($remaining->getDroppedIndexes() as $index) { + $this->require(in_array(strtolower($index->getObjectName()->toString()), $indexes['dropped'], true), $id, $table, 'existing index ' . $index->getObjectName()->toString() . ' conflicts with its declaration'); + } + } + + /** + * The blueprint has no foreign-key vocabulary, so any remaining constraint change is undeclared. + */ + private function requireNoForeignKeyChanges(TableDiff $diff, string $id, string $table): void { + $this->require($diff->getAddedForeignKeys() === [] && $diff->getDroppedForeignKeyConstraintNames() === [], $id, $table, 'an undeclared foreign key would change'); + } + + private function describe(TableDiff $diff): string { + $parts = []; + foreach ($diff->getAddedColumns() as $column) { + $parts[] = 'column ' . $column->getObjectName()->toString() . ' would be added'; + } + foreach ($diff->getChangedColumns() as $columnDiff) { + $parts[] = 'column ' . $columnDiff->getOldColumn()->getObjectName()->toString() . ' differs from its declaration'; + } + foreach ($diff->getDroppedColumns() as $column) { + $parts[] = 'column ' . $column->getObjectName()->toString() . ' would be dropped'; + } + + return $parts === [] ? 'table would be altered' : implode('; ', $parts); + } + + /** + * @param array $assets + * + * @return list + */ + private function names(array $assets): array { + return array_values(array_map(static fn (Table|Column|Index $asset): string => strtolower($asset->getObjectName()->toString()), $assets)); + } + + private function tableName(TableDiff $diff): string { + return $diff->getOldTable()->getObjectName()->toString(); + } + + private function require(bool $condition, string $id, string $table, string $change): void { + if (! $condition) { + throw IncompatibleSchema::forChange($id, $table, $change); + } + } +} diff --git a/src/Database/Migration/Schema/SchemaState.php b/src/Database/Migration/Schema/SchemaState.php new file mode 100644 index 0000000..0a613f7 --- /dev/null +++ b/src/Database/Migration/Schema/SchemaState.php @@ -0,0 +1,31 @@ + $timestamps Physical table.column keys. + */ + public function __construct( + public Schema $schema, + public array $timestamps = [], + ) { + } + + /** + * Isolate mutable Doctrine objects when computing another migration state. + */ + public function __clone(): void { + $this->schema = clone $this->schema; + } +} diff --git a/src/Database/Migration/Schema/TableBlueprint.php b/src/Database/Migration/Schema/TableBlueprint.php new file mode 100644 index 0000000..5aa67f4 --- /dev/null +++ b/src/Database/Migration/Schema/TableBlueprint.php @@ -0,0 +1,304 @@ + + */ + private array $columns = []; + /** + * @var list, unique: bool}> + */ + private array $indexes = []; + /** + * @var list + */ + private array $primary = []; + /** + * @var list + */ + private array $droppedColumns = []; + /** + * @var list + */ + private array $droppedIndexes = []; + private ?string $comment = null; + + /** + * Receive the resolved table name. + * + * @param non-empty-string $name + * + * @internal Constructed by Foundation; applications receive this object through provider wiring or migration callbacks. + */ + public function __construct( + private readonly string $name, + ) { + } + + /** + * Return the physical table name. + * + * @return non-empty-string + */ + public function name(): string { + return $this->name; + } + + /** + * Unsigned auto-incrementing BIGINT primary key. + * + * @param non-empty-string $name + */ + public function bigIncrements(string $name = 'id'): ColumnDefinition { + $this->primary = [$name]; + + return $this->column($name, Types::BIGINT)->unsigned()->autoIncrement(); + } + + /** + * Declare a variable-length string. + * + * @param non-empty-string $name + */ + public function string(string $name, int $length = 191): ColumnDefinition { + return $this->column($name, Types::STRING, ['length' => $length]); + } + + /** + * TEXT (up to 64 KiB). Use longText() for LONGTEXT. + * + * @param non-empty-string $name + */ + public function text(string $name): ColumnDefinition { + return $this->column($name, Types::TEXT, ['length' => 65535]); + } + + /** + * Declare a long text column. + * + * @param non-empty-string $name + */ + public function longText(string $name): ColumnDefinition { + return $this->column($name, Types::TEXT); + } + + /** + * Declare an integer column. + * + * @param non-empty-string $name + */ + public function integer(string $name): ColumnDefinition { + return $this->column($name, Types::INTEGER); + } + + /** + * Declare an unsigned integer column. + * + * @param non-empty-string $name + */ + public function unsignedInteger(string $name): ColumnDefinition { + return $this->integer($name)->unsigned(); + } + + /** + * Declare a large integer column. + * + * @param non-empty-string $name + */ + public function bigInteger(string $name): ColumnDefinition { + return $this->column($name, Types::BIGINT); + } + + /** + * Declare an unsigned large integer column. + * + * @param non-empty-string $name + */ + public function unsignedBigInteger(string $name): ColumnDefinition { + return $this->bigInteger($name)->unsigned(); + } + + /** + * Declare boolean storage. + * + * @param non-empty-string $name + */ + public function boolean(string $name): ColumnDefinition { + return $this->column($name, Types::BOOLEAN); + } + + /** + * Declare an exact decimal precision and scale. + * + * @param non-empty-string $name + */ + public function decimal(string $name, int $precision = 10, int $scale = 2): ColumnDefinition { + return $this->column($name, Types::DECIMAL, ['precision' => $precision, 'scale' => $scale]); + } + + /** + * DATETIME with optional fractional-second precision (0-6). + * + * @param non-empty-string $name + */ + public function dateTime(string $name, ?int $precision = null): ColumnDefinition { + $definition = new ColumnDefinition($name, Types::DATETIME_MUTABLE, [], $precision); + $this->columns[] = $definition; + + return $definition; + } + + /** + * VARBINARY of the given length. + * + * @param non-empty-string $name + */ + public function binary(string $name, int $length): ColumnDefinition { + return $this->column($name, Types::BINARY, ['length' => $length]); + } + + /** + * Declare the primary key columns. + * + * @param non-empty-string $columns + */ + public function primary(string ...$columns): self { + $this->primary = array_values($columns); + + return $this; + } + + /** + * Declare a unique index over one or more columns. + * + * @param non-empty-string $name + * @param non-empty-string $columns + * + * @throws \InvalidArgumentException When no columns are supplied. + */ + public function unique(string $name, string ...$columns): self { + if ($columns === []) { + throw new \InvalidArgumentException('An index must contain at least one column.'); + } + + $this->indexes[] = ['name' => $name, 'columns' => array_values($columns), 'unique' => true]; + + return $this; + } + + /** + * Declare a secondary index over one or more columns. + * + * @param non-empty-string $name + * @param non-empty-string $columns + * + * @throws \InvalidArgumentException When no columns are supplied. + */ + public function index(string $name, string ...$columns): self { + if ($columns === []) { + throw new \InvalidArgumentException('An index must contain at least one column.'); + } + + $this->indexes[] = ['name' => $name, 'columns' => array_values($columns), 'unique' => false]; + + return $this; + } + + /** + * Remove a column owned by this migration. + * + * @param non-empty-string $name + */ + public function dropColumn(string $name): self { + $this->droppedColumns[] = $name; + + return $this; + } + + /** + * Remove an index owned by this migration. + * + * @param non-empty-string $name + */ + public function dropIndex(string $name): self { + $this->droppedIndexes[] = $name; + + return $this; + } + + /** + * Set the table comment. + */ + public function comment(string $comment): self { + $this->comment = $comment; + + return $this; + } + + /** + * Translate the collected declarations onto the Doctrine table. + * + * @internal Called by Blueprint::apply(). + */ + public function applyTo(TableEditor $editor, SchemaState $state): Table { + foreach ($this->droppedIndexes as $index) { + $editor->dropIndexByUnquotedName($index); + } + foreach ($this->droppedColumns as $column) { + $editor->dropColumnByUnquotedName($column); + unset($state->timestamps[strtolower($this->name . '.' . $column)]); + } + foreach ($this->columns as $column) { + $column->applyTo($editor); + $key = strtolower($this->name . '.' . $column->name()); + $attributes = $column->timestampAttributes(); + unset($state->timestamps[$key]); + + if ($attributes !== null) { + $state->timestamps[$key] = $attributes; + } + } + + if ($this->primary !== []) { + $editor->setPrimaryKeyConstraint(PrimaryKeyConstraint::editor()->setUnquotedColumnNames(...$this->primary)->create()); + } + foreach ($this->indexes as $index) { + $editor->addIndex(Index::editor()->setUnquotedName($index['name']) + ->setUnquotedColumnNames(...$index['columns']) + ->setType($index['unique'] ? IndexType::UNIQUE : IndexType::REGULAR)->create()); + } + + if ($this->comment !== null) { + $editor->setComment($this->comment); + } + + return $editor->create(); + } + + /** + * @param non-empty-string $name + * @param array{length?: int, precision?: int, scale?: int} $options + */ + private function column(string $name, string $type, array $options = []): ColumnDefinition { + $definition = new ColumnDefinition($name, $type, $options); + $this->columns[] = $definition; + + return $definition; + } +} diff --git a/src/Database/Migration/Schema/ValueObjects/SchemaPlan.php b/src/Database/Migration/Schema/ValueObjects/SchemaPlan.php new file mode 100644 index 0000000..4721062 --- /dev/null +++ b/src/Database/Migration/Schema/ValueObjects/SchemaPlan.php @@ -0,0 +1,26 @@ + $sql + * @param list $tableNames + */ + public function __construct( + public SchemaState $schema, + public array $sql, + public array $tableNames, + ) { + } +} diff --git a/src/Database/Migration/Session.php b/src/Database/Migration/Session.php deleted file mode 100644 index 7b82fc8..0000000 --- a/src/Database/Migration/Session.php +++ /dev/null @@ -1,68 +0,0 @@ -lease->renew(); - - try { - $migration->up($this->schema); - } catch (Throwable $throwable) { - throw MigrationFailed::whileRunning($migration->id(), $throwable); - } - - $this->lease->renew(); - } - - /** - * Renew the migration lease immediately before and after reverting a migration. - * - * @throws DatabaseContextChanged When the active database context changes. - * @throws MigrationFailed When the migration fails while rolling back. - * @throws MigrationLockFailed When migration lock ownership is lost. - * @throws LockUnavailableException When the lock backend cannot determine the refresh result. - */ - public function revert(Migration $migration): void { - $this->lease->renew(); - - try { - $migration->down($this->schema); - } catch (Throwable $throwable) { - throw MigrationFailed::whileRollingBack($migration->id(), $throwable); - } - - $this->lease->renew(); - } -} diff --git a/src/Database/Migration/Store.php b/src/Database/Migration/Store.php deleted file mode 100644 index 5d223a9..0000000 --- a/src/Database/Migration/Store.php +++ /dev/null @@ -1,188 +0,0 @@ -lockName) === '') { - throw new InvalidArgumentException('The migration lock name cannot be empty.'); - } - - if ($this->lockTtl < 1) { - throw new InvalidArgumentException('The migration lock TTL must be at least one second.'); - } - } - - /** - * Initialize or reconcile the complete migration store before migrations run. - * - * @throws DatabaseException When migration storage cannot be initialized. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - */ - public function initialize(): void { - $scopeId = $this->scope->capture(); - - $this->storeSchema->initializeLock(); - $lease = $this->acquireLease($scopeId); - - try { - $this->storeSchema->initializeLedger(); - } catch (Throwable $failure) { - $this->releasePreservingFailure($lease, $failure); - } - - $lease->release(); - } - - /** - * Drop the migration ledger while preserving shared lock storage. - * - * @throws DatabaseException When the ledger cannot be dropped. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - * @throws UninitializedStore When migration storage has not been initialized. - */ - public function drop(): void { - $this->withMigrationLock(function (): void { - $this->storeSchema->dropLedger(); - }); - } - - /** - * Determine whether the migration subsystem storage is ready. - * - * @throws DatabaseException When migration storage cannot be inspected. - */ - public function isInitialized(): bool { - return $this->storeSchema->hasLedger() && $this->storeSchema->hasLock(); - } - - /** - * Determine whether recorded migration state can be read. - * - * @throws DatabaseException When migration storage cannot be inspected. - */ - public function hasLedger(): bool { - return $this->storeSchema->hasLedger(); - } - - /** - * Run an operation against initialized migration storage while holding its lock. - * - * @template T - * - * @param callable(Session): T $operation The operation receives a session that maintains the migration lease around each schema change. - * - * @throws DatabaseException When migration storage cannot be inspected. - * @throws MigrationLockFailed When the lock cannot be acquired, renewed, or released. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - * @throws UninitializedStore When migration storage has not been initialized. - * - * @return T - */ - public function withMigrationLock(callable $operation): mixed { - $scopeId = $this->scope->capture(); - - // Migration lock storage must exist before lock acquisition. - $hasLockTable = $this->storeSchema->hasLock(); - $this->scope->assertCurrent($scopeId); - - if (! $hasLockTable) { - throw new UninitializedStore(); - } - - $lease = $this->acquireLease($scopeId); - - try { - // The ledger may have changed before this process acquired the lock. - $hasLedger = $this->hasLedger(); - $this->scope->assertCurrent($scopeId); - - if (! $hasLedger) { - throw new UninitializedStore(); - } - - $result = $operation($this->sessionFactory->create($lease)); - } catch (Throwable $failure) { - $this->releasePreservingFailure($lease, $failure); - } - - $lease->release(); - - return $result; - } - - /** - * Acquire a migration lease in the captured database scope. - * - * The lock table is the one bootstrap exception: it must exist before its own - * database-backed lock can be acquired. - * - * @throws DatabaseException When database scope validation fails. - * @throws MigrationLockFailed When the lock cannot be acquired. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - */ - private function acquireLease(int $scopeId): Lease { - $this->scope->assertCurrent($scopeId); - $token = $this->lock->acquire($this->lockName, $this->lockTtl); - $this->scope->assertCurrent($scopeId); - - if ($token === null) { - throw MigrationLockFailed::forLock($this->lockName); - } - - return $this->leaseFactory->create( - $this->lock, - $this->scope, - $scopeId, - $token, - $this->lockTtl - ); - } - - /** - * Attempt to release a migration lease without replacing the primary failure. - * - * @throws Throwable Always rethrows the primary operation failure. - */ - private function releasePreservingFailure(Lease $lease, Throwable $failure): never { - try { - $lease->release(); - } catch (Throwable) { - // Preserve the primary failure and let the lock expire when cleanup is uncertain. - } - - throw $failure; - } -} diff --git a/src/Database/Migration/StoreSchema.php b/src/Database/Migration/StoreSchema.php deleted file mode 100644 index b353184..0000000 --- a/src/Database/Migration/StoreSchema.php +++ /dev/null @@ -1,110 +0,0 @@ -lockTable); - - $blueprint->column(new Column('name', 'varbinary', 191)); - $blueprint->column(new Column('owner', 'varbinary', 64)); - $blueprint->dateTime('expires_at', 6); - $blueprint->dateTime('created_at', 6); - $blueprint->dateTime('updated_at', 6); - $blueprint->primary('name'); - $blueprint->index('expires_at', 'expires_at'); - - $this->initialize($blueprint); - } - - /** - * Create or recover the ledger that records completed migration batches. - * - * @throws DatabaseException When the migration ledger cannot be created or reconciled. - */ - public function initializeLedger(): void { - $blueprint = Blueprint::for($this->migrationTable); - - $blueprint->bigIncrements('id'); - $blueprint->column(new Column('migration', 'varbinary', 191)); - $blueprint->unsignedInteger('batch'); - $blueprint->dateTime('ran_at'); - $blueprint->unique('migration', 'migration'); - $blueprint->index('batch', 'batch'); - - $this->initialize($blueprint); - } - - /** - * Determine whether the migration lock table exists in the active database scope. - * - * @throws DatabaseException When the lock table cannot be inspected. - */ - public function hasLock(): bool { - return $this->schema->hasTable($this->lockTable); - } - - /** - * Determine whether the migration ledger exists in the active database scope. - * - * @throws DatabaseException When the migration ledger cannot be inspected. - */ - public function hasLedger(): bool { - return $this->schema->hasTable($this->migrationTable); - } - - /** - * Drop the migration ledger while preserving the shared lock table. - * - * @throws DatabaseException When the migration ledger cannot be dropped. - */ - public function dropLedger(): void { - $this->schema->drop($this->migrationTable); - } - - /** - * Create missing storage through the public schema API or reconcile storage already owned by Foundation. - * - * @throws DatabaseException When the storage table cannot be created or reconciled. - */ - private function initialize(Blueprint $blueprint): void { - if (! $this->schema->hasTable($blueprint->table())) { - $this->schema->create($blueprint); - - return; - } - - $blueprint->assertValidForCreate(); - $this->reconciler->reconcile($blueprint); - } -} diff --git a/src/Database/Migration/ValueObjects/Id.php b/src/Database/Migration/ValueObjects/Id.php index 8c52090..f757b59 100644 --- a/src/Database/Migration/ValueObjects/Id.php +++ b/src/Database/Migration/ValueObjects/Id.php @@ -14,7 +14,7 @@ /** * Validate and retain an identifier that can be stored safely in the ledger. * - * @throws InvalidMigrationId When the identifier is blank, padded, integer-like, or too long for the ledger. + * @throws InvalidMigrationId When the identifier is blank, padded, reserved, or too long for the ledger. */ public function __construct( public string $value @@ -27,8 +27,8 @@ public function __construct( throw new InvalidMigrationId(sprintf('Migration IDs cannot exceed %d bytes.', self::MAX_BYTES)); } - if (preg_match('/^(?:0|-?[1-9][0-9]*)$/D', $this->value) === 1) { - throw new InvalidMigrationId('Migration IDs cannot be integer-like strings.'); + if (in_array($this->value, ['0', 'latest'], true)) { + throw new InvalidMigrationId('Migration IDs cannot use the reserved targets 0 or latest.'); } } } diff --git a/src/Database/Migration/ValueObjects/MigrationStatus.php b/src/Database/Migration/ValueObjects/MigrationStatus.php new file mode 100644 index 0000000..af36d4d --- /dev/null +++ b/src/Database/Migration/ValueObjects/MigrationStatus.php @@ -0,0 +1,31 @@ +migration === null) { + return 'missing'; + } + + return $this->appliedAt === null ? 'pending' : 'applied'; + } +} diff --git a/src/Database/Migration/ValueObjects/Record.php b/src/Database/Migration/ValueObjects/Record.php deleted file mode 100644 index d3a925b..0000000 --- a/src/Database/Migration/ValueObjects/Record.php +++ /dev/null @@ -1,19 +0,0 @@ - $ran - * @param list $rolledBack - * @param list $skipped - */ - public function __construct( - public array $ran = [], - public array $rolledBack = [], - public array $skipped = [] - ) { - } - - /** - * Return the number of migrations applied or rolled back. - */ - public function count(): int { - return count($this->ran) + count($this->rolledBack); - } -} diff --git a/src/Database/Migration/ValueObjects/Status.php b/src/Database/Migration/ValueObjects/Status.php deleted file mode 100644 index 6de682d..0000000 --- a/src/Database/Migration/ValueObjects/Status.php +++ /dev/null @@ -1,89 +0,0 @@ -migration, - state: self::APPLIED, - batch: $record->batch, - ranAt: $record->ranAt - ); - } - - /** - * Describe a ledger record whose migration implementation is unavailable. - */ - public static function unavailable(Record $record): self { - return new self( - migration: $record->migration, - state: self::UNAVAILABLE, - batch: $record->batch, - ranAt: $record->ranAt - ); - } - - /** - * Determine whether this configured migration has not been applied. - */ - public function isPending(): bool { - return $this->state === self::PENDING; - } - - /** - * Determine whether this configured migration is recorded as applied. - */ - public function isApplied(): bool { - return $this->state === self::APPLIED; - } - - /** - * Determine whether the recorded migration implementation is unavailable. - */ - public function isUnavailable(): bool { - return $this->state === self::UNAVAILABLE; - } - - /** - * Return the state name for presentation. - * - * @return self::PENDING|self::APPLIED|self::UNAVAILABLE - */ - public function state(): string { - return $this->state; - } -} diff --git a/src/Database/Migration/ValueObjects/Step.php b/src/Database/Migration/ValueObjects/Step.php new file mode 100644 index 0000000..66675ee --- /dev/null +++ b/src/Database/Migration/ValueObjects/Step.php @@ -0,0 +1,23 @@ + $sql DDL statements; empty when a prior attempt already completed the change. + */ + public function __construct( + public string $id, + public string $migration, + public bool $reverse, + public array $sql, + public bool $hasDataStep, + ) { + } +} diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php deleted file mode 100644 index d1f354e..0000000 --- a/src/Database/Query/Query.php +++ /dev/null @@ -1,86 +0,0 @@ - $bindings - */ - public function __construct( - private QueryReader $database, - private string $sql, - private array $bindings = [] - ) { - } - - /** - * Return the SQL template before bindings are prepared. - */ - public function toSql(): string { - return $this->sql; - } - - /** - * Return the bindings in placeholder order. - * - * @return list - */ - public function bindings(): array { - return $this->bindings; - } - - /** - * Prepare the SQL template with its captured bindings. - * - * @throws QueryException When WordPress cannot prepare the statement. - */ - public function toPreparedSql(): string { - return $this->database->prepare($this->sql, ...$this->bindings); - } - - /** - * Execute the query and return every matching row. - * - * @throws DatabaseException When the database returns a row in an invalid shape. - * @throws QueryException When the query fails. - * - * @return list> - */ - public function get(): array { - return $this->database->rows($this->sql, ...$this->bindings); - } - - /** - * Execute the query and return its first row when present. - * - * @throws DatabaseException When the database returns a row in an invalid shape. - * @throws QueryException When the query fails. - * - * @return array|null - */ - public function first(): ?array { - return $this->database->row($this->sql, ...$this->bindings); - } - - /** - * Execute the query and return the first column from its first row. - * - * @throws QueryException When the query fails. - */ - public function value(): mixed { - return $this->database->value($this->sql, ...$this->bindings); - } -} diff --git a/src/Database/Query/QueryBuilder.php b/src/Database/Query/QueryBuilder.php deleted file mode 100644 index 7d6e3f3..0000000 --- a/src/Database/Query/QueryBuilder.php +++ /dev/null @@ -1,311 +0,0 @@ - - */ - private array $columns = ['*']; - - /** - * @var list - */ - private array $where = []; - - /** - * @var list - */ - private array $bindings = []; - - /** - * @var list - */ - private array $orderBy = []; - - private ?int $limit = null; - - private ?int $offset = null; - - private ?string $aggregate = null; - - /** - * Begin a query for one table and an optional SQL alias. - */ - public function __construct( - private readonly QueryGateway $database, - private readonly Table $table, - private readonly ?string $alias = null - ) { - } - - /** - * Replace the selected columns, or restore the wildcard when none are supplied. - */ - public function select(string ...$columns): self { - $this->columns = $columns === [] ? ['*'] : array_values($columns); - - return $this; - } - - /** - * Compare a column to a value. NULL values use IS NULL or IS NOT NULL semantics. - * - * @throws InvalidArgumentException When the column or operator is invalid, or the operator cannot compare against NULL. - */ - public function where(string $column, string $operator, mixed $value): self { - $operator = $this->operator($operator); - - if ($value === null) { - if (! in_array($operator, ['=', '!=', '<>'], true)) { - throw new InvalidArgumentException('NULL comparisons only support =, !=, and <> operators.'); - } - - $this->where[] = sprintf( - '%s IS%s NULL', - $this->quoteColumn($column), - $operator === '=' ? '' : ' NOT' - ); - - return $this; - } - - $this->where[] = sprintf('%s %s %%s', $this->quoteColumn($column), $operator); - $this->bindings[] = $value; - - return $this; - } - - /** - * Add an ordered result column and direction. - * - * @throws InvalidArgumentException When the column or direction is invalid. - */ - public function orderBy(string $column, string $direction = 'ASC'): self { - $direction = strtoupper($direction); - - if (! in_array($direction, ['ASC', 'DESC'], true)) { - throw new InvalidArgumentException('Order direction must be ASC or DESC.'); - } - - $this->orderBy[] = sprintf('%s %s', $this->quoteColumn($column), $direction); - - return $this; - } - - /** - * Limit the result count and optionally skip an offset. - * - * @throws InvalidArgumentException When the limit or offset is invalid. - */ - public function limit(int $limit, ?int $offset = null): self { - if ($limit < 1) { - throw new InvalidArgumentException('Query limit must be greater than zero.'); - } - - if ($offset !== null && $offset < 0) { - throw new InvalidArgumentException('Query offset cannot be negative.'); - } - - $this->limit = $limit; - $this->offset = $offset; - - return $this; - } - - /** - * Return the maximum value for a column matching the current predicates. - * - * Ordering and pagination do not constrain aggregate input and are ignored. - * - * @throws DatabaseException When table-name resolution or query execution fails. - * @throws InvalidArgumentException When the column is invalid. - */ - public function max(string $column): mixed { - $query = clone $this; - $query->aggregate = sprintf('MAX(%s)', $query->quoteColumn($column)); - $query->orderBy = []; - $query->limit = null; - $query->offset = null; - - return $query->toQuery()->value(); - } - - /** - * Create an inspectable query from the current builder state. - * - * @throws DatabaseException When table-name resolution or validation fails. - * @throws InvalidArgumentException When a selected column is invalid. - */ - public function toQuery(): Query { - return new Query($this->database, $this->toSql(), $this->bindings()); - } - - /** - * Render the current query as a SQL template with placeholders. - * - * @throws DatabaseException When table-name resolution or validation fails. - * @throws InvalidArgumentException When a selected column is invalid. - */ - public function toSql(): string { - $sql = sprintf( - 'SELECT %s FROM %s%s', - $this->selectSql(), - $this->database->quoteIdentifier($this->database->tableName($this->table)), - $this->aliasSql() - ); - - if ($this->where !== []) { - $sql .= ' WHERE ' . implode(' AND ', $this->where); - } - - if ($this->orderBy !== []) { - $sql .= ' ORDER BY ' . implode(', ', $this->orderBy); - } - - if ($this->limit !== null) { - $sql .= ' LIMIT %d'; - - if ($this->offset !== null) { - $sql .= ' OFFSET %d'; - } - } - - return $sql; - } - - /** - * Return query bindings in the same order as rendered placeholders. - * - * @return list - */ - public function bindings(): array { - $bindings = $this->bindings; - - if ($this->limit !== null) { - $bindings[] = $this->limit; - - if ($this->offset !== null) { - $bindings[] = $this->offset; - } - } - - return $bindings; - } - - /** - * Render and prepare the current query with all bindings. - * - * @throws DatabaseException When table-name resolution or query preparation fails. - * @throws InvalidArgumentException When a selected column is invalid. - */ - public function toPreparedSql(): string { - return $this->database->prepare($this->toSql(), ...$this->bindings()); - } - - /** - * Execute the query and return every matching row. - * - * @throws DatabaseException When table-name resolution or query execution fails. - * @throws InvalidArgumentException When a selected column is invalid. - * - * @return list> - */ - public function get(): array { - return $this->toQuery()->get(); - } - - /** - * Execute the query with a one-row limit and return the first match. - * - * @throws DatabaseException When table-name resolution or query execution fails. - * @throws InvalidArgumentException When a selected column is invalid. - * - * @return array|null - */ - public function first(): ?array { - $query = clone $this; - $query->limit = 1; - - return $query->toQuery()->first(); - } - - /** - * Render an aggregate expression or the configured selected columns. - */ - private function selectSql(): string { - if ($this->aggregate !== null) { - return $this->aggregate; - } - - return implode(', ', array_map(fn (string $column): string => $this->quoteColumn($column, true), $this->columns)); - } - - /** - * Render the validated table alias clause when one is configured. - */ - private function aliasSql(): string { - if ($this->alias === null || $this->alias === '') { - return ''; - } - - return ' AS ' . $this->database->quoteIdentifier($this->alias); - } - - /** - * Normalize and validate a supported comparison operator. - * - * @throws InvalidArgumentException When the operator is unsupported. - */ - private function operator(string $operator): string { - $operator = strtoupper(trim($operator)); - - if (! in_array($operator, ['=', '!=', '<>', '>', '>=', '<', '<=', 'LIKE'], true)) { - throw new InvalidArgumentException(sprintf('Unsupported query operator: %s.', $operator)); - } - - return $operator; - } - - /** - * Quote each segment of a qualified column reference. - * - * For example, p.ID becomes `p`.`ID`. When wildcards are allowed, - * p.* becomes `p`.*. - * - * @throws InvalidArgumentException When the column contains an empty segment or a disallowed wildcard. - */ - private function quoteColumn(string $column, bool $allowWildcard = false): string { - $segments = explode('.', $column); - $last = array_key_last($segments); - $quoted = []; - - foreach ($segments as $index => $segment) { - if ($segment === '') { - throw new InvalidArgumentException(sprintf('Invalid query column: %s.', $column)); - } - - if ($segment === '*') { - if (! $allowWildcard || $index !== $last) { - throw new InvalidArgumentException(sprintf('Invalid query column wildcard: %s.', $column)); - } - - $quoted[] = '*'; - continue; - } - - $quoted[] = $this->database->quoteIdentifier($segment); - } - - return implode('.', $quoted); - } -} diff --git a/src/Database/README.md b/src/Database/README.md index 412a2a4..12cddef 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -3,9 +3,9 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -Foundation Database provides WordPress-backed table gateways, versioned -migrations, a small query API, database-backed locks, and a WP-CLI deployment -workflow built on `wpdb` and `dbDelta()`. +Foundation Database provides a shared Doctrine DBAL connection, application tables, +managed transactions, declarative migrations, and an optional database lock backend +for WordPress applications. ## Installation diff --git a/src/Database/Schema.php b/src/Database/Schema.php deleted file mode 100644 index 64f48c4..0000000 --- a/src/Database/Schema.php +++ /dev/null @@ -1,104 +0,0 @@ -assertValidForCreate(); - - if ($this->database->tableExists($blueprint->table())) { - $this->reconciler->verify($blueprint); - - return; - } - - $this->reconciler->reconcile($blueprint); - } - - /** - * Apply explicit additions, modifications, and removals to an existing table. - * - * @throws DatabaseException When the table is missing or a schema change cannot be applied or verified. - * @throws InvalidArgumentException When the alteration blueprint is invalid. - */ - public function alter(Blueprint $blueprint): void { - $this->editor->alter($blueprint); - } - - /** - * Execute a complete, trusted schema statement without placeholder binding. - * - * @throws DatabaseException When the statement cannot be executed. - */ - public function execute(string $sql): void { - $this->database->execute($sql); - } - - /** - * Determine whether a table exists in the active database scope. - * - * @throws DatabaseException When table inspection fails. - */ - public function hasTable(Table $table): bool { - return $this->database->tableExists($table); - } - - /** - * Determine whether a named index exists on a table. - * - * @throws DatabaseException When index inspection fails. - */ - public function hasIndex(Table $table, string $index): bool { - return $this->database->indexExists($table, $index); - } - - /** - * Drop a table when it exists. - * - * @throws DatabaseException When the table name is invalid or the statement cannot be executed. - */ - public function drop(Table $table): void { - $this->database->execute(sprintf( - 'DROP TABLE IF EXISTS %s', - $this->database->quoteIdentifier($this->database->tableName($table)) - )); - } - - /** - * Quote a trusted schema identifier while escaping embedded backticks. - */ - public function quoteIdentifier(string $identifier): string { - return $this->database->quoteIdentifier($identifier); - } -} diff --git a/src/Database/Schema/Contracts/SchemaExecutor.php b/src/Database/Schema/Contracts/SchemaExecutor.php deleted file mode 100644 index 1c33e34..0000000 --- a/src/Database/Schema/Contracts/SchemaExecutor.php +++ /dev/null @@ -1,20 +0,0 @@ -last_error !== '') { - throw new QueryException($wpdb->last_error, $sql, [], $wpdb->last_error); - } - } -} diff --git a/src/Database/Schema/Editor.php b/src/Database/Schema/Editor.php deleted file mode 100644 index ef4e9c5..0000000 --- a/src/Database/Schema/Editor.php +++ /dev/null @@ -1,282 +0,0 @@ -assertValidForAlter(); - $table = $blueprint->table(); - - if (! $this->database->tableExists($table)) { - throw new DatabaseException(sprintf( - 'Cannot alter missing table %s.', - $this->database->tableName($table) - )); - } - - $this->reconciler->verifyTable($table); - $completedReplacements = $this->completedIndexReplacements($blueprint); - - $clauses = [ - ...$this->indexRemovalClauses($blueprint, $completedReplacements), - ...$this->columnRemovalClauses($blueprint), - ...$this->columnAdditionClauses($blueprint), - ...$this->columnChangeClauses($blueprint), - ...$this->indexAdditionClauses($blueprint, $completedReplacements), - ]; - - if ($clauses !== []) { - $this->database->execute(sprintf( - 'ALTER TABLE %s %s', - $this->database->quoteIdentifier($this->database->tableName($table)), - implode(', ', $clauses) - )); - } - - $this->reconciler->verify($blueprint); - $this->verifyRemovedColumns($blueprint); - $this->verifyRemovedIndexes($blueprint); - } - - /** - * Find requested index replacements that already match the physical schema. - * - * @throws DatabaseException When index metadata cannot be inspected. - * - * @return list Lowercase names of completed replacements. - */ - private function completedIndexReplacements(Blueprint $blueprint): array { - $completed = []; - - foreach ($blueprint->indexes() as $index) { - if (! $this->replacesIndex($blueprint, $index)) { - continue; - } - - if ($this->reconciler->indexMatches($blueprint->table(), $index)) { - $completed[] = strtolower($index->name); - } - } - - return $completed; - } - - /** - * Build clauses for secondary indexes that still need removal. - * - * @param list $completedReplacements Lowercase names of completed replacements. - * - * @throws DatabaseException When index existence cannot be inspected. - * - * @return list - */ - private function indexRemovalClauses(Blueprint $blueprint, array $completedReplacements): array { - $clauses = []; - - foreach ($blueprint->droppedIndexes() as $index) { - if (in_array(strtolower($index), $completedReplacements, true)) { - continue; - } - - if ($this->database->indexExists($blueprint->table(), $index)) { - $clauses[] = 'DROP INDEX ' . $this->database->quoteIdentifier($index); - } - } - - return $clauses; - } - - /** - * Build clauses for columns that still exist after a previous attempt. - * - * @return list - */ - private function columnRemovalClauses(Blueprint $blueprint): array { - $clauses = []; - - foreach ($blueprint->droppedColumns() as $column) { - if ($this->database->columnExists($blueprint->table(), $column)) { - $clauses[] = 'DROP COLUMN ' . $this->database->quoteIdentifier($column); - } - } - - return $clauses; - } - - /** - * Build clauses for columns not already present after a previous attempt. - * - * @return list - */ - private function columnAdditionClauses(Blueprint $blueprint): array { - $clauses = []; - - foreach ($blueprint->addedColumns() as $column) { - if ($this->database->columnExists($blueprint->table(), $column->name)) { - $this->reconciler->verifyColumn($blueprint->table(), $column); - continue; - } - - $clauses[] = 'ADD COLUMN ' . $column->sql(); - } - - return $clauses; - } - - /** - * Build clauses for columns explicitly marked for modification. - * - * @throws DatabaseException When a column selected for modification is missing or cannot be inspected. - * - * @return list - */ - private function columnChangeClauses(Blueprint $blueprint): array { - $clauses = []; - $table = $blueprint->table(); - - foreach ($blueprint->changedColumns() as $column) { - if (! $this->database->columnExists($table, $column->name)) { - throw new DatabaseException(sprintf( - 'Cannot change missing column %s on %s.', - $column->name, - $this->database->tableName($table) - )); - } - - if ($this->reconciler->columnMatches($table, $column)) { - continue; - } - - $clauses[] = 'MODIFY COLUMN ' . $column->sql(); - } - - return $clauses; - } - - /** - * Build clauses for indexes that still need adding or replacing. - * - * @param list $completedReplacements Lowercase names of completed replacements. - * - * @throws DatabaseException When index metadata cannot be inspected or an existing addition is incompatible. - * - * @return list - */ - private function indexAdditionClauses(Blueprint $blueprint, array $completedReplacements): array { - $clauses = []; - - foreach ($blueprint->indexes() as $index) { - if (in_array(strtolower($index->name), $completedReplacements, true)) { - continue; - } - - if ($this->replacesIndex($blueprint, $index)) { - $clauses[] = 'ADD ' . $index->sql(); - continue; - } - - if ($this->database->indexExists($blueprint->table(), $index->name)) { - $this->reconciler->verifyIndex($blueprint->table(), $index); - continue; - } - - $clauses[] = 'ADD ' . $index->sql(); - } - - return $clauses; - } - - /** - * Determine whether the alteration replaces an index under the same name. - */ - private function replacesIndex(Blueprint $blueprint, Index $index): bool { - foreach ($blueprint->droppedIndexes() as $droppedIndex) { - if (strcasecmp($droppedIndex, $index->name) === 0) { - return true; - } - } - - return false; - } - - /** - * Confirm columns requested for removal are absent after alteration. - * - * @throws DatabaseException When a removed column remains in the physical table. - */ - private function verifyRemovedColumns(Blueprint $blueprint): void { - foreach ($blueprint->droppedColumns() as $column) { - if ($this->database->columnExists($blueprint->table(), $column)) { - throw new DatabaseException(sprintf( - 'Database schema alteration did not remove column %s from %s.', - $column, - $this->database->tableName($blueprint->table()) - )); - } - } - } - - /** - * Confirm indexes requested only for removal are absent after alteration. - * - * @throws DatabaseException When a removed index remains in the physical table. - */ - private function verifyRemovedIndexes(Blueprint $blueprint): void { - foreach ($blueprint->droppedIndexes() as $index) { - if ($this->blueprintAddsIndex($blueprint, $index)) { - continue; - } - - if ($this->database->indexExists($blueprint->table(), $index)) { - throw new DatabaseException(sprintf( - 'Database schema alteration did not remove index %s from %s.', - $index, - $this->database->tableName($blueprint->table()) - )); - } - } - } - - /** - * Determine whether the blueprint declares an index under the supplied name. - */ - private function blueprintAddsIndex(Blueprint $blueprint, string $name): bool { - foreach ($blueprint->indexes() as $index) { - if (strcasecmp($index->name, $name) === 0) { - return true; - } - } - - return false; - } -} diff --git a/src/Database/Schema/PhysicalIndexCollection.php b/src/Database/Schema/PhysicalIndexCollection.php deleted file mode 100644 index 2a764b3..0000000 --- a/src/Database/Schema/PhysicalIndexCollection.php +++ /dev/null @@ -1,167 +0,0 @@ - $indexes - */ - private function __construct( - private array $indexes - ) { - } - - /** - * Build a normalized collection from raw SHOW INDEX rows. - * - * @param list> $rows - * - * @throws DatabaseException When the database returns invalid index metadata. - */ - public static function fromRows(array $rows, string $tableName): self { - /** @var array, hasExpressions: bool}> $indexes */ - $indexes = []; - - foreach ($rows as $row) { - $name = $row['Key_name'] ?? null; - $column = $row['Column_name'] ?? null; - $expression = $row['Expression'] ?? null; - $isExpression = $column === null && is_string($expression) && trim($expression) !== ''; - $indexType = $row['Index_type'] ?? null; - $collation = $row['Collation'] ?? null; - $nonUnique = filter_var($row['Non_unique'] ?? null, FILTER_VALIDATE_INT); - $sequence = filter_var($row['Seq_in_index'] ?? null, FILTER_VALIDATE_INT, [ - 'options' => ['min_range' => 1], - ]); - - if ( - ! is_string($name) - || $name === '' - || (! $isExpression && (! is_string($column) || $column === '')) - || ! is_string($indexType) - || $indexType === '' - || ($collation !== null && (! is_string($collation) || ! in_array(strtoupper($collation), ['A', 'D'], true))) - || ! in_array($nonUnique, [0, 1], true) - || ! is_int($sequence) - ) { - throw self::invalidMetadata($tableName); - } - - if (strcasecmp($name, 'PRIMARY') === 0 && $nonUnique !== 0) { - throw self::invalidMetadata($tableName, $name); - } - - $key = strtolower($name); - $type = self::typeFromMetadata($name, $indexType, $nonUnique); - - if (isset($indexes[$key]) && $indexes[$key]['type'] !== $type) { - throw self::invalidMetadata($tableName, $name); - } - - $indexes[$key] ??= [ - 'name' => $name, - 'type' => $type, - 'columns' => [], - 'hasExpressions' => false, - ]; - - if ($isExpression) { - $column = '(' . $expression . ')'; - $indexes[$key]['hasExpressions'] = true; - } - - if (isset($indexes[$key]['columns'][$sequence])) { - throw self::invalidMetadata($tableName, $name); - } - - $subPart = $row['Sub_part'] ?? null; - - if ($subPart !== null) { - $subPart = filter_var($subPart, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); - - if (! is_int($subPart)) { - throw self::invalidMetadata($tableName, $name); - } - - $column .= '(' . $subPart . ')'; - } - - if (strtoupper((string) $collation) === 'D') { - $column .= ' DESC'; - } - - $indexes[$key]['columns'][$sequence] = $isExpression ? $column : strtolower($column); - } - - $states = []; - - foreach ($indexes as $key => $index) { - ksort($index['columns']); - - if (array_keys($index['columns']) !== range(1, count($index['columns']))) { - throw self::invalidMetadata($tableName, $index['name']); - } - - $states[$key] = new IndexState( - $index['name'], - $index['type'], - array_values($index['columns']), - $index['hasExpressions'] - ); - } - - return new self($states); - } - - /** - * Return every physical index keyed by its lowercase database name. - * - * @return array - */ - public function all(): array { - return $this->indexes; - } - - /** - * Normalize storage metadata to the semantic index type used for comparison. - */ - private static function typeFromMetadata(string $name, string $indexType, int $nonUnique): string { - if (strcasecmp($name, 'PRIMARY') === 0) { - return IndexType::PRIMARY; - } - - $type = strtolower($indexType); - - if (in_array($type, [IndexState::FULLTEXT, IndexState::SPATIAL, IndexState::RTREE], true)) { - return $type; - } - - if ($nonUnique === 0) { - return IndexType::UNIQUE; - } - - return IndexType::KEY; - } - - /** - * Build the stable exception used when SHOW INDEX metadata cannot be trusted. - */ - private static function invalidMetadata(string $tableName, ?string $indexName = null): DatabaseException { - return new DatabaseException(sprintf( - 'Database returned invalid index metadata for %s.', - $indexName === null ? $tableName : $tableName . '.' . $indexName - )); - } -} diff --git a/src/Database/Schema/Reconciler.php b/src/Database/Schema/Reconciler.php deleted file mode 100644 index 2337a3f..0000000 --- a/src/Database/Schema/Reconciler.php +++ /dev/null @@ -1,699 +0,0 @@ -table(); - $this->executor->execute($this->createTableSql($table, $blueprint)); - $this->applyBinaryDefaults($table, $blueprint); - - $columnProperties = $this->reconcileColumnComments($table, $blueprint); - - $this->assertMatches($blueprint, $columnProperties); - } - - /** - * Verify only the columns and indexes requested by a migration blueprint. - * - * Physical columns and indexes absent from the blueprint belong to other - * migrations or external owners and do not participate in this check. - * - * @throws DatabaseException When declared schema state is missing, incompatible, or cannot be inspected. - */ - public function verify(Blueprint $blueprint): void { - $this->assertMatches($blueprint, $this->inspectColumns($blueprint->table(), $blueprint->columns())); - } - - /** - * Verify that a table uses the charset and collation configured by WordPress. - * - * @throws DatabaseException When table metadata is missing, invalid, or incompatible. - */ - public function verifyTable(Table $table): void { - $this->throwForDifferences($table, $this->tableCharsetDifferences($table)); - } - - /** - * Determine whether a column already has its requested definition. - * - * @throws DatabaseException When column metadata cannot be inspected. - */ - public function columnMatches(Table $table, Column $column): bool { - return $this->columnDifferences( - [$column], - $this->inspectColumns($table, [$column]) - ) === []; - } - - /** - * Verify one existing column before an alteration treats it as completed work. - * - * @throws DatabaseException When the column differs from the requested declaration or cannot be inspected. - */ - public function verifyColumn(Table $table, Column $column): void { - $this->throwForDifferences( - $table, - $this->columnDifferences([$column], $this->inspectColumns($table, [$column])) - ); - } - - /** - * Verify one existing index before an alteration treats it as completed work. - * - * @throws DatabaseException When the index differs from the requested declaration or cannot be inspected. - */ - public function verifyIndex(Table $table, Index $index): void { - $this->throwForDifferences($table, $this->indexDifferences($table, [$index])); - } - - /** - * Determine whether an index already has its requested definition. - * - * @throws DatabaseException When index metadata cannot be inspected. - */ - public function indexMatches(Table $table, Index $index): bool { - return $this->indexDifferences($table, [$index]) === []; - } - - /** - * Fail when requested columns or indexes do not match physical storage. - * - * @param array $columnProperties - * - * @throws DatabaseException When requested schema state is missing or incompatible. - */ - private function assertMatches(Blueprint $blueprint, array $columnProperties): void { - $table = $blueprint->table(); - - $this->throwForDifferences( - $table, - $this->differences($table, $blueprint->columns(), $blueprint->indexes(), $columnProperties) - ); - } - - /** - * Return differences between requested declarations and physical storage. - * - * @param list $columns - * @param list $indexes - * @param array $columnProperties - * - * @throws DatabaseException When requested index state cannot be inspected. - * - * @return list - */ - private function differences(Table $table, array $columns, array $indexes, array $columnProperties): array { - return [ - ...$this->tableCharsetDifferences($table), - ...$this->declarationDifferences($table, $columns, $indexes, $columnProperties), - ]; - } - - /** - * Return requested column and index differences without reinspecting table defaults. - * - * @param list $columns - * @param list $indexes - * @param array $columnProperties - * - * @throws DatabaseException When requested index state cannot be inspected. - * - * @return list - */ - private function declarationDifferences(Table $table, array $columns, array $indexes, array $columnProperties): array { - return [ - ...$this->columnDifferences($columns, $columnProperties), - ...$this->indexDifferences($table, $indexes), - ]; - } - - /** - * Throw a schema exception containing every requested-state difference. - * - * @param list $differences - * - * @throws DatabaseException When requested schema state is missing or incompatible. - */ - private function throwForDifferences(Table $table, array $differences): void { - if ($differences === []) { - return; - } - - throw new DatabaseException(sprintf( - 'Database schema operation did not apply the requested state for %s: %s.', - $this->database->tableName($table), - implode('; ', $differences) - )); - } - - /** - * Build the CREATE TABLE statement passed to the WordPress schema executor. - * - * @throws DatabaseException When the physical table name is invalid. - */ - private function createTableSql(Table $table, Blueprint $blueprint): string { - $parts = []; - - foreach ($blueprint->columns() as $column) { - $parts[] = ' ' . $column->sql(); - } - - foreach ($blueprint->indexes() as $index) { - $parts[] = ' ' . $index->sql(); - } - - return sprintf( - "CREATE TABLE %s (\n%s\n) %s;", - $this->database->quoteIdentifier($this->database->tableName($table)), - implode(",\n", $parts), - $this->database->charsetCollate() - ); - } - - /** - * Reconcile binary string defaults that dbDelta cannot represent safely. - * - * @throws DatabaseException When a default cannot be reconciled. - */ - private function applyBinaryDefaults(Table $table, Blueprint $blueprint): void { - foreach ($blueprint->columns() as $column) { - $default = $column->defaultSql(); - - if ($default === null || ! str_starts_with($default, "X'")) { - continue; - } - - $this->database->execute(sprintf( - 'ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s', - $this->database->quoteIdentifier($this->database->tableName($table)), - $this->database->quoteIdentifier($column->name), - $default - )); - } - } - - /** - * Apply comment changes that dbDelta does not reliably reconcile and retain - * the final physical properties for verification. - * - * MySQL requires the complete column declaration when changing or removing a - * comment, so every supported attribute is rendered from the declared column. - * - * @throws DatabaseException When column metadata is missing, invalid, or cannot be replaced. - * - * @return array - */ - private function reconcileColumnComments(Table $table, Blueprint $blueprint): array { - $columnProperties = []; - - foreach ($blueprint->columns() as $column) { - $properties = $this->columnProperties($table, $column); - $comment = $column->commentText() ?? ''; - - if ($comment !== $properties['comment']) { - $this->database->execute(sprintf( - 'ALTER TABLE %s MODIFY COLUMN %s', - $this->database->quoteIdentifier($this->database->tableName($table)), - $column->sql() - )); - - $properties = $this->columnProperties($table, $column); - } - - $columnProperties[$column->name] = $properties; - } - - return $columnProperties; - } - - /** - * Inspect every column declared by the blueprint for post-operation verification. - * - * @param list $columns - * - * @throws DatabaseException When column metadata is missing or invalid. - * - * @return array - */ - private function inspectColumns(Table $table, array $columns): array { - $properties = []; - - foreach ($columns as $column) { - $properties[$column->name] = $this->columnProperties($table, $column); - } - - return $properties; - } - - /** - * Return every column property that still differs from the declared schema. - * - * @param list $columns - * @param array $columnProperties - * - * @return list - */ - private function columnDifferences(array $columns, array $columnProperties): array { - $differences = []; - - foreach ($columns as $column) { - $properties = $columnProperties[$column->name]; - $comment = $column->commentText() ?? ''; - - if ($this->normalizeColumnType($properties['type']) !== $this->normalizeColumnType($column->typeSql())) { - $differences[] = sprintf( - 'column %s expected type %s, found %s', - $column->name, - $column->typeSql(), - $properties['type'] - ); - } - - if ($properties['nullable'] !== $column->nullable) { - $differences[] = sprintf( - 'column %s expected %s, found %s', - $column->name, - $column->nullable ? 'NULL' : 'NOT NULL', - $properties['nullable'] ? 'NULL' : 'NOT NULL' - ); - } - - if (! $this->defaultMatches($column, $properties['default'])) { - $differences[] = sprintf( - 'column %s expected %s, found %s', - $column->name, - $column->defaultSql() === null ? 'no default' : 'DEFAULT ' . $column->defaultSql(), - $properties['default'] === null ? 'DEFAULT NULL' : 'DEFAULT ' . (string) $properties['default'] - ); - } - - $expectedExtra = $column->autoIncrement ? 'auto_increment' : ''; - $onUpdate = $column->onUpdateSql(); - - if ($onUpdate !== null) { - $expectedExtra = trim($expectedExtra . ' on update ' . $this->normalizeCurrentTimestamp($onUpdate)); - } - - $actualExtra = $this->normalizeExtra($properties['extra']); - - if ($expectedExtra !== $actualExtra) { - $differences[] = sprintf( - 'column %s expected extra %s, found %s', - $column->name, - $expectedExtra === '' ? 'none' : $expectedExtra, - $actualExtra === '' ? 'none' : $actualExtra - ); - } - - if ($comment !== $properties['comment']) { - $differences[] = sprintf( - 'column %s expected comment %s, found %s', - $column->name, - $comment === '' ? 'none' : $comment, - $properties['comment'] === '' ? 'none' : $properties['comment'] - ); - } - - if (! $this->collationMatches($properties['collation'])) { - $differences[] = sprintf( - 'column %s expected collation %s, found %s', - $column->name, - $this->expectedCollationDescription(), - (string) $properties['collation'] - ); - } - } - - return $differences; - } - - /** - * Read and validate the database metadata used to verify a column definition. - * - * @throws DatabaseException When column metadata is missing or invalid. - * - * @return ColumnProperties - */ - private function columnProperties(Table $table, Column $column): array { - $row = $this->database->row( - 'SHOW FULL COLUMNS FROM %i WHERE Field = %s', - $this->database->tableName($table), - $column->name - ); - - if ($row === null) { - throw new DatabaseException(sprintf( - 'Database schema reconciliation could not inspect %s.%s.', - $this->database->tableName($table), - $column->name - )); - } - - $type = $row['Type'] ?? null; - $nullable = $row['Null'] ?? null; - $extra = $row['Extra'] ?? null; - $comment = $row['Comment'] ?? ''; - $collation = $row['Collation'] ?? null; - - if ( - ! is_string($type) - || $type === '' - || ! is_string($nullable) - || ! in_array(strtoupper($nullable), ['YES', 'NO'], true) - || ! array_key_exists('Default', $row) - || ! is_string($extra) - || ! is_string($comment) - || ($collation !== null && (! is_string($collation) || $collation === '')) - ) { - throw new DatabaseException(sprintf( - 'Database returned invalid column metadata for %s.%s.', - $this->database->tableName($table), - $column->name - )); - } - - return [ - 'type' => $type, - 'nullable' => strtoupper($nullable) === 'YES', - 'default' => $row['Default'], - 'extra' => $extra, - 'comment' => $comment, - 'collation' => $collation, - ]; - } - - /** - * Return a difference when the table default cannot represent WordPress text safely. - * - * @throws DatabaseException When table metadata is missing or invalid. - * - * @return list - */ - private function tableCharsetDifferences(Table $table): array { - $expected = $this->expectedCharsetCollation(); - - if ($expected === null) { - return []; - } - - $tableName = $this->database->tableName($table); - $row = $this->database->row('SHOW TABLE STATUS WHERE Name = %s', $tableName); - $actual = $row['Collation'] ?? null; - - if (! is_string($actual) || $actual === '') { - throw new DatabaseException(sprintf('Database returned invalid table metadata for %s.', $tableName)); - } - - if ($this->collationMatches($actual)) { - return []; - } - - return [sprintf( - 'table expected collation %s, found %s', - $this->expectedCollationDescription(), - $actual - )]; - } - - /** - * Parse the charset and optional explicit collation configured by WordPress. - * - * @return array{charset: string, collation: ?string}|null - */ - private function expectedCharsetCollation(): ?array { - $clause = $this->database->charsetCollate(); - - if (preg_match('/\bCHARACTER\s+SET\s+([A-Za-z0-9_]+)/i', $clause, $charset) !== 1) { - return null; - } - - $collation = preg_match('/\bCOLLATE\s+([A-Za-z0-9_]+)/i', $clause, $matches) === 1 - ? strtolower($matches[1]) - : null; - - return [ - 'charset' => strtolower($charset[1]), - 'collation' => $collation, - ]; - } - - /** - * Determine whether a physical collation satisfies WordPress configuration. - */ - private function collationMatches(?string $actual): bool { - if ($actual === null) { - return true; - } - - $expected = $this->expectedCharsetCollation(); - - if ($expected === null) { - return true; - } - - $actual = strtolower($actual); - - return $expected['collation'] === null - ? str_starts_with($actual, $expected['charset'] . '_') - : $actual === $expected['collation']; - } - - /** - * Describe the configured collation requirement in reconciliation errors. - */ - private function expectedCollationDescription(): string { - $expected = $this->expectedCharsetCollation(); - - if ($expected === null) { - return 'configured by WordPress'; - } - - return $expected['collation'] ?? $expected['charset'] . '_*'; - } - - /** - * Return every missing or incompatible declared index difference. - * - * @param list $indexes - * - * @throws DatabaseException When the database returns invalid index metadata. - * - * @return list - */ - private function indexDifferences(Table $table, array $indexes): array { - $expected = $this->expectedIndexes($indexes); - $differences = []; - - if ($expected === []) { - return []; - } - - $actual = $this->physicalIndexes($table, array_keys($expected)); - - foreach ($expected as $name => $index) { - if (! isset($actual[$name])) { - $differences[] = sprintf('index %s expected %s, found missing', $index->name, $index->describe()); - continue; - } - - if (! $index->hasSameDefinitionAs($actual[$name])) { - $differences[] = sprintf( - 'index %s expected %s, found %s', - $index->name, - $index->describe(), - $actual[$name]->describe() - ); - } - } - - return $differences; - } - - /** - * Normalize the indexes declared by a table definition for comparison with database metadata. - * - * @param list $definitions - * - * @return array - */ - private function expectedIndexes(array $definitions): array { - $indexes = []; - - foreach ($definitions as $index) { - $state = IndexState::fromDefinition($index); - - $indexes[strtolower($state->name)] = $state; - } - - return $indexes; - } - - /** - * Normalize semantically irrelevant integer display widths before comparison. - */ - private function normalizeColumnType(string $type): string { - $type = trim($type); - - if (preg_match('/\A([a-z]+)(.*)\z/is', $type, $parts) !== 1) { - return $type; - } - - $name = strtolower($parts[1]); - $suffix = $parts[2]; - - if ($name === 'integer') { - $name = 'int'; - } - - if ($name === 'bool' || $name === 'boolean') { - $name = 'tinyint'; - $suffix = '(1)' . $suffix; - } - - if (in_array($name, ['datetime', 'timestamp'], true) - && preg_match('/\A\s*(?:\(\s*(\d+)\s*\))?\z/', $suffix, $precision) === 1 - ) { - $digits = (int) ($precision[1] ?? 0); - - if ($digits <= 6) { - return $name . ($digits === 0 ? '' : sprintf('(%d)', $digits)); - } - } - - if (in_array($name, ['tinyint', 'smallint', 'mediumint', 'int', 'bigint'], true)) { - $suffix = preg_replace('/\A\(\d+\)/', '', $suffix) ?? $suffix; - - return trim($name . ' ' . strtolower(trim($suffix))); - } - - return $name . $suffix; - } - - /** - * Read and normalize the physical indexes reported by MariaDB or MySQL. - * - * @param list $expectedNames - * - * @throws DatabaseException When the database returns invalid index metadata. - * - * @return array - */ - private function physicalIndexes(Table $table, array $expectedNames): array { - $tableName = $this->database->tableName($table); - $expected = array_fill_keys(array_map(strtolower(...), $expectedNames), true); - $rows = array_values(array_filter( - $this->database->rows('SHOW INDEX FROM %i', $tableName), - static function (array $row) use ($expected): bool { - $name = $row['Key_name'] ?? null; - - return is_string($name) && isset($expected[strtolower($name)]); - } - )); - - return PhysicalIndexCollection::fromRows( - $rows, - $tableName - )->all(); - } - - /** - * Determine whether a database-reported default matches the declared column default. - */ - private function defaultMatches(Column $column, mixed $actual): bool { - if ($column->default === null) { - return $actual === null; - } - - if ($actual === null) { - return false; - } - - if ($column->default instanceof CurrentTimestamp) { - return is_string($actual) - && $this->normalizeCurrentTimestamp($actual) === $this->normalizeCurrentTimestamp((string) $column->defaultSql()); - } - - if (is_bool($column->default)) { - return $this->integerDefaultMatches($column->default ? 1 : 0, $actual); - } - - if (is_int($column->default)) { - return $this->integerDefaultMatches($column->default, $actual); - } - - if (is_float($column->default)) { - return is_numeric($actual) && (float) $actual === $column->default; - } - - return (string) $actual === (string) $column->default; - } - - /** - * Compare an integer default with MySQL integer, decimal, or bit-literal metadata. - */ - private function integerDefaultMatches(int $expected, mixed $actual): bool { - $actual = (string) $actual; - - if (preg_match("/^b'([01]+)'$/i", $actual, $bits) === 1) { - return bindec($bits[1]) === $expected; - } - - if (preg_match('/^([+-]?\d+)(?:\.0+)?$/', $actual, $integer) !== 1) { - return false; - } - - return filter_var($integer[1], FILTER_VALIDATE_INT) === $expected; - } - - /** - * Normalize equivalent MySQL Extra metadata before comparing column definitions. - */ - private function normalizeExtra(string $extra): string { - $extra = preg_replace('/\bDEFAULT_GENERATED\b/i', '', $extra) ?? $extra; - - return $this->normalizeCurrentTimestamp(preg_replace('/\s+/', ' ', $extra) ?? $extra); - } - - /** - * Normalize current-timestamp expression spelling without changing its precision. - */ - private function normalizeCurrentTimestamp(string $expression): string { - $expression = strtolower(trim($expression)); - $expression = preg_replace('/\bcurrent_timestamp\s*\(\s*0?\s*\)/', 'current_timestamp', $expression) ?? $expression; - - return preg_replace('/\bcurrent_timestamp\s*\(\s*([1-6])\s*\)/', 'current_timestamp($1)', $expression) ?? $expression; - } -} diff --git a/src/Database/Schema/ValueObjects/IndexState.php b/src/Database/Schema/ValueObjects/IndexState.php deleted file mode 100644 index 19cb1b4..0000000 --- a/src/Database/Schema/ValueObjects/IndexState.php +++ /dev/null @@ -1,70 +0,0 @@ - $columns - */ - public function __construct( - public string $name, - private string $type, - private array $columns, - private bool $hasExpressions = false - ) { - } - - /** - * Normalize a declared table index for comparison with physical metadata. - */ - public static function fromDefinition(Index $index): self { - $name = $index->isPrimary() ? 'PRIMARY' : $index->name; - $type = match (true) { - $index->isPrimary() => IndexType::PRIMARY, - $index->isUnique() => IndexType::UNIQUE, - default => IndexType::KEY, - }; - - return new self( - $name, - $type, - array_map(strtolower(...), $index->columns) - ); - } - - /** - * Determine whether another normalized index has the same definition. - */ - public function hasSameDefinitionAs(self $other): bool { - return strcasecmp($this->name, $other->name) === 0 - && $this->type === $other->type - && $this->columns === $other->columns - && $this->hasExpressions === $other->hasExpressions; - } - - /** - * Format the index type and columns for schema reconciliation diagnostics. - */ - public function describe(): string { - return sprintf('%s (%s)', strtoupper($this->type), implode(', ', $this->columns)); - } -} diff --git a/src/Database/Table/Blueprint.php b/src/Database/Table/Blueprint.php deleted file mode 100644 index 2b10ec2..0000000 --- a/src/Database/Table/Blueprint.php +++ /dev/null @@ -1,512 +0,0 @@ - - */ - private array $columns = []; - - /** - * @var list - */ - private array $indexes = []; - - /** - * @var array - */ - private array $droppedIndexes = []; - - /** - * @var array - */ - private array $droppedColumns = []; - - /** - * Create an empty blueprint for one table. - */ - private function __construct( - private readonly Table $table - ) { - } - - /** - * Begin defining the columns and indexes for a table. - */ - public static function for(Table $table): self { - return new self($table); - } - - /** - * Return the table whose schema this blueprint changes. - */ - public function table(): Table { - return $this->table; - } - - /** - * Add an unsigned BIGINT AUTO_INCREMENT column and make it the primary key. - * - * @throws InvalidArgumentException When the generated column name is already defined. - */ - public function bigIncrements(string $name): ColumnDefinition { - $column = $this - ->column(new Column($name, 'bigint', 20)) - ->unsigned() - ->autoIncrement(); - - $this->primary($name); - - return $column; - } - - /** - * Add a VARCHAR column with the requested maximum length. - * - * @throws InvalidArgumentException When the column name is already defined. - */ - public function string(string $name, int $length = 191): ColumnDefinition { - return $this->column(new Column($name, 'varchar', $length)); - } - - /** - * Add an unsigned INT column with the requested display width. - * - * @throws InvalidArgumentException When the column name is already defined. - */ - public function unsignedInteger(string $name, int $length = 10): ColumnDefinition { - return $this->column(new Column($name, 'int', $length, unsigned: true)); - } - - /** - * Add a signed INT column with the requested display width. - * - * @throws InvalidArgumentException When the column name is already defined. - */ - public function integer(string $name, int $length = 10): ColumnDefinition { - return $this->column(new Column($name, 'int', $length)); - } - - /** - * Add a signed TINYINT column with the requested display width. - * - * @throws InvalidArgumentException When the column name is already defined. - */ - public function tinyInteger(string $name, int $length = 3): ColumnDefinition { - return $this->column(new Column($name, 'tinyint', $length)); - } - - /** - * Add a signed BIGINT column with the requested display width. - * - * @throws InvalidArgumentException When the column name is already defined. - */ - public function bigInteger(string $name, int $length = 20): ColumnDefinition { - return $this->column(new Column($name, 'bigint', $length)); - } - - /** - * Add a DATETIME column, optionally with fractional-second precision. - * - * @throws InvalidArgumentException When the column name is already defined or precision is outside the database-supported range. - */ - public function dateTime(string $name, ?int $precision = null): ColumnDefinition { - if ($precision !== null && ($precision < 0 || $precision > 6)) { - throw new InvalidArgumentException('Datetime precision must be between 0 and 6.'); - } - - return $this->column(new Column($name, 'datetime', $precision === 0 ? null : $precision)); - } - - /** - * Add a TIMESTAMP column, optionally with fractional-second precision. - * - * @throws InvalidArgumentException When the column name is already defined or precision is outside the database-supported range. - */ - public function timestamp(string $name, ?int $precision = null): ColumnDefinition { - if ($precision !== null && ($precision < 0 || $precision > 6)) { - throw new InvalidArgumentException('Timestamp precision must be between 0 and 6.'); - } - - return $this->column(new Column($name, 'timestamp', $precision === 0 ? null : $precision)); - } - - /** - * Add a TEXT column. - * - * @throws InvalidArgumentException When the column name is already defined. - */ - public function text(string $name): ColumnDefinition { - return $this->column(new Column($name, 'text')); - } - - /** - * Add a LONGTEXT column. - * - * @throws InvalidArgumentException When the column name is already defined. - */ - public function longText(string $name): ColumnDefinition { - return $this->column(new Column($name, 'longtext')); - } - - /** - * Add a custom column type and return its fluent declaration. - * - * Use this method when the named helpers do not represent the required - * database type, for example a DECIMAL or VARBINARY column. - * - * @throws InvalidArgumentException When the column name is already defined. - */ - public function column(Column $column): ColumnDefinition { - $key = strtolower($column->name); - - if (isset($this->columns[$key])) { - throw new InvalidArgumentException(sprintf('Column %s is already defined.', $column->name)); - } - - $definition = new ColumnDefinition($column); - $this->columns[$key] = $definition; - - return $definition; - } - - /** - * Add a primary key over the columns in their declared order. - * - * @throws InvalidArgumentException When no columns are provided or a column name is blank. - */ - public function primary(string ...$columns): self { - $this->indexes[] = new Index('primary', array_values($columns), IndexType::PRIMARY); - - return $this; - } - - /** - * Add a named unique index over the columns in their declared order. - * - * @throws InvalidArgumentException When the index name is blank, no columns are provided, or a column name is blank. - */ - public function unique(string $name, string ...$columns): self { - $this->indexes[] = new Index($name, array_values($columns), IndexType::UNIQUE); - - return $this; - } - - /** - * Add a named non-unique index over the columns in their declared order. - * - * @throws InvalidArgumentException When the index name is blank, no columns are provided, or a column name is blank. - */ - public function index(string $name, string ...$columns): self { - $this->indexes[] = new Index($name, array_values($columns), IndexType::KEY); - - return $this; - } - - /** - * Remove a named secondary index when this blueprint is used to alter a table. - * - * Repeated declarations are collapsed so retrying a partially completed - * migration does not issue the same removal more than once. - * - * @throws InvalidArgumentException When the name is blank or refers to the primary key. - */ - public function dropIndex(string $name): self { - if (trim($name) === '') { - throw new InvalidArgumentException('The dropped index name cannot be blank.'); - } - - if (strcasecmp($name, 'PRIMARY') === 0) { - throw new InvalidArgumentException('Use Schema::execute() with explicit ALTER TABLE SQL to remove the PRIMARY index.'); - } - - $this->droppedIndexes[strtolower($name)] = $name; - - return $this; - } - - /** - * Remove a column when this blueprint is used to alter a table. - * - * Repeated declarations are collapsed so a migration remains safe to retry - * after the database applied its DDL but before the ledger was updated. - * - * @throws InvalidArgumentException When the column name is blank. - */ - public function dropColumn(string $name): self { - if (trim($name) === '') { - throw new InvalidArgumentException('The dropped column name cannot be blank.'); - } - - $this->droppedColumns[strtolower($name)] = $name; - - return $this; - } - - /** - * Return immutable snapshots of the configured columns in declaration order. - * - * @return list - */ - public function columns(): array { - return array_values(array_map( - static fn (ColumnDefinition $definition): Column => $definition->toColumn(), - $this->columns - )); - } - - /** - * Return columns that an alteration should add when they are missing. - * - * @return list - */ - public function addedColumns(): array { - return $this->columnsByIntent(false); - } - - /** - * Return columns explicitly marked for modification. - * - * @return list - */ - public function changedColumns(): array { - return $this->columnsByIntent(true); - } - - /** - * Return the configured indexes in declaration order. - * - * @return list - */ - public function indexes(): array { - return $this->indexes; - } - - /** - * Return named secondary indexes that an alteration should remove first. - * - * @return list - */ - public function droppedIndexes(): array { - return array_values($this->droppedIndexes); - } - - /** - * Return columns that an alteration should remove. - * - * @return list - */ - public function droppedColumns(): array { - return array_values($this->droppedColumns); - } - - /** - * Return errors that would make this an invalid initial table definition. - * - * @return list - */ - public function errorsForCreate(): array { - $errors = $this->commonErrors(); - - if ($this->columns === []) { - array_unshift($errors, sprintf('Table %s does not define any columns.', $this->table->unprefixedName())); - } - - if ($this->droppedIndexes !== [] || $this->droppedColumns !== []) { - $errors[] = 'A table creation blueprint cannot remove columns or indexes.'; - } - - foreach ($this->columns as $column) { - if ($column->changesExistingColumn()) { - $errors[] = sprintf('Column %s cannot be marked for change when creating a table.', $column->toColumn()->name); - } - } - - foreach ($this->columns() as $column) { - if ($column->autoIncrement && ! $this->isFirstColumnInAnyIndex($column->name)) { - $errors[] = sprintf('AUTO_INCREMENT column %s must be the first column in an index.', $column->name); - } - } - - foreach ($this->indexes as $index) { - foreach ($index->columns as $column) { - if (! isset($this->columns[strtolower($column)])) { - $errors[] = sprintf('Index %s references missing column %s.', $index->name, $column); - } - } - } - - return $errors; - } - - /** - * Return errors that would make this an invalid table alteration. - * - * Alteration indexes may reference columns already present in the physical - * table, so they are not required to appear in this migration's blueprint. - * - * @return list - */ - public function errorsForAlter(): array { - $errors = $this->commonErrors(); - - if ($this->columns === [] && $this->indexes === [] && $this->droppedIndexes === [] && $this->droppedColumns === []) { - array_unshift($errors, sprintf('Table %s does not define any schema changes.', $this->table->unprefixedName())); - } - - foreach ($this->droppedColumns as $key => $column) { - if (isset($this->columns[$key])) { - $errors[] = sprintf('Column %s cannot be declared and removed by the same blueprint.', $column); - } - } - - foreach ($this->addedColumns() as $column) { - if ($column->autoIncrement && ! $this->isFirstColumnInAnyIndex($column->name)) { - $errors[] = sprintf('AUTO_INCREMENT column %s must be the first column in an index.', $column->name); - } - } - - foreach ($this->indexes as $index) { - foreach ($index->columns as $column) { - if (isset($this->droppedColumns[strtolower($column)])) { - $errors[] = sprintf( - 'Index %s references column %s, which is removed by the same blueprint.', - $index->name, - $column - ); - } - } - } - - return $errors; - } - - /** - * Assert that this blueprint can create its table. - * - * @throws InvalidArgumentException When the initial table definition is invalid. - */ - public function assertValidForCreate(): void { - $this->throwForErrors($this->errorsForCreate()); - } - - /** - * Assert that this blueprint can alter an existing table. - * - * @throws InvalidArgumentException When the requested schema changes are invalid. - */ - public function assertValidForAlter(): void { - $this->throwForErrors($this->errorsForAlter()); - } - - /** - * Return errors shared by creation and alteration blueprints. - * - * @return list - */ - private function commonErrors(): array { - $errors = []; - $columns = $this->columns(); - - foreach ($columns as $column) { - array_push($errors, ...$column->validationErrors()); - } - - $autoIncrementColumns = array_values(array_filter( - $columns, - static fn (Column $column): bool => $column->autoIncrement - )); - - if (count($autoIncrementColumns) > 1) { - $errors[] = 'A table can define only one AUTO_INCREMENT column.'; - } - - /** @var array $secondaryIndexes */ - $secondaryIndexes = []; - $primaryCount = 0; - - foreach ($this->indexes as $index) { - if ($index->isPrimary()) { - $primaryCount++; - continue; - } - - if (strcasecmp($index->name, 'PRIMARY') === 0) { - $errors[] = 'The PRIMARY index name is reserved for the primary key.'; - continue; - } - - $key = strtolower($index->name); - $secondaryIndexes[$key] ??= ['name' => $index->name, 'count' => 0]; - $secondaryIndexes[$key]['count']++; - } - - foreach ($secondaryIndexes as $index) { - if ($index['count'] > 1) { - $errors[] = sprintf('Index %s is defined more than once.', $index['name']); - } - } - - if ($primaryCount > 1) { - $errors[] = 'A table can define only one primary key.'; - } - - return $errors; - } - - /** - * Return columns matching the requested alteration intent. - * - * @return list - */ - private function columnsByIntent(bool $changesExistingColumn): array { - $columns = []; - - foreach ($this->columns as $column) { - if ($column->changesExistingColumn() === $changesExistingColumn) { - $columns[] = $column->toColumn(); - } - } - - return $columns; - } - - /** - * Throw one validation exception containing every blueprint error. - * - * @param list $errors - * - * @throws InvalidArgumentException When at least one validation error is present. - */ - private function throwForErrors(array $errors): void { - if ($errors === []) { - return; - } - - throw new InvalidArgumentException(implode(' ', $errors)); - } - - /** - * Determine whether a column leads at least one declared table index. - */ - private function isFirstColumnInAnyIndex(string $column): bool { - foreach ($this->indexes as $index) { - if (strcasecmp($index->columns[0], $column) === 0) { - return true; - } - } - - return false; - } -} diff --git a/src/Database/Table/Column.php b/src/Database/Table/Column.php deleted file mode 100644 index 3b6eb46..0000000 --- a/src/Database/Table/Column.php +++ /dev/null @@ -1,260 +0,0 @@ -hasDefault && $this->default !== null) { - throw new InvalidArgumentException(sprintf( - 'Column %s must set hasDefault when a default value is provided.', - $this->name - )); - } - } - - /** - * Render this column as the SQL fragment used in a CREATE TABLE statement. - * - * @throws InvalidArgumentException When current-timestamp attributes use an invalid type or precision. - */ - public function sql(): string { - $sql = sprintf( - '%s %s%s', - $this->quoteSqlIdentifier($this->name), - $this->typeSql(), - $this->nullable ? ' NULL' : ' NOT NULL' - ); - - $default = $this->defaultSql(); - - if ($default !== null) { - $sql .= sprintf(' DEFAULT %s', $default); - } - - $onUpdate = $this->onUpdateSql(); - - if ($onUpdate !== null) { - $sql .= sprintf(' ON UPDATE %s', $onUpdate); - } - - if ($this->autoIncrement) { - $sql .= ' AUTO_INCREMENT'; - } - - if ($this->comment !== null) { - $sql .= ' COMMENT ' . $this->comment->sql(); - } - - return $sql; - } - - /** - * Render the database type, optional length, and unsigned attribute. - */ - public function typeSql(): string { - return sprintf( - '%s%s', - $this->canonicalType(), - $this->unsigned ? ' unsigned' : '' - ); - } - - /** - * Return the SQL literal or current-timestamp expression for an explicit default. - * - * @throws InvalidArgumentException When a current-timestamp default uses an invalid type or precision. - */ - public function defaultSql(): ?string { - if (! $this->hasDefault) { - return null; - } - - if ($this->default instanceof CurrentTimestamp) { - return $this->currentTimestampSql(); - } - - return $this->formatDefault($this->default); - } - - /** - * Return the automatic-update expression, or null when none is configured. - * - * @throws InvalidArgumentException When automatic updates use an invalid type or precision. - */ - public function onUpdateSql(): ?string { - return $this->onUpdateCurrentTimestamp ? $this->currentTimestampSql() : null; - } - - /** - * Return the descriptive comment stored in database metadata. - */ - public function commentText(): ?string { - return $this->comment?->comment; - } - - /** - * Return errors for incompatible attributes on this completed column definition. - * - * @return list - */ - public function validationErrors(): array { - $errors = []; - - if ($this->autoIncrement) { - preg_match('/\A([a-z]+)/i', trim($this->type), $matches); - $type = strtolower($matches[1] ?? ''); - - if (! in_array($type, self::AUTO_INCREMENT_TYPES, true)) { - $errors[] = sprintf('Column %s must use an integer type because it uses AUTO_INCREMENT.', $this->name); - } - - if ($this->nullable) { - $errors[] = sprintf('Column %s cannot be nullable because it uses AUTO_INCREMENT.', $this->name); - } - - if ($this->hasDefault) { - $errors[] = sprintf('Column %s cannot define a default because it uses AUTO_INCREMENT.', $this->name); - } - } - - if (! $this->autoIncrement && $this->hasDefault && $this->default === null && ! $this->nullable) { - $errors[] = sprintf('Column %s cannot use DEFAULT NULL unless it is nullable.', $this->name); - } - - if (($this->default instanceof CurrentTimestamp || $this->onUpdateCurrentTimestamp) && $this->currentTimestampPrecision() === null) { - $errors[] = sprintf( - 'Column %s must use DATETIME or TIMESTAMP with precision between 0 and 6 for CURRENT_TIMESTAMP attributes.', - $this->name - ); - } - - return $errors; - } - - /** - * Resolve supported temporal precision from the complete SQL type. - */ - private function currentTimestampPrecision(): ?int { - if (preg_match('/\A(?:datetime|timestamp)(?:\s*\(\s*(\d+)\s*\))?\z/i', $this->typeSql(), $parts) !== 1) { - return null; - } - - $precision = (int) ($parts[1] ?? 0); - - return $precision <= 6 ? $precision : null; - } - - /** - * Render the current-timestamp expression shared by defaults and updates. - * - * @throws InvalidArgumentException When the column is not a supported temporal declaration. - */ - private function currentTimestampSql(): string { - $precision = $this->currentTimestampPrecision(); - - if ($precision === null) { - throw new InvalidArgumentException(sprintf( - 'Column %s must use DATETIME or TIMESTAMP with precision between 0 and 6 for CURRENT_TIMESTAMP attributes.', - $this->name - )); - } - - return $precision === 0 ? 'CURRENT_TIMESTAMP' : sprintf('CURRENT_TIMESTAMP(%d)', $precision); - } - - /** - * Format a PHP scalar as a SQL literal suitable for a column default. - */ - private function formatDefault(mixed $default): string { - if ($default === null) { - return 'NULL'; - } - - if (is_bool($default)) { - return $default ? '1' : '0'; - } - - if (is_int($default) || is_float($default)) { - return (string) $default; - } - - $default = (string) $default; - - if (preg_match("/['\\\\\x00-\x1F\x7F]/", $default) === 1) { - return "X'" . bin2hex($default) . "'"; - } - - return "'" . $default . "'"; - } - - /** - * Normalize common MySQL type aliases and numeric type arguments before SQL is emitted. - * - * Literal-bearing types such as ENUM and SET are otherwise preserved byte for - * byte so normalization cannot change their declared values. - */ - private function canonicalType(): string { - $type = trim($this->type); - - if ($this->length !== null) { - $type .= sprintf('(%d)', $this->length); - } - - if (preg_match('/\Adouble\s+precision\z/i', $type) === 1) { - return 'double'; - } - - if (preg_match('/\A(?:decimal|numeric|dec)\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)\z/i', $type, $parts) === 1) { - return sprintf('decimal(%d,%d)', (int) $parts[1], (int) $parts[2]); - } - - if (preg_match('/\A(?:decimal|numeric|dec)\s*\(\s*(\d+)\s*\)\z/i', $type, $parts) === 1) { - return sprintf('decimal(%d,0)', (int) $parts[1]); - } - - if (preg_match('/\A(?:decimal|numeric|dec)\z/i', $type) === 1) { - return 'decimal(10,0)'; - } - - return $type; - } -} diff --git a/src/Database/Table/ColumnDefinition.php b/src/Database/Table/ColumnDefinition.php deleted file mode 100644 index 0b00c82..0000000 --- a/src/Database/Table/ColumnDefinition.php +++ /dev/null @@ -1,163 +0,0 @@ -name = $column->name; - $this->type = $column->type; - $this->length = $column->length; - $this->unsigned = $column->unsigned; - $this->nullable = $column->nullable; - $this->default = $column->default; - $this->hasDefault = $column->hasDefault; - $this->autoIncrement = $column->autoIncrement; - $this->comment = $column->comment; - $this->onUpdateCurrentTimestamp = $column->onUpdateCurrentTimestamp; - } - - /** - * Configure whether this column uses an unsigned numeric type. - */ - public function unsigned(bool $unsigned = true): self { - $this->unsigned = $unsigned; - - return $this; - } - - /** - * Configure whether this column accepts NULL values. - */ - public function nullable(bool $nullable = true): self { - $this->nullable = $nullable; - - return $this; - } - - /** - * Require this column to contain a non-NULL value. - */ - public function notNull(): self { - return $this->nullable(false); - } - - /** - * Configure the default value for this column. - */ - public function default(mixed $default): self { - $this->default = $default; - $this->hasDefault = true; - - return $this; - } - - /** - * Default a DATETIME or TIMESTAMP column to the database session's current time. - * - * This replaces any earlier default without changing nullability or updates. - */ - public function useCurrent(): self { - return $this->default(new CurrentTimestamp()); - } - - /** - * Automatically update a DATETIME or TIMESTAMP column when its row changes. - * - * This leaves the default and nullability unchanged. - */ - public function useCurrentOnUpdate(): self { - $this->onUpdateCurrentTimestamp = true; - - return $this; - } - - /** - * Configure this column to use MySQL's AUTO_INCREMENT attribute. - */ - public function autoIncrement(): self { - $this->autoIncrement = true; - - return $this; - } - - /** - * Configure the descriptive comment stored with this column. - * - * @throws InvalidArgumentException When the comment requires SQL-mode-dependent escaping. - */ - public function comment(string $comment): self { - $this->comment = new ColumnComment($comment); - - return $this; - } - - /** - * Mark this declaration as a modification of an existing column. - * - * Without this marker, a declaration in an alteration adds the column when - * it is missing and must already match when it exists during a retry. - */ - public function change(): self { - $this->changesExistingColumn = true; - - return $this; - } - - /** - * Determine whether an alteration should modify the existing column. - */ - public function changesExistingColumn(): bool { - return $this->changesExistingColumn; - } - - /** - * Return the immutable column represented by this declaration. - */ - public function toColumn(): Column { - return new Column( - name: $this->name, - type: $this->type, - length: $this->length, - unsigned: $this->unsigned, - nullable: $this->nullable, - default: $this->default, - hasDefault: $this->hasDefault, - autoIncrement: $this->autoIncrement, - comment: $this->comment, - onUpdateCurrentTimestamp: $this->onUpdateCurrentTimestamp - ); - } -} diff --git a/src/Database/Table/CreateTable.php b/src/Database/Table/CreateTable.php deleted file mode 100644 index 892b509..0000000 --- a/src/Database/Table/CreateTable.php +++ /dev/null @@ -1,44 +0,0 @@ -migrationId; - } - - /** - * Create the table or verify its existing state from the complete initial blueprint. - */ - public function up(Schema $schema): void { - $schema->create($this->blueprint); - } - - /** - * Drop the complete managed table when this creation migration is reversed. - */ - public function down(Schema $schema): void { - $schema->drop($this->blueprint->table()); - } -} diff --git a/src/Database/Table/Index.php b/src/Database/Table/Index.php deleted file mode 100644 index 65db4e7..0000000 --- a/src/Database/Table/Index.php +++ /dev/null @@ -1,90 +0,0 @@ - $columns - * - * @throws InvalidArgumentException When the index name, columns, or type are invalid. - */ - public function __construct( - public string $name, - public array $columns, - string $type = IndexType::KEY - ) { - if (trim($this->name) === '') { - throw new InvalidArgumentException('Index name cannot be blank.'); - } - - if ($this->columns === []) { - throw new InvalidArgumentException('An index must define at least one column.'); - } - - foreach ($this->columns as $column) { - if (trim($column) === '') { - throw new InvalidArgumentException(sprintf('Index %s contains a blank column name.', $this->name)); - } - } - - if (! in_array($type, [IndexType::PRIMARY, IndexType::UNIQUE, IndexType::KEY], true)) { - throw new InvalidArgumentException(sprintf('Unsupported index type "%s".', $type)); - } - - $this->type = $type; - } - - /** - * Determine whether this declaration defines the table's primary key. - */ - public function isPrimary(): bool { - return $this->type === IndexType::PRIMARY; - } - - /** - * Determine whether this declaration defines a unique secondary index. - */ - public function isUnique(): bool { - return $this->type === IndexType::UNIQUE; - } - - /** - * Render this index declaration in the format expected by dbDelta(). - */ - public function sql(): string { - $columns = implode(', ', array_map( - fn (string $column): string => $this->quoteSqlIdentifier($column), - $this->columns - )); - - // dbDelta() requires two spaces between PRIMARY KEY and the column list. - return match ($this->type) { - IndexType::PRIMARY => sprintf('PRIMARY KEY (%s)', $columns), - IndexType::UNIQUE => sprintf('UNIQUE KEY %s (%s)', $this->quotedName(), $columns), - IndexType::KEY => sprintf('KEY %s (%s)', $this->quotedName(), $columns), - }; - } - - /** - * Quote the index name for use in a SQL declaration. - */ - private function quotedName(): string { - return $this->quoteSqlIdentifier($this->name); - } -} diff --git a/src/Database/Table/IndexType.php b/src/Database/Table/IndexType.php deleted file mode 100644 index 8e3dc36..0000000 --- a/src/Database/Table/IndexType.php +++ /dev/null @@ -1,16 +0,0 @@ -database; - } - - /** - * Return the stable table name before WordPress scope is applied. + * Return the stable name before WordPress applies its site prefix. */ abstract public function unprefixedName(): string; /** - * Resolve a validated physical table name for the active WordPress database scope. + * Resolve this table's physical name at the current site. * - * Prefixing and MySQL identifier-length validation are owned by the database - * name-resolution boundary so callers can rely on the returned name. + * @throws DatabaseException When the physical name is invalid. * - * @throws DatabaseException When the resolved physical table name is invalid. + * @return non-empty-string */ final public function name(): string { - return $this->database->tableName($this); + return $this->names->tableName($this); } /** - * Begin a query against this table. + * Quote the resolved name for native Doctrine expressions and SQL. + * + * @throws DatabaseException When the physical name is invalid. + * @throws Exception When the platform cannot be determined. + */ + final public function quotedName(): string { + return $this->connection->getDatabasePlatform()->quoteSingleIdentifier($this->name()); + } + + /** + * Start a fresh native query with this table as its source. + * + * @throws DatabaseException When the physical name is invalid. + * @throws Exception When the platform cannot be determined. */ final public function query(?string $alias = null): QueryBuilder { - return new QueryBuilder($this->database, $this, $alias); + return $this->connection->createQueryBuilder()->from($this->quotedName(), $alias); } /** - * Insert one row into this table. + * Insert a row and return its affected-row count. * - * @param array $data + * @param array $data Application-owned column names and bound values. + * @param array $types * - * @throws DatabaseException When the resolved physical table name is invalid. - * @throws QueryException When the insert fails. + * @throws DatabaseException When the physical name is invalid. + * @throws Exception When insertion fails. */ - final public function insert(array $data): int { - return $this->database->insert($this, $data); + final public function insert(array $data, array $types = []): int|string { + return $this->connection->insert($this->quotedName(), $data, $types); } /** - * Insert one row and return the connection's auto-increment identifier. + * Insert a row and return its generated identifier without truncation. * - * @param array $data + * @param array $data + * @param array $types * - * @throws DatabaseException When the resolved physical table name is invalid. - * @throws QueryException When the insert fails. + * @throws DatabaseException When the physical name is invalid. + * @throws Exception When insertion fails or no generated identifier is available. */ - final public function insertGetId(array $data): int { - return $this->database->insertGetId($this, $data); + final public function insertGetId(array $data, array $types = []): int|string { + $this->insert($data, $types); + + return $this->connection->lastInsertId(); } /** - * Update rows in this table matching equality-based column values. + * Update rows matching equality criteria, returning the affected-row count. * - * @param array $data - * @param array $where + * @param array $data + * @param array $where + * @param array $types * - * @throws DatabaseException When the resolved physical table name is invalid. - * @throws QueryException When the update fails. + * @throws InvalidArgumentException When criteria are empty. + * @throws DatabaseException When the physical name is invalid. + * @throws Exception When the update fails. */ - final public function update(array $data, array $where): int { - return $this->database->update($this, $data, $where); + final public function update(array $data, array $where, array $types = []): int|string { + if ($where === []) { + throw new InvalidArgumentException('Table updates require criteria; use the connection for an intentional whole-table update.'); + } + + return $this->connection->update($this->quotedName(), $data, $where, $types); } /** - * Delete rows from this table matching equality-based column values. + * Delete rows matching equality criteria, returning the affected-row count. * - * @param array $where + * @param array $where + * @param array $types * - * @throws DatabaseException When the resolved physical table name is invalid. - * @throws QueryException When the delete fails. + * @throws InvalidArgumentException When criteria are empty. + * @throws DatabaseException When the physical name is invalid. + * @throws Exception When deletion fails. */ - final public function delete(array $where): int { - return $this->database->delete($this, $where); + final public function delete(array $where, array $types = []): int|string { + if ($where === []) { + throw new InvalidArgumentException('Table deletions require criteria; use the connection for an intentional whole-table delete.'); + } + + return $this->connection->delete($this->quotedName(), $where, $types); } } diff --git a/src/Database/Table/TableNameResolver.php b/src/Database/Table/TableNameResolver.php new file mode 100644 index 0000000..d540903 --- /dev/null +++ b/src/Database/Table/TableNameResolver.php @@ -0,0 +1,42 @@ +unprefixedName(); + + if ($unprefixed === '' || trim($unprefixed) !== $unprefixed) { + throw new DatabaseException('The unprefixed database table name cannot be blank or contain surrounding whitespace.'); + } + $name = $this->scope->resolveTableName($unprefixed); + + if ($name === '' || preg_match('/\A[A-Za-z0-9_]+\z/', $name) !== 1 || strlen($name) > 64) { + throw new DatabaseException('Physical table names must contain at most 64 ASCII letters, numbers, or underscores.'); + } + + return $name; + } +} diff --git a/src/Database/Table/Tables/LockTable.php b/src/Database/Table/Tables/LockTable.php index f87648c..7e78e86 100644 --- a/src/Database/Table/Tables/LockTable.php +++ b/src/Database/Table/Tables/LockTable.php @@ -2,14 +2,14 @@ namespace StellarWP\Foundation\Database\Table\Tables; -use StellarWP\Foundation\Database\Contracts\TableGateway; -use StellarWP\Foundation\Database\Migration\StoreSchema; +use Doctrine\DBAL\Connection; +use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Database\Table\Table; /** - * Defines the database-backed lock table used during migration runs. + * Identifies the optional application database-lock table. * - * @see StoreSchema::initializeLock() + * @internal Storage is initialized by DatabaseLock::initialize(). */ final readonly class LockTable extends Table { @@ -18,9 +18,10 @@ */ public function __construct( private string $unprefixedTableName, - TableGateway $database + Connection $connection, + TableNameResolver $names, ) { - parent::__construct($database); + parent::__construct($connection, $names); } /** diff --git a/src/Database/Table/Tables/MigrationTable.php b/src/Database/Table/Tables/MigrationTable.php index a2fd2d8..a453f0c 100644 --- a/src/Database/Table/Tables/MigrationTable.php +++ b/src/Database/Table/Tables/MigrationTable.php @@ -2,14 +2,14 @@ namespace StellarWP\Foundation\Database\Table\Tables; -use StellarWP\Foundation\Database\Contracts\TableGateway; -use StellarWP\Foundation\Database\Migration\StoreSchema; +use Doctrine\DBAL\Connection; +use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Database\Table\Table; /** * Defines the migration ledger table used to record completed migrations. * - * @see StoreSchema::initializeLedger() + * @internal Storage is initialized by the migration history. */ final readonly class MigrationTable extends Table { @@ -18,9 +18,10 @@ */ public function __construct( private string $unprefixedTableName, - TableGateway $database + Connection $connection, + TableNameResolver $names, ) { - parent::__construct($database); + parent::__construct($connection, $names); } /** diff --git a/src/Database/Table/ValueObjects/ColumnComment.php b/src/Database/Table/ValueObjects/ColumnComment.php deleted file mode 100644 index 61ed0db..0000000 --- a/src/Database/Table/ValueObjects/ColumnComment.php +++ /dev/null @@ -1,35 +0,0 @@ -comment, '\\')) { - throw new InvalidArgumentException('Database column comments cannot contain backslashes.'); - } - - if (preg_match('/[\x00-\x1F\x7F]/', $this->comment) === 1) { - throw new InvalidArgumentException('Database column comments cannot contain control characters.'); - } - } - - /** - * Render the comment as a SQL string literal under every MySQL SQL mode. - */ - public function sql(): string { - return "'" . str_replace("'", "''", $this->comment) . "'"; - } -} diff --git a/src/Database/Table/ValueObjects/CurrentTimestamp.php b/src/Database/Table/ValueObjects/CurrentTimestamp.php deleted file mode 100644 index fe59099..0000000 --- a/src/Database/Table/ValueObjects/CurrentTimestamp.php +++ /dev/null @@ -1,12 +0,0 @@ -=8.3", "stellarwp/foundation-container": "^2.0", "stellarwp/foundation-lock": "^2.0", - "stellarwp/foundation-wpcli": "^2.0" + "stellarwp/foundation-wpcli": "^2.0", + "doctrine/dbal": "^4.4", + "ext-mysqli": "*" }, "autoload": { "psr-4": { diff --git a/src/Database/stubs/alter-table-migration.stub b/src/Database/stubs/alter-table-migration.stub index e114f8f..ebdbab6 100644 --- a/src/Database/stubs/alter-table-migration.stub +++ b/src/Database/stubs/alter-table-migration.stub @@ -5,7 +5,6 @@ namespace {{ namespace }}; use {{ foundation_database_irreversible_migration }}; use {{ foundation_database_migration }}; use {{ foundation_database_schema }}; -use {{ foundation_database_blueprint }}; use {{ table_namespace }}\{{ table_class }}; final readonly class {{ class }} implements Migration { @@ -21,18 +20,17 @@ final readonly class {{ class }} implements Migration { return self::ID; } - public function up( Schema $schema ): void { - $blueprint = Blueprint::for( $this->table ); + public function up( Blueprint $schema ): void { + $table = $schema->table( $this->table ); // Declare only the additions, changes, and removals owned by this migration. - $schema->alter( $blueprint ); } /** * @throws IrreversibleMigration Until a safe inverse is implemented. */ - public function down( Schema $schema ): void { + public function down( Blueprint $schema ): void { throw IrreversibleMigration::forMigration( self::ID ); } diff --git a/src/Database/stubs/create-table-migration.stub b/src/Database/stubs/create-table-migration.stub index ddbc8e1..549d3dc 100644 --- a/src/Database/stubs/create-table-migration.stub +++ b/src/Database/stubs/create-table-migration.stub @@ -4,7 +4,6 @@ namespace {{ namespace }}; use {{ foundation_database_migration }}; use {{ foundation_database_schema }}; -use {{ foundation_database_blueprint }}; use {{ table_namespace }}\{{ table_class }}; final readonly class {{ class }} implements Migration { @@ -20,17 +19,16 @@ final readonly class {{ class }} implements Migration { return self::ID; } - public function up( Schema $schema ): void { - $blueprint = Blueprint::for( $this->table ); + public function up( Blueprint $schema ): void { + $table = $schema->create( $this->table ); - $blueprint->bigIncrements( 'id' ); + $table->bigIncrements( 'id' ); // Define the complete initial schema before running this migration. - $schema->create( $blueprint ); } - public function down( Schema $schema ): void { + public function down( Blueprint $schema ): void { $schema->drop( $this->table ); } diff --git a/src/Database/stubs/migration.stub b/src/Database/stubs/migration.stub index 7d4de6c..9b4e82f 100644 --- a/src/Database/stubs/migration.stub +++ b/src/Database/stubs/migration.stub @@ -14,14 +14,14 @@ final readonly class {{ class }} implements Migration { return self::ID; } - public function up( Schema $schema ): void { + public function up( Blueprint $schema ): void { // Add migration behavior here. } /** * @throws IrreversibleMigration Until a safe inverse is implemented. */ - public function down( Schema $schema ): void { + public function down( Blueprint $schema ): void { throw IrreversibleMigration::forMigration( self::ID ); } diff --git a/src/Database/stubs/provider.stub b/src/Database/stubs/provider.stub index ce84098..bbc35fa 100644 --- a/src/Database/stubs/provider.stub +++ b/src/Database/stubs/provider.stub @@ -7,17 +7,9 @@ use {{ foundation_database_provider }}; use {{ foundation_service_provider }} as Service_Provider; final class {{ class }} extends Service_Provider { - private bool $registered = false; - public function register(): void { - if ( $this->registered ) { - return; - } - $this->register_tables(); $this->register_migrations(); - - $this->registered = true; } private function register_tables(): void { diff --git a/tests/Support/Fixtures/Database/AddRecoveryStatusMigration.php b/tests/Support/Fixtures/Database/AddRecoveryStatusMigration.php deleted file mode 100644 index f291b34..0000000 --- a/tests/Support/Fixtures/Database/AddRecoveryStatusMigration.php +++ /dev/null @@ -1,48 +0,0 @@ -table); - $blueprint->string('status', 20)->default('pending'); - $blueprint->dropIndex('lookup'); - $blueprint->index('lookup', 'status', 'name'); - - $schema->alter($blueprint); - } - - /** - * Prevent removal of application status data without an explicit recovery plan. - * - * @throws IrreversibleMigration Always, because this fixture only defines forward recovery. - */ - public function down(Schema $schema): void { - throw new IrreversibleMigration($this->id()); - } -} diff --git a/tests/Support/Fixtures/Database/CommentReconciliationTable.php b/tests/Support/Fixtures/Database/CommentReconciliationTable.php deleted file mode 100644 index 3223b67..0000000 --- a/tests/Support/Fixtures/Database/CommentReconciliationTable.php +++ /dev/null @@ -1,38 +0,0 @@ -unprefixedName; - } - - public function blueprint(): Blueprint { - $table = Blueprint::for($this); - - $this->comment($table->bigIncrements('id')); - $this->comment($table->string('description', 100)->nullable()->default('fallback')); - - return $table; - } - - private function comment(ColumnDefinition $column): void { - if ($this->comment !== null) { - $column->comment($this->comment); - } - } -} diff --git a/tests/Support/Fixtures/Database/CommentedTable.php b/tests/Support/Fixtures/Database/CommentedTable.php deleted file mode 100644 index bf42171..0000000 --- a/tests/Support/Fixtures/Database/CommentedTable.php +++ /dev/null @@ -1,35 +0,0 @@ -unprefixedName; - } - - public function blueprint(): Blueprint { - $table = Blueprint::for($this); - - $table->bigIncrements('id'); - $description = $table->text('description'); - - if ($this->comment !== null) { - $description->comment($this->comment); - } - - return $table; - } -} diff --git a/tests/Support/Fixtures/Database/Consumer/Entry_Table.php b/tests/Support/Fixtures/Database/Consumer/Entry_Table.php new file mode 100644 index 0000000..3f30e32 --- /dev/null +++ b/tests/Support/Fixtures/Database/Consumer/Entry_Table.php @@ -0,0 +1,18 @@ +id; - } - - public function up(Schema $schema): void { - $this->scope->currentId = $this->nextScopeId; - } - - public function down(Schema $schema): void { - $this->scope->currentId = $this->nextScopeId; - } -} diff --git a/tests/Support/Fixtures/Database/DatabaseTestCase.php b/tests/Support/Fixtures/Database/DatabaseTestCase.php new file mode 100644 index 0000000..da26141 --- /dev/null +++ b/tests/Support/Fixtures/Database/DatabaseTestCase.php @@ -0,0 +1,108 @@ + */ + protected array $tables = []; + + protected function setUp(): void { + parent::setUp(); + $this->suffix = 'doctrine_' . bin2hex(random_bytes(5)); + $overrides = $this->configuration(); + + if ($overrides !== []) { + $this->container = (new ContainerFactory())->create(new ArrayConfiguration( + array_replace_recursive(require dirname(__DIR__, 3) . '/config.php', $overrides), + )); + } + $this->source = new wpdb(constant('DB_USER'), constant('DB_PASSWORD'), constant('DB_NAME'), constant('DB_HOST')); + $this->source->set_prefix($GLOBALS['wpdb']->base_prefix); + $this->source->set_blog_id(get_current_blog_id()); + $this->observerSource = new wpdb(constant('DB_USER'), constant('DB_PASSWORD'), constant('DB_NAME'), constant('DB_HOST')); + $this->container->register(DatabaseProvider::class); + $this->container->singleton(wpdb::class, $this->source); + $this->connection = $this->container->get(Connection::class); + $this->observer = (new NativeConnectionFactory())->create($this->native($this->observerSource), constant('DB_NAME')); + $this->table = $this->privateTable($this->suffix); + $this->observer->executeStatement("CREATE TABLE {$this->table} (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL) ENGINE=InnoDB"); + $this->observer->insert($this->table, ['id' => 1, 'name' => 'Original']); + } + + protected function tearDown(): void { + try { + try { + $native = $this->source->__get('dbh'); + + if ($native instanceof mysqli) { + $native->rollback(); + } + } catch (Throwable) { + // Connection-loss tests deliberately kill or close this session. + } + foreach (array_reverse($this->tables) as $table) { + $this->observer->executeStatement('DROP TABLE IF EXISTS ' . $table); + } + $this->observerSource->close(); + + try { + $this->source->close(); + } catch (Throwable) { + // A failed rollback may already have closed the borrowed handle. + } + } finally { + parent::tearDown(); + } + } + + /** + * Configuration overrides merged over tests/config.php before providers register. The random + * suffix is already available, so tests can name private tables through configuration. + * + * @return array + */ + protected function configuration(): array { + return []; + } + + protected function privateTable(string $name): string { + $quoted = $this->observer->getDatabasePlatform()->quoteSingleIdentifier($this->source->prefix . $name); + $this->tables[] = $quoted; + + return $quoted; + } + + protected function native(wpdb $source): mysqli { + $native = $source->__get('dbh'); + self::assertInstanceOf(mysqli::class, $native); + + return $native; + } + + protected function killConnection(): void { + $this->observer->executeStatement('KILL CONNECTION ' . $this->native($this->source)->thread_id); + } + + protected function assertOriginal(): void { + self::assertSame(['Original'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table)); + } +} diff --git a/tests/Support/Fixtures/Database/DateTimePrecisionTable.php b/tests/Support/Fixtures/Database/DateTimePrecisionTable.php deleted file mode 100644 index 5c2ead5..0000000 --- a/tests/Support/Fixtures/Database/DateTimePrecisionTable.php +++ /dev/null @@ -1,27 +0,0 @@ -unprefixedName; - } - - public function blueprint(): Blueprint { - $table = Blueprint::for($this); - - $table->bigIncrements('id'); - $table->dateTime('occurred_at', 0); - - return $table; - } -} diff --git a/tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php b/tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php new file mode 100644 index 0000000..a3c2c0f --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php @@ -0,0 +1,32 @@ +table($this->table)->boolean('archived')->default(false); + } + + public function down(Blueprint $schema): void { + throw IrreversibleMigration::forMigration(self::ID); + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/AddEntryNote.php b/tests/Support/Fixtures/Database/Declarative/AddEntryNote.php new file mode 100644 index 0000000..5dbb16c --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/AddEntryNote.php @@ -0,0 +1,36 @@ +table($this->table)->string('note', 100)->nullable(); + } + + public function down(Blueprint $schema): void { + $schema->table($this->table)->dropColumn('note'); + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php b/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php new file mode 100644 index 0000000..50c94e9 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php @@ -0,0 +1,35 @@ +executeStatement("UPDATE `{$this->physicalName}` SET status = 'active' WHERE status = ''"); + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/CreateEntries.php b/tests/Support/Fixtures/Database/Declarative/CreateEntries.php new file mode 100644 index 0000000..c959b0f --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/CreateEntries.php @@ -0,0 +1,41 @@ +create($this->table); + + $entries->bigIncrements('id'); + $entries->string('name', 50); + $entries->string('status', 20)->default('active'); + $entries->decimal('amount', 12, 4)->default('0'); + $entries->binary('token', 16)->nullable(); + $entries->dateTime('created_at', 6)->useCurrent(); + $entries->dateTime('updated_at', 6)->useCurrent()->useCurrentOnUpdate(); + + $entries->index('status', 'status'); + } + + public function down(Blueprint $schema): void { + $schema->drop($this->table); + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php b/tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php new file mode 100644 index 0000000..c0c337d --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php @@ -0,0 +1,33 @@ +create($this->table); + $tags->bigIncrements('id'); + $tags->string('label', 50); + } + + public function down(Blueprint $schema): void { + $schema->drop($this->table); + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/DropEntryTags.php b/tests/Support/Fixtures/Database/Declarative/DropEntryTags.php new file mode 100644 index 0000000..c31771d --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/DropEntryTags.php @@ -0,0 +1,32 @@ +drop($this->table); + } + + public function down(Blueprint $schema): void { + throw IrreversibleMigration::forMigration(self::ID); + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/EntriesTable.php b/tests/Support/Fixtures/Database/Declarative/EntriesTable.php new file mode 100644 index 0000000..9604261 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/EntriesTable.php @@ -0,0 +1,20 @@ +unprefixedName; + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php b/tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php new file mode 100644 index 0000000..068ac46 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php @@ -0,0 +1,31 @@ +table($this->table)->index('note_lookup', 'note'); + } + + public function down(Blueprint $schema): void { + $schema->table($this->table)->dropIndex('note_lookup'); + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php b/tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php new file mode 100644 index 0000000..bdeb5ac --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php @@ -0,0 +1,34 @@ +create($this->table); + $tags->bigIncrements('id'); + $tags->string('label', 80); + $tags->string('slug', 80); + } + + public function down(Blueprint $schema): void { + $schema->drop($this->table); + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php b/tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php new file mode 100644 index 0000000..5fea52c --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php @@ -0,0 +1,31 @@ +table($this->table)->dateTime('created_at', 3)->useCurrent()->useCurrentOnUpdate()->change(); + } + + public function down(Blueprint $schema): void { + $schema->table($this->table)->dateTime('created_at', 6)->useCurrent()->useCurrentOnUpdate()->change(); + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php b/tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php new file mode 100644 index 0000000..664a26a --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php @@ -0,0 +1,31 @@ +table($this->table)->dropIndex('note_lookup')->unique('note_lookup', 'note'); + } + + public function down(Blueprint $schema): void { + $schema->table($this->table)->dropIndex('note_lookup')->index('note_lookup', 'note'); + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/TagsTable.php b/tests/Support/Fixtures/Database/Declarative/TagsTable.php new file mode 100644 index 0000000..286e6af --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/TagsTable.php @@ -0,0 +1,20 @@ +unprefixedName; + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php b/tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php new file mode 100644 index 0000000..9a11ce1 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php @@ -0,0 +1,31 @@ +table($this->table)->dateTime('created_at', 6)->useCurrent()->useCurrentOnUpdate()->change(); + } + + public function down(Blueprint $schema): void { + $schema->table($this->table)->dateTime('created_at', 6)->useCurrent()->change(); + } +} diff --git a/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php b/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php new file mode 100644 index 0000000..89ccf32 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php @@ -0,0 +1,31 @@ +table($this->table)->comment('Imported entries')->string('name', 120)->nullable()->change(); + } + + public function down(Blueprint $schema): void { + $schema->table($this->table)->comment('')->string('name', 50)->change(); + } +} diff --git a/tests/Support/Fixtures/Database/ExternalTable.php b/tests/Support/Fixtures/Database/ExternalTable.php deleted file mode 100644 index 9736053..0000000 --- a/tests/Support/Fixtures/Database/ExternalTable.php +++ /dev/null @@ -1,15 +0,0 @@ -id; - } - - public function up(Schema $schema): void { - if ($this->failUp) { - throw new RuntimeException('Migration up failed.'); - } - - $schema->execute('up:' . $this->id); - } - - public function down(Schema $schema): void { - if ($this->failDown) { - throw new RuntimeException('Migration down failed.'); - } - - $schema->execute('down:' . $this->id); - } -} diff --git a/tests/Support/Fixtures/Database/FakeDatabase.php b/tests/Support/Fixtures/Database/FakeDatabase.php deleted file mode 100644 index f73c9f5..0000000 --- a/tests/Support/Fixtures/Database/FakeDatabase.php +++ /dev/null @@ -1,165 +0,0 @@ - - */ - public array $executed = []; - - /** - * @var list - */ - public array $rowQueries = []; - - /** - * @var list - */ - public array $rowsQueries = []; - - /** - * @var list|callable(string, self): (array|null)|null> - */ - public array $rowResults = []; - - /** - * @var list>> - */ - public array $rowsResults = []; - - /** - * @var list - */ - public array $executeResults = []; - - public int $insertId = 1; - - public int $insertResult = 1; - - /** - * @var array|null - */ - public ?array $tableStatus = ['Collation' => 'utf8mb4_general_ci']; - - public function tableName(Table $table): string { - return $this->prefix . $table->unprefixedName(); - } - - public function tableExists(Table $table): bool { - return $this->row('SHOW TABLES LIKE %s', $this->escLike($this->tableName($table))) !== null; - } - - public function columnExists(Table $table, string $column): bool { - return $this->row('SHOW COLUMNS FROM %i LIKE %s', $this->tableName($table), $this->escLike($column)) !== null; - } - - public function indexExists(Table $table, string $index): bool { - return $this->row('SHOW INDEX FROM %i WHERE Key_name = %s', $this->tableName($table), $index) !== null; - } - - public function execute(string $sql, mixed ...$bindings): int { - $this->executed[] = $this->prepare($sql, ...$bindings); - - return array_shift($this->executeResults) ?? 1; - } - - /** - * @return array|null - */ - public function row(string $sql, mixed ...$bindings): ?array { - $query = $this->prepare($sql, ...$bindings); - $this->rowQueries[] = $query; - - if (str_starts_with($sql, 'SHOW TABLE STATUS ')) { - return $this->tableStatus; - } - - $result = array_shift($this->rowResults); - - if (is_callable($result)) { - return $result($query, $this); - } - - return $result; - } - - /** - * @return list> - */ - public function rows(string $sql, mixed ...$bindings): array { - $this->rowsQueries[] = $this->prepare($sql, ...$bindings); - - return array_shift($this->rowsResults) ?? []; - } - - public function value(string $sql, mixed ...$bindings): mixed { - $row = $this->row($sql, ...$bindings); - - if ($row === null) { - return null; - } - - return reset($row); - } - - public function insert(Table $table, array $data): int { - $this->executed[] = 'INSERT ' . $this->tableName($table); - - return $this->insertResult; - } - - public function insertGetId(Table $table, array $data): int { - $this->insert($table, $data); - - return $this->insertId; - } - - public function update(Table $table, array $data, array $where): int { - $this->executed[] = 'UPDATE ' . $this->tableName($table); - - return array_shift($this->executeResults) ?? 1; - } - - public function delete(Table $table, array $where): int { - $this->executed[] = 'DELETE ' . $this->tableName($table); - - return array_shift($this->executeResults) ?? 1; - } - - public function prepare(string $sql, mixed ...$bindings): string { - $position = 0; - - return preg_replace_callback('/(? */ - public array $queries = []; - - public function prepare(string $sql, mixed ...$bindings): string { - return $sql; - } - - public function row(string $sql, mixed ...$bindings): ?array { - $this->queries[] = $sql; - - return null; - } - - public function rows(string $sql, mixed ...$bindings): array { - $this->queries[] = $sql; - - return []; - } - - public function value(string $sql, mixed ...$bindings): mixed { - $this->queries[] = $sql; - - return null; - } - - public function quoteIdentifier(string $identifier): string { - return '`' . str_replace('`', '``', $identifier) . '`'; - } - - public function tableName(Table $table): string { - return 'wp_' . $table->unprefixedName(); - } -} diff --git a/tests/Support/Fixtures/Database/GeneratedStyleTable.php b/tests/Support/Fixtures/Database/GeneratedStyleTable.php deleted file mode 100644 index 10fca9d..0000000 --- a/tests/Support/Fixtures/Database/GeneratedStyleTable.php +++ /dev/null @@ -1,15 +0,0 @@ - $records Already downloaded and validated records. + * + * @throws Throwable When the replacement fails or commit is uncertain. + */ + public function replace(array $records): int { + return $this->connection->transactional(function () use ($records): int { + $this->entries->deleteAll(); + + return $this->entries->insertBatch($records); + }); + } +} diff --git a/tests/Support/Fixtures/Database/Import/EntryRepository.php b/tests/Support/Fixtures/Database/Import/EntryRepository.php new file mode 100644 index 0000000..13f2b4c --- /dev/null +++ b/tests/Support/Fixtures/Database/Import/EntryRepository.php @@ -0,0 +1,80 @@ +connection->executeStatement('DELETE FROM ' . $this->table()); + } + + /** + * Insert validated records in batches of at most 500 rows. + * + * @param list $records + * + * @throws Exception When any batch fails. + */ + public function insertBatch(array $records): int { + $count = 0; + $table = $this->table(); + foreach (array_chunk($records, 500) as $batch) { + $values = implode(', ', array_fill(0, count($batch), '(?, ?)')); + $parameters = []; + foreach ($batch as $record) { + $parameters[] = $record['id']; + $parameters[] = $record['name']; + } + $count += (int) $this->connection->executeStatement("INSERT INTO {$table} (id, name) VALUES {$values}", $parameters); + } + + return $count; + } + + /** + * Find a bounded page of matching entries. + * + * @throws Exception When the query fails. + * + * @return list> + */ + public function findByName(string $name): array { + return $this->connection->createQueryBuilder() + ->select('id', 'name') + ->from($this->table()) + ->where('name = :name') + ->setParameter('name', $name) + ->orderBy('id', 'ASC') + ->setMaxResults(50) + ->executeQuery() + ->fetchAllAssociative(); + } + + private function table(): string { + return $this->connection->getDatabasePlatform()->quoteSingleIdentifier($this->scope->resolveTableName($this->tableName)); + } +} diff --git a/tests/Support/Fixtures/Database/InMemoryRepository.php b/tests/Support/Fixtures/Database/InMemoryRepository.php deleted file mode 100644 index 2dc6c8e..0000000 --- a/tests/Support/Fixtures/Database/InMemoryRepository.php +++ /dev/null @@ -1,65 +0,0 @@ - - */ - private array $records = []; - - private int $nextId = 1; - - /** - * @return array - */ - public function all(): array { - return $this->records; - } - - public function hasRun(string $migration): bool { - return isset($this->records[$migration]); - } - - public function recordRun(string $migration, int $batch): void { - $record = new Record( - id: $this->nextId++, - migration: $migration, - batch: $batch, - ranAt: new DateTimeImmutable('2026-01-01 00:00:00') - ); - - $this->records[$migration] = $record; - } - - public function deleteRun(string $migration): bool { - if (! isset($this->records[$migration])) { - return false; - } - - unset($this->records[$migration]); - - return true; - } - - public function latestBatch(): ?int { - $batches = array_map(static fn (Record $record): int => $record->batch, $this->records); - - return $batches === [] ? null : max($batches); - } - - /** - * @return list - */ - public function recordsForBatch(int $batch): array { - return array_values(array_filter( - $this->records, - static fn (Record $record): bool => $record->batch === $batch - )); - } -} diff --git a/tests/Support/Fixtures/Database/IndexReconciliationTable.php b/tests/Support/Fixtures/Database/IndexReconciliationTable.php deleted file mode 100644 index 57ea791..0000000 --- a/tests/Support/Fixtures/Database/IndexReconciliationTable.php +++ /dev/null @@ -1,43 +0,0 @@ - $indexColumns - */ - public function __construct( - private string $unprefixedName, - private bool $includeIndex, - private array $indexColumns = ['email'], - private string $indexType = IndexType::UNIQUE - ) { - } - - public function unprefixedName(): string { - return $this->unprefixedName; - } - - public function blueprint(): Blueprint { - $definition = Blueprint::for($this); - - $definition->bigIncrements('id'); - $definition->string('email'); - $definition->string('tenant'); - - if (! $this->includeIndex) { - return $definition; - } - - if ($this->indexType === IndexType::UNIQUE) { - return $definition->unique('email_unique', ...$this->indexColumns); - } - - return $definition->index('email_unique', ...$this->indexColumns); - } -} diff --git a/tests/Support/Fixtures/Database/LostAdvisoryLockReply.php b/tests/Support/Fixtures/Database/LostAdvisoryLockReply.php new file mode 100644 index 0000000..b4c10a9 --- /dev/null +++ b/tests/Support/Fixtures/Database/LostAdvisoryLockReply.php @@ -0,0 +1,26 @@ +unprefixedName; - } - - public function blueprint(): Blueprint { - $table = Blueprint::for($this); - $table->bigIncrements('id'); - $table->string('status', 20)->default('draft'); - $table->dateTime('created_at', $this->precision)->useCurrent(); - $table->dateTime('updated_at', $this->precision)->nullable()->useCurrent()->useCurrentOnUpdate(); - $table->timestamp('created_stamp', $this->precision)->useCurrent(); - $table->timestamp('updated_stamp', $this->precision)->nullable()->useCurrent()->useCurrentOnUpdate(); - $table->timestamp('processed_at', $this->precision)->nullable()->default(null)->useCurrentOnUpdate(); - - return $table; - } -} diff --git a/tests/Support/Fixtures/Database/NativeConnectionFactory.php b/tests/Support/Fixtures/Database/NativeConnectionFactory.php new file mode 100644 index 0000000..ed6dc7f --- /dev/null +++ b/tests/Support/Fixtures/Database/NativeConnectionFactory.php @@ -0,0 +1,49 @@ +setSchemaManagerFactory(new \Doctrine\DBAL\Schema\DefaultSchemaManagerFactory()); + $configuration->setMiddlewares([new class($native) implements Middleware { + public function __construct( + private readonly mysqli $native, + ) { + } + + public function wrap(Driver $driver): Driver { + return new class($driver, $this->native) extends AbstractDriverMiddleware { + public function __construct( + Driver $driver, + private readonly mysqli $native, + ) { + parent::__construct($driver); + } + + public function connect(array $params): Driver\Connection { + return new Driver\Mysqli\Connection($this->native); + } + }; + } + }]); + $connection = DriverManager::getConnection(['driver' => 'mysqli', 'dbname' => $database], $configuration); + + return $connection; + } +} diff --git a/tests/Support/Fixtures/Database/NoopMigration.php b/tests/Support/Fixtures/Database/NoopMigration.php deleted file mode 100644 index e21b26a..0000000 --- a/tests/Support/Fixtures/Database/NoopMigration.php +++ /dev/null @@ -1,24 +0,0 @@ -id; - } - - public function up(Schema $schema): void { - } - - public function down(Schema $schema): void { - } -} diff --git a/tests/Support/Fixtures/Database/RecordingSchema.php b/tests/Support/Fixtures/Database/RecordingSchema.php deleted file mode 100644 index a7ad411..0000000 --- a/tests/Support/Fixtures/Database/RecordingSchema.php +++ /dev/null @@ -1,59 +0,0 @@ - - */ - public array $statements = []; - - /** - * @var array - */ - public array $tables = []; - - /** - * @var array> - */ - public array $indexes = []; - - public function create(Blueprint $blueprint): void { - $name = $blueprint->table()->unprefixedName(); - $this->tables[$name] = true; - $this->statements[] = 'create:' . $name; - } - - public function alter(Blueprint $blueprint): void { - $this->statements[] = 'alter:' . $blueprint->table()->unprefixedName(); - } - - public function execute(string $sql): void { - $this->statements[] = $sql; - } - - public function hasTable(Table $table): bool { - return $this->tables[$table->unprefixedName()] ?? false; - } - - public function hasIndex(Table $table, string $index): bool { - return $this->indexes[$table->unprefixedName()][$index] ?? false; - } - - public function drop(Table $table): void { - $name = $table->unprefixedName(); - - unset($this->tables[$name]); - - $this->statements[] = 'drop:' . $name; - } - - public function quoteIdentifier(string $identifier): string { - return '`' . str_replace('`', '``', $identifier) . '`'; - } -} diff --git a/tests/Support/Fixtures/Database/RecordingSchemaExecutor.php b/tests/Support/Fixtures/Database/RecordingSchemaExecutor.php deleted file mode 100644 index f650bad..0000000 --- a/tests/Support/Fixtures/Database/RecordingSchemaExecutor.php +++ /dev/null @@ -1,17 +0,0 @@ - - */ - public array $statements = []; - - public function execute(string $sql): void { - $this->statements[] = $sql; - } -} diff --git a/tests/Support/Fixtures/Database/RecoveryTable.php b/tests/Support/Fixtures/Database/RecoveryTable.php deleted file mode 100644 index 55dbea5..0000000 --- a/tests/Support/Fixtures/Database/RecoveryTable.php +++ /dev/null @@ -1,18 +0,0 @@ -unprefixedName; - } - - public function blueprint(): Blueprint { - $table = Blueprint::for($this); - - $table->bigIncrements('id'); - $table->integer('attempts')->default($this->attemptsDefault); - $table->dateTime('completed_at')->nullable($this->completedAtNullable); - $table->string('label')->default(''); - $table->column(new Column('ratio', 'decimal(10,2)'))->default(1.25); - $table->column(new Column('enabled', 'bit', 1))->default(true); - - return $table; - } -} diff --git a/tests/Support/Fixtures/Database/TestDatabaseScope.php b/tests/Support/Fixtures/Database/TestDatabaseScope.php deleted file mode 100644 index 3691616..0000000 --- a/tests/Support/Fixtures/Database/TestDatabaseScope.php +++ /dev/null @@ -1,32 +0,0 @@ -currentId; - } - - public function assertCurrent(int $scopeId): void { - $this->assertions++; - - if ($this->currentId !== $scopeId) { - throw new DatabaseContextChanged('test context ' . $scopeId, 'test context ' . $this->currentId); - } - } -} diff --git a/tests/Support/Fixtures/Database/TestDatabaseTable.php b/tests/Support/Fixtures/Database/TestDatabaseTable.php deleted file mode 100644 index 06fa896..0000000 --- a/tests/Support/Fixtures/Database/TestDatabaseTable.php +++ /dev/null @@ -1,48 +0,0 @@ -unprefixedTableName; - } - - public function blueprint(): Blueprint { - $table = Blueprint::for($this); - $table->bigIncrements('id'); - - return $table; - } - - /** - * Archive records matching the supplied status. - * - * @throws DatabaseException When the physical table name is invalid. - * @throws QueryException When the update fails. - */ - public function archiveByStatus(string $status): int { - return $this->database()->execute( - 'UPDATE %i SET status = %s WHERE status = %s', - $this->name(), - 'archived', - $status - ); - } -} diff --git a/tests/Support/Fixtures/Database/TestMigration.php b/tests/Support/Fixtures/Database/TestMigration.php deleted file mode 100644 index 917f47f..0000000 --- a/tests/Support/Fixtures/Database/TestMigration.php +++ /dev/null @@ -1,26 +0,0 @@ -id; - } - - public function up(Schema $schema): void { - $schema->execute('up:' . $this->id); - } - - public function down(Schema $schema): void { - $schema->execute('down:' . $this->id); - } -} diff --git a/tests/Support/Fixtures/Database/TestTable.php b/tests/Support/Fixtures/Database/TestTable.php deleted file mode 100644 index 4aa14b6..0000000 --- a/tests/Support/Fixtures/Database/TestTable.php +++ /dev/null @@ -1,25 +0,0 @@ -unprefixedName; - } - - public function blueprint(): Blueprint { - $table = Blueprint::for($this); - $table->bigIncrements('id'); - - return $table; - } -} diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index 1ad0f2b..8e2243d 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -3,91 +3,46 @@ use StellarWP\Foundation\Container\Configuration\ArrayConfiguration; use StellarWP\Foundation\Container\ContainerFactory; use StellarWP\Foundation\Database\Cli\Migrate; -use StellarWP\Foundation\Database\Contracts\Migration; -use StellarWP\Foundation\Database\Contracts\Schema as SchemaContract; -use StellarWP\Foundation\Database\Database; -use StellarWP\Foundation\Database\Lock\DatabaseLock; -use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; -use StellarWP\Foundation\Database\Migration\Factories\LeaseFactory; -use StellarWP\Foundation\Database\Migration\Factories\SessionFactory; -use StellarWP\Foundation\Database\Migration\Migrator; -use StellarWP\Foundation\Database\Migration\Repository; -use StellarWP\Foundation\Database\Migration\Store; -use StellarWP\Foundation\Database\Migration\StoreSchema; -use StellarWP\Foundation\Database\Schema; -use StellarWP\Foundation\Database\Schema\DbDelta; -use StellarWP\Foundation\Database\Schema\Editor; -use StellarWP\Foundation\Database\Schema\Reconciler; -use StellarWP\Foundation\Database\Scope\SiteScope; -use StellarWP\Foundation\Database\Table\Blueprint; -use StellarWP\Foundation\Database\Table\Tables\LockTable; -use StellarWP\Foundation\Database\Table\Tables\MigrationTable; -use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; +use StellarWP\Foundation\Database\Contracts\Table; +use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Database\Migration\Contracts\Migration; +use StellarWP\Foundation\Database\Migration\Schema\Blueprint; use StellarWP\Foundation\WPCli\CommandContext; use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; if (! class_exists(WP_CLI::class)) { return; } - require_once dirname(__DIR__, 4) . '/vendor/autoload.php'; WP_CLI::add_hook('after_wp_load', static function (): void { - $wpdb = $GLOBALS['wpdb'] ?? null; - - if (! $wpdb instanceof wpdb) { - return; - } - - if (! defined('ABSPATH')) { - return; - } - - $container = (new ContainerFactory())->create(new ArrayConfiguration()); - - $scope = new SiteScope($wpdb); - $database = new Database($wpdb, $scope); - $reconciler = new Reconciler($database, new DbDelta()); - $schema = new Schema($database, $reconciler, new Editor($database, $reconciler)); - $migrationTableName = 'foundation_cli_migrations'; - $lockTableName = 'foundation_cli_locks'; - $exampleTable = 'foundation_cli_example'; - $migrationTable = new MigrationTable($migrationTableName, $database); - $lockTable = new LockTable($lockTableName, $database); - $repository = new Repository($migrationTable); - $lock = new DatabaseLock($database, $lockTable); - $storeSchema = new StoreSchema($schema, $reconciler, $migrationTable, $lockTable); - $store = new Store($storeSchema, new LeaseFactory(), new SessionFactory($schema), $scope, $lock, 'nx-foundation-database-migrations', 300); - - $migration = new class(new TestTable($exampleTable)) implements Migration { + $container = (new ContainerFactory())->create(new ArrayConfiguration([ + 'database' => ['migrations_table' => 'foundation_cli_migrations'], + ])); + $container->register(DatabaseProvider::class); + $table = new class implements Table { + public function unprefixedName(): string { + return 'foundation_cli_example'; + } + }; + $migration = new class($table) implements Migration { public function __construct( - private readonly TestTable $table + private readonly Table $table, ) { } public function id(): string { - return '2026_06_23_000001_create_foundation_cli_example'; + return '20260623000001'; } - public function up(SchemaContract $schema): void { - $blueprint = Blueprint::for($this->table); - $blueprint->bigIncrements('id'); - - $schema->create($blueprint); + public function up(Blueprint $schema): void { + $schema->create($this->table)->bigIncrements(); } - public function down(SchemaContract $schema): void { + public function down(Blueprint $schema): void { $schema->drop($this->table); } }; - - $command = new Migrate( - new Migrator( - new MigrationCollection([$migration]), - $repository, - $store - ) - ); - - $command->register(new CommandContext(new CommandPrefix('foundation'))); + $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); + $container->get(Migrate::class)->register(new CommandContext(new CommandPrefix('foundation'))); }); diff --git a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php index 0662b05..d78405d 100644 --- a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php +++ b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php @@ -70,7 +70,7 @@ public function test_it_generates_a_database_table_from_project_autoload_default $this->assertSame(Command::SUCCESS, $statusCode); $this->assertFileExists($path); $this->assertStringContainsString('Created: src/Database/Tables/Reports_Table.php', $tester->getDisplay()); - $this->assertStringContainsString('Create a migration that defines this table with Blueprint and Schema::create().', $tester->getDisplay()); + $this->assertStringContainsString('Create a migration that defines this table with Blueprint::create().', $tester->getDisplay()); $contents = (string) file_get_contents($path); @@ -114,10 +114,10 @@ public function test_database_table_generator_can_create_and_register_its_initia $provider = (string) file_get_contents($providerPath); $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $migration); - $this->assertStringContainsString('$blueprint = Blueprint::for( $this->table );', $migration); - $this->assertStringContainsString("\$blueprint->bigIncrements( 'id' );", $migration); + + $this->assertStringContainsString("\$table->bigIncrements( 'id' );", $migration); $this->assertStringContainsString('Define the complete initial schema before running this migration.', $migration); - $this->assertStringContainsString('$schema->create( $blueprint );', $migration); + $this->assertStringContainsString('$table = $schema->create( $this->table );', $migration); $this->assertStringContainsString('$schema->drop( $this->table );', $migration); $this->assertStringContainsString('$this->container->singleton( Reports_Table::class );', $provider); $this->assertStringContainsString('$c->get( Create_Reports_Table::class ),', $provider); @@ -254,15 +254,15 @@ public function test_it_generates_a_database_migration_from_project_autoload_def $contents = (string) file_get_contents($path); $this->assertStringContainsString('namespace Acme\\Plugin\\Database\\Migrations;', $contents); - $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Migration;', $contents); - $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Schema;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $contents); $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); $this->assertStringContainsString('final readonly class Create_Reports_Table implements Migration {', $contents); $this->assertStringContainsString("public const string ID = '2026_06_26_000001_create_reports_table';", $contents); $this->assertStringContainsString('private Reports_Table $table', $contents); - $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Table\\Blueprint;', $contents); - $this->assertStringContainsString('$blueprint = Blueprint::for( $this->table );', $contents); - $this->assertStringContainsString('$schema->create( $blueprint );', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $contents); + + $this->assertStringContainsString('$table = $schema->create( $this->table );', $contents); $this->assertStringContainsString('$schema->drop( $this->table );', $contents); $this->assertStringNotContainsString('CreateTable', $contents); } @@ -303,8 +303,8 @@ public function test_it_generates_a_table_alteration_migration_without_destructi $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration;', $contents); $this->assertStringContainsString('private Reports_Table $table', $contents); - $this->assertStringContainsString('$blueprint = Blueprint::for( $this->table );', $contents); - $this->assertStringContainsString('$schema->alter( $blueprint );', $contents); + + $this->assertStringContainsString('$table = $schema->table( $this->table );', $contents); $this->assertStringContainsString('throw IrreversibleMigration::forMigration( self::ID );', $contents); $this->assertStringNotContainsString('$schema->drop( $this->table );', $contents); } @@ -662,8 +662,8 @@ public function test_database_alteration_migrations_accept_generation_options(): $this->assertSame(Command::SUCCESS, $statusCode); $this->assertStringContainsString('namespace Acme\\Plugin\\Storage\\Migrations;', $contents); $this->assertStringContainsString('use Acme\\Plugin\\Storage\\Audit_Log_Table;', $contents); - $this->assertStringContainsString('$blueprint = Blueprint::for( $this->table );', $contents); - $this->assertStringContainsString('$schema->alter( $blueprint );', $contents); + + $this->assertStringContainsString('$table = $schema->table( $this->table );', $contents); $this->assertStringNotContainsString('$schema->drop( $this->table );', $contents); } @@ -683,8 +683,8 @@ public function test_database_provider_generator_accepts_generation_options(): v $this->assertFalse($this->providerCommand($root)->getDefinition()->hasOption('force')); $this->assertStringContainsString('namespace Acme\\Plugin\\Storage;', $contents); $this->assertStringContainsString('final class Database_Provider extends Service_Provider {', $contents); - $this->assertStringContainsString('private bool $registered = false;', $contents); - $this->assertStringContainsString('if ( $this->registered ) {', $contents); + $this->assertStringNotContainsString('private bool $registered = false;', $contents); + $this->assertStringNotContainsString('if ( $this->registered ) {', $contents); } public function test_database_provider_generator_refuses_to_replace_an_existing_provider(): void { @@ -1912,20 +1912,20 @@ public function test_database_generators_use_strauss_namespace_prefix_for_founda $genericContents = (string) file_get_contents($root . '/src/Database/Migrations/Bump_Version.php'); $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Table\\Table;', $tableContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Contracts\\Migration;', $migrationContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Contracts\\Schema;', $migrationContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Table\\Blueprint;', $migrationContents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration;', $migrationContents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $migrationContents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $migrationContents); $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration;', $alterContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Contracts\\Migration;', $alterContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Contracts\\Schema;', $alterContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Table\\Blueprint;', $alterContents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration;', $alterContents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $alterContents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $alterContents); $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration;', $genericContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Contracts\\Migration;', $genericContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Contracts\\Schema;', $genericContents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration;', $genericContents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $genericContents); $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Database;', $tableContents); $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Table\\Table;', $tableContents); - $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Table\\Blueprint;', $tableContents); - $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Migration;', $migrationContents); + $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $tableContents); + $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration;', $migrationContents); } public function test_database_provider_generator_uses_strauss_namespace_prefix_for_foundation_imports(): void { @@ -2196,7 +2196,9 @@ public static function invalidMigrationIdProvider(): iterable { yield 'padded' => [' padded ', 'surrounding whitespace']; - yield 'integer-like' => ['123', 'integer-like']; + yield 'reserved zero' => ['0', 'reserved']; + + yield 'reserved latest' => ['latest', 'reserved']; yield 'over ledger limit' => [str_repeat('a', 192), 'cannot exceed 191 bytes']; } diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php deleted file mode 100644 index 0d1b22f..0000000 --- a/tests/Unit/Database/Cli/MigrateTest.php +++ /dev/null @@ -1,256 +0,0 @@ -loadWpCliUtilities(); - - $scope = new TestDatabaseScope(); - $database = new FakeDatabase(); - $migrationTable = new MigrationTable('nx_foundation_migrations', $database); - $repository = new InMemoryRepository(); - $schema = new RecordingSchema(); - $lock = new InMemoryLock(new SystemClock()); - $storeSchema = new StoreSchema( - $schema, - new Reconciler($database, new RecordingSchemaExecutor()), - $migrationTable, - new LockTable('nx_foundation_locks', $database) - ); - $store = new Store($storeSchema, new LeaseFactory(), new SessionFactory($schema), $scope, $lock, 'nx-foundation-database-migrations', 300); - $command = new Migrate( - new Migrator( - new MigrationCollection(), - $repository, - $store - ) - ); - - $command->register(new CommandContext(new CommandPrefix('foundation'))); - - $deferredAdditions = WP_CLI::get_deferred_additions(); - - $this->assertArrayHasKey('foundation migrate', $deferredAdditions); - $this->assertSame('foundation', $deferredAdditions['foundation migrate']['parent']); - $this->assertSame('List and manage database migrations.', $deferredAdditions['foundation migrate']['args']['shortdesc']); - $this->assertSame([ - [ - 'type' => 'flag', - 'name' => 'run', - 'description' => 'Run pending migrations.', - 'optional' => true, - 'default' => false, - ], - [ - 'type' => 'flag', - 'name' => 'rollback', - 'description' => 'Rollback the latest migration batch.', - 'optional' => true, - 'default' => false, - ], - [ - 'type' => 'flag', - 'name' => 'refresh', - 'description' => 'Rollback and rerun all migrations.', - 'optional' => true, - 'default' => false, - ], - [ - 'type' => 'flag', - 'name' => 'drop-store', - 'description' => 'Drop only the migration ledger.', - 'optional' => true, - 'default' => false, - ], - [ - 'type' => 'flag', - 'name' => 'initialize', - 'description' => 'Initialize or reconcile Foundation migration storage.', - 'optional' => true, - 'default' => false, - ], - [ - 'type' => 'flag', - 'name' => 'yes', - 'description' => 'Skip confirmation prompts for destructive actions.', - 'optional' => true, - 'default' => false, - ], - ], $deferredAdditions['foundation migrate']['args']['synopsis']); - } - - public function test_it_initializes_database_storage_without_running_migrations(): void { - [$command, $repository, $schema] = $this->newCommand(); - - $this->assertSame(0, $command->runCommand([], ['initialize' => true])); - - $this->assertSame([], $repository->all()); - $this->assertSame([ - 'create:nx_foundation_locks', - 'create:nx_foundation_migrations', - ], $schema->statements); - } - - public function test_it_runs_pending_migrations(): void { - [$command, $repository, $schema] = $this->newCommand(); - $command->runCommand([], ['initialize' => true]); - $schema->statements = []; - - $this->assertSame(0, $command->runCommand([], ['run' => true])); - - $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); - $this->assertSame([ - 'up:2026_06_23_000001_create_example', - ], $schema->statements); - } - - public function test_it_rolls_back_the_latest_migration_batch(): void { - [$command, $repository, $schema] = $this->newCommand(); - - $command->runCommand([], ['initialize' => true]); - $command->runCommand([], ['run' => true]); - $schema->statements = []; - - $this->assertSame(0, $command->runCommand([], ['rollback' => true])); - - $this->assertFalse($repository->hasRun('2026_06_23_000001_create_example')); - $this->assertContains('down:2026_06_23_000001_create_example', $schema->statements); - } - - public function test_it_refreshes_database_migrations(): void { - [$command, $repository, $schema] = $this->newCommand(); - - $command->runCommand([], ['initialize' => true]); - $command->runCommand([], ['run' => true]); - $schema->statements = []; - - $this->assertSame(0, $command->runCommand([], [ - 'refresh' => true, - 'yes' => true, - ])); - - $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); - $this->assertContains('down:2026_06_23_000001_create_example', $schema->statements); - $this->assertContains('up:2026_06_23_000001_create_example', $schema->statements); - } - - public function test_it_drops_the_migration_store(): void { - [$command, , $schema] = $this->newCommand(); - - $command->runCommand([], ['initialize' => true]); - - $this->assertSame(0, $command->runCommand([], [ - 'drop-store' => true, - 'yes' => true, - ])); - - $this->assertSame(['nx_foundation_locks' => true], $schema->tables); - $this->assertContains('drop:nx_foundation_migrations', $schema->statements); - $this->assertNotContains('drop:nx_foundation_locks', $schema->statements); - } - - public function test_it_shows_a_warning_when_status_tables_do_not_exist(): void { - [$command] = $this->newCommand(); - - $this->expectOutputRegex('/2026_06_23_000001_create_example[|\s]+pending/'); - - $this->assertSame(0, $command->runCommand()); - } - - public function test_it_shows_migration_status_when_tables_exist(): void { - [$command] = $this->newCommand(); - - $command->runCommand([], ['initialize' => true]); - $command->runCommand([], ['run' => true]); - - $this->expectOutputRegex('/2026_06_23_000001_create_example[|\s]+applied[|\s]+1[|\s]+2026-01-01 00:00:00/'); - - $this->assertSame(0, $command->runCommand()); - } - - public function test_it_shows_unavailable_recorded_migrations(): void { - [$command, $repository] = $this->newCommand(); - - $command->runCommand([], ['initialize' => true]); - $repository->recordRun('missing_migration', 1); - - $this->expectOutputRegex('/missing_migration[|\s]+unavailable/'); - - $this->assertSame(0, $command->runCommand()); - } - - /** - * @return array{Migrate, InMemoryRepository, RecordingSchema} - */ - private function newCommand(): array { - $this->loadWpCliUtilities(); - - $scope = new TestDatabaseScope(); - $database = new FakeDatabase(); - $wpSchema = new RecordingSchema(); - $repository = new InMemoryRepository(); - $migrationTable = new MigrationTable('nx_foundation_migrations', $database); - $lock = new InMemoryLock(new SystemClock()); - $storeSchema = new StoreSchema( - $wpSchema, - new Reconciler($database, new RecordingSchemaExecutor()), - $migrationTable, - new LockTable('nx_foundation_locks', $database) - ); - $store = new Store($storeSchema, new LeaseFactory(), new SessionFactory($wpSchema), $scope, $lock, 'nx-foundation-database-migrations', 300); - $command = new Migrate( - new Migrator( - new MigrationCollection([ - new TestMigration('2026_06_23_000001_create_example'), - ]), - $repository, - $store - ) - ); - - return [$command, $repository, $wpSchema]; - } - - private function loadWpCliUtilities(): void { - $wpCliRoot = dirname(__DIR__, 4) . '/vendor/wp-cli/wp-cli'; - - if (! defined('WP_CLI_ROOT')) { - define('WP_CLI_ROOT', $wpCliRoot); - } - - require_once $wpCliRoot . '/php/utils.php'; - } -} diff --git a/tests/Unit/Database/Exceptions/QueryExceptionTest.php b/tests/Unit/Database/Exceptions/QueryExceptionTest.php deleted file mode 100644 index cf53e54..0000000 --- a/tests/Unit/Database/Exceptions/QueryExceptionTest.php +++ /dev/null @@ -1,34 +0,0 @@ -assertSame('Query failed.', $exception->getMessage()); - $this->assertSame('SELECT * FROM %i WHERE id = %d', $exception->sql()); - $this->assertSame(['foundation_table', 23], $exception->bindings()); - $this->assertSame('Table does not exist.', $exception->databaseError()); - $this->assertSame($previous, $exception->getPrevious()); - } - - public function test_it_allows_missing_bindings_and_database_error(): void { - $exception = new QueryException('Query failed.', 'SELECT 1'); - - $this->assertSame([], $exception->bindings()); - $this->assertNull($exception->databaseError()); - } -} diff --git a/tests/Unit/Database/Lock/DatabaseLockTest.php b/tests/Unit/Database/Lock/DatabaseLockTest.php deleted file mode 100644 index ea6178f..0000000 --- a/tests/Unit/Database/Lock/DatabaseLockTest.php +++ /dev/null @@ -1,289 +0,0 @@ -database = new FakeDatabase(); - $this->lock = new DatabaseLock( - $this->database, - new LockTable('network_foundation_locks', $this->database) - ); - } - - public function test_it_acquires_a_database_lock_when_the_written_owner_matches(): void { - $this->database->rowResults[] = [ - 'expires_at' => '2026-01-01 00:01:00.123456', - ]; - - $token = $this->lock->acquire('queue:sync', 60); - - $this->assertInstanceOf(LockToken::class, $token); - $this->assertSame('queue:sync', $token->name); - $this->assertSame('2026-01-01 00:01:00.123456', $token->expiresAt->format('Y-m-d H:i:s.u')); - $this->assertSame('UTC', $token->expiresAt->getTimezone()->getName()); - $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $this->database->executed[0]); - $this->assertStringContainsString('TIMESTAMPADD(SECOND, 60, UTC_TIMESTAMP(6))', $this->database->executed[0]); - $this->assertStringNotContainsString('VALUES(owner)', $this->database->executed[0]); - $this->assertStringContainsString('owner =', $this->database->rowQueries[0]); - $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->rowQueries[0]); - } - - public function test_it_uses_one_physical_table_for_acquisition_and_verification(): void { - $database = $this->mock(Database::class); - $database->shouldReceive('tableName') - ->once() - ->with(Mockery::type(LockTable::class)) - ->andReturn('wp_foundation_locks'); - $database->shouldReceive('execute')->once()->andReturn(1); - $database->shouldReceive('row')->once()->andReturn([ - 'expires_at' => '2026-01-01 00:01:00.000000', - ]); - $lock = new DatabaseLock( - $database, - new LockTable('foundation_locks', $database) - ); - - $this->assertNotNull($lock->acquire('queue:sync', 60)); - } - - public function test_it_uses_one_physical_table_for_refresh_and_verification(): void { - $database = $this->mock(Database::class); - $database->shouldReceive('tableName') - ->once() - ->with(Mockery::type(LockTable::class)) - ->andReturn('wp_foundation_locks'); - $database->shouldReceive('execute')->once()->andReturn(1); - $database->shouldReceive('row')->once()->andReturn([ - 'expires_at' => '2026-01-01 00:02:00.000000', - ]); - $lock = new DatabaseLock( - $database, - new LockTable('foundation_locks', $database) - ); - $token = new LockToken( - 'queue:sync', - 'owner', - new DateTimeImmutable('2026-01-01 00:01:00') - ); - - $this->assertNotNull($lock->refresh($token, 60)); - } - - public function test_it_returns_null_when_the_acquired_lease_is_not_active_during_readback(): void { - $this->database->rowResults[] = null; - - $this->assertNull($this->lock->acquire('queue:sync', 60)); - $this->assertStringContainsString('owner =', $this->database->rowQueries[0]); - $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->rowQueries[0]); - } - - public function test_it_releases_a_lock_for_the_matching_owner(): void { - $token = new LockToken( - name: 'queue:sync', - owner: 'owner', - expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') - ); - - $this->database->executeResults[] = 1; - - $this->assertTrue($this->lock->release($token)); - $this->assertStringContainsString('DELETE FROM `wp_network_foundation_locks`', $this->database->executed[0]); - $this->assertStringContainsString('owner', $this->database->executed[0]); - $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->executed[0]); - } - - public function test_it_refreshes_a_lock_for_the_matching_owner(): void { - $token = new LockToken( - name: 'queue:sync', - owner: 'owner', - expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') - ); - - $this->database->executeResults[] = 1; - $this->database->rowResults[] = ['expires_at' => '2026-01-01 00:02:00.654321']; - - $refreshed = $this->lock->refresh($token, 120); - - $this->assertInstanceOf(LockToken::class, $refreshed); - $this->assertSame('2026-01-01 00:02:00.654321', $refreshed->expiresAt->format('Y-m-d H:i:s.u')); - $this->assertStringContainsString('UPDATE `wp_network_foundation_locks`', $this->database->executed[0]); - $this->assertStringContainsString('TIMESTAMPADD(SECOND, 120, UTC_TIMESTAMP(6))', $this->database->executed[0]); - } - - public function test_it_returns_null_when_a_refreshed_lease_is_not_active_during_readback(): void { - $token = new LockToken( - name: 'queue:sync', - owner: 'owner', - expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') - ); - - $this->database->executeResults[] = 0; - - $this->assertNull($this->lock->refresh($token, 120)); - } - - public function test_it_returns_a_token_when_refresh_reports_no_change_but_the_lease_is_active(): void { - $token = new LockToken( - name: 'queue:sync', - owner: 'owner', - expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') - ); - - $this->database->executeResults[] = 0; - $this->database->rowResults[] = ['expires_at' => '2026-01-01 00:02:00.000000']; - - $this->assertInstanceOf(LockToken::class, $this->lock->refresh($token, 120)); - } - - public function test_it_returns_null_when_a_refreshed_lock_can_no_longer_be_read(): void { - $token = new LockToken( - name: 'queue:sync', - owner: 'owner', - expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') - ); - - $this->database->executeResults[] = 1; - $this->database->rowResults[] = null; - - $this->assertNull($this->lock->refresh($token, 120)); - } - - public function test_it_fails_closed_when_the_database_omits_the_lock_expiration(): void { - $this->database->rowResults[] = []; - - $this->expectException(LockUnavailableException::class); - $this->expectExceptionMessage('invalid lock expiration'); - - $this->lock->acquire('queue:sync', 60); - } - - public function test_it_fails_closed_when_the_database_returns_an_invalid_lock_expiration(): void { - $this->database->rowResults[] = [ - 'expires_at' => 'invalid', - ]; - - $this->expectException(LockUnavailableException::class); - $this->expectExceptionMessage('invalid lock expiration'); - - $this->lock->acquire('queue:sync', 60); - } - - public function test_it_checks_whether_a_lock_is_acquired(): void { - $this->database->rowResults[] = ['name' => 'queue:sync']; - - $this->assertTrue($this->lock->isAcquired('queue:sync')); - $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->rowQueries[0]); - } - - public function test_it_rejects_an_invalid_ttl(): void { - $this->expectException(InvalidArgumentException::class); - - $this->lock->acquire('queue:sync', 0); - } - - public function test_it_rejects_an_empty_name(): void { - $this->expectException(InvalidArgumentException::class); - - $this->lock->isAcquired(''); - } - - public function test_it_rejects_names_longer_than_the_database_column(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('cannot exceed 191 bytes'); - - $this->lock->acquire(str_repeat('a', 192), 60); - } - - /** - * @dataProvider unavailableOperationProvider - */ - #[DataProvider('unavailableOperationProvider')] - public function test_it_normalizes_database_failures(callable $operation, string $databaseMethod): void { - $database = $this->mock(Database::class); - - $database->shouldReceive('tableName')->with(Mockery::type(LockTable::class))->andReturn('wp_nx_foundation_locks'); - $database->shouldReceive($databaseMethod)->andThrow(new QueryException('Query failed.', 'SELECT 1')); - - try { - $operation(new DatabaseLock( - $database, - new LockTable('nx_foundation_locks', $database) - )); - $this->fail('Expected the database failure to be normalized.'); - } catch (LockUnavailableException $exception) { - $this->assertInstanceOf(QueryException::class, $exception->getPrevious()); - } - } - - /** - * @dataProvider tableNameResolutionOperationProvider - */ - #[DataProvider('tableNameResolutionOperationProvider')] - public function test_it_normalizes_table_name_resolution_failures(callable $operation): void { - $database = $this->mock(Database::class); - $database->shouldReceive('tableName')->andThrow(new DatabaseException('Invalid table name.')); - - $this->expectException(LockUnavailableException::class); - - $operation(new DatabaseLock( - $database, - new LockTable('nx_foundation_locks', $database) - )); - } - - /** - * @return array - */ - public static function tableNameResolutionOperationProvider(): array { - $token = new LockToken( - name: 'queue:sync', - owner: 'owner', - expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') - ); - - return [ - 'acquire' => [static fn (DatabaseLock $lock): ?LockToken => $lock->acquire('queue:sync', 60)], - 'refresh' => [static fn (DatabaseLock $lock): ?LockToken => $lock->refresh($token, 60)], - ]; - } - - /** - * @return array - */ - public static function unavailableOperationProvider(): array { - $token = new LockToken( - name: 'queue:sync', - owner: 'owner', - expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') - ); - - return [ - 'acquire' => [static fn (DatabaseLock $lock): ?LockToken => $lock->acquire('queue:sync', 60), 'execute'], - 'release' => [static fn (DatabaseLock $lock): bool => $lock->release($token), 'execute'], - 'refresh' => [static fn (DatabaseLock $lock): ?LockToken => $lock->refresh($token, 60), 'execute'], - 'is acquired' => [static fn (DatabaseLock $lock): bool => $lock->isAcquired('queue:sync'), 'row'], - ]; - } -} diff --git a/tests/Unit/Database/Migration/CollectionTest.php b/tests/Unit/Database/Migration/CollectionTest.php deleted file mode 100644 index 4a4e88b..0000000 --- a/tests/Unit/Database/Migration/CollectionTest.php +++ /dev/null @@ -1,101 +0,0 @@ -add($second); - - $indexed = [ - $first->id() => $first, - $second->id() => $second, - $third->id() => $third, - ]; - - $this->assertSame($indexed, $collection->all()); - $this->assertSame([$first, $second, $third], $collection->values()); - $this->assertSame($indexed, iterator_to_array($collection)); - } - - public function test_explicit_custom_ids_are_ordered_lexically(): void { - $last = new TestMigration('reports_020_add_status'); - $first = new TestMigration('reports_010_create_table'); - $collection = new Collection([$last, $first]); - - $this->assertSame([$first, $last], $collection->values()); - } - - public function test_it_rejects_duplicate_migration_ids(): void { - $this->expectException(DuplicateMigration::class); - - new Collection([ - new TestMigration('2026_01_01_000001_create_users'), - new TestMigration('2026_01_01_000001_create_users'), - ]); - } - - public function test_a_rejected_addition_does_not_partially_mutate_the_collection(): void { - $existing = new TestMigration('2026_01_01_000002_existing'); - $collection = new Collection([$existing]); - - try { - $collection->add( - new TestMigration('2026_01_01_000001_new'), - new TestMigration($existing->id()) - ); - $this->fail('Expected the duplicate migration to be rejected.'); - } catch (DuplicateMigration) { - $this->assertSame([$existing], $collection->values()); - } - } - - public function test_it_rejects_blank_migration_ids(): void { - $this->expectException(InvalidMigrationId::class); - - new Collection([new TestMigration(' ')]); - } - - public function test_it_rejects_padded_migration_ids(): void { - $this->expectException(InvalidMigrationId::class); - - new Collection([new TestMigration(' migration')]); - } - - public function test_it_rejects_migration_ids_larger_than_the_ledger_column(): void { - $this->expectException(InvalidMigrationId::class); - $this->expectExceptionMessage('cannot exceed 191 bytes'); - - new Collection([new TestMigration(str_repeat('a', 192))]); - } - - public function test_it_rejects_integer_like_migration_ids_that_php_would_coerce_to_array_keys(): void { - $this->expectException(InvalidMigrationId::class); - $this->expectExceptionMessage('cannot be integer-like strings'); - - new Collection([new TestMigration('123')]); - } - - public function test_it_accepts_case_distinct_ids_at_the_maximum_length(): void { - $upper = new TestMigration(str_repeat('A', 191)); - $lower = new TestMigration(str_repeat('a', 191)); - $collection = new Collection([$upper, $lower]); - - $this->assertSame([ - $upper->id() => $upper, - $lower->id() => $lower, - ], $collection->all()); - $this->assertSame([$upper, $lower], $collection->values()); - } -} diff --git a/tests/Unit/Database/Migration/ColumnDefinitionTest.php b/tests/Unit/Database/Migration/ColumnDefinitionTest.php new file mode 100644 index 0000000..5700add --- /dev/null +++ b/tests/Unit/Database/Migration/ColumnDefinitionTest.php @@ -0,0 +1,76 @@ + + */ + public static function decimalDefaults(): iterable { + yield 'pads zero to scale' => ['0', 4, '0.0000']; + + yield 'pads fraction' => ['1.5', 4, '1.5000']; + + yield 'negative integer' => ['-3', 2, '-3.00']; + + yield 'exact large value survives' => ['9007199254740993.01', 2, '9007199254740993.01']; + + yield 'excess precision is left for the comparator to surface' => ['1.23456', 4, '1.23456']; + + yield 'scale zero drops the point' => ['12', 0, '12']; + } + + /** + * @dataProvider decimalDefaults + */ + #[DataProvider('decimalDefaults')] + public function test_decimal_defaults_are_normalized_as_strings(string $declared, int $scale, string $expected): void { + $table = Table::editor()->setUnquotedName('t'); + (new ColumnDefinition('amount', Types::DECIMAL, ['precision' => 20, 'scale' => $scale]))->default($declared)->applyTo($table); + + self::assertSame($expected, $table->create()->getColumn('amount')->getDefault()); + } + + public function test_change_replaces_type_default_and_comment(): void { + $table = Table::editor()->setUnquotedName('t'); + $table->addColumn(\Doctrine\DBAL\Schema\Column::editor()->setUnquotedName('value')->setTypeName(Types::STRING)->setLength(20)->setDefaultValue('x')->setComment('old')->create()); + + (new ColumnDefinition('value', Types::INTEGER))->change()->applyTo($table); + + $column = $table->create()->getColumn('value'); + self::assertSame(Types::INTEGER, Type::lookupName($column->getType())); + self::assertNull($column->getDefault()); + self::assertSame('', $column->getComment()); + self::assertTrue($column->getNotnull()); + } + + public function test_change_on_a_missing_column_fails_instead_of_adding_one(): void { + $table = Table::editor()->setUnquotedName('t'); + $table->addColumn(\Doctrine\DBAL\Schema\Column::editor()->setUnquotedName('quantity')->setTypeName(Types::INTEGER)->create()); + + $this->expectException(InvalidTableModification::class); + (new ColumnDefinition('quanity', Types::INTEGER))->change()->applyTo($table); + } + + public function test_explicit_zero_precision_matches_omitted_precision(): void { + $explicit = Table::editor()->setUnquotedName('t'); + (new ColumnDefinition('created_at', Types::DATETIME_MUTABLE, [], 0))->useCurrent()->applyTo($explicit); + $omitted = Table::editor()->setUnquotedName('t'); + (new ColumnDefinition('created_at', Types::DATETIME_MUTABLE))->useCurrent()->applyTo($omitted); + + self::assertSame('CURRENT_TIMESTAMP', $explicit->create()->getColumn('created_at')->getDefault()); + self::assertSame($omitted->create()->getColumn('created_at')->toArray(), $explicit->create()->getColumn('created_at')->toArray()); + } +} diff --git a/tests/Unit/Database/Migration/Factories/LeaseFactoryTest.php b/tests/Unit/Database/Migration/Factories/LeaseFactoryTest.php deleted file mode 100644 index 5c86045..0000000 --- a/tests/Unit/Database/Migration/Factories/LeaseFactoryTest.php +++ /dev/null @@ -1,30 +0,0 @@ -assertNotSame( - $factory->create($lock, $scope, 1, $token, 300), - $factory->create($lock, $scope, 1, $token, 300) - ); - } -} diff --git a/tests/Unit/Database/Migration/Factories/SessionFactoryTest.php b/tests/Unit/Database/Migration/Factories/SessionFactoryTest.php deleted file mode 100644 index 14e6111..0000000 --- a/tests/Unit/Database/Migration/Factories/SessionFactoryTest.php +++ /dev/null @@ -1,42 +0,0 @@ -create($this->lease()); - - $session->apply(new TestMigration('2026_09_01_000001_create_example')); - - $this->assertSame(['up:2026_09_01_000001_create_example'], $schema->statements); - } - - public function test_it_creates_a_new_session_for_each_migration_operation(): void { - $schema = new RecordingSchema(); - $factory = new SessionFactory($schema); - $lease = $this->lease(); - - $this->assertNotSame($factory->create($lease), $factory->create($lease)); - } - - private function lease(): Lease { - $lock = new InMemoryLock(new SystemClock()); - $token = $lock->acquire('nx-foundation-database-migrations', 300); - - $this->assertNotNull($token); - - return new Lease($lock, new TestDatabaseScope(), 1, $token, 300); - } -} diff --git a/tests/Unit/Database/Migration/LeaseTest.php b/tests/Unit/Database/Migration/LeaseTest.php deleted file mode 100644 index ef810a4..0000000 --- a/tests/Unit/Database/Migration/LeaseTest.php +++ /dev/null @@ -1,83 +0,0 @@ -token('2026-09-01 10:05:00'); - $refreshed = $this->token('2026-09-01 10:10:00'); - $scope = new TestDatabaseScope(); - $lock = $this->createMock(Lock::class); - $lock->expects($this->once()) - ->method('refresh') - ->with($initial, 300) - ->willReturn($refreshed); - $lock->expects($this->once()) - ->method('release') - ->with($refreshed) - ->willReturn(true); - - $lease = new Lease($lock, $scope, 1, $initial, 300); - $lease->renew(); - $lease->release(); - - $this->assertSame(4, $scope->assertions); - } - - public function test_it_fails_when_lock_ownership_is_lost_during_renewal(): void { - $token = $this->token('2026-09-01 10:05:00'); - $lock = $this->createMock(Lock::class); - $lock->expects($this->once()) - ->method('refresh') - ->with($token, 300) - ->willReturn(null); - - $this->expectException(MigrationLockFailed::class); - $this->expectExceptionMessage('ownership was lost'); - - (new Lease($lock, new TestDatabaseScope(), 1, $token, 300))->renew(); - } - - public function test_it_fails_when_lock_ownership_cannot_be_confirmed_during_release(): void { - $token = $this->token('2026-09-01 10:05:00'); - $lock = $this->createMock(Lock::class); - $lock->expects($this->once()) - ->method('release') - ->with($token) - ->willReturn(false); - - $this->expectException(MigrationLockFailed::class); - $this->expectExceptionMessage('Could not confirm ownership'); - - (new Lease($lock, new TestDatabaseScope(), 1, $token, 300))->release(); - } - - public function test_it_does_not_touch_the_lock_after_the_database_scope_changes(): void { - $scope = new TestDatabaseScope(); - $scope->currentId = 2; - $lock = $this->createMock(Lock::class); - $lock->expects($this->never())->method('refresh'); - - $this->expectException(DatabaseContextChanged::class); - - (new Lease($lock, $scope, 1, $this->token('2026-09-01 10:05:00'), 300))->renew(); - } - - private function token(string $expiration): LockToken { - return new LockToken( - 'nx-foundation-database-migrations', - 'owner', - new DateTimeImmutable($expiration) - ); - } -} diff --git a/tests/Unit/Database/Migration/MigrationCollectionTest.php b/tests/Unit/Database/Migration/MigrationCollectionTest.php new file mode 100644 index 0000000..408fb28 --- /dev/null +++ b/tests/Unit/Database/Migration/MigrationCollectionTest.php @@ -0,0 +1,66 @@ +identity; + } + public function up(Blueprint $schema): void { + } + public function down(Blueprint $schema): void { + } + }; + } + + public function test_order_and_identity_are_byte_exact_even_for_numeric_ids(): void { + $ids = ['a', '10', '2', '01', 'A', '20260922000100']; + $collection = new MigrationCollection(array_map($this->migration(...), $ids)); + self::assertSame(['01', '10', '2', '20260922000100', 'A', 'a'], $collection->ids()); + foreach ($ids as $id) { + self::assertSame($id, $collection->get($id)->id()); + } + self::assertFalse($collection->has('missing')); + } + + public function test_duplicate_ids_fail_before_execution(): void { + $this->expectException(\InvalidArgumentException::class); + new MigrationCollection([$this->migration('123'), $this->migration('123')]); + } + + /** @return iterable */ + public static function invalidIds(): iterable { + yield 'blank' => ['']; + + yield 'padded' => [' id']; + + yield 'too long' => [str_repeat('x', 192)]; + + yield 'none' => ['0']; + + yield 'latest' => ['latest']; + } + + /** + * @dataProvider invalidIds + */ + #[DataProvider('invalidIds')] + public function test_invalid_ids_fail_at_collection_boundary(string $id): void { + $this->expectException(InvalidMigrationId::class); + new MigrationCollection([$this->migration($id)]); + } +} diff --git a/tests/Unit/Database/Migration/MigratorExecutionTest.php b/tests/Unit/Database/Migration/MigratorExecutionTest.php deleted file mode 100644 index a5740eb..0000000 --- a/tests/Unit/Database/Migration/MigratorExecutionTest.php +++ /dev/null @@ -1,1087 +0,0 @@ -repository = new InMemoryRepository(); - $this->schema = new RecordingSchema(); - $this->clock = new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')); - $this->lock = new InMemoryLock($this->clock); - $this->scope = new TestDatabaseScope(); - $this->database = new FakeDatabase(); - - (new Store( - $this->storeSchema($this->schema), - new LeaseFactory(), - new SessionFactory($this->schema), - $this->scope, - $this->lock, - 'nx-foundation-database-migrations', - 300 - ))->initialize(); - - $this->schema->statements = []; - } - - public function test_it_rejects_a_blank_migration_lock_name(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('lock name cannot be empty'); - - new Store( - $this->storeSchema($this->schema), - new LeaseFactory(), - new SessionFactory($this->schema), - $this->scope, - $this->lock, - lockName: ' ', - lockTtl: 300 - ); - } - - public function test_it_rejects_an_invalid_migration_lock_ttl(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('TTL must be at least one second'); - - new Store( - $this->storeSchema($this->schema), - new LeaseFactory(), - new SessionFactory($this->schema), - $this->scope, - $this->lock, - lockName: 'nx-foundation-database-migrations', - lockTtl: 0 - ); - } - - public function test_it_runs_pending_migrations_in_ascending_id_order(): void { - $result = $this->configured( - new TestMigration('2026_01_01_000002_create_posts'), - new TestMigration('2026_01_01_000001_create_users'), - )->run(); - - $this->assertSame([ - '2026_01_01_000001_create_users', - '2026_01_01_000002_create_posts', - ], $result->ran); - $this->assertSame([ - 'up:2026_01_01_000001_create_users', - 'up:2026_01_01_000002_create_posts', - ], $this->schema->statements); - $this->assertSame(1, $this->repository->all()['2026_01_01_000001_create_users']->batch); - $this->assertSame(1, $this->repository->all()['2026_01_01_000002_create_posts']->batch); - } - - public function test_it_uses_one_ledger_snapshot_when_running_pending_migrations(): void { - $existing = new Record( - id: 1, - migration: '2026_01_01_000001_create_users', - batch: 1, - ranAt: new DateTimeImmutable('2026-01-01 00:00:00') - ); - $recordedMigration = '2026_01_01_000002_create_posts'; - $repository = $this->createMock(Repository::class); - $repository->expects($this->once()) - ->method('all') - ->willReturn([$existing->migration => $existing]); - $repository->expects($this->once()) - ->method('latestBatch') - ->willReturn(1); - $repository->expects($this->once()) - ->method('recordRun') - ->with($recordedMigration, 2); - - $result = $this->migrator( - $this->collection( - new TestMigration($existing->migration), - new TestMigration($recordedMigration) - ), - repository: $repository - )->run(); - - $this->assertSame([$recordedMigration], $result->ran); - $this->assertSame([$existing->migration], $result->skipped); - } - - public function test_it_stops_and_releases_the_lock_when_an_applied_migration_cannot_be_recorded(): void { - $first = new TestMigration('2026_01_01_000001_create_users'); - $second = new TestMigration('2026_01_01_000002_create_posts'); - $repository = $this->createMock(Repository::class); - $repository->method('all')->willReturn([]); - $repository->method('latestBatch')->willReturn(null); - $repository->expects($this->once()) - ->method('recordRun') - ->with($first->id(), 1) - ->willThrowException(LedgerFailure::notInsertedAfterRun($first->id())); - - $this->expectException(LedgerFailure::class); - - try { - $this->migrator( - $this->collection($first, $second), - repository: $repository - )->run(); - } finally { - $this->assertSame(['up:' . $first->id()], $this->schema->statements); - $this->assertFalse($this->lock->isAcquired('nx-foundation-database-migrations')); - } - } - - public function test_it_renews_the_lock_around_each_migration(): void { - $first = $this->createMock(Migration::class); - $first->method('id')->willReturn('2026_01_01_000001_create_users'); - $first->method('up')->willReturnCallback(function (Schema $schema): void { - $schema->execute('up:2026_01_01_000001_create_users'); - $this->clock->advance(9); - }); - - $second = $this->createMock(Migration::class); - $second->method('id')->willReturn('2026_01_01_000002_create_posts'); - $second->method('up')->willReturnCallback(function (Schema $schema): void { - $schema->execute('up:2026_01_01_000002_create_posts'); - $this->clock->advance(9); - }); - - $result = $this->migrator( - $this->collection($first, $second), - lockTtl: 10 - )->run(); - - $this->assertSame([ - '2026_01_01_000001_create_users', - '2026_01_01_000002_create_posts', - ], $result->ran); - } - - public function test_it_does_not_record_a_migration_after_its_lock_expires(): void { - $migration = $this->createMock(Migration::class); - $migration->method('id')->willReturn('2026_01_01_000001_create_users'); - $migration->method('up')->willReturnCallback(function (Schema $schema): void { - $schema->execute('up:2026_01_01_000001_create_users'); - $this->clock->advance(10); - }); - - $this->expectException(MigrationLockFailed::class); - $this->expectExceptionMessage('Could not refresh migration lock'); - - try { - $this->migrator( - $this->collection($migration), - lockTtl: 10 - )->run(); - } finally { - $this->assertSame(['up:2026_01_01_000001_create_users'], $this->schema->statements); - $this->assertFalse($this->repository->hasRun('2026_01_01_000001_create_users')); - } - } - - public function test_it_releases_the_latest_token_when_post_migration_renewal_loses_ownership(): void { - $migration = new TestMigration('2026_01_01_000001_create_users'); - $acquired = new LockToken('custom-migrations', 'owner', new DateTimeImmutable('2026-01-01 00:02:00')); - $beforeRun = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:04:00')); - $lock = $this->createMock(Lock::class); - $lock->expects($this->once()) - ->method('acquire') - ->with('custom-migrations', 120) - ->willReturn($acquired); - $lock->expects($this->exactly(2)) - ->method('refresh') - ->willReturnOnConsecutiveCalls($beforeRun, null); - $lock->expects($this->once()) - ->method('release') - ->with($beforeRun) - ->willReturn(true); - - $this->expectException(MigrationLockFailed::class); - $this->expectExceptionMessage('ownership was lost'); - - try { - $this->migrator( - $this->collection($migration), - lock: $lock, - lockName: 'custom-migrations', - lockTtl: 120 - )->run(); - } finally { - $this->assertSame(['up:' . $migration->id()], $this->schema->statements); - $this->assertFalse($this->repository->hasRun($migration->id())); - } - } - - public function test_it_does_not_release_in_a_database_scope_changed_during_renewal(): void { - $migration = new TestMigration('2026_01_01_000001_create_users'); - $token = $this->lockToken(); - $lock = $this->createMock(Lock::class); - $lock->expects($this->once()) - ->method('acquire') - ->willReturn($token); - $lock->expects($this->once()) - ->method('refresh') - ->with($token, 300) - ->willReturnCallback(function () use ($token): LockToken { - $this->scope->currentId = 2; - - return $token; - }); - $lock->expects($this->never())->method('release'); - - $this->expectException(DatabaseContextChanged::class); - - try { - $this->migrator($this->collection($migration), lock: $lock)->run(); - } finally { - $this->assertSame([], $this->schema->statements); - $this->assertFalse($this->repository->hasRun($migration->id())); - } - } - - public function test_it_skips_migrations_that_have_already_run(): void { - $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - )->run(); - $this->schema->statements = []; - - $result = $this->configured( - new TestMigration('2026_01_01_000003_create_comments'), - new TestMigration('2026_01_01_000002_create_posts'), - new TestMigration('2026_01_01_000001_create_users'), - )->run(); - - $this->assertSame([ - '2026_01_01_000002_create_posts', - '2026_01_01_000003_create_comments', - ], $result->ran); - $this->assertSame(['2026_01_01_000001_create_users'], $result->skipped); - $this->assertSame([ - 'up:2026_01_01_000002_create_posts', - 'up:2026_01_01_000003_create_comments', - ], $this->schema->statements); - $this->assertSame(2, $this->repository->all()['2026_01_01_000002_create_posts']->batch); - $this->assertSame(2, $this->repository->all()['2026_01_01_000003_create_comments']->batch); - } - - public function test_it_rolls_back_the_latest_batch_in_reverse_order(): void { - $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - )->run(); - $this->configured( - new TestMigration('2026_01_01_000002_create_posts'), - new TestMigration('2026_01_01_000003_create_comments'), - )->run(); - - $this->schema->statements = []; - - $result = $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - new TestMigration('2026_01_01_000002_create_posts'), - new TestMigration('2026_01_01_000003_create_comments'), - )->rollback(); - - $this->assertSame([ - '2026_01_01_000003_create_comments', - '2026_01_01_000002_create_posts', - ], $result->rolledBack); - $this->assertSame([ - 'down:2026_01_01_000003_create_comments', - 'down:2026_01_01_000002_create_posts', - ], $this->schema->statements); - $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); - $this->assertFalse($this->repository->hasRun('2026_01_01_000002_create_posts')); - } - - public function test_it_rolls_back_in_reverse_execution_order_instead_of_reverse_id_order(): void { - $executedFirst = new TestMigration('2026_01_01_000003_executed_first'); - $executedLast = new TestMigration('2026_01_01_000001_executed_last'); - $ranAt = new DateTimeImmutable('2026-01-01 00:00:00'); - $repository = $this->createMock(Repository::class); - - $repository->method('latestBatch')->willReturn(1); - $repository->method('recordsForBatch')->willReturn([ - new Record(200, $executedFirst->id(), 1, $ranAt), - new Record(100, $executedLast->id(), 1, $ranAt), - ]); - $repository->expects($this->exactly(2)) - ->method('deleteRun') - ->willReturn(true); - - $result = $this->migrator( - $this->collection($executedFirst, $executedLast), - repository: $repository - )->rollback(); - - $this->assertSame([ - $executedLast->id(), - $executedFirst->id(), - ], $result->rolledBack); - $this->assertSame([ - 'down:' . $executedLast->id(), - 'down:' . $executedFirst->id(), - ], $this->schema->statements); - } - - public function test_it_renews_the_lock_around_each_rollback(): void { - $first = $this->createMock(Migration::class); - $first->method('id')->willReturn('2026_01_01_000001_create_users'); - $first->method('down')->willReturnCallback(function (Schema $schema): void { - $schema->execute('down:2026_01_01_000001_create_users'); - $this->clock->advance(9); - }); - - $second = $this->createMock(Migration::class); - $second->method('id')->willReturn('2026_01_01_000002_create_posts'); - $second->method('down')->willReturnCallback(function (Schema $schema): void { - $schema->execute('down:2026_01_01_000002_create_posts'); - $this->clock->advance(9); - }); - - $migrator = $this->migrator( - $this->collection($first, $second), - lockTtl: 10 - ); - $migrator->run(); - - $result = $migrator->rollback(); - - $this->assertSame([ - '2026_01_01_000002_create_posts', - '2026_01_01_000001_create_users', - ], $result->rolledBack); - } - - public function test_it_preserves_a_migration_record_when_its_rollback_lock_expires(): void { - $migration = $this->createMock(Migration::class); - $migration->method('id')->willReturn('2026_01_01_000001_create_users'); - $migration->method('down')->willReturnCallback(function (Schema $schema): void { - $schema->execute('down:2026_01_01_000001_create_users'); - $this->clock->advance(10); - }); - - $this->configured(new TestMigration('2026_01_01_000001_create_users'))->run(); - $this->schema->statements = []; - - $this->expectException(MigrationLockFailed::class); - $this->expectExceptionMessage('Could not refresh migration lock'); - - try { - $this->migrator( - $this->collection($migration), - lockTtl: 10 - )->rollback(); - } finally { - $this->assertSame(['down:2026_01_01_000001_create_users'], $this->schema->statements); - $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); - } - } - - public function test_it_rolls_back_an_explicit_batch_when_it_is_still_latest(): void { - $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - )->run(); - $this->configured( - new TestMigration('2026_01_01_000002_create_posts'), - )->run(); - - $result = $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - new TestMigration('2026_01_01_000002_create_posts'), - )->rollback(2); - - $this->assertSame(['2026_01_01_000002_create_posts'], $result->rolledBack); - $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); - $this->assertFalse($this->repository->hasRun('2026_01_01_000002_create_posts')); - } - - public function test_it_rejects_rolling_back_an_older_batch(): void { - $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - )->run(); - $this->configured( - new TestMigration('2026_01_01_000002_create_posts'), - )->run(); - - $this->schema->statements = []; - - $this->expectException(InvalidRollbackBatch::class); - $this->expectExceptionMessage('batch 1 cannot be rolled back because the latest recorded batch is 2'); - - try { - $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - new TestMigration('2026_01_01_000002_create_posts'), - )->rollback(1); - } finally { - $this->assertSame([], $this->schema->statements); - $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); - $this->assertTrue($this->repository->hasRun('2026_01_01_000002_create_posts')); - } - } - - public function test_it_returns_an_empty_result_when_there_is_no_batch_to_roll_back(): void { - $result = $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - )->rollback(); - - $this->assertSame([], $result->rolledBack); - $this->assertSame(0, $result->count()); - } - - public function test_it_rejects_an_explicit_batch_when_no_batch_remains(): void { - $this->expectException(InvalidRollbackBatch::class); - $this->expectExceptionMessage('batch 1 cannot be rolled back because the latest recorded batch is none'); - - $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - )->rollback(1); - } - - public function test_it_rejects_unavailable_rollback_records_before_changing_schema(): void { - $this->repository->recordRun('2026_01_01_000001_create_users', 1); - $this->repository->recordRun('2026_01_01_000001_missing_migration', 1); - - $this->expectException(UnavailableMigration::class); - $this->expectExceptionMessage('2026_01_01_000001_missing_migration'); - - try { - $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - )->rollback(); - } finally { - $this->assertSame([], $this->schema->statements); - $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); - } - } - - public function test_it_rejects_unavailable_refresh_records_before_changing_schema(): void { - $this->repository->recordRun('2026_01_01_000001_create_users', 1); - $this->repository->recordRun('2026_01_01_000002_missing_migration', 1); - - $this->expectException(UnavailableMigration::class); - - try { - $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - )->refresh(); - } finally { - $this->assertSame([], $this->schema->statements); - $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); - } - } - - public function test_it_refreshes_all_ran_migrations_then_runs_them_again(): void { - $migrations = $this->collection( - new TestMigration('2026_01_01_000001_create_users'), - new TestMigration('2026_01_01_000002_create_posts'), - ); - - $migrator = $this->migrator($migrations); - $migrator->run(); - $this->schema->statements = []; - - $result = $migrator->refresh(); - - $this->assertSame([ - '2026_01_01_000002_create_posts', - '2026_01_01_000001_create_users', - ], $result->rolledBack); - $this->assertSame([ - '2026_01_01_000001_create_users', - '2026_01_01_000002_create_posts', - ], $result->ran); - $this->assertSame([ - 'down:2026_01_01_000002_create_posts', - 'down:2026_01_01_000001_create_users', - 'up:2026_01_01_000001_create_users', - 'up:2026_01_01_000002_create_posts', - ], $this->schema->statements); - } - - public function test_it_renews_one_lock_through_both_phases_of_refresh(): void { - $migration = new TestMigration('2026_01_01_000001_create_users'); - $acquired = new LockToken('custom-migrations', 'owner', new DateTimeImmutable('2026-01-01 00:01:00')); - $beforeRollback = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:02:00')); - $afterRollback = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:03:00')); - $beforeRun = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:04:00')); - $afterRun = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:05:00')); - $expectedTokens = [$acquired, $beforeRollback, $afterRollback, $beforeRun]; - $refreshedTokens = [$beforeRollback, $afterRollback, $beforeRun, $afterRun]; - $lock = $this->createMock(Lock::class); - - $this->repository->recordRun($migration->id(), 1); - $lock->expects($this->once()) - ->method('acquire') - ->with('custom-migrations', 10) - ->willReturn($acquired); - $lock->expects($this->exactly(4)) - ->method('refresh') - ->willReturnCallback(function (LockToken $token, int $ttl) use (&$expectedTokens, &$refreshedTokens): ?LockToken { - $this->assertSame(array_shift($expectedTokens), $token); - $this->assertSame(10, $ttl); - - return array_shift($refreshedTokens); - }); - $lock->expects($this->once()) - ->method('release') - ->with($afterRun) - ->willReturn(true); - - $migrator = $this->migrator( - $this->collection($migration), - lock: $lock, - lockName: 'custom-migrations', - lockTtl: 10 - ); - - $result = $migrator->refresh(); - - $this->assertSame(['2026_01_01_000001_create_users'], $result->rolledBack); - $this->assertSame(['2026_01_01_000001_create_users'], $result->ran); - } - - public function test_refresh_uses_one_migration_snapshot_for_rollback_and_run(): void { - $collection = new Collection(); - $late = new TestMigration('2026_01_01_000002_create_posts'); - $migration = $this->createMock(Migration::class); - - $migration->method('id')->willReturn('2026_01_01_000001_create_users'); - $migration->method('up') - ->willReturnCallback(static function (Schema $schema): void { - $schema->execute('up:2026_01_01_000001_create_users'); - }); - $migration->method('down') - ->willReturnCallback(static function (Schema $schema) use ($collection, $late): void { - $schema->execute('down:2026_01_01_000001_create_users'); - $collection->add($late); - }); - - $collection->add($migration); - $migrator = $this->migrator($collection); - $migrator->run(); - $result = $migrator->refresh(); - - $this->assertSame(['2026_01_01_000001_create_users'], $result->rolledBack); - $this->assertSame(['2026_01_01_000001_create_users'], $result->ran); - $this->assertSame([$migration, $late], $collection->values()); - } - - public function test_it_returns_status_for_configured_migrations(): void { - $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - )->run(); - - $statuses = $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - new TestMigration('2026_01_01_000002_create_posts'), - )->status(); - - $this->assertTrue($statuses[0]->isApplied()); - $this->assertSame(1, $statuses[0]->batch); - $this->assertTrue($statuses[1]->isPending()); - $this->assertNull($statuses[1]->batch); - } - - public function test_it_returns_status_for_unavailable_recorded_migrations(): void { - $migrator = $this->configured( - new TestMigration('2026_01_01_000002_create_posts'), - ); - $this->repository->recordRun('2026_01_01_000001_missing_migration', 1); - - $statuses = $migrator->status(); - - $this->assertTrue($statuses[0]->isPending()); - $this->assertTrue($statuses[1]->isUnavailable()); - $this->assertSame('2026_01_01_000001_missing_migration', $statuses[1]->migration); - } - - public function test_it_treats_migration_ids_as_case_sensitive(): void { - $result = $this->configured( - new TestMigration('CreateReports'), - new TestMigration('createreports'), - )->run(); - - $this->assertSame(['CreateReports', 'createreports'], $result->ran); - $this->assertCount(2, $this->repository->all()); - } - - public function test_it_fails_when_the_migration_lock_is_already_owned(): void { - $this->lock->acquire('nx-foundation-database-migrations', 300); - - $this->expectException(MigrationLockFailed::class); - $this->expectExceptionMessage('Could not acquire migration lock'); - - $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - )->run(); - } - - public function test_it_reports_a_scope_change_before_lock_contention(): void { - $lock = $this->createMock(Lock::class); - $lock->expects($this->once()) - ->method('acquire') - ->willReturnCallback(function (): null { - $this->scope->currentId = 2; - - return null; - }); - $lock->expects($this->never())->method('release'); - - $this->expectException(DatabaseContextChanged::class); - - $this->migrator(new Collection(), lock: $lock)->run(); - } - - public function test_it_propagates_lock_acquisition_failures_with_the_configured_policy(): void { - $lock = $this->createMock(Lock::class); - $lock->expects($this->once()) - ->method('acquire') - ->with('custom-migrations', 120) - ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); - $lock->expects($this->never()) - ->method('release'); - - $migrator = $this->migrator( - $this->collection(new TestMigration('2026_01_01_000001_create_users')), - lock: $lock, - lockName: 'custom-migrations', - lockTtl: 120 - ); - - $this->expectException(LockUnavailableException::class); - - $migrator->run(); - } - - public function test_it_releases_the_lock_when_renewal_fails_before_a_migration_runs(): void { - $migration = new TestMigration('2026_01_01_000001_create_users'); - $token = new LockToken('custom-migrations', 'owner', new DateTimeImmutable('2026-01-01 00:02:00')); - $lock = $this->createMock(Lock::class); - - $lock->expects($this->once()) - ->method('acquire') - ->with('custom-migrations', 120) - ->willReturn($token); - $lock->expects($this->once()) - ->method('refresh') - ->with($token, 120) - ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); - $lock->expects($this->once()) - ->method('release') - ->with($token) - ->willReturn(true); - - $this->expectException(LockUnavailableException::class); - - try { - $this->migrator( - $this->collection($migration), - lock: $lock, - lockName: 'custom-migrations', - lockTtl: 120 - )->run(); - } finally { - $this->assertSame([], $this->schema->statements); - $this->assertFalse($this->repository->hasRun($migration->id())); - } - } - - public function test_it_uses_the_configured_ttl_and_releases_the_latest_refreshed_token(): void { - $acquired = new LockToken('custom-migrations', 'owner', new DateTimeImmutable('2026-01-01 00:02:00')); - $beforeRun = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:04:00')); - $afterRun = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:06:00')); - $expected = [$acquired, $beforeRun]; - $refreshed = [$beforeRun, $afterRun]; - $lock = $this->createMock(Lock::class); - - $lock->expects($this->once()) - ->method('acquire') - ->with('custom-migrations', 120) - ->willReturn($acquired); - $lock->expects($this->exactly(2)) - ->method('refresh') - ->willReturnCallback(function (LockToken $token, int $ttl) use (&$expected, &$refreshed): ?LockToken { - $this->assertSame(array_shift($expected), $token); - $this->assertSame(120, $ttl); - - return array_shift($refreshed); - }); - $lock->expects($this->once()) - ->method('release') - ->with($afterRun) - ->willReturn(true); - - $this->migrator( - $this->collection(new TestMigration('2026_01_01_000001_create_users')), - lock: $lock, - lockName: 'custom-migrations', - lockTtl: 120 - )->run(); - } - - public function test_it_stops_before_running_a_migration_when_lock_renewal_loses_ownership(): void { - $token = $this->lockToken(); - $lock = $this->createMock(Lock::class); - $lock->expects($this->once()) - ->method('acquire') - ->willReturn($token); - $lock->expects($this->once()) - ->method('refresh') - ->with($token, 300) - ->willReturn(null); - $lock->expects($this->once()) - ->method('release') - ->with($token) - ->willReturn(false); - - $this->expectException(MigrationLockFailed::class); - $this->expectExceptionMessage('Could not refresh migration lock'); - - try { - $this->migrator( - $this->collection(new TestMigration('2026_01_01_000001_create_users')), - lock: $lock, - )->run(); - } finally { - $this->assertSame([], $this->schema->statements); - $this->assertFalse($this->repository->hasRun('2026_01_01_000001_create_users')); - } - } - - public function test_it_releases_the_lock_when_initialization_fails(): void { - $storeSchema = $this->createMock(Schema::class); - $storeSchema->method('create') - ->willReturnCallback(static function (Blueprint $blueprint): void { - if ($blueprint->table() instanceof MigrationTable) { - throw new DatabaseException('Could not prepare the migration ledger.'); - } - }); - - $store = new Store( - $this->storeSchema($storeSchema), - new LeaseFactory(), - new SessionFactory($storeSchema), - $this->scope, - $this->lock, - 'nx-foundation-database-migrations', - 300 - ); - - $this->expectException(DatabaseException::class); - - try { - $store->initialize(); - } finally { - $this->assertNotNull($this->lock->acquire('nx-foundation-database-migrations', 300)); - } - } - - public function test_it_fails_when_migration_lock_ownership_cannot_be_confirmed_during_release(): void { - $token = $this->lockToken(); - $lock = $this->createMock(Lock::class); - $lock->expects($this->once()) - ->method('acquire') - ->with('nx-foundation-database-migrations', 300) - ->willReturn($token); - $lock->expects($this->exactly(2)) - ->method('refresh') - ->with($token, 300) - ->willReturn($token); - $lock->expects($this->once()) - ->method('release') - ->with($token) - ->willReturn(false); - - $migrator = $this->migrator( - $this->collection(new TestMigration('2026_01_01_000001_create_users')), - lock: $lock, - ); - - $this->expectException(MigrationLockFailed::class); - $this->expectExceptionMessage('Could not confirm ownership'); - - try { - $migrator->run(); - } finally { - $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); - } - } - - public function test_it_reports_a_scope_change_during_lock_release(): void { - $token = new LockToken( - 'nx-foundation-database-migrations', - 'owner', - new DateTimeImmutable('2026-01-01 00:05:00') - ); - $lock = $this->createMock(Lock::class); - $lock->expects($this->once())->method('acquire')->willReturn($token); - $lock->expects($this->once()) - ->method('release') - ->with($token) - ->willReturnCallback(function (): bool { - $this->scope->currentId = 2; - - return true; - }); - - $this->expectException(DatabaseContextChanged::class); - - $this->migrator(new Collection(), lock: $lock)->run(); - } - - public function test_it_preserves_the_migration_failure_when_lock_release_is_unavailable(): void { - $token = $this->lockToken(); - $lock = $this->createMock(Lock::class); - $lock->expects($this->once()) - ->method('acquire') - ->willReturn($token); - $lock->expects($this->once()) - ->method('refresh') - ->with($token, 300) - ->willReturn($token); - $lock->expects($this->once()) - ->method('release') - ->with($token) - ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); - - $migrator = $this->migrator( - $this->collection(new FailingMigration('2026_01_01_000001_create_users', failUp: true)), - lock: $lock, - ); - - $this->expectException(MigrationFailed::class); - $this->expectExceptionMessage('failed while running'); - - $migrator->run(); - } - - public function test_it_propagates_lock_release_failures_after_a_successful_migration(): void { - $token = $this->lockToken(); - $lock = $this->createMock(Lock::class); - $lock->expects($this->once()) - ->method('acquire') - ->willReturn($token); - $lock->expects($this->exactly(2)) - ->method('refresh') - ->with($token, 300) - ->willReturn($token); - $lock->expects($this->once()) - ->method('release') - ->with($token) - ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); - - $migrator = $this->migrator( - $this->collection(new TestMigration('2026_01_01_000001_create_users')), - lock: $lock, - ); - - $this->expectException(LockUnavailableException::class); - - try { - $migrator->run(); - } finally { - $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); - } - } - - public function test_it_preserves_the_migration_failure_when_release_cannot_confirm_ownership(): void { - $token = $this->lockToken(); - $lock = $this->createMock(Lock::class); - $lock->expects($this->once()) - ->method('acquire') - ->willReturn($token); - $lock->expects($this->once()) - ->method('refresh') - ->with($token, 300) - ->willReturn($token); - $lock->expects($this->once()) - ->method('release') - ->with($token) - ->willReturn(false); - - $migrator = $this->migrator( - $this->collection(new FailingMigration('2026_01_01_000001_create_users', failUp: true)), - lock: $lock, - ); - - $this->expectException(MigrationFailed::class); - $this->expectExceptionMessage('failed while running'); - - $migrator->run(); - } - - public function test_it_does_not_record_a_failed_migration(): void { - $this->expectException(MigrationFailed::class); - $this->expectExceptionMessage('failed while running'); - - try { - $this->configured( - new FailingMigration('2026_01_01_000001_create_users', failUp: true), - )->run(); - } finally { - $this->assertFalse($this->repository->hasRun('2026_01_01_000001_create_users')); - } - } - - public function test_it_aborts_when_a_migration_changes_the_database_scope(): void { - $migration = new ContextChangingMigration( - '2026_01_01_000001_change_context', - $this->scope, - 2 - ); - - $this->expectException(DatabaseContextChanged::class); - - try { - $this->configured($migration)->run(); - } finally { - $this->assertFalse($this->repository->hasRun($migration->id())); - $this->assertTrue($this->lock->isAcquired('nx-foundation-database-migrations')); - } - } - - public function test_it_does_not_delete_a_record_when_rollback_fails(): void { - $this->configured( - new TestMigration('2026_01_01_000001_create_users'), - )->run(); - - $this->expectException(MigrationFailed::class); - $this->expectExceptionMessage('failed while rolling back'); - - try { - $this->configured( - new FailingMigration('2026_01_01_000001_create_users', failDown: true), - )->rollback(); - } finally { - $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); - } - } - - public function test_it_fails_when_a_rolled_back_ledger_record_is_not_deleted(): void { - $migration = new TestMigration('2026_01_01_000001_create_users'); - $repository = $this->createMock(Repository::class); - $repository->method('latestBatch')->willReturn(1); - $repository->method('recordsForBatch')->willReturn([ - new Record(1, $migration->id(), 1, new DateTimeImmutable('2026-01-01 00:00:00')), - ]); - $repository->expects($this->once()) - ->method('deleteRun') - ->with($migration->id()) - ->willReturn(false); - - $this->expectException(LedgerFailure::class); - $this->expectExceptionMessage('was rolled back but its ledger record was not deleted'); - - try { - $this->migrator($this->collection($migration), repository: $repository)->rollback(); - } finally { - $this->assertSame(['down:' . $migration->id()], $this->schema->statements); - } - } - - private function lockToken(): LockToken { - $token = $this->lock->acquire('nx-foundation-database-migrations', 300); - - $this->assertNotNull($token); - - return $token; - } - - private function configured(Migration ...$migrations): Migrator { - return $this->migrator($this->collection(...$migrations)); - } - - private function migrator( - Collection $migrations, - ?Lock $lock = null, - ?Schema $schema = null, - ?Repository $repository = null, - string $lockName = 'nx-foundation-database-migrations', - int $lockTtl = 300 - ): Migrator { - $schema ??= $this->schema; - $lock ??= $this->lock; - $repository ??= $this->repository; - $store = new Store( - $this->storeSchema($schema), - new LeaseFactory(), - new SessionFactory($schema), - $this->scope, - $lock, - $lockName, - $lockTtl - ); - - return new Migrator( - $migrations, - $repository, - $store - ); - } - - /** - * Build migration storage around the test schema and configured table names. - */ - private function storeSchema(Schema $schema): StoreSchema { - return new StoreSchema( - $schema, - new Reconciler($this->database, new RecordingSchemaExecutor()), - new MigrationTable('nx_foundation_migrations', $this->database), - new LockTable('nx_foundation_locks', $this->database) - ); - } - - private function collection(Migration ...$migrations): Collection { - return new Collection($migrations); - } -} diff --git a/tests/Unit/Database/Migration/MigratorTest.php b/tests/Unit/Database/Migration/MigratorTest.php deleted file mode 100644 index db244bb..0000000 --- a/tests/Unit/Database/Migration/MigratorTest.php +++ /dev/null @@ -1,290 +0,0 @@ -newMigrator(); - - $result = $migrator->run(); - - $this->assertSame(['2026_06_23_000001_create_example'], $result->ran); - $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); - $this->assertSame([ - 'up:2026_06_23_000001_create_example', - ], $schema->statements); - } - - public function test_it_rolls_back_configured_migrations_against_initialized_storage(): void { - [$migrator, $repository, $schema] = $this->newMigrator(); - - $migrator->run(); - $schema->statements = []; - - $result = $migrator->rollback(); - - $this->assertSame(['2026_06_23_000001_create_example'], $result->rolledBack); - $this->assertFalse($repository->hasRun('2026_06_23_000001_create_example')); - $this->assertSame([ - 'down:2026_06_23_000001_create_example', - ], $schema->statements); - } - - public function test_it_refreshes_configured_migrations_against_initialized_storage(): void { - [$migrator, $repository, $schema] = $this->newMigrator(); - - $migrator->run(); - $schema->statements = []; - - $result = $migrator->refresh(); - - $this->assertSame(['2026_06_23_000001_create_example'], $result->rolledBack); - $this->assertSame(['2026_06_23_000001_create_example'], $result->ran); - $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); - $this->assertSame([ - 'down:2026_06_23_000001_create_example', - 'up:2026_06_23_000001_create_example', - ], $schema->statements); - } - - public function test_it_exposes_migration_status_for_configured_migrations(): void { - [$migrator, , $schema] = $this->newMigrator(); - - $this->assertTrue($migrator->status()[0]->isPending()); - $this->assertSame([], $schema->statements); - - $migrator->run(); - - $this->assertTrue($migrator->status()[0]->isApplied()); - } - - public function test_it_initializes_and_drops_the_migration_store(): void { - [$migrator, , $schema] = $this->newMigrator(initialize: false); - - $this->assertFalse($migrator->isInitialized()); - - $migrator->initialize(); - - $this->assertTrue($migrator->isInitialized()); - - $migrator->dropStore(); - - $this->assertFalse($migrator->isInitialized()); - $this->assertContains('drop:nx_foundation_migrations', $schema->statements); - $this->assertNotContains('drop:nx_foundation_locks', $schema->statements); - $this->assertTrue($schema->tables['nx_foundation_locks']); - } - - public function test_store_initialization_and_drop_do_not_refresh_the_lock(): void { - $token = new LockToken( - 'nx-foundation-database-migrations', - 'owner', - new DateTimeImmutable('+5 minutes') - ); - $lock = $this->createMock(Lock::class); - $lock->expects($this->exactly(2)) - ->method('acquire') - ->willReturn($token); - $lock->expects($this->never()) - ->method('refresh'); - $lock->expects($this->exactly(2)) - ->method('release') - ->with($token) - ->willReturn(true); - - [$migrator] = $this->newMigrator($lock); - - $migrator->dropStore(); - } - - public function test_it_does_not_drop_the_store_while_another_migration_owns_the_lock(): void { - $lock = new InMemoryLock(new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00'))); - [$migrator, , $schema] = $this->newMigrator($lock); - - $token = $lock->acquire('nx-foundation-database-migrations', 300); - - $this->assertNotNull($token); - $this->expectException(MigrationLockFailed::class); - - try { - $migrator->dropStore(); - } finally { - $this->assertTrue($schema->tables['nx_foundation_migrations']); - } - } - - public function test_it_does_not_initialize_the_ledger_while_another_migration_owns_the_lock(): void { - $lock = new InMemoryLock(new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00'))); - [$migrator, , $schema] = $this->newMigrator($lock, false); - $token = $lock->acquire('nx-foundation-database-migrations', 300); - - $this->assertNotNull($token); - $this->expectException(MigrationLockFailed::class); - - try { - $migrator->initialize(); - } finally { - $this->assertTrue($schema->tables['nx_foundation_locks']); - $this->assertArrayNotHasKey('nx_foundation_migrations', $schema->tables); - } - } - - public function test_status_uses_the_existing_ledger_when_shared_lock_storage_is_missing(): void { - [$migrator, , $schema] = $this->newMigrator(); - - $migrator->run(); - unset($schema->tables['nx_foundation_locks']); - - $this->assertFalse($migrator->isInitialized()); - $this->assertTrue($migrator->status()[0]->isApplied()); - } - - public function test_it_rejects_migration_operations_before_storage_is_initialized(): void { - [$migrator, , $schema] = $this->newMigrator(initialize: false); - - $this->expectException(UninitializedStore::class); - - try { - $migrator->run(); - } finally { - $this->assertSame([], $schema->statements); - } - } - - public function test_it_does_not_acquire_the_migration_lock_without_lock_storage(): void { - $lock = $this->createMock(Lock::class); - $lock->expects($this->never())->method('acquire'); - - [$migrator, , $schema] = $this->newMigrator($lock, initialize: false); - $schema->tables['nx_foundation_migrations'] = true; - - $this->expectException(UninitializedStore::class); - - $migrator->run(); - } - - public function test_it_rechecks_storage_after_acquiring_the_migration_lock(): void { - $schema = new RecordingSchema(); - $scope = new TestDatabaseScope(); - $database = new FakeDatabase(); - $migrationTable = new MigrationTable('nx_foundation_migrations', $database); - $lockTable = new LockTable('nx_foundation_locks', $database); - $token = new LockToken( - 'nx-foundation-database-migrations', - 'owner', - new DateTimeImmutable('+5 minutes') - ); - $lock = $this->createMock(Lock::class); - - $schema->tables[$migrationTable->unprefixedName()] = true; - $schema->tables[$lockTable->unprefixedName()] = true; - - $lock->expects($this->once()) - ->method('acquire') - ->willReturnCallback(static function () use ($schema, $migrationTable, $token): LockToken { - unset($schema->tables[$migrationTable->unprefixedName()]); - - return $token; - }); - $lock->expects($this->once()) - ->method('release') - ->with($token) - ->willReturn(true); - - $migrator = new Migrator( - new Collection([new TestMigration('2026_06_23_000001_create_example')]), - new InMemoryRepository(), - new Store( - new StoreSchema( - $schema, - new Reconciler($database, new RecordingSchemaExecutor()), - $migrationTable, - $lockTable - ), - new LeaseFactory(), - new SessionFactory($schema), - $scope, - $lock, - 'nx-foundation-database-migrations', - 300 - ) - ); - - $this->expectException(UninitializedStore::class); - - $migrator->run(); - } - - /** - * @return array{Migrator, InMemoryRepository, RecordingSchema} - */ - private function newMigrator(?Lock $lock = null, bool $initialize = true): array { - $lock ??= new InMemoryLock(new SystemClock()); - - $schema = new RecordingSchema(); - $repository = new InMemoryRepository(); - $scope = new TestDatabaseScope(); - $database = new FakeDatabase(); - $migrationTable = new MigrationTable('nx_foundation_migrations', $database); - $lockTable = new LockTable('nx_foundation_locks', $database); - $store = new Store( - new StoreSchema( - $schema, - new Reconciler($database, new RecordingSchemaExecutor()), - $migrationTable, - $lockTable - ), - new LeaseFactory(), - new SessionFactory($schema), - $scope, - $lock, - 'nx-foundation-database-migrations', - 300 - ); - - $migrator = new Migrator( - new Collection([ - new TestMigration('2026_06_23_000001_create_example'), - ]), - $repository, - $store - ); - - if ($initialize) { - $migrator->initialize(); - $schema->statements = []; - } - - return [ - $migrator, - $repository, - $schema, - ]; - } -} diff --git a/tests/Unit/Database/Migration/RepositoryTest.php b/tests/Unit/Database/Migration/RepositoryTest.php deleted file mode 100644 index 9c92f11..0000000 --- a/tests/Unit/Database/Migration/RepositoryTest.php +++ /dev/null @@ -1,108 +0,0 @@ -database = new FakeDatabase(); - $this->repository = new Repository( - new MigrationTable('network_foundation_migrations', $this->database) - ); - } - - public function test_it_returns_all_migration_records_indexed_by_migration_id(): void { - $this->database->rowsResults[] = [ - [ - 'id' => 1, - 'migration' => '2026_01_01_000001_create_users', - 'batch' => 1, - 'ran_at' => '2026-01-01 00:00:00', - ], - ]; - - $records = $this->repository->all(); - - $this->assertArrayHasKey('2026_01_01_000001_create_users', $records); - $this->assertSame(1, $records['2026_01_01_000001_create_users']->id); - $this->assertStringEndsWith('ORDER BY `id` ASC', $this->database->rowsQueries[0]); - } - - public function test_it_records_a_migration_run(): void { - $this->repository->recordRun('2026_01_01_000001_create_users', 2); - - $this->assertSame('INSERT wp_network_foundation_migrations', $this->database->executed[0]); - $this->assertSame([], $this->database->rowQueries); - } - - public function test_it_rejects_an_unconfirmed_migration_run(): void { - $this->database->insertResult = 0; - - $this->expectException(LedgerFailure::class); - $this->expectExceptionMessage('ran but its ledger record was not inserted'); - - $this->repository->recordRun('2026_01_01_000001_create_users', 2); - } - - public function test_it_rejects_invalid_migration_ids_before_writing_to_the_ledger(): void { - $this->expectException(InvalidMigrationId::class); - - $this->repository->recordRun(' invalid', 2); - } - - public function test_it_rejects_invalid_migration_ids_read_from_the_ledger(): void { - $this->database->rowsResults[] = [[ - 'id' => 1, - 'migration' => '123', - 'batch' => 1, - 'ran_at' => '2026-01-01 00:00:00', - ]]; - - $this->expectException(InvalidMigrationId::class); - - $this->repository->all(); - } - - public function test_it_deletes_a_migration_run(): void { - $this->database->executeResults[] = 1; - - $this->assertTrue($this->repository->deleteRun('2026_01_01_000001_create_users')); - $this->assertSame('DELETE wp_network_foundation_migrations', $this->database->executed[0]); - } - - public function test_it_reports_when_no_migration_run_was_deleted(): void { - $this->database->executeResults[] = 0; - - $this->assertFalse($this->repository->deleteRun('2026_01_01_000001_create_users')); - } - - public function test_it_returns_records_for_a_batch(): void { - $this->database->rowsResults[] = [ - [ - 'id' => 2, - 'migration' => '2026_01_01_000002_create_posts', - 'batch' => 3, - 'ran_at' => '2026-01-01 00:00:00', - ], - ]; - - $records = $this->repository->recordsForBatch(3); - - $this->assertCount(1, $records); - $this->assertSame('2026_01_01_000002_create_posts', $records[0]->migration); - $this->assertStringEndsWith('ORDER BY `id` ASC', $this->database->rowsQueries[0]); - } -} diff --git a/tests/Unit/Database/Migration/SessionTest.php b/tests/Unit/Database/Migration/SessionTest.php deleted file mode 100644 index 4792645..0000000 --- a/tests/Unit/Database/Migration/SessionTest.php +++ /dev/null @@ -1,103 +0,0 @@ -createMock(Migration::class); - $migration->method('id')->willReturn('2026_09_01_000001_create_reports'); - $migration->expects($this->once()) - ->method('up') - ->willReturnCallback(static function (Schema $schema) use (&$events): void { - $events[] = 'apply'; - }); - $session = new Session(new RecordingSchema(), $this->lease(2, $events)); - - $session->apply($migration); - - $this->assertSame(['renew', 'apply', 'renew'], $events); - } - - public function test_it_maintains_the_lease_around_a_reverted_migration(): void { - $events = []; - $migration = $this->createMock(Migration::class); - $migration->method('id')->willReturn('2026_09_01_000001_create_reports'); - $migration->expects($this->once()) - ->method('down') - ->willReturnCallback(static function (Schema $schema) use (&$events): void { - $events[] = 'revert'; - }); - $session = new Session(new RecordingSchema(), $this->lease(2, $events)); - - $session->revert($migration); - - $this->assertSame(['renew', 'revert', 'renew'], $events); - } - - public function test_it_distinguishes_apply_failures_from_lease_renewal(): void { - $events = []; - $session = new Session(new RecordingSchema(), $this->lease(1, $events)); - - $this->expectException(MigrationFailed::class); - $this->expectExceptionMessage('failed while running'); - - try { - $session->apply(new FailingMigration('2026_09_01_000001_create_reports', failUp: true)); - } finally { - $this->assertSame(['renew'], $events); - } - } - - public function test_it_distinguishes_revert_failures_from_lease_renewal(): void { - $events = []; - $session = new Session(new RecordingSchema(), $this->lease(1, $events)); - - $this->expectException(MigrationFailed::class); - $this->expectExceptionMessage('failed while rolling back'); - - try { - $session->revert(new FailingMigration('2026_09_01_000001_create_reports', failDown: true)); - } finally { - $this->assertSame(['renew'], $events); - } - } - - /** - * Create a migration lease that records each expected renewal. - * - * @param list $events - */ - private function lease(int $expectedRenewals, array &$events): Lease { - $token = new LockToken( - 'nx-foundation-database-migrations', - 'owner', - new DateTimeImmutable('+5 minutes') - ); - $lock = $this->createMock(Lock::class); - $lock->expects($this->exactly($expectedRenewals)) - ->method('refresh') - ->with($token, 300) - ->willReturnCallback(static function () use (&$events, $token): LockToken { - $events[] = 'renew'; - - return $token; - }); - - return new Lease($lock, new TestDatabaseScope(), 1, $token, 300); - } -} diff --git a/tests/Unit/Database/Migration/TableBlueprintTest.php b/tests/Unit/Database/Migration/TableBlueprintTest.php new file mode 100644 index 0000000..0d8a179 --- /dev/null +++ b/tests/Unit/Database/Migration/TableBlueprintTest.php @@ -0,0 +1,39 @@ + + */ + public static function indexDeclarations(): iterable { + yield 'secondary' => ['index']; + + yield 'unique' => ['unique']; + } + + /** + * Reject an empty declaration at the Foundation boundary. + * + * @dataProvider indexDeclarations + */ + #[DataProvider('indexDeclarations')] + public function test_an_index_requires_columns(string $method): void { + $table = new TableBlueprint('entries'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('An index must contain at least one column.'); + $table->$method('lookup'); + } +} diff --git a/tests/Unit/Database/Migration/ValueObjects/ResultTest.php b/tests/Unit/Database/Migration/ValueObjects/ResultTest.php deleted file mode 100644 index 70dce8a..0000000 --- a/tests/Unit/Database/Migration/ValueObjects/ResultTest.php +++ /dev/null @@ -1,19 +0,0 @@ -assertSame(2, $result->count()); - } -} diff --git a/tests/Unit/Database/Migration/ValueObjects/StatusTest.php b/tests/Unit/Database/Migration/ValueObjects/StatusTest.php deleted file mode 100644 index 13fc25d..0000000 --- a/tests/Unit/Database/Migration/ValueObjects/StatusTest.php +++ /dev/null @@ -1,60 +0,0 @@ -assertSame('2026_01_01_000001_create_users', $status->migration); - $this->assertSame('pending', $status->state()); - $this->assertTrue($status->isPending()); - $this->assertFalse($status->isApplied()); - $this->assertFalse($status->isUnavailable()); - $this->assertNull($status->batch); - $this->assertNull($status->ranAt); - } - - public function test_it_describes_a_recorded_migration(): void { - $record = $this->record(); - - $status = Status::applied($record); - - $this->assertSame($record->migration, $status->migration); - $this->assertSame('applied', $status->state()); - $this->assertFalse($status->isPending()); - $this->assertTrue($status->isApplied()); - $this->assertFalse($status->isUnavailable()); - $this->assertSame($record->batch, $status->batch); - $this->assertSame($record->ranAt, $status->ranAt); - } - - public function test_it_describes_an_unavailable_recorded_migration(): void { - $record = $this->record(); - - $status = Status::unavailable($record); - - $this->assertSame($record->migration, $status->migration); - $this->assertSame('unavailable', $status->state()); - $this->assertFalse($status->isPending()); - $this->assertFalse($status->isApplied()); - $this->assertTrue($status->isUnavailable()); - $this->assertSame($record->batch, $status->batch); - $this->assertSame($record->ranAt, $status->ranAt); - } - - private function record(): Record { - return new Record( - id: 1, - migration: '2026_01_01_000001_create_users', - batch: 2, - ranAt: new DateTimeImmutable('2026-01-01 00:00:00') - ); - } -} diff --git a/tests/Unit/Database/Query/QueryBuilderTest.php b/tests/Unit/Database/Query/QueryBuilderTest.php deleted file mode 100644 index 0c81eb4..0000000 --- a/tests/Unit/Database/Query/QueryBuilderTest.php +++ /dev/null @@ -1,201 +0,0 @@ -query('reports', $database, 'r') - ->select('id', 'title') - ->where('status', '=', 'published') - ->orderBy('id', 'DESC') - ->limit(10, 5); - - $this->assertSame( - 'SELECT `id`, `title` FROM `wp_reports` AS `r` WHERE `status` = %s ORDER BY `id` DESC LIMIT %d OFFSET %d', - $query->toSql() - ); - $this->assertSame(['published', 10, 5], $query->bindings()); - $this->assertSame( - "SELECT `id`, `title` FROM `wp_reports` AS `r` WHERE `status` = 'published' ORDER BY `id` DESC LIMIT 10 OFFSET 5", - $query->toPreparedSql() - ); - } - - public function test_it_only_requires_query_capabilities(): void { - $gateway = new FakeQueryGateway(); - $query = new QueryBuilder($gateway, $this->table('reports')); - - $this->assertSame('SELECT * FROM `wp_reports`', $query->toSql()); - $this->assertSame([], $query->get()); - $this->assertSame(['SELECT * FROM `wp_reports`'], $gateway->queries); - } - - public function test_it_quotes_qualified_columns_and_select_wildcards_by_segment(): void { - $query = $this->query('posts', alias: 'p') - ->select('*', 'p.ID', 'p.*') - ->where('p.status', '=', 'publish') - ->where('p.deleted_at', '=', null) - ->orderBy('p.ID'); - - $this->assertSame( - 'SELECT *, `p`.`ID`, `p`.* FROM `wp_posts` AS `p` WHERE `p`.`status` = %s AND `p`.`deleted_at` IS NULL ORDER BY `p`.`ID` ASC', - $query->toSql() - ); - $this->assertSame(['publish'], $query->bindings()); - } - - public function test_it_escapes_each_qualified_column_segment(): void { - $query = $this->query('posts')->where('p`ost.I`D', '=', 1); - - $this->assertSame('SELECT * FROM `wp_posts` WHERE `p``ost`.`I``D` = %s', $query->toSql()); - } - - /** - * @dataProvider invalidColumnProvider - */ - #[DataProvider('invalidColumnProvider')] - public function test_it_rejects_invalid_qualified_columns(string $column): void { - $this->expectException(InvalidArgumentException::class); - - $this->query('posts')->select($column)->toSql(); - } - - /** - * @return iterable - */ - public static function invalidColumnProvider(): iterable { - yield 'empty segment' => ['p..ID']; - - yield 'non-terminal wildcard' => ['p.*.ID']; - } - - public function test_it_rejects_wildcards_outside_selects(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid query column wildcard'); - - $this->query('posts')->where('p.*', '=', 1); - } - - public function test_it_rejects_unsupported_operators(): void { - $this->expectException(InvalidArgumentException::class); - - $this->query('reports')->where('status', 'BETWEEN', ['a', 'z']); - } - - public function test_it_builds_null_comparisons_without_bindings(): void { - $query = $this->query('reports') - ->where('deleted_at', '=', null) - ->where('archived_at', '!=', null) - ->where('expired_at', '<>', null) - ->where('status', '=', 'published'); - - $this->assertSame( - 'SELECT * FROM `wp_reports` WHERE `deleted_at` IS NULL AND `archived_at` IS NOT NULL AND `expired_at` IS NOT NULL AND `status` = %s', - $query->toSql() - ); - $this->assertSame(['published'], $query->bindings()); - } - - public function test_it_rejects_invalid_null_comparisons(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('NULL comparisons only support =, !=, and <> operators.'); - - $this->query('reports')->where('updated_at', '>', null); - } - - public function test_it_rejects_invalid_order_directions(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Order direction must be ASC or DESC.'); - - $this->query('reports')->orderBy('id', 'SIDEWAYS'); - } - - public function test_it_rejects_invalid_limits(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Query limit must be greater than zero.'); - - $this->query('reports')->limit(0); - } - - public function test_it_rejects_negative_offsets(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Query offset cannot be negative.'); - - $this->query('reports')->limit(10, -1); - } - - public function test_it_reads_the_first_row(): void { - $database = new FakeDatabase(); - $database->rowResults[] = ['name' => 'first']; - - $this->assertSame( - ['name' => 'first'], - $this->query('reports', $database)->where('id', '=', 1)->first() - ); - $this->assertStringEndsWith('LIMIT 1', $database->rowQueries[0]); - } - - public function test_reading_the_first_row_does_not_mutate_the_builder_and_preserves_its_offset(): void { - $database = new FakeDatabase(); - $database->rowResults[] = ['name' => 'sixth']; - $query = $this->query('reports', $database)->limit(25, 5); - - $this->assertSame(['name' => 'sixth'], $query->first()); - $this->assertStringEndsWith('LIMIT 1 OFFSET 5', $database->rowQueries[0]); - $this->assertSame('SELECT * FROM `wp_reports` LIMIT %d OFFSET %d', $query->toSql()); - $this->assertSame([25, 5], $query->bindings()); - } - - public function test_it_selects_all_columns_by_default(): void { - $query = $this->query('reports'); - - $this->assertSame('SELECT * FROM `wp_reports`', $query->toSql()); - } - - public function test_it_builds_query_objects(): void { - $query = $this->query('reports')->where('id', '=', 10)->toQuery(); - - $this->assertInstanceOf(Query::class, $query); - $this->assertSame('SELECT * FROM `wp_reports` WHERE `id` = %s', $query->toSql()); - $this->assertSame([10], $query->bindings()); - } - - public function test_it_returns_the_maximum_value_without_mutating_the_builder(): void { - $database = new FakeDatabase(); - $database->rowResults[] = ['maximum' => '4']; - $query = $this->query('reports', $database) - ->where('status', '=', 'complete') - ->orderBy('batch') - ->limit(10, 5); - - $this->assertSame('4', $query->max('batch')); - $this->assertSame( - "SELECT MAX(`batch`) FROM `wp_reports` WHERE `status` = 'complete'", - $database->rowQueries[0] - ); - $this->assertSame( - 'SELECT * FROM `wp_reports` WHERE `status` = %s ORDER BY `batch` ASC LIMIT %d OFFSET %d', - $query->toSql() - ); - $this->assertSame(['complete', 10, 5], $query->bindings()); - } - - private function table(string $name): TestTable { - return new TestTable($name); - } - - private function query(string $table, ?FakeDatabase $database = null, ?string $alias = null): QueryBuilder { - return new QueryBuilder($database ?? new FakeDatabase(), $this->table($table), $alias); - } -} diff --git a/tests/Unit/Database/Query/QueryTest.php b/tests/Unit/Database/Query/QueryTest.php deleted file mode 100644 index e3dce3f..0000000 --- a/tests/Unit/Database/Query/QueryTest.php +++ /dev/null @@ -1,45 +0,0 @@ -mock(QueryExecutor::class); - $database->shouldReceive('prepare') - ->once() - ->with('SELECT * FROM %i WHERE status = %s', 'wp_reports', 'published') - ->andReturn("SELECT * FROM `wp_reports` WHERE status = 'published'"); - $query = new Query($database, 'SELECT * FROM %i WHERE status = %s', ['wp_reports', 'published']); - - $this->assertSame('SELECT * FROM %i WHERE status = %s', $query->toSql()); - $this->assertSame(['wp_reports', 'published'], $query->bindings()); - $this->assertSame("SELECT * FROM `wp_reports` WHERE status = 'published'", $query->toPreparedSql()); - } - - public function test_it_executes_rows_first_and_value_queries(): void { - $database = $this->mock(QueryExecutor::class); - $query = new Query($database, 'SELECT name FROM %i WHERE id = %d', ['wp_reports', 1]); - - $database->shouldReceive('rows') - ->once() - ->with('SELECT name FROM %i WHERE id = %d', 'wp_reports', 1) - ->andReturn([['name' => 'first']]); - $database->shouldReceive('row') - ->once() - ->with('SELECT name FROM %i WHERE id = %d', 'wp_reports', 1) - ->andReturn(['name' => 'first']); - $database->shouldReceive('value') - ->once() - ->with('SELECT name FROM %i WHERE id = %d', 'wp_reports', 1) - ->andReturn(3); - - $this->assertSame([['name' => 'first']], $query->get()); - $this->assertSame(['name' => 'first'], $query->first()); - $this->assertSame(3, $query->value()); - } -} diff --git a/tests/Unit/Database/Schema/DbDeltaTest.php b/tests/Unit/Database/Schema/DbDeltaTest.php deleted file mode 100644 index 6b63ff9..0000000 --- a/tests/Unit/Database/Schema/DbDeltaTest.php +++ /dev/null @@ -1,21 +0,0 @@ -expectException(DatabaseException::class); - $this->expectExceptionMessage('WordPress dbDelta() is not available.'); - - (new DbDelta())->execute('CREATE TABLE example (id bigint)'); - } -} diff --git a/tests/Unit/Database/Schema/EditorTest.php b/tests/Unit/Database/Schema/EditorTest.php deleted file mode 100644 index 36b8b08..0000000 --- a/tests/Unit/Database/Schema/EditorTest.php +++ /dev/null @@ -1,216 +0,0 @@ -dropIndex('legacy_status'); - $change->dropColumn('legacy_payload'); - $change->dateTime('published_at')->nullable(); - $change->string('status', 40)->default('draft')->change(); - $change->index('status_published_at', 'status', 'published_at'); - - $database->rowResults = [ - ['table' => 'wp_reports'], - ['Key_name' => 'legacy_status'], - ['Field' => 'legacy_payload'], - null, - ['Field' => 'status'], - ['Type' => 'varchar(20)', 'Null' => 'NO', 'Default' => 'draft', 'Extra' => '', 'Comment' => ''], - null, - ['Type' => 'datetime', 'Null' => 'YES', 'Default' => null, 'Extra' => '', 'Comment' => ''], - ['Type' => 'varchar(40)', 'Null' => 'NO', 'Default' => 'draft', 'Extra' => '', 'Comment' => ''], - null, - null, - ]; - $database->rowsResults[] = [ - self::indexRow('status_published_at', 1, 1, 'status'), - self::indexRow('status_published_at', 1, 2, 'published_at'), - ]; - - $editor->alter($change); - - $this->assertSame([ - 'ALTER TABLE `wp_reports` DROP INDEX `legacy_status`, DROP COLUMN `legacy_payload`, ADD COLUMN `published_at` datetime NULL, MODIFY COLUMN `status` varchar(40) NOT NULL DEFAULT \'draft\', ADD KEY `status_published_at` (`status`, `published_at`)', - ], $database->executed); - } - - public function test_it_treats_completed_additions_and_removals_as_a_retry(): void { - $database = new FakeDatabase(); - $editor = new Editor($database, new Reconciler($database, new RecordingSchemaExecutor())); - $change = Blueprint::for(new TestTable('reports')); - - $change->dropIndex('legacy_status'); - $change->dateTime('published_at')->nullable(); - $change->index('published_at', 'published_at'); - - $database->rowResults = [ - ['table' => 'wp_reports'], - null, - ['Field' => 'published_at'], - ['Type' => 'datetime', 'Null' => 'YES', 'Default' => null, 'Extra' => '', 'Comment' => ''], - ['Key_name' => 'published_at'], - ['Type' => 'datetime', 'Null' => 'YES', 'Default' => null, 'Extra' => '', 'Comment' => ''], - null, - ]; - $database->rowsResults = [[ - self::indexRow('published_at', 1, 1, 'published_at'), - ], [ - self::indexRow('published_at', 1, 1, 'published_at'), - ]]; - - $editor->alter($change); - - $this->assertSame([], $database->executed); - } - - public function test_it_replaces_a_functional_index_with_a_column_index(): void { - $database = new FakeDatabase(); - $editor = new Editor($database, new Reconciler($database, new RecordingSchemaExecutor())); - $change = Blueprint::for(new TestTable('reports')); - $change->dropIndex('lookup'); - $change->index('lookup', 'status'); - - $database->rowResults = [ - ['table' => 'wp_reports'], - ['Key_name' => 'lookup'], - ]; - $database->rowsResults = [[ - array_replace(self::indexRow('lookup', 1, 1, 'status'), [ - 'Column_name' => null, - 'Expression' => 'lower(`status`)', - ]), - ], [self::indexRow('lookup', 1, 1, 'status')]]; - - $editor->alter($change); - - $this->assertSame([ - 'ALTER TABLE `wp_reports` DROP INDEX `lookup`, ADD KEY `lookup` (`status`)', - ], $database->executed); - } - - public function test_it_rejects_a_change_to_a_missing_column(): void { - $database = new FakeDatabase(); - $editor = new Editor($database, new Reconciler($database, new RecordingSchemaExecutor())); - $change = Blueprint::for(new TestTable('reports')); - - $change->string('status', 40)->change(); - - $database->rowResults = [ - ['table' => 'wp_reports'], - null, - ]; - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('Cannot change missing column status on wp_reports.'); - - $editor->alter($change); - } - - public function test_it_rejects_an_incompatible_existing_column_before_executing_removals(): void { - $database = new FakeDatabase(); - $editor = new Editor($database, new Reconciler($database, new RecordingSchemaExecutor())); - $change = Blueprint::for(new TestTable('reports')); - - $change->dropColumn('legacy_payload'); - $change->string('status', 40); - - $database->rowResults = [ - ['table' => 'wp_reports'], - ['Field' => 'legacy_payload'], - ['Field' => 'status'], - ['Type' => 'int(10)', 'Null' => 'NO', 'Default' => null, 'Extra' => '', 'Comment' => ''], - ]; - - try { - $editor->alter($change); - $this->fail('Expected the incompatible existing column to be rejected.'); - } catch (DatabaseException $exception) { - $this->assertStringContainsString('column status expected type varchar(40), found int(10)', $exception->getMessage()); - } - - $this->assertSame([], $database->executed); - } - - public function test_it_rejects_an_incompatible_existing_index_before_executing_removals(): void { - $database = new FakeDatabase(); - $editor = new Editor($database, new Reconciler($database, new RecordingSchemaExecutor())); - $change = Blueprint::for(new TestTable('reports')); - - $change->dropColumn('legacy_payload'); - $change->index('status_lookup', 'status'); - - $database->rowResults = [ - ['table' => 'wp_reports'], - ['Field' => 'legacy_payload'], - ['Key_name' => 'status_lookup'], - ]; - $database->rowsResults[] = [ - self::indexRow('status_lookup', 1, 1, 'legacy_status'), - ]; - - try { - $editor->alter($change); - $this->fail('Expected the incompatible existing index to be rejected.'); - } catch (DatabaseException $exception) { - $this->assertStringContainsString('index status_lookup expected KEY (status), found KEY (legacy_status)', $exception->getMessage()); - } - - $this->assertSame([], $database->executed); - } - - public function test_it_skips_a_column_change_that_already_matches_the_requested_state(): void { - $database = new FakeDatabase(); - $editor = new Editor($database, new Reconciler($database, new RecordingSchemaExecutor())); - $change = Blueprint::for(new TestTable('reports')); - - $change->string('status', 40)->default('draft')->change(); - - $properties = ['Type' => 'varchar(40)', 'Null' => 'NO', 'Default' => 'draft', 'Extra' => '', 'Comment' => '']; - $database->rowResults = [ - ['table' => 'wp_reports'], - ['Field' => 'status'], - $properties, - $properties, - ]; - - $editor->alter($change); - - $this->assertSame([], $database->executed); - } - - /** - * @return array - */ - private static function indexRow( - string $name, - int $nonUnique, - int $sequence, - string $column - ): array { - return [ - 'Key_name' => $name, - 'Non_unique' => $nonUnique, - 'Seq_in_index' => $sequence, - 'Column_name' => $column, - 'Sub_part' => null, - 'Index_type' => 'BTREE', - 'Collation' => 'A', - ]; - } -} diff --git a/tests/Unit/Database/Schema/PhysicalIndexCollectionTest.php b/tests/Unit/Database/Schema/PhysicalIndexCollectionTest.php deleted file mode 100644 index 58e25de..0000000 --- a/tests/Unit/Database/Schema/PhysicalIndexCollectionTest.php +++ /dev/null @@ -1,135 +0,0 @@ -all(); - - $primary = $indexes['primary']; - $search = $indexes['catalog_search']; - - $this->assertSame('PRIMARY', $primary->name); - $this->assertTrue($primary->hasSameDefinitionAs(new IndexState('PRIMARY', IndexType::PRIMARY, ['id']))); - $this->assertSame('Catalog_Search', $search->name); - $this->assertTrue($search->hasSameDefinitionAs(new IndexState('catalog_search', IndexState::FULLTEXT, ['title', 'sku(32) desc']))); - $this->assertSame('FULLTEXT (title, sku(32) desc)', $search->describe()); - $this->assertSame([ - 'primary' => $primary, - 'catalog_search' => $search, - ], $indexes); - } - - public function test_it_distinguishes_functional_indexes_from_column_indexes(): void { - $indexes = PhysicalIndexCollection::fromRows([ - array_replace(self::indexRow('lookup', 1, 1, 'status'), [ - 'Column_name' => null, - 'Expression' => 'lower(`status`)', - ]), - ], 'wp_catalog')->all(); - - $this->assertSame('KEY ((lower(`status`)))', $indexes['lookup']->describe()); - $this->assertFalse($indexes['lookup']->hasSameDefinitionAs(new IndexState('lookup', IndexType::KEY, ['status']))); - $this->assertFalse($indexes['lookup']->hasSameDefinitionAs(new IndexState('lookup', IndexType::KEY, ['(lower(`status`))']))); - } - - /** - * @dataProvider invalidMetadata - * - * @param list> $rows - */ - #[DataProvider('invalidMetadata')] - public function test_it_rejects_invalid_physical_index_metadata(array $rows, string $message): void { - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage($message); - - PhysicalIndexCollection::fromRows($rows, 'wp_catalog'); - } - - /** - * @return iterable>, string}> - */ - public static function invalidMetadata(): iterable { - yield 'missing column and expression' => [[ - array_replace(self::indexRow('lookup', 1, 1, 'status'), ['Column_name' => null]), - ], 'invalid index metadata for wp_catalog.']; - - yield 'empty expression' => [[ - array_replace(self::indexRow('lookup', 1, 1, 'status'), ['Column_name' => null, 'Expression' => ' ']), - ], 'invalid index metadata for wp_catalog.']; - - yield 'missing row fields' => [ - [['Key_name' => 'PRIMARY']], - 'invalid index metadata for wp_catalog.', - ]; - - yield 'inconsistent type' => [[ - self::indexRow('catalog', 1, 1, 'name'), - self::indexRow('catalog', 0, 2, 'sku'), - ], 'invalid index metadata for wp_catalog.catalog.']; - - yield 'duplicate sequence' => [[ - self::indexRow('catalog', 1, 1, 'name'), - self::indexRow('catalog', 1, 1, 'sku'), - ], 'invalid index metadata for wp_catalog.catalog.']; - - yield 'invalid collation' => [[ - self::indexRow('catalog', 1, 1, 'name', collation: 'X'), - ], 'invalid index metadata for wp_catalog.']; - - yield 'invalid uniqueness flag' => [[ - self::indexRow('catalog', 2, 1, 'name'), - ], 'invalid index metadata for wp_catalog.']; - - yield 'non-unique primary index' => [[ - self::indexRow('PRIMARY', 1, 1, 'id'), - ], 'invalid index metadata for wp_catalog.PRIMARY.']; - - yield 'invalid sequence' => [[ - self::indexRow('catalog', 1, 0, 'name'), - ], 'invalid index metadata for wp_catalog.']; - - yield 'invalid prefix length' => [[ - self::indexRow('catalog', 1, 1, 'name', 0), - ], 'invalid index metadata for wp_catalog.catalog.']; - - yield 'non-contiguous sequence' => [[ - self::indexRow('catalog', 1, 2, 'name'), - ], 'invalid index metadata for wp_catalog.catalog.']; - } - - /** - * @return array - */ - private static function indexRow( - string $name, - int $nonUnique, - int $sequence, - string $column, - ?int $subPart = null, - string $indexType = 'BTREE', - ?string $collation = 'A' - ): array { - return [ - 'Key_name' => $name, - 'Non_unique' => $nonUnique, - 'Seq_in_index' => $sequence, - 'Column_name' => $column, - 'Sub_part' => $subPart, - 'Index_type' => $indexType, - 'Collation' => $collation, - ]; - } -} diff --git a/tests/Unit/Database/Schema/ReconcilerTest.php b/tests/Unit/Database/Schema/ReconcilerTest.php deleted file mode 100644 index 3818a8c..0000000 --- a/tests/Unit/Database/Schema/ReconcilerTest.php +++ /dev/null @@ -1,534 +0,0 @@ -rowResults[] = ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment']; - $database->rowsResults[] = [self::indexRow('PRIMARY', 0, 1, 'id')]; - $executor = new RecordingSchemaExecutor(); - $reconciler = new Reconciler($database, $executor); - - $reconciler->reconcile((new TestTable('example'))->blueprint()); - - $this->assertStringContainsString('CREATE TABLE `wp_example`', $executor->statements[0]); - $this->assertSame("SHOW FULL COLUMNS FROM `wp_example` WHERE Field = 'id'", $database->rowQueries[0]); - $this->assertSame('SHOW INDEX FROM `wp_example`', $database->rowsQueries[0]); - } - - public function test_it_accepts_matching_column_defaults_and_nullability(): void { - $database = new FakeDatabase(); - $database->rowResults = [ - ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], - ['Type' => 'int(10)', 'Null' => 'NO', 'Default' => '5', 'Extra' => ''], - ['Type' => 'datetime', 'Null' => 'YES', 'Default' => null, 'Extra' => ''], - ['Type' => 'varchar(191)', 'Null' => 'NO', 'Default' => '', 'Extra' => ''], - ['Type' => 'decimal(10,2)', 'Null' => 'NO', 'Default' => '1.25', 'Extra' => ''], - ['Type' => 'bit(1)', 'Null' => 'NO', 'Default' => "b'1'", 'Extra' => ''], - ]; - $database->rowsResults[] = [self::indexRow('PRIMARY', 0, 1, 'id')]; - $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); - - $reconciler->reconcile((new SchemaReconciliationTable('example', 5, true))->blueprint()); - - $this->assertSame([], $database->executed); - } - - /** - * @dataProvider automaticTimestampMetadata - * - * @param array $metadata - */ - #[DataProvider('automaticTimestampMetadata')] - public function test_it_compares_automatic_timestamp_semantics(Column $column, array $metadata, bool $matches): void { - $database = new FakeDatabase(); - $database->rowResults[] = $metadata + [ - 'Type' => 'datetime', - 'Null' => 'NO', - 'Default' => 'CURRENT_TIMESTAMP', - 'Extra' => 'on update CURRENT_TIMESTAMP', - ]; - $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); - - $this->assertSame($matches, $reconciler->columnMatches(new TestTable('example'), $column)); - } - - /** - * @return array, bool}> - */ - public static function automaticTimestampMetadata(): array { - $seconds = new Column('updated_at', 'datetime', default: new CurrentTimestamp(), hasDefault: true, onUpdateCurrentTimestamp: true); - $fractional = new Column('updated_at', 'TIMESTAMP(3)', default: new CurrentTimestamp(), hasDefault: true, onUpdateCurrentTimestamp: true); - $created = new Column('created_at', 'datetime', default: new CurrentTimestamp(), hasDefault: true); - $literal = new Column('label', 'varchar', 40, default: 'CURRENT_TIMESTAMP', hasDefault: true); - - return [ - 'mysql generated marker' => [$seconds, ['Extra' => 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP'], true], - 'mariadb parentheses' => [$seconds, ['Default' => 'current_timestamp()', 'Extra' => 'on update current_timestamp()'], true], - 'explicit zero precision' => [$seconds, ['Type' => 'DATETIME(0)', 'Default' => 'CURRENT_TIMESTAMP(0)', 'Extra' => 'on update current_timestamp(0)'], true], - 'fractional precision' => [$fractional, ['Type' => 'timestamp(3)', 'Default' => 'current_timestamp(3)', 'Extra' => 'on update CURRENT_TIMESTAMP(3)'], true], - 'leading-zero declaration' => [ - new Column('updated_at', 'datetime(06)', default: new CurrentTimestamp(), hasDefault: true, onUpdateCurrentTimestamp: true), - ['Type' => 'datetime(6)', 'Default' => 'current_timestamp(6)', 'Extra' => 'on update current_timestamp(6)'], - true, - ], - 'missing default' => [$seconds, ['Default' => null], false], - 'literal date default' => [$seconds, ['Default' => '2026-01-01 00:00:00'], false], - 'missing automatic update' => [$seconds, ['Extra' => 'DEFAULT_GENERATED'], false], - 'unexpected automatic update' => [$created, [], false], - 'different column precision' => [$fractional, ['Type' => 'timestamp(6)', 'Default' => 'current_timestamp(3)', 'Extra' => 'on update current_timestamp(3)'], false], - 'different default precision' => [$fractional, ['Type' => 'timestamp(3)', 'Default' => 'current_timestamp(6)', 'Extra' => 'on update current_timestamp(3)'], false], - 'different update precision' => [$fractional, ['Type' => 'timestamp(3)', 'Default' => 'current_timestamp(3)', 'Extra' => 'on update current_timestamp(6)'], false], - 'unknown extra retained' => [$seconds, ['Extra' => 'on update CURRENT_TIMESTAMP INVISIBLE'], false], - 'literal text matches' => [$literal, ['Type' => 'varchar(40)', 'Extra' => ''], true], - 'literal text remains case sensitive' => [$literal, ['Type' => 'varchar(40)', 'Default' => 'current_timestamp', 'Extra' => ''], false], - 'literal text does not normalize parentheses' => [$literal, ['Type' => 'varchar(40)', 'Default' => 'CURRENT_TIMESTAMP()', 'Extra' => ''], false], - ]; - } - - public function test_it_accepts_matching_index_metadata(): void { - $database = new FakeDatabase(); - $database->rowResults = self::indexTableColumns(); - $database->rowsResults[] = [ - self::indexRow('PRIMARY', 0, 1, 'id'), - self::indexRow('email_unique', 0, 1, 'email'), - ]; - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new IndexReconciliationTable('example', true))->blueprint()); - - $this->assertSame('SHOW INDEX FROM `wp_example`', $database->rowsQueries[0]); - } - - public function test_it_orders_composite_index_metadata_by_sequence(): void { - $database = new FakeDatabase(); - $database->rowResults = self::indexTableColumns(); - $database->rowsResults[] = [ - self::indexRow('PRIMARY', 0, 1, 'id'), - self::indexRow('email_unique', 0, 2, 'tenant'), - self::indexRow('email_unique', 0, 1, 'email'), - ]; - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new IndexReconciliationTable('example', true, ['email', 'tenant']))->blueprint()); - - $this->assertSame('SHOW INDEX FROM `wp_example`', $database->rowsQueries[0]); - } - - public function test_it_rejects_reordered_composite_index_columns(): void { - $database = new FakeDatabase(); - $database->rowResults = self::indexTableColumns(); - $database->rowsResults[] = [ - self::indexRow('PRIMARY', 0, 1, 'id'), - self::indexRow('email_unique', 0, 1, 'tenant'), - self::indexRow('email_unique', 0, 2, 'email'), - ]; - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('index email_unique expected UNIQUE (email, tenant), found UNIQUE (tenant, email)'); - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new IndexReconciliationTable('example', true, ['email', 'tenant']))->blueprint()); - } - - public function test_it_rejects_descending_index_columns(): void { - $database = new FakeDatabase(); - $database->rowResults = self::indexTableColumns(); - $database->rowsResults[] = [ - self::indexRow('PRIMARY', 0, 1, 'id'), - self::indexRow('email_unique', 0, 1, 'email', collation: 'D'), - ]; - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('index email_unique expected UNIQUE (email), found UNIQUE (email desc)'); - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new IndexReconciliationTable('example', true))->blueprint()); - } - - public function test_it_rejects_a_semantically_different_index_type(): void { - $database = new FakeDatabase(); - $database->rowResults = self::indexTableColumns(); - $database->rowsResults[] = [ - self::indexRow('PRIMARY', 0, 1, 'id'), - self::indexRow('email_unique', 1, 1, 'email', indexType: 'FULLTEXT'), - ]; - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('expected KEY (email), found FULLTEXT (email)'); - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new IndexReconciliationTable('example', true, indexType: IndexType::KEY))->blueprint()); - } - - public function test_it_accepts_hash_as_an_index_storage_method(): void { - $database = new FakeDatabase(); - $database->rowResults = self::indexTableColumns(); - $database->rowsResults[] = [ - self::indexRow('PRIMARY', 0, 1, 'id'), - self::indexRow('email_unique', 1, 1, 'email', indexType: 'HASH'), - ]; - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new IndexReconciliationTable('example', true, indexType: IndexType::KEY))->blueprint()); - - $this->assertSame('SHOW INDEX FROM `wp_example`', $database->rowsQueries[0]); - } - - /** - * @dataProvider indexDifferences - * - * @param list> $indexes - */ - #[DataProvider('indexDifferences')] - public function test_it_rejects_indexes_that_do_not_match_the_definition( - bool $includeUniqueIndex, - array $indexes, - string $message - ): void { - $database = new FakeDatabase(); - $database->rowResults = self::indexTableColumns(); - $database->rowsResults[] = $indexes; - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage($message); - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new IndexReconciliationTable('example', $includeUniqueIndex))->blueprint()); - } - - /** - * @return iterable>, string}> - */ - public static function indexDifferences(): iterable { - yield 'missing' => [ - true, - [self::indexRow('PRIMARY', 0, 1, 'id')], - 'index email_unique expected UNIQUE (email), found missing', - ]; - - yield 'changed uniqueness' => [ - true, - [ - self::indexRow('PRIMARY', 0, 1, 'id'), - self::indexRow('email_unique', 1, 1, 'email'), - ], - 'index email_unique expected UNIQUE (email), found KEY (email)', - ]; - - yield 'changed columns' => [ - true, - [ - self::indexRow('PRIMARY', 0, 1, 'id'), - self::indexRow('email_unique', 0, 1, 'id'), - ], - 'index email_unique expected UNIQUE (email), found UNIQUE (id)', - ]; - - yield 'column prefix' => [ - true, - [ - self::indexRow('PRIMARY', 0, 1, 'id'), - self::indexRow('email_unique', 0, 1, 'email', 32), - ], - 'index email_unique expected UNIQUE (email), found UNIQUE (email(32))', - ]; - } - - public function test_it_ignores_indexes_not_declared_by_the_migration_blueprint(): void { - $database = new FakeDatabase(); - $database->rowResults = self::indexTableColumns(); - $database->rowsResults[] = [ - self::indexRow('PRIMARY', 0, 1, 'id'), - self::indexRow('externally_managed_search', 1, 1, 'email', indexType: 'FULLTEXT'), - ]; - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new IndexReconciliationTable('example', false))->blueprint()); - - $this->addToAssertionCount(1); - } - - public function test_it_ignores_unsupported_metadata_for_an_undeclared_functional_index(): void { - $database = new FakeDatabase(); - $database->rowResults = self::indexTableColumns(); - $database->rowsResults[] = [ - self::indexRow('PRIMARY', 0, 1, 'id'), - [ - 'Key_name' => 'externally_managed_expression', - 'Non_unique' => 1, - 'Seq_in_index' => 1, - 'Column_name' => null, - 'Expression' => 'lower(`email`)', - 'Sub_part' => null, - 'Index_type' => 'BTREE', - 'Collation' => 'A', - ], - ]; - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new IndexReconciliationTable('example', false))->blueprint()); - - $this->addToAssertionCount(1); - } - - public function test_it_preserves_case_and_spacing_inside_custom_type_literals(): void { - $database = new FakeDatabase(); - $database->rowResults[] = [ - 'Type' => "enum('a b')", - 'Null' => 'NO', - 'Default' => null, - 'Extra' => '', - 'Comment' => '', - ]; - $blueprint = Blueprint::for(new TestTable('example')); - $blueprint->column(new Column('status', "enum('A B')")); - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage("column status expected type enum('A B'), found enum('a b')"); - - (new Reconciler($database, new RecordingSchemaExecutor()))->verify($blueprint); - } - - public function test_it_rejects_a_table_using_an_incompatible_wordpress_charset(): void { - $database = new FakeDatabase(); - $database->tableStatus = ['Collation' => 'latin1_swedish_ci']; - $database->rowResults = [[ - 'Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment', - ]]; - $database->rowsResults[] = [self::indexRow('PRIMARY', 0, 1, 'id')]; - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('table expected collation utf8mb4_*, found latin1_swedish_ci'); - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new TestTable('example'))->blueprint()); - } - - public function test_it_rejects_a_text_column_using_an_incompatible_wordpress_charset(): void { - $database = new FakeDatabase(); - $database->rowResults[] = [ - 'Type' => 'varchar(20)', - 'Null' => 'NO', - 'Default' => null, - 'Extra' => '', - 'Comment' => '', - 'Collation' => 'latin1_swedish_ci', - ]; - $blueprint = Blueprint::for(new TestTable('example')); - $blueprint->string('status', 20); - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('column status expected collation utf8mb4_*, found latin1_swedish_ci'); - - (new Reconciler($database, new RecordingSchemaExecutor()))->verify($blueprint); - } - - public function test_it_rejects_invalid_index_metadata(): void { - $database = new FakeDatabase(); - $database->rowResults = self::indexTableColumns(); - $database->rowsResults[] = [['Key_name' => 'PRIMARY']]; - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('returned invalid index metadata for wp_example'); - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new IndexReconciliationTable('example', true))->blueprint()); - } - - public function test_it_fails_when_column_defaults_and_nullability_remain_unapplied(): void { - $database = new FakeDatabase(); - $database->rowResults = [ - ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], - ['Type' => 'int(10)', 'Null' => 'NO', 'Default' => 'not-an-integer', 'Extra' => ''], - ['Type' => 'datetime', 'Null' => 'NO', 'Default' => null, 'Extra' => ''], - ['Type' => 'varchar(191)', 'Null' => 'NO', 'Default' => '', 'Extra' => ''], - ['Type' => 'decimal(10,2)', 'Null' => 'NO', 'Default' => '1.25', 'Extra' => ''], - ['Type' => 'bit(1)', 'Null' => 'NO', 'Default' => "b'1'", 'Extra' => ''], - ]; - $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('column attempts expected DEFAULT 5, found DEFAULT not-an-integer; column completed_at expected NULL, found NOT NULL'); - - $reconciler->reconcile((new SchemaReconciliationTable('example', 5, true))->blueprint()); - } - - public function test_it_does_not_treat_a_missing_default_as_an_empty_string(): void { - $database = new FakeDatabase(); - $database->rowResults = [ - ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], - ['Type' => 'int(10)', 'Null' => 'NO', 'Default' => '5', 'Extra' => ''], - ['Type' => 'datetime', 'Null' => 'NO', 'Default' => null, 'Extra' => ''], - ['Type' => 'varchar(191)', 'Null' => 'NO', 'Default' => null, 'Extra' => ''], - ['Type' => 'decimal(10,2)', 'Null' => 'NO', 'Default' => '1.25', 'Extra' => ''], - ['Type' => 'bit(1)', 'Null' => 'NO', 'Default' => "b'1'", 'Extra' => ''], - ]; - $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage("column label expected DEFAULT '', found DEFAULT NULL"); - - $reconciler->reconcile((new SchemaReconciliationTable('example', 5, false))->blueprint()); - } - - public function test_it_rejects_unapplied_column_extra_attributes(): void { - $database = new FakeDatabase(); - $database->rowResults[] = ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => '']; - $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('column id expected extra auto_increment, found none'); - - $reconciler->reconcile((new TestTable('example'))->blueprint()); - } - - public function test_it_reports_column_and_index_differences_together(): void { - $database = new FakeDatabase(); - $database->rowResults = self::indexTableColumns(); - $database->rowResults[0] = ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => '']; - $database->rowsResults[] = [self::indexRow('PRIMARY', 0, 1, 'id')]; - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage( - 'column id expected extra auto_increment, found none; index email_unique expected UNIQUE (email), found missing' - ); - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new IndexReconciliationTable('example', true))->blueprint()); - } - - public function test_it_reconciles_an_unapplied_column_comment(): void { - $database = new FakeDatabase(); - $database->rowResults = [ - ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment', 'Comment' => ''], - ['Type' => 'text', 'Null' => 'NO', 'Default' => null, 'Extra' => '', 'Comment' => ''], - ['Type' => 'text', 'Null' => 'NO', 'Default' => null, 'Extra' => '', 'Comment' => 'Public description'], - ]; - $database->rowsResults[] = [self::indexRow('PRIMARY', 0, 1, 'id')]; - $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); - - $reconciler->reconcile((new CommentedTable('example', 'Public description'))->blueprint()); - - $this->assertSame([ - 'ALTER TABLE `wp_example` MODIFY COLUMN `description` text NOT NULL COMMENT \'Public description\'', - ], $database->executed); - } - - public function test_it_does_not_replace_a_column_when_its_comment_matches(): void { - $database = new FakeDatabase(); - $database->rowResults = [ - ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment', 'Comment' => ''], - ['Type' => 'text', 'Null' => 'NO', 'Default' => null, 'Extra' => '', 'Comment' => 'Public description'], - ]; - $database->rowsResults[] = [self::indexRow('PRIMARY', 0, 1, 'id')]; - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new CommentedTable('example', 'Public description'))->blueprint()); - - $this->assertSame([], $database->executed); - } - - public function test_it_removes_an_unapplied_column_comment(): void { - $database = new FakeDatabase(); - $database->rowResults = [ - ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment', 'Comment' => ''], - ['Type' => 'text', 'Null' => 'NO', 'Default' => null, 'Extra' => '', 'Comment' => 'Old description'], - ['Type' => 'text', 'Null' => 'NO', 'Default' => null, 'Extra' => '', 'Comment' => ''], - ]; - $database->rowsResults[] = [self::indexRow('PRIMARY', 0, 1, 'id')]; - $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); - - $reconciler->reconcile((new CommentedTable('example', null))->blueprint()); - - $this->assertSame([ - 'ALTER TABLE `wp_example` MODIFY COLUMN `description` text NOT NULL', - ], $database->executed); - } - - public function test_it_rejects_a_column_comment_that_remains_unapplied(): void { - $database = new FakeDatabase(); - $database->rowResults = [ - ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment', 'Comment' => ''], - ['Type' => 'text', 'Null' => 'NO', 'Default' => null, 'Extra' => '', 'Comment' => ''], - ['Type' => 'text', 'Null' => 'NO', 'Default' => null, 'Extra' => '', 'Comment' => ''], - ]; - $database->rowsResults[] = [self::indexRow('PRIMARY', 0, 1, 'id')]; - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('column description expected comment Public description, found none'); - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new CommentedTable('example', 'Public description'))->blueprint()); - } - - public function test_it_rejects_missing_column_metadata(): void { - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('could not inspect wp_example.id'); - - (new Reconciler(new FakeDatabase(), new RecordingSchemaExecutor())) - ->reconcile((new TestTable('example'))->blueprint()); - } - - public function test_it_rejects_invalid_column_metadata(): void { - $database = new FakeDatabase(); - $database->rowResults[] = ['Type' => 'bigint(20) unsigned', 'Null' => 'MAYBE', 'Default' => null, 'Extra' => '']; - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('returned invalid column metadata for wp_example.id'); - - (new Reconciler($database, new RecordingSchemaExecutor())) - ->reconcile((new TestTable('example'))->blueprint()); - } - - /** - * @return list> - */ - private static function indexTableColumns(): array { - return [ - ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], - ['Type' => 'varchar(191)', 'Null' => 'NO', 'Default' => null, 'Extra' => ''], - ['Type' => 'varchar(191)', 'Null' => 'NO', 'Default' => null, 'Extra' => ''], - ]; - } - - /** - * @return array - */ - private static function indexRow( - string $name, - int $nonUnique, - int $sequence, - string $column, - ?int $subPart = null, - string $indexType = 'BTREE', - ?string $collation = 'A' - ): array { - return [ - 'Key_name' => $name, - 'Non_unique' => $nonUnique, - 'Seq_in_index' => $sequence, - 'Column_name' => $column, - 'Sub_part' => $subPart, - 'Index_type' => $indexType, - 'Collation' => $collation, - ]; - } -} diff --git a/tests/Unit/Database/Schema/ValueObjects/IndexStateTest.php b/tests/Unit/Database/Schema/ValueObjects/IndexStateTest.php deleted file mode 100644 index 661e2a9..0000000 --- a/tests/Unit/Database/Schema/ValueObjects/IndexStateTest.php +++ /dev/null @@ -1,19 +0,0 @@ -assertTrue($expected->hasSameDefinitionAs(new IndexState('CATALOG_LOOKUP', IndexType::KEY, ['catalog_id', 'status']))); - $this->assertFalse($expected->hasSameDefinitionAs(new IndexState('other_lookup', IndexType::KEY, ['catalog_id', 'status']))); - $this->assertFalse($expected->hasSameDefinitionAs(new IndexState('catalog_lookup', IndexType::UNIQUE, ['catalog_id', 'status']))); - $this->assertFalse($expected->hasSameDefinitionAs(new IndexState('catalog_lookup', IndexType::KEY, ['status', 'catalog_id']))); - } -} diff --git a/tests/Unit/Database/SchemaTest.php b/tests/Unit/Database/SchemaTest.php deleted file mode 100644 index c039b9f..0000000 --- a/tests/Unit/Database/SchemaTest.php +++ /dev/null @@ -1,134 +0,0 @@ -rowResults[] = ['table' => 'wp_example']; - $database->rowResults[] = ['Key_name' => 'example_key']; - $schema = $this->schema($database); - $table = new TestTable('example%'); - $indexTable = new TestTable('example'); - - $this->assertTrue($schema->hasTable($table)); - $this->assertTrue($schema->hasIndex($indexTable, 'example_key')); - $this->assertStringContainsString("SHOW TABLES LIKE 'wp\\\\_example\\\\%'", $database->rowQueries[0]); - $this->assertStringContainsString('SHOW INDEX FROM `wp_example`', $database->rowQueries[1]); - } - - public function test_it_alters_a_table_from_explicit_blueprint_operations(): void { - $database = new FakeDatabase(); - $schema = $this->schema($database); - $table = new TestTable('example'); - $change = Blueprint::for($table)->dropIndex('example_key'); - - $database->rowResults = [ - ['table' => 'wp_example'], - ['Key_name' => 'example_key'], - null, - ]; - - $schema->alter($change); - - $this->assertSame('ALTER TABLE `wp_example` DROP INDEX `example_key`', $database->executed[0]); - } - - public function test_it_creates_a_table_from_a_migration_blueprint(): void { - $database = new FakeDatabase(); - $executor = new RecordingSchemaExecutor(); - $schema = $this->schema($database, $executor); - $table = new TestTable('example'); - - $database->rowResults[] = null; - $database->rowResults[] = ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment']; - $database->rowsResults[] = [[ - 'Key_name' => 'PRIMARY', - 'Non_unique' => 0, - 'Seq_in_index' => 1, - 'Column_name' => 'id', - 'Sub_part' => null, - 'Index_type' => 'BTREE', - 'Collation' => 'A', - ]]; - - $schema->create($table->blueprint()); - - $this->assertStringContainsString('CREATE TABLE `wp_example`', $executor->statements[0]); - } - - public function test_it_verifies_an_existing_table_without_reapplying_an_old_creation_blueprint(): void { - $database = new FakeDatabase(); - $executor = new RecordingSchemaExecutor(); - $schema = $this->schema($database, $executor); - $table = new TestTable('example'); - - $database->rowResults[] = ['table' => 'wp_example']; - $database->rowResults[] = ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment']; - $database->rowsResults[] = [[ - 'Key_name' => 'PRIMARY', - 'Non_unique' => 0, - 'Seq_in_index' => 1, - 'Column_name' => 'id', - 'Sub_part' => null, - 'Index_type' => 'BTREE', - 'Collation' => 'A', - ]]; - - $schema->create($table->blueprint()); - - $this->assertSame([], $executor->statements); - } - - public function test_it_rejects_an_incompatible_existing_table_without_reapplying_the_creation_blueprint(): void { - $database = new FakeDatabase(); - $executor = new RecordingSchemaExecutor(); - $schema = $this->schema($database, $executor); - $table = new TestTable('example'); - - $database->rowResults[] = ['table' => 'wp_example']; - $database->rowResults[] = ['Type' => 'bigint(20)', 'Null' => 'NO', 'Default' => null, 'Extra' => '']; - $database->rowsResults[] = [[ - 'Key_name' => 'PRIMARY', - 'Non_unique' => 0, - 'Seq_in_index' => 1, - 'Column_name' => 'id', - 'Sub_part' => null, - 'Index_type' => 'BTREE', - 'Collation' => 'A', - ]]; - - try { - $schema->create($table->blueprint()); - $this->fail('Expected the incompatible existing table to be rejected.'); - } catch (DatabaseException $exception) { - $this->assertStringContainsString('column id expected type bigint(20) unsigned, found bigint(20)', $exception->getMessage()); - } - - $this->assertSame([], $executor->statements); - } - - public function test_it_exposes_identifier_helpers(): void { - $database = new FakeDatabase(); - $schema = $this->schema($database); - - $this->assertSame('`weird``table`', $schema->quoteIdentifier('weird`table')); - } - - private function schema(FakeDatabase $database, ?RecordingSchemaExecutor $executor = null): Schema { - $reconciler = new Reconciler($database, $executor ?? new RecordingSchemaExecutor()); - - return new Schema($database, $reconciler, new Editor($database, $reconciler)); - } -} diff --git a/tests/Unit/Database/Table/BlueprintTest.php b/tests/Unit/Database/Table/BlueprintTest.php deleted file mode 100644 index e3c20fb..0000000 --- a/tests/Unit/Database/Table/BlueprintTest.php +++ /dev/null @@ -1,473 +0,0 @@ -string('status', 20); - - $definition->string('category', 40); - $status->nullable()->default('draft'); - - $this->assertInstanceOf(ColumnDefinition::class, $status); - $this->assertSame([ - "`status` varchar(20) NULL DEFAULT 'draft'", - '`category` varchar(40) NOT NULL', - ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); - } - - public function test_it_collects_columns_and_indexes(): void { - $table = new TestTable('reports'); - $definition = Blueprint::for($table); - - $definition->bigIncrements('id'); - $definition->string('status', 20); - $definition->text('payload'); - $definition->dateTime('created_at'); - $definition->index('status', 'status'); - - $this->assertCount(4, $definition->columns()); - $this->assertCount(2, $definition->indexes()); - $this->assertSame($table, $definition->table()); - $this->assertSame([], $definition->errorsForCreate()); - } - - public function test_it_records_explicit_index_removals_for_an_alteration(): void { - $blueprint = Blueprint::for(new TestTable('reports')); - - $blueprint->dropIndex('legacy_status'); - - $this->assertSame(['legacy_status'], $blueprint->droppedIndexes()); - $this->assertSame([], $blueprint->errorsForAlter()); - } - - public function test_it_directs_primary_key_removals_to_explicit_schema_sql(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Use Schema::execute() with explicit ALTER TABLE SQL to remove the PRIMARY index.'); - - Blueprint::for(new TestTable('reports'))->dropIndex('PRIMARY'); - } - - public function test_it_records_explicit_column_removals_for_an_alteration(): void { - $blueprint = Blueprint::for(new TestTable('reports')); - - $blueprint->dropColumn('legacy_status'); - $blueprint->dropColumn('legacy_status'); - - $this->assertSame(['legacy_status'], $blueprint->droppedColumns()); - $this->assertSame([], $blueprint->errorsForAlter()); - } - - public function test_it_rejects_declaring_and_removing_the_same_column(): void { - $blueprint = Blueprint::for(new TestTable('reports')); - $blueprint->string('status'); - $blueprint->dropColumn('status'); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('cannot be declared and removed'); - - $blueprint->assertValidForAlter(); - } - - public function test_it_allows_an_alteration_index_to_reference_an_existing_table_column(): void { - $blueprint = Blueprint::for(new TestTable('reports')); - - $blueprint->index('status', 'status'); - - $this->assertSame([], $blueprint->errorsForAlter()); - } - - public function test_it_rejects_an_alteration_index_that_references_a_removed_column(): void { - $blueprint = Blueprint::for(new TestTable('reports')); - - $blueprint->dropColumn('status'); - $blueprint->index('status_lookup', 'status'); - - $this->assertSame([ - 'Index status_lookup references column status, which is removed by the same blueprint.', - ], $blueprint->errorsForAlter()); - } - - public function test_it_rejects_index_removals_from_a_creation_blueprint(): void { - $blueprint = Blueprint::for(new TestTable('reports')); - $blueprint->bigIncrements('id'); - $blueprint->dropIndex('legacy_status'); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('A table creation blueprint cannot remove columns or indexes.'); - - $blueprint->assertValidForCreate(); - } - - public function test_it_rejects_an_empty_alteration_blueprint(): void { - $blueprint = Blueprint::for(new TestTable('reports')); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Table reports does not define any schema changes.'); - - $blueprint->assertValidForAlter(); - } - - public function test_it_marks_only_explicit_column_changes_for_modification(): void { - $blueprint = Blueprint::for(new TestTable('reports')); - - $blueprint->string('status', 20)->change(); - $blueprint->dateTime('published_at')->nullable(); - - $this->assertSame(['published_at'], array_map( - static fn ($column): string => $column->name, - $blueprint->addedColumns() - )); - $this->assertSame(['status'], array_map( - static fn ($column): string => $column->name, - $blueprint->changedColumns() - )); - } - - public function test_it_quotes_index_names_and_columns_and_escapes_embedded_backticks(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->string('report`status'); - $definition->index('report`lookup', 'report`status'); - - $this->assertSame( - 'KEY `report``lookup` (`report``status`)', - $definition->indexes()[0]->sql() - ); - } - - public function test_it_defines_queue_style_columns_with_modifiers(): void { - $definition = Blueprint::for(new TestTable('queue')); - - $definition->bigIncrements('id'); - $definition->string('queue', 255); - $definition->string('task_handler', 255); - $definition->longText('args'); - $definition->integer('priority', 3)->nullable(); - $definition->dateTime('run_after')->default('0000-00-00 00:00:00'); - $definition->integer('taken')->default(0); - $definition->integer('done')->nullable()->default(0); - $definition->tinyInteger('tries')->unsigned()->default(0); - $definition->tinyInteger('failed', 1)->unsigned()->default(false); - $definition->index('done', 'done'); - $definition->index('taken_failed', 'taken', 'failed'); - $definition->index('taken_failed_done', 'taken', 'failed', 'done'); - - $this->assertSame([ - '`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', - '`queue` varchar(255) NOT NULL', - '`task_handler` varchar(255) NOT NULL', - '`args` longtext NOT NULL', - '`priority` int(3) NULL', - "`run_after` datetime NOT NULL DEFAULT '0000-00-00 00:00:00'", - '`taken` int(10) NOT NULL DEFAULT 0', - '`done` int(10) NULL DEFAULT 0', - '`tries` tinyint(3) unsigned NOT NULL DEFAULT 0', - '`failed` tinyint(1) unsigned NOT NULL DEFAULT 0', - ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); - - $this->assertCount(4, $definition->indexes()); - $this->assertSame([], $definition->errorsForCreate()); - } - - public function test_it_defines_less_common_column_helpers(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->bigInteger('remote_id')->unsigned(); - $definition->string('status')->nullable()->notNull()->default('draft'); - $definition->text('payload')->comment('json payload'); - - $this->assertSame([ - '`remote_id` bigint(20) unsigned NOT NULL', - "`status` varchar(191) NOT NULL DEFAULT 'draft'", - "`payload` text NOT NULL COMMENT 'json payload'", - ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); - } - - /** - * @dataProvider temporalHelpers - * - * @param 'dateTime'|'timestamp' $helper - */ - #[DataProvider('temporalHelpers')] - public function test_it_defines_temporal_precision_boundaries(string $helper, string $type): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->{$helper}('omitted')->useCurrent(); - $definition->{$helper}('seconds', 0)->useCurrent(); - $definition->{$helper}('microseconds', 6)->useCurrent()->useCurrentOnUpdate(); - - $this->assertSame([ - "`omitted` {$type} NOT NULL DEFAULT CURRENT_TIMESTAMP", - "`seconds` {$type} NOT NULL DEFAULT CURRENT_TIMESTAMP", - "`microseconds` {$type}(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)", - ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); - $this->assertSame([], $definition->errorsForCreate()); - } - - /** - * @return array - */ - public static function temporalHelpers(): array { - return [ - 'datetime' => ['dateTime', 'datetime'], - 'timestamp' => ['timestamp', 'timestamp'], - ]; - } - - public function test_it_supports_update_only_nullable_and_custom_temporal_columns(): void { - $definition = Blueprint::for(new TestTable('reports')); - $definition->timestamp('processed_at', 3)->nullable()->default(null)->useCurrentOnUpdate(); - $definition->column(new Column('updated_at', 'datetime(6)'))->useCurrent()->useCurrentOnUpdate(); - - $this->assertSame([ - '`processed_at` timestamp(3) NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP(3)', - '`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)', - ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); - $this->assertSame([], $definition->errorsForCreate()); - } - - /** - * @dataProvider invalidTemporalPrecisionProvider - * - * @param 'dateTime'|'timestamp' $helper - */ - #[DataProvider('invalidTemporalPrecisionProvider')] - public function test_it_rejects_invalid_temporal_precision(string $helper, int $precision): void { - $this->expectException(InvalidArgumentException::class); - - Blueprint::for(new TestTable('reports'))->{$helper}('created_at', $precision); - } - - /** - * @return array - */ - public static function invalidTemporalPrecisionProvider(): array { - return [ - 'datetime negative' => ['dateTime', -1], - 'datetime above maximum' => ['dateTime', 7], - 'timestamp negative' => ['timestamp', -1], - 'timestamp above maximum' => ['timestamp', 7], - ]; - } - - public function test_it_rejects_a_current_timestamp_default_on_a_non_temporal_creation_column(): void { - $definition = Blueprint::for(new TestTable('reports')); - $definition->string('status')->useCurrent(); - - $this->expectException(InvalidArgumentException::class); - - $definition->assertValidForCreate(); - } - - public function test_it_rejects_invalid_direct_temporal_precision_in_an_alteration(): void { - $definition = Blueprint::for(new TestTable('reports')); - $definition->column(new Column('updated_at', 'datetime(7)'))->useCurrentOnUpdate()->change(); - - $this->expectException(InvalidArgumentException::class); - - $definition->assertValidForAlter(); - } - - public function test_it_validates_the_completed_default_rather_than_transient_modifiers(): void { - $definition = Blueprint::for(new TestTable('reports')); - $definition->string('status')->useCurrent()->default('pending'); - - $this->assertSame([], $definition->errorsForCreate()); - $this->assertSame("`status` varchar(191) NOT NULL DEFAULT 'pending'", $definition->columns()[0]->sql()); - } - - public function test_it_rejects_indexes_that_reference_missing_columns(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->string('status', 20); - $definition->index('missing_index', 'missing'); - - $this->assertSame(['Index missing_index references missing column missing.'], $definition->errorsForCreate()); - - $this->expectException(InvalidArgumentException::class); - - $definition->assertValidForCreate(); - } - - public function test_it_rejects_tables_without_columns(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $this->assertSame(['Table reports does not define any columns.'], $definition->errorsForCreate()); - } - - public function test_it_rejects_invalid_final_column_states(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->bigIncrements('id')->nullable(); - $definition->dateTime('completed_at')->default(null); - - $this->assertSame([ - 'Column id cannot be nullable because it uses AUTO_INCREMENT.', - 'Column completed_at cannot use DEFAULT NULL unless it is nullable.', - ], $definition->errorsForCreate()); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Column id cannot be nullable because it uses AUTO_INCREMENT.'); - - $definition->assertValidForCreate(); - } - - public function test_final_column_validation_is_independent_of_modifier_order(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->bigIncrements('id')->nullable()->notNull(); - $definition->dateTime('completed_at')->default(null)->nullable(); - - $this->assertSame([], $definition->errorsForCreate()); - } - - public function test_it_rejects_an_unindexed_auto_increment_column(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->integer('sequence')->autoIncrement(); - - $this->assertSame([ - 'AUTO_INCREMENT column sequence must be the first column in an index.', - ], $definition->errorsForCreate()); - } - - public function test_it_rejects_an_unindexed_auto_increment_column_added_by_an_alteration(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->integer('sequence')->autoIncrement(); - - $this->assertSame([ - 'AUTO_INCREMENT column sequence must be the first column in an index.', - ], $definition->errorsForAlter()); - } - - public function test_it_rejects_multiple_auto_increment_columns(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->bigIncrements('id'); - $definition->integer('legacy_id')->autoIncrement(); - $definition->index('legacy_id', 'legacy_id'); - - $this->assertSame([ - 'A table can define only one AUTO_INCREMENT column.', - ], $definition->errorsForCreate()); - } - - public function test_it_requires_an_auto_increment_column_to_lead_its_index(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->string('tenant'); - $definition->integer('sequence')->autoIncrement(); - $definition->index('tenant_sequence', 'tenant', 'sequence'); - - $this->assertSame([ - 'AUTO_INCREMENT column sequence must be the first column in an index.', - ], $definition->errorsForCreate()); - } - - public function test_it_accepts_an_auto_increment_column_leading_a_secondary_index(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->integer('sequence')->autoIncrement(); - $definition->string('tenant'); - $definition->index('sequence_tenant', 'sequence', 'tenant'); - - $this->assertSame([], $definition->errorsForCreate()); - } - - public function test_it_rejects_indexes_without_columns(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('An index must define at least one column.'); - - Blueprint::for(new TestTable('reports'))->index('empty_index'); - } - - public function test_it_rejects_duplicate_column_names_case_insensitively(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Column status is already defined.'); - - $definition = Blueprint::for(new TestTable('reports')); - - $definition->string('Status')->nullable(); - $definition->text('status'); - } - - public function test_it_matches_index_column_references_case_insensitively(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->string('Status'); - $definition->index('status_lookup', 'status'); - - $this->assertSame([], $definition->errorsForCreate()); - } - - public function test_it_reports_duplicate_primary_keys(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->bigIncrements('id'); - $definition->string('status'); - $definition->primary('status'); - - $this->assertContains('A table can define only one primary key.', $definition->errorsForCreate()); - } - - public function test_it_reports_duplicate_index_names(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->bigIncrements('id'); - $definition->string('status'); - $definition->string('type'); - $definition->index('status_lookup', 'status'); - $definition->index('status_lookup', 'type'); - - $this->assertContains('Index status_lookup is defined more than once.', $definition->errorsForCreate()); - } - - public function test_it_reports_duplicate_index_names_case_insensitively(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->string('status'); - $definition->string('type'); - $definition->index('Status_Lookup', 'status'); - $definition->index('status_lookup', 'type'); - - $this->assertContains('Index Status_Lookup is defined more than once.', $definition->errorsForCreate()); - } - - public function test_it_reports_every_duplicate_index_name(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->string('status'); - $definition->string('type'); - $definition->index('status_lookup', 'status'); - $definition->index('status_lookup', 'type'); - $definition->index('type_lookup', 'type'); - $definition->index('TYPE_LOOKUP', 'status'); - - $this->assertSame([ - 'Index status_lookup is defined more than once.', - 'Index type_lookup is defined more than once.', - ], $definition->errorsForCreate()); - } - - public function test_it_rejects_primary_as_a_secondary_index_name(): void { - $definition = Blueprint::for(new TestTable('reports')); - - $definition->bigIncrements('id'); - $definition->string('status'); - $definition->index('PRIMARY', 'status'); - - $this->assertContains('The PRIMARY index name is reserved for the primary key.', $definition->errorsForCreate()); - } -} diff --git a/tests/Unit/Database/Table/ColumnDefinitionTest.php b/tests/Unit/Database/Table/ColumnDefinitionTest.php deleted file mode 100644 index 3c9d348..0000000 --- a/tests/Unit/Database/Table/ColumnDefinitionTest.php +++ /dev/null @@ -1,104 +0,0 @@ -unsigned() - ->nullable() - ->notNull() - ->autoIncrement() - ->comment('Internal identifier') - ->toColumn(); - - $this->assertSame( - "`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'Internal identifier'", - $column->sql() - ); - } - - public function test_an_explicit_null_default_does_not_change_nullability(): void { - $column = (new ColumnDefinition(new Column('completed_at', 'datetime'))) - ->default(null) - ->toColumn(); - - $this->assertSame('`completed_at` datetime NOT NULL DEFAULT NULL', $column->sql()); - } - - public function test_modifiers_are_idempotent(): void { - $column = (new ColumnDefinition(new Column('id', 'bigint', 20))) - ->autoIncrement() - ->autoIncrement() - ->toColumn(); - - $this->assertSame('`id` bigint(20) NOT NULL AUTO_INCREMENT', $column->sql()); - } - - public function test_created_columns_are_immutable_snapshots(): void { - $definition = new ColumnDefinition(new Column('updated_at', 'datetime', 6)); - $original = $definition->toColumn(); - $current = $definition->useCurrent()->toColumn(); - $automatic = $definition->useCurrentOnUpdate()->toColumn(); - $nullable = $definition->nullable()->default(null)->toColumn(); - - $this->assertSame('`updated_at` datetime(6) NOT NULL', $original->sql()); - $this->assertSame('`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)', $current->sql()); - $this->assertSame( - '`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)', - $automatic->sql() - ); - $this->assertSame('`updated_at` datetime(6) NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP(6)', $nullable->sql()); - } - - public function test_the_last_default_modifier_wins_without_changing_automatic_updates(): void { - $definition = new ColumnDefinition(new Column('updated_at', 'timestamp', 3)); - $definition->nullable()->useCurrentOnUpdate()->default(null)->useCurrent(); - - $this->assertSame( - '`updated_at` timestamp(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)', - $definition->toColumn()->sql() - ); - - $definition->default('CURRENT_TIMESTAMP'); - - $this->assertSame( - "`updated_at` timestamp(3) NULL DEFAULT 'CURRENT_TIMESTAMP' ON UPDATE CURRENT_TIMESTAMP(3)", - $definition->toColumn()->sql() - ); - } - - public function test_automatic_updates_do_not_add_a_default_or_change_nullability(): void { - $definition = new ColumnDefinition(new Column('updated_at', 'datetime')); - $definition->useCurrentOnUpdate()->useCurrentOnUpdate(); - - $this->assertSame('`updated_at` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP', $definition->toColumn()->sql()); - } - - public function test_it_preserves_automatic_attributes_from_a_direct_column_seed(): void { - $definition = new ColumnDefinition(new Column( - 'updated_at', - 'TIMESTAMP(6)', - null, - false, - true, - new CurrentTimestamp(), - true, - false, - new ColumnComment('Database managed'), - true - )); - - $this->assertSame( - "`updated_at` TIMESTAMP(6) NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT 'Database managed'", - $definition->toColumn()->sql() - ); - } -} diff --git a/tests/Unit/Database/Table/ColumnTest.php b/tests/Unit/Database/Table/ColumnTest.php deleted file mode 100644 index 13cf831..0000000 --- a/tests/Unit/Database/Table/ColumnTest.php +++ /dev/null @@ -1,246 +0,0 @@ -assertSame('`queue_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $column->sql()); - } - - public function test_it_quotes_column_names_and_escapes_embedded_backticks(): void { - $this->assertSame( - '`report``status` varchar(20) NOT NULL', - (new Column('report`status', 'varchar', 20))->sql() - ); - } - - public function test_it_renders_nullable_and_default_values(): void { - $this->assertSame( - "`status` varchar(20) NULL DEFAULT 'pending'", - (new Column('status', 'varchar', 20, nullable: true, default: 'pending', hasDefault: true))->sql() - ); - - $this->assertSame( - '`attempts` int(10) unsigned NOT NULL DEFAULT 0', - (new Column('attempts', 'int', 10, unsigned: true, default: 0, hasDefault: true))->sql() - ); - } - - public function test_it_renders_explicit_null_and_boolean_defaults(): void { - $this->assertSame( - '`completed_at` datetime NULL DEFAULT NULL', - (new Column('completed_at', 'datetime', nullable: true, hasDefault: true))->sql() - ); - - $this->assertSame( - '`enabled` tinyint(1) unsigned NOT NULL DEFAULT 1', - (new Column('enabled', 'tinyint', 1, unsigned: true, default: true, hasDefault: true))->sql() - ); - } - - public function test_it_renders_typed_column_comments(): void { - $column = new Column( - 'id', - 'bigint', - 20, - autoIncrement: true, - comment: new ColumnComment("Customer's identifier; internal metadata") - ); - - $this->assertSame( - "`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'Customer''s identifier; internal metadata'", - $column->sql() - ); - } - - public function test_it_preserves_existing_positional_constructor_arguments(): void { - $column = new Column('status', 'varchar', 20, false, true, 'pending', true, false, new ColumnComment('Workflow')); - - $this->assertSame("`status` varchar(20) NULL DEFAULT 'pending' COMMENT 'Workflow'", $column->sql()); - } - - /** - * @dataProvider temporalTypes - */ - #[DataProvider('temporalTypes')] - public function test_it_uses_column_precision_for_both_current_timestamp_expressions( - string $type, - ?int $length, - string $expectedType, - string $expectedExpression - ): void { - $column = new Column( - 'updated_at', - $type, - $length, - default: new CurrentTimestamp(), - hasDefault: true, - onUpdateCurrentTimestamp: true - ); - - $this->assertSame( - "`updated_at` {$expectedType} NOT NULL DEFAULT {$expectedExpression} ON UPDATE {$expectedExpression}", - $column->sql() - ); - $this->assertSame([], $column->validationErrors()); - } - - /** - * @return array - */ - public static function temporalTypes(): array { - return [ - 'datetime seconds' => ['datetime', null, 'datetime', 'CURRENT_TIMESTAMP'], - 'timestamp explicit zero' => ['timestamp', 0, 'timestamp(0)', 'CURRENT_TIMESTAMP'], - 'minimum fraction' => ['datetime', 1, 'datetime(1)', 'CURRENT_TIMESTAMP(1)'], - 'maximum fraction' => ['timestamp', 6, 'timestamp(6)', 'CURRENT_TIMESTAMP(6)'], - 'embedded precision' => ['datetime(6)', null, 'datetime(6)', 'CURRENT_TIMESTAMP(6)'], - 'embedded zero' => ['timestamp(0)', null, 'timestamp(0)', 'CURRENT_TIMESTAMP'], - 'case and whitespace' => [' TIMESTAMP ( 3 ) ', null, 'TIMESTAMP ( 3 )', 'CURRENT_TIMESTAMP(3)'], - ]; - } - - public function test_current_timestamp_text_remains_a_literal_default(): void { - $column = new Column('created_at', 'datetime', default: 'CURRENT_TIMESTAMP', hasDefault: true); - - $this->assertSame("`created_at` datetime NOT NULL DEFAULT 'CURRENT_TIMESTAMP'", $column->sql()); - $this->assertSame("'CURRENT_TIMESTAMP'", $column->defaultSql()); - $this->assertNull($column->onUpdateSql()); - } - - /** - * @dataProvider invalidTemporalTypes - */ - #[DataProvider('invalidTemporalTypes')] - public function test_it_rejects_invalid_temporal_declarations_before_rendering( - string $type, - ?int $length, - bool $useCurrent - ): void { - $column = new Column( - 'managed_at', - $type, - $length, - default: $useCurrent ? new CurrentTimestamp() : null, - hasDefault: $useCurrent, - onUpdateCurrentTimestamp: ! $useCurrent - ); - - $this->assertCount(1, $column->validationErrors()); - $this->expectException(InvalidArgumentException::class); - - $column->sql(); - } - - /** - * @return array - */ - public static function invalidTemporalTypes(): array { - return [ - 'non-temporal default' => ['varchar', 20, true], - 'non-temporal update' => ['int', 10, false], - 'date is not datetime' => ['date', null, true], - 'negative precision' => ['datetime', -1, true], - 'excess precision' => ['timestamp', 7, false], - 'embedded negative precision' => ['timestamp(-1)', null, false], - 'embedded excess precision' => ['datetime(7)', null, true], - 'duplicate precision' => ['datetime(3)', 3, false], - 'fractional precision' => ['timestamp(1.5)', null, true], - 'trailing type attributes' => ['datetime unsigned', null, false], - ]; - } - - public function test_it_canonicalizes_common_custom_type_spellings(): void { - $this->assertSame('double', (new Column('measurement', 'DOUBLE PRECISION'))->typeSql()); - $this->assertSame('decimal(10,2)', (new Column('amount', 'decimal(10, 2)'))->typeSql()); - $this->assertSame("enum('a, b','c')", (new Column('state', "enum('a, b','c')"))->typeSql()); - } - - public function test_it_reports_invalid_final_column_states(): void { - $this->assertSame( - ['Column id cannot be nullable because it uses AUTO_INCREMENT.'], - (new Column('id', 'bigint', 20, nullable: true, autoIncrement: true))->validationErrors() - ); - - $this->assertSame( - ['Column completed_at cannot use DEFAULT NULL unless it is nullable.'], - (new Column('completed_at', 'datetime', hasDefault: true))->validationErrors() - ); - } - - /** - * @dataProvider decimalTypes - */ - #[DataProvider('decimalTypes')] - public function test_it_normalizes_decimal_precision_and_scale(string $type, ?int $length, string $expected): void { - $this->assertSame($expected, (new Column('amount', $type, $length))->typeSql()); - } - - /** - * @return array - */ - public static function decimalTypes(): array { - return [ - 'precision' => ['decimal(12)', null, 'decimal(12,0)'], - 'numeric precision' => ['NUMERIC(12)', null, 'decimal(12,0)'], - 'dec precision' => ['dec(12)', null, 'decimal(12,0)'], - 'separate precision' => ['decimal', 12, 'decimal(12,0)'], - 'numeric separate precision' => ['numeric', 12, 'decimal(12,0)'], - 'dec separate precision' => ['dec', 12, 'decimal(12,0)'], - 'default precision' => ['decimal', null, 'decimal(10,0)'], - 'numeric default precision' => ['numeric', null, 'decimal(10,0)'], - 'dec default precision' => ['dec', null, 'decimal(10,0)'], - 'explicit scale' => ['decimal(12, 2)', null, 'decimal(12,2)'], - 'numeric explicit scale' => ['NUMERIC(12, 2)', null, 'decimal(12,2)'], - 'dec explicit scale' => ['dec(12, 2)', null, 'decimal(12,2)'], - ]; - } - - public function test_it_rejects_auto_increment_on_non_integer_columns(): void { - $this->assertSame( - ['Column slug must use an integer type because it uses AUTO_INCREMENT.'], - (new Column('slug', 'varchar', 191, autoIncrement: true))->validationErrors() - ); - } - - public function test_it_rejects_explicit_defaults_on_auto_increment_columns(): void { - $this->assertSame( - ['Column id cannot define a default because it uses AUTO_INCREMENT.'], - (new Column('id', 'bigint', 20, default: 10, hasDefault: true, autoIncrement: true))->validationErrors() - ); - } - - public function test_it_escapes_string_defaults_as_sql_literals(): void { - $column = new Column('label', 'varchar', 50, default: "customer's \\ path", hasDefault: true); - - $this->assertSame( - "`label` varchar(50) NOT NULL DEFAULT X'637573746f6d65722773205c2070617468'", - $column->sql() - ); - $this->assertSame("X'637573746f6d65722773205c2070617468'", $column->defaultSql()); - $this->assertNull((new Column('label', 'varchar', 50))->defaultSql()); - } - - public function test_it_rejects_a_default_without_an_explicit_default_state(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('must set hasDefault'); - - new Column('status', 'varchar', 20, default: 'pending'); - } -} diff --git a/tests/Unit/Database/Table/CreateTableTest.php b/tests/Unit/Database/Table/CreateTableTest.php deleted file mode 100644 index a425fb8..0000000 --- a/tests/Unit/Database/Table/CreateTableTest.php +++ /dev/null @@ -1,51 +0,0 @@ -blueprint()); - - $this->assertSame('foundation_example_table', $migration->id()); - } - - public function test_it_creates_missing_tables(): void { - $table = new TestTable('example'); - $migration = new CreateTable('foundation_example_table', $table->blueprint()); - $schema = new RecordingSchema(); - - $migration->up($schema); - - $this->assertTrue($schema->hasTable($table)); - } - - public function test_it_delegates_existing_table_verification_to_schema(): void { - $table = new TestTable('example'); - $migration = new CreateTable('foundation_example_table', $table->blueprint()); - $schema = new RecordingSchema(); - - $schema->tables['example'] = true; - - $migration->up($schema); - - $this->assertSame(['create:example'], $schema->statements); - } - - public function test_it_drops_tables_when_rolled_back(): void { - $table = new TestTable('example'); - $migration = new CreateTable('foundation_example_table', $table->blueprint()); - $schema = new RecordingSchema(); - - $schema->tables['example'] = true; - - $migration->down($schema); - - $this->assertFalse($schema->hasTable($table)); - } -} diff --git a/tests/Unit/Database/Table/IndexTest.php b/tests/Unit/Database/Table/IndexTest.php deleted file mode 100644 index 2b5eeed..0000000 --- a/tests/Unit/Database/Table/IndexTest.php +++ /dev/null @@ -1,66 +0,0 @@ -assertTrue($primary->isPrimary()); - $this->assertFalse($primary->isUnique()); - $this->assertFalse($unique->isPrimary()); - $this->assertTrue($unique->isUnique()); - $this->assertFalse($key->isPrimary()); - $this->assertFalse($key->isUnique()); - } - - /** - * @dataProvider invalidTypeProvider - */ - #[DataProvider('invalidTypeProvider')] - public function test_it_rejects_unknown_index_types(string $type): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage(sprintf('Unsupported index type "%s".', $type)); - - new Index('lookup', ['status'], $type); - } - - public function test_it_rejects_a_blank_index_name(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Index name cannot be blank.'); - - new Index(' ', ['status']); - } - - public function test_it_rejects_an_empty_column_list(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('An index must define at least one column.'); - - new Index('status_lookup', []); - } - - public function test_it_rejects_blank_column_names(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Index status_lookup contains a blank column name.'); - - new Index('status_lookup', ['status', ' ']); - } - - /** - * @return iterable - */ - public static function invalidTypeProvider(): iterable { - yield 'blank' => ['']; - - yield 'unknown' => ['hash']; - } -} diff --git a/tests/Unit/Database/Table/TableNameResolverTest.php b/tests/Unit/Database/Table/TableNameResolverTest.php new file mode 100644 index 0000000..29d73ef --- /dev/null +++ b/tests/Unit/Database/Table/TableNameResolverTest.php @@ -0,0 +1,41 @@ + + */ + public static function invalidNames(): iterable { + yield 'blank' => ['', 'wp_']; + + yield 'padded' => [' entries', 'wp_']; + + yield 'sql syntax' => ['entries;DROP', 'wp_']; + + yield 'qualified' => ['other.entries', 'wp_']; + + yield 'physical limit includes prefix' => [str_repeat('a', 63), 'wp_']; + } + + /** + * @dataProvider invalidNames + */ + #[DataProvider('invalidNames')] + public function test_invalid_names_fail_before_sql(string $name, string $prefix): void { + $scope = $this->createMock(DatabaseScope::class); + $scope->method('resolveTableName')->willReturn($prefix . $name); + $table = $this->createMock(Table::class); + $table->method('unprefixedName')->willReturn($name); + $this->expectException(DatabaseException::class); + (new TableNameResolver($scope))->tableName($table); + } +} diff --git a/tests/Unit/Database/Table/TableTest.php b/tests/Unit/Database/Table/TableTest.php deleted file mode 100644 index e71c011..0000000 --- a/tests/Unit/Database/Table/TableTest.php +++ /dev/null @@ -1,77 +0,0 @@ -assertTrue($method->isProtected()); - $this->assertTrue($method->isFinal()); - } - - public function test_it_resolves_the_current_database_scope_for_each_operation(): void { - $database = new FakeDatabase(); - $table = new TestDatabaseTable('reports', $database); - - $this->assertSame('reports', $table->unprefixedName()); - $this->assertSame('wp_reports', $table->name()); - - $database->prefix = 'wp_2_'; - - $this->assertSame('wp_2_reports', $table->name()); - $this->assertSame('SELECT * FROM `wp_2_reports` AS `r`', $table->query('r')->toSql()); - } - - public function test_it_provides_table_bound_write_operations(): void { - $database = new FakeDatabase(); - $database->insertResult = 1; - $database->insertId = 42; - $database->executeResults = [2, 1]; - $table = new TestDatabaseTable('reports', $database); - - $this->assertSame(1, $table->insert(['status' => 'draft'])); - $this->assertSame(42, $table->insertGetId(['status' => 'draft'])); - $this->assertSame(2, $table->update(['status' => 'published'], ['id' => 42])); - $this->assertSame(1, $table->delete(['id' => 42])); - $this->assertSame([ - 'INSERT wp_reports', - 'INSERT wp_reports', - 'UPDATE wp_reports', - 'DELETE wp_reports', - ], $database->executed); - } - - public function test_an_externally_managed_table_can_use_the_table_gateway_without_defining_a_schema(): void { - $database = new FakeDatabase(); - $database->insertResult = 1; - $table = new ExternalTable($database); - - $this->assertSame('wp_external_reports', $table->name()); - $this->assertSame('SELECT * FROM `wp_external_reports`', $table->query()->toSql()); - $this->assertSame(1, $table->insert(['status' => 'imported'])); - } - - public function test_subclasses_can_add_table_bound_operations_using_the_active_database_scope(): void { - $database = new FakeDatabase(); - $table = new TestDatabaseTable('reports', $database); - - $this->assertSame(1, $table->archiveByStatus('draft')); - - $database->prefix = 'wp_2_'; - - $this->assertSame(1, $table->archiveByStatus('pending')); - $this->assertSame([ - "UPDATE `wp_reports` SET status = 'archived' WHERE status = 'draft'", - "UPDATE `wp_2_reports` SET status = 'archived' WHERE status = 'pending'", - ], $database->executed); - } -} diff --git a/tests/Unit/Database/Table/Tables/LockTableTest.php b/tests/Unit/Database/Table/Tables/LockTableTest.php deleted file mode 100644 index ad23ffa..0000000 --- a/tests/Unit/Database/Table/Tables/LockTableTest.php +++ /dev/null @@ -1,76 +0,0 @@ -rowResults = [ - null, - null, - ['Type' => 'varbinary(191)', 'Null' => 'NO', 'Default' => null, 'Extra' => ''], - ['Type' => 'varbinary(64)', 'Null' => 'NO', 'Default' => null, 'Extra' => ''], - ...array_fill(0, 3, ['Type' => 'datetime(6)', 'Null' => 'NO', 'Default' => null, 'Extra' => '']), - ]; - $database->rowsResults[] = [ - ['Key_name' => 'PRIMARY', 'Non_unique' => 0, 'Seq_in_index' => 1, 'Column_name' => 'name', 'Sub_part' => null, 'Index_type' => 'BTREE', 'Collation' => 'A'], - ['Key_name' => 'expires_at', 'Non_unique' => 1, 'Seq_in_index' => 1, 'Column_name' => 'expires_at', 'Sub_part' => null, 'Index_type' => 'BTREE', 'Collation' => 'A'], - ]; - $executor = new RecordingSchemaExecutor(); - $reconciler = new Reconciler($database, $executor); - $schema = new DatabaseSchema($database, $reconciler, new Editor($database, $reconciler)); - $table = new LockTable('network_foundation_locks', $database); - - (new StoreSchema( - $schema, - $reconciler, - new MigrationTable('network_foundation_migrations', $database), - $table - ))->initializeLock(); - - $this->assertSame('wp_network_foundation_locks', $table->name()); - $this->assertStringContainsString('CREATE TABLE `wp_network_foundation_locks`', $executor->statements[0]); - $this->assertStringContainsString('`name` varbinary(191)', $executor->statements[0]); - $this->assertStringContainsString('`owner` varbinary(64)', $executor->statements[0]); - $this->assertStringContainsString('`expires_at` datetime(6)', $executor->statements[0]); - $this->assertStringContainsString('`created_at` datetime(6)', $executor->statements[0]); - $this->assertStringContainsString('`updated_at` datetime(6)', $executor->statements[0]); - $this->assertStringContainsString('PRIMARY KEY (`name`)', $executor->statements[0]); - $this->assertStringContainsString('KEY `expires_at`', $executor->statements[0]); - } - - public function test_it_drops_the_lock_table(): void { - $database = new FakeDatabase(); - $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); - $schema = new DatabaseSchema($database, $reconciler, new Editor($database, $reconciler)); - $table = new LockTable('network_foundation_locks', $database); - - $schema->drop($table); - - $this->assertSame('DROP TABLE IF EXISTS `wp_network_foundation_locks`', $database->executed[0]); - } - - public function test_it_resolves_its_name_from_the_active_scope(): void { - $database = new FakeDatabase(); - $table = new LockTable('foundation_locks', $database); - - $database->prefix = 'wp_2_'; - - $this->assertSame('wp_2_foundation_locks', $table->name()); - - $database->prefix = 'wp_3_'; - - $this->assertSame('wp_3_foundation_locks', $table->name()); - } -} diff --git a/tests/Unit/Database/Table/Tables/MigrationTableTest.php b/tests/Unit/Database/Table/Tables/MigrationTableTest.php deleted file mode 100644 index 60dcc17..0000000 --- a/tests/Unit/Database/Table/Tables/MigrationTableTest.php +++ /dev/null @@ -1,73 +0,0 @@ -rowResults = [ - null, - null, - ['Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], - ['Type' => 'varbinary(191)', 'Null' => 'NO', 'Default' => null, 'Extra' => ''], - ['Type' => 'int(10) unsigned', 'Null' => 'NO', 'Default' => null, 'Extra' => ''], - ['Type' => 'datetime', 'Null' => 'NO', 'Default' => null, 'Extra' => ''], - ]; - $database->rowsResults[] = [ - ['Key_name' => 'PRIMARY', 'Non_unique' => 0, 'Seq_in_index' => 1, 'Column_name' => 'id', 'Sub_part' => null, 'Index_type' => 'BTREE', 'Collation' => 'A'], - ['Key_name' => 'migration', 'Non_unique' => 0, 'Seq_in_index' => 1, 'Column_name' => 'migration', 'Sub_part' => null, 'Index_type' => 'BTREE', 'Collation' => 'A'], - ['Key_name' => 'batch', 'Non_unique' => 1, 'Seq_in_index' => 1, 'Column_name' => 'batch', 'Sub_part' => null, 'Index_type' => 'BTREE', 'Collation' => 'A'], - ]; - $executor = new RecordingSchemaExecutor(); - $reconciler = new Reconciler($database, $executor); - $schema = new DatabaseSchema($database, $reconciler, new Editor($database, $reconciler)); - $table = new MigrationTable('network_foundation_migrations', $database); - - (new StoreSchema( - $schema, - $reconciler, - $table, - new LockTable('network_foundation_locks', $database) - ))->initializeLedger(); - - $this->assertSame('wp_network_foundation_migrations', $table->name()); - $this->assertStringContainsString('CREATE TABLE `wp_network_foundation_migrations`', $executor->statements[0]); - $this->assertStringContainsString('`migration` varbinary(191)', $executor->statements[0]); - $this->assertStringContainsString('UNIQUE KEY `migration`', $executor->statements[0]); - } - - public function test_it_drops_the_migration_table(): void { - $database = new FakeDatabase(); - $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); - $schema = new DatabaseSchema($database, $reconciler, new Editor($database, $reconciler)); - $table = new MigrationTable('network_foundation_migrations', $database); - - $schema->drop($table); - - $this->assertSame('DROP TABLE IF EXISTS `wp_network_foundation_migrations`', $database->executed[0]); - } - - public function test_it_resolves_its_name_from_the_active_scope(): void { - $database = new FakeDatabase(); - $table = new MigrationTable('foundation_migrations', $database); - - $database->prefix = 'wp_2_'; - - $this->assertSame('wp_2_foundation_migrations', $table->name()); - - $database->prefix = 'wp_3_'; - - $this->assertSame('wp_3_foundation_migrations', $table->name()); - } -} diff --git a/tests/Unit/Database/Table/ValueObjects/ColumnCommentTest.php b/tests/Unit/Database/Table/ValueObjects/ColumnCommentTest.php deleted file mode 100644 index 4a57025..0000000 --- a/tests/Unit/Database/Table/ValueObjects/ColumnCommentTest.php +++ /dev/null @@ -1,34 +0,0 @@ -assertSame("'Customer''s description; internal metadata'", $comment->sql()); - } - - public function test_it_rejects_backslashes(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Database column comments cannot contain backslashes.'); - - new ColumnComment('Filesystem path: C:\\cache'); - } - - public function test_it_rejects_control_characters(): void { - foreach (["line\nbreak", "carriage\rreturn", "tab\tcharacter", "null\0byte", "delete\x7Fbyte"] as $comment) { - try { - new ColumnComment($comment); - $this->fail('Expected the database column comment to be rejected.'); - } catch (InvalidArgumentException $exception) { - $this->assertSame('Database column comments cannot contain control characters.', $exception->getMessage()); - } - } - } -} diff --git a/tests/integration/Database/AdvisoryMigrationTest.php b/tests/integration/Database/AdvisoryMigrationTest.php new file mode 100644 index 0000000..ec07597 --- /dev/null +++ b/tests/integration/Database/AdvisoryMigrationTest.php @@ -0,0 +1,391 @@ +observerSource->set_prefix($this->source->prefix); + $this->observerSource->set_blog_id(get_current_blog_id()); + $this->contender = (new ContainerFactory())->create(new ArrayConfiguration([])); + $this->contender->register(DatabaseProvider::class); + $this->contender->singleton(wpdb::class, $this->observerSource); + $this->resource = $this->source->prefix . $this->suffix . '_ledger'; + $this->ledger = $this->privateTable($this->suffix . '_ledger'); + } + + /** + * @param Closure(Connection): void $operation + */ + private function runner(Container $container, Closure $operation, ?string $resource = null): Migrator { + $connection = $container->get(Connection::class); + $scope = $container->get(DatabaseScope::class); + $names = $container->get(TableNameResolver::class); + $resource ??= $scope->resolveTableName($this->suffix . '_ledger'); + $table = new MigrationTable(substr($resource, strlen($scope->resolveTableName(''))), $connection, $names); + $migration = new class($operation) implements MigratesData, Migration { + /** @param Closure(Connection): void $operation */ + public function __construct(private readonly Closure $operation) { + } + public function id(): string { + return '20260922000001'; + } + public function up(Blueprint $schema): void { + } + public function down(Blueprint $schema): void { + } + public function migrate(Connection $connection): void { + ($this->operation)($connection); + } + }; + $migrations = new MigrationCollection([$migration]); + + return new Migrator($connection, $container->get(WordPressSession::class), $scope, + new History($connection, $table), new SchemaPlanner($connection, $names, $migrations), $migrations); + } + + private function lockName(?string $resource = null): string { + return hash('sha256', constant('DB_NAME') . "\0" . ($resource ?? $this->resource)); + } + + private function assertLockAvailable(?string $resource = null): void { + self::assertSame(1, (int) $this->observer->fetchOne('SELECT IS_FREE_LOCK(?)', [$this->lockName($resource)])); + } + + public function test_an_unclosed_data_transaction_is_rolled_back_without_recording_success(): void { + try { + $this->runner($this->container, function (Connection $connection): void { + $connection->beginTransaction(); + $connection->executeStatement("UPDATE {$this->table} SET name = 'Uncommitted'"); + })->migrate(); + self::fail('A callback cannot return success with an open transaction.'); + } catch (MigrationInterrupted $failure) { + self::assertStringContainsString('left a transaction open', $failure->getMessage()); + $this->assertOriginal(); + self::assertFalse($this->connection->isTransactionActive()); + self::assertSame(0, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->ledger)); + $this->assertLockAvailable(); + } + } + + public function test_competing_runner_cannot_plan_until_ddl_and_history_finish(): void { + $second = $this->runner($this->contender, function (Connection $connection): void { + $connection->executeStatement("UPDATE {$this->table} SET name = CONCAT(name, '|duplicate')"); + }); + $first = $this->runner($this->container, function (Connection $connection) use ($second): void { + $connection->executeStatement("ALTER TABLE {$this->table} ADD migrated INT NOT NULL DEFAULT 1"); + $connection->executeStatement("UPDATE {$this->table} SET name = CONCAT(name, '|once')"); + + try { + $second->migrate(); + self::fail('The second session must not run before history is recorded.'); + } catch (MigrationAlreadyRunning) { + self::assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + } + }); + $first->migrate(); + self::assertSame([], $second->migrate()); + self::assertSame('Original|once', $this->observer->fetchOne("SELECT name FROM {$this->table}")); + self::assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertLockAvailable(); + } + + public function test_contention_does_not_create_the_history_table(): void { + $name = $this->lockName(); + self::assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [$name])); + + try { + $this->runner($this->container, static function (): void { + })->migrate(); + self::fail('Expected contention.'); + } catch (MigrationAlreadyRunning) { + self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->resource])); + } finally { + $this->observer->fetchOne('SELECT RELEASE_LOCK(?)', [$name]); + } + } + + public function test_another_application_can_migrate_while_the_first_is_locked(): void { + $other = $this->resource . '_other'; + $this->privateTable($this->suffix . '_ledger_other'); + $second = $this->runner($this->contender, static function (): void { + }, $other); + $this->runner($this->container, static function () use ($second): void { + self::assertCount(1, $second->migrate()); + })->migrate(); + $this->assertLockAvailable(); + $this->assertLockAvailable($other); + } + + public function test_different_site_prefixes_do_not_share_a_lock(): void { + $otherPrefix = $this->source->prefix . $this->suffix . '_site2_'; + $this->observerSource->set_prefix($otherPrefix); + $other = $otherPrefix . $this->suffix . '_ledger'; + $this->tables[] = $this->observer->getDatabasePlatform()->quoteSingleIdentifier($other); + $second = $this->runner($this->contender, static function (): void { + }, $other); + $this->runner($this->container, static function () use ($second): void { + self::assertCount(1, $second->migrate()); + })->migrate(); + $this->assertLockAvailable($other); + } + + public function test_lock_survives_commits_and_rollbacks_in_a_migration(): void { + $this->runner($this->container, function (Connection $connection): void { + $connection->transactional(function (Connection $connection): void { + $connection->executeStatement("UPDATE {$this->table} SET name = 'Committed'"); + }); + self::assertSame(0, (int) $this->observer->fetchOne('SELECT IS_FREE_LOCK(?)', [$this->lockName()])); + + try { + $connection->transactional(static function (): void { + throw new RuntimeException('Discard this inner transaction'); + }); + } catch (RuntimeException) { + self::assertSame(0, (int) $this->observer->fetchOne('SELECT IS_FREE_LOCK(?)', [$this->lockName()])); + } + })->migrate(); + $this->assertLockAvailable(); + } + + public function test_killed_session_cannot_record_success_after_a_caught_error(): void { + try { + $this->runner($this->container, function (Connection $connection): void { + $this->killConnection(); + + try { + $connection->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); + } catch (Throwable) { + // Simulate migration code that catches and ignores a lost connection. + } + })->migrate(); + self::fail('A caught connection loss must remain terminal.'); + } catch (MigrationInterrupted) { + $this->assertOriginal(); + self::assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertLockAvailable(); + } + $this->runner($this->contender, static function (): void { + })->migrate(); + self::assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + } + + public function test_business_exception_survives_lost_connection_during_cleanup(): void { + $expected = new RuntimeException('Business failure'); + + try { + $this->runner($this->container, function () use ($expected): void { + $this->killConnection(); + + throw $expected; + })->migrate(); + self::fail('Expected the original exception.'); + } catch (Throwable $failure) { + self::assertSame($expected, $failure); + $this->assertLockAvailable(); + } + } + + public function test_site_change_blocks_the_ledger_and_releases_only_the_original_lock(): void { + $site = self::factory()->blog->create(); + self::assertIsInt($site); + + try { + $this->runner($this->container, static function () use ($site): void { + switch_to_blog($site); + })->migrate(); + self::fail('A migration cannot change WordPress sites.'); + } catch (MigrationInterrupted) { + self::assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertLockAvailable(); + } finally { + restore_current_blog(); + } + } + + public function test_manual_lock_release_is_detected_before_more_sql(): void { + try { + $this->runner($this->container, function (): void { + $this->native($this->source)->query('SELECT RELEASE_ALL_LOCKS()'); + })->migrate(); + self::fail('A normal return must not hide lost ownership.'); + } catch (MigrationInterrupted) { + self::assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertLockAvailable(); + } + } + + public function test_replacing_the_wordpress_connection_is_terminal_even_if_restored(): void { + $native = $this->native($this->source); + + try { + $this->runner($this->container, function (Connection $connection) use ($native): void { + $this->source->__set('dbh', $this->native($this->observerSource)); + + try { + $connection->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); + } catch (MigrationInterrupted) { + $this->source->__set('dbh', $native); + } + })->migrate(); + self::fail('Restoring the connection must not reset terminal failure.'); + } catch (MigrationInterrupted) { + $this->assertOriginal(); + self::assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertLockAvailable(); + } finally { + $this->source->__set('dbh', $native); + } + } + + public function test_caught_sql_failure_remains_terminal_after_inner_transaction_cleanup(): void { + try { + $this->runner($this->container, function (Connection $connection): void { + try { + $connection->transactional(function (Connection $connection): void { + $connection->insert(trim($this->table, '`'), ['id' => 1, 'name' => 'Duplicate']); + }); + } catch (Throwable) { + // The transaction ends, but this migration must remain failed. + } + })->migrate(); + self::fail('A caught SQL failure must prevent the history write.'); + } catch (MigrationInterrupted) { + self::assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertOriginal(); + $this->assertLockAvailable(); + } + } + + public function test_migration_failure_releases_lock_for_a_fresh_retry(): void { + try { + $this->runner($this->container, static function (): void { + throw new RuntimeException('Migration failed'); + })->migrate(); + self::fail('Expected failure.'); + } catch (RuntimeException $failure) { + self::assertSame('Migration failed', $failure->getMessage()); + $this->assertLockAvailable(); + } + self::assertCount(1, $this->runner($this->container, static function (): void { + })->migrate()); + } + + public function test_ambient_transaction_is_rejected_without_committing_its_writes(): void { + $native = $this->native($this->source); + $native->begin_transaction(); + $native->query("UPDATE {$this->table} SET name = 'Uncommitted'"); + + try { + $this->runner($this->container, static function (): void { + })->migrate(); + self::fail('Expected an ambient transaction rejection.'); + } catch (Throwable) { + $this->assertOriginal(); + self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->resource])); + $this->assertLockAvailable(); + } finally { + $native->rollback(); + } + } + + public function test_disabled_autocommit_is_rejected(): void { + $native = $this->native($this->source); + $native->autocommit(false); + + try { + $this->runner($this->container, static function (): void { + })->migrate(); + self::fail('Expected disabled autocommit rejection.'); + } catch (RuntimeException $failure) { + self::assertSame('Migrations require autocommit to be enabled.', $failure->getMessage()); + $this->assertLockAvailable(); + } finally { + $native->autocommit(true); + } + } + + public function test_nested_migration_run_is_rejected(): void { + $this->runner($this->container, function (): void { + try { + $this->runner($this->container, static function (): void { + })->migrate(); + self::fail('Expected nested migration rejection.'); + } catch (RuntimeException $failure) { + self::assertSame('Start migrations outside an existing transaction or migration run.', $failure->getMessage()); + } + })->migrate(); + $this->assertLockAvailable(); + } + + public function test_composed_runner_resolves_the_current_prefix_between_operations(): void { + $runner = $this->runner($this->container, static function (): void { + }); + $this->source->set_prefix($this->source->prefix . 'changed_'); + $ledger = $this->privateTable($this->suffix . '_ledger'); + self::assertCount(1, $runner->migrate()); + self::assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $ledger)); + self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->resource])); + } + + public function test_missing_acquisition_acknowledgement_discards_the_session_and_its_lock(): void { + $this->native($this->source)->close(); + $this->source->__set('dbh', new LostAdvisoryLockReply(constant('DB_HOST'), constant('DB_USER'), constant('DB_PASSWORD'), constant('DB_NAME'))); + + try { + $this->runner($this->container, static function (): void { + })->migrate(); + self::fail('A lost acquisition reply cannot permit execution.'); + } catch (RuntimeException $failure) { + self::assertSame('Lost advisory lock acquisition reply', $failure->getMessage()); + self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->resource])); + $this->assertLockAvailable(); + } + } + + public function test_a_closed_handle_during_failure_preserves_the_business_exception(): void { + $expected = new RuntimeException('Failed after closing the connection'); + + try { + $this->runner($this->container, function () use ($expected): void { + $this->native($this->source)->close(); + + throw $expected; + })->migrate(); + self::fail('Expected the business exception.'); + } catch (Throwable $failure) { + self::assertSame($expected, $failure); + $this->assertLockAvailable(); + } + } +} diff --git a/tests/integration/Database/DatabaseLockTest.php b/tests/integration/Database/DatabaseLockTest.php new file mode 100644 index 0000000..dddf4c4 --- /dev/null +++ b/tests/integration/Database/DatabaseLockTest.php @@ -0,0 +1,76 @@ + ['locks_table' => $this->suffix . '_locks', 'migrations_table' => $this->suffix . '_history']]; + } + + protected function setUp(): void { + parent::setUp(); + $this->privateTable($this->suffix . '_locks'); + $this->privateTable($this->suffix . '_history'); + } + + public function test_migrations_do_not_create_application_lock_storage(): void { + $this->container->get(Migrator::class)->migrate(); + self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->container->get(LockTable::class)->name()])); + $this->expectException(LockUnavailableException::class); + $this->container->get(DatabaseLock::class)->acquire('test', 60); + } + + public function test_a_missing_wordpress_connection_is_reported_through_the_lock_contract(): void { + $lock = $this->container->get(DatabaseLock::class); + $lock->initialize(); + $native = $this->native($this->source); + $this->source->__set('dbh', null); + + try { + $this->expectException(LockUnavailableException::class); + $lock->acquire('resource', 60); + } finally { + $this->source->__set('dbh', $native); + } + } + + public function test_explicit_setup_contention_refresh_and_owner_safe_release(): void { + $lock = $this->container->get(DatabaseLock::class); + $lock->initialize(); + $lock->initialize(); + $token = $lock->acquire("A ' quoted resource", 60); + self::assertInstanceOf(LockToken::class, $token); + self::assertTrue($lock->isAcquired($token->name)); + self::assertNull($lock->acquire($token->name, 60)); + $otherOwner = new LockToken($token->name, 'another-owner', $token->expiresAt); + self::assertFalse($lock->release($otherOwner)); + self::assertNull($lock->refresh($otherOwner, 60)); + $renewed = $lock->refresh($token, 120); + self::assertInstanceOf(LockToken::class, $renewed); + self::assertGreaterThan($token->expiresAt, $renewed->expiresAt); + self::assertTrue($lock->release($renewed)); + self::assertFalse($lock->isAcquired($token->name)); + } + + public function test_expired_owner_cannot_release_a_replacement_and_names_are_case_sensitive(): void { + $lock = $this->container->get(DatabaseLock::class); + $lock->initialize(); + $token = $lock->acquire('resource', 60); + self::assertInstanceOf(LockToken::class, $token); + $this->connection->executeStatement('UPDATE ' . $this->container->get(LockTable::class)->quotedName() . ' SET expires_at = UTC_TIMESTAMP(6) - INTERVAL 1 SECOND'); + $replacement = $lock->acquire('resource', 60); + self::assertInstanceOf(LockToken::class, $replacement); + self::assertNotSame($token->owner, $replacement->owner); + self::assertFalse($lock->release($token)); + self::assertInstanceOf(LockToken::class, $lock->acquire('Resource', 60)); + self::assertTrue($lock->release($replacement)); + } +} diff --git a/tests/integration/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php deleted file mode 100644 index bc3ef21..0000000 --- a/tests/integration/Database/DatabaseProviderTest.php +++ /dev/null @@ -1,345 +0,0 @@ -container->register(WPCliProvider::class); - $this->container->register(DatabaseProvider::class); - - $commands = $this->container->get(WPCliProvider::COMMANDS); - - $this->assertSame([], $this->container->get(DatabaseProvider::MIGRATIONS)); - $this->assertSame($GLOBALS['wpdb']->prefix . 'nx_foundation_migrations', $this->container->get(MigrationTable::class)->name()); - $this->assertSame($GLOBALS['wpdb']->prefix . 'nx_foundation_locks', $this->container->get(LockTable::class)->name()); - $this->assertContainsOnlyInstancesOf(RegistrableCommand::class, $commands); - $this->assertTrue($this->containsMigrateCommand((array) $commands)); - $this->assertInstanceOf(DatabaseLock::class, $this->container->get(DatabaseLock::class)); - $this->assertInstanceOf(SiteScope::class, $this->container->get(DatabaseScope::class)); - $this->assertInstanceOf(Migrator::class, $this->container->get(Migrator::class)); - $this->assertInstanceOf(Migrate::class, $this->container->get(Migrate::class)); - } - - public function test_it_does_not_duplicate_cli_contributions_when_registered_repeatedly(): void { - $this->container->register(DatabaseProvider::class); - $this->container->register(DatabaseProvider::class); - - $commands = array_values(array_filter( - (array) $this->container->get(WPCliProvider::COMMANDS), - static fn (mixed $command): bool => $command instanceof Migrate - )); - - $this->assertCount(1, $commands); - } - - public function test_database_capability_contracts_resolve_to_the_configured_database_facade(): void { - $this->container->register(DatabaseProvider::class); - - $database = $this->container->get(Database::class); - - $this->assertSame($database, $this->container->get(DatabaseContract::class)); - $this->assertSame($database, $this->container->get(CharsetCollationProvider::class)); - $this->assertSame($database, $this->container->get(QueryExecutor::class)); - $this->assertSame($database, $this->container->get(QueryGateway::class)); - $this->assertSame($database, $this->container->get(QueryReader::class)); - $this->assertSame($database, $this->container->get(SchemaInspector::class)); - $this->assertSame($database, $this->container->get(SqlDialect::class)); - $this->assertSame($database, $this->container->get(TableNameResolver::class)); - $this->assertSame($database, $this->container->get(TableGateway::class)); - $this->assertSame($database, $this->container->get(TableWriter::class)); - } - - public function test_it_autowires_constructorless_generated_tables_through_the_inherited_gateway_dependency(): void { - $this->container->register(DatabaseProvider::class); - - $table = $this->container->get(GeneratedStyleTable::class); - - $this->assertInstanceOf(GeneratedStyleTable::class, $table); - $this->assertSame('generated_style', $table->unprefixedName()); - $this->assertSame($GLOBALS['wpdb']->prefix . 'generated_style', $table->name()); - } - - public function test_database_capability_contracts_follow_an_application_database_replacement(): void { - $this->container->register(DatabaseProvider::class); - - $database = new FakeDatabase(); - $this->container->singleton(DatabaseContract::class, $database); - - $this->assertSame($database, $this->container->get(CharsetCollationProvider::class)); - $this->assertSame($database, $this->container->get(QueryExecutor::class)); - $this->assertSame($database, $this->container->get(QueryGateway::class)); - $this->assertSame($database, $this->container->get(QueryReader::class)); - $this->assertSame($database, $this->container->get(SchemaInspector::class)); - $this->assertSame($database, $this->container->get(SqlDialect::class)); - $this->assertSame($database, $this->container->get(TableNameResolver::class)); - $this->assertSame($database, $this->container->get(TableGateway::class)); - $this->assertSame($database, $this->container->get(TableWriter::class)); - } - - public function test_it_registers_configured_database_configuration(): void { - $container = $this->newContainer([ - 'foundation' => [ - 'prefix' => 'your-plugin', - ], - 'database' => [ - 'migrations_table' => 'custom_migrations', - 'locks_table' => 'custom_locks', - 'lock_name' => 'custom-migrations', - 'lock_ttl' => '120', - ], - 'wpcli' => [ - 'command_prefix' => 'custom', - ], - ]); - - $container->register(WPCliProvider::class); - $container->register(DatabaseProvider::class); - - $this->assertSame($GLOBALS['wpdb']->prefix . 'custom_migrations', $container->get(MigrationTable::class)->name()); - $this->assertSame($GLOBALS['wpdb']->prefix . 'custom_locks', $container->get(LockTable::class)->name()); - $this->assertSame('custom', $container->get(CommandPrefix::class)->value); - } - - public function test_it_rejects_an_invalid_foundation_prefix_when_database_resources_are_overridden(): void { - $container = $this->newContainer([ - 'foundation' => [ - 'prefix' => 'Invalid Prefix', - ], - 'database' => [ - 'migrations_table' => 'custom_migrations', - 'locks_table' => 'custom_locks', - 'lock_name' => 'custom-migrations', - ], - ]); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('lowercase kebab-case'); - - $container->register(DatabaseProvider::class); - } - - public function test_it_scopes_default_resources_with_the_foundation_prefix(): void { - $container = $this->newContainer([ - 'foundation' => [ - 'prefix' => 'your-plugin', - ], - ]); - - $container->register(WPCliProvider::class); - $container->register(DatabaseProvider::class); - - $this->assertSame($GLOBALS['wpdb']->prefix . 'your_plugin_foundation_migrations', $container->get(MigrationTable::class)->name()); - $this->assertSame($GLOBALS['wpdb']->prefix . 'your_plugin_foundation_locks', $container->get(LockTable::class)->name()); - $this->assertSame('your-plugin', $container->get(CommandPrefix::class)->value); - } - - public function test_migrator_reuses_the_same_services_across_complete_site_operations(): void { - $suffix = str_replace('.', '_', uniqid('', true)); - $migrationsTable = 'foundation_multisite_migrations_' . $suffix; - $locksTable = 'foundation_multisite_locks_' . $suffix; - $migration = new NoopMigration('2026_08_24_000001_multisite_migration'); - $container = $this->newContainer([ - 'database' => [ - 'migrations_table' => $migrationsTable, - 'locks_table' => $locksTable, - ], - ]); - $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); - $container->register(WPCliProvider::class); - $container->register(DatabaseProvider::class); - - $migrator = $container->get(Migrator::class); - $database = $container->get(Database::class); - $migrationTable = $container->get(MigrationTable::class); - $lockTable = $container->get(LockTable::class); - $mainSiteId = get_current_blog_id(); - $mainTables = [$migrationTable->name(), $lockTable->name()]; - $siteId = 0; - $siteTables = []; - - try { - $migrator->initialize(); - $this->assertSame([$migration->id()], $migrator->run()->ran); - - $createdSite = $this->factory()->blog->create(); - - if ($createdSite instanceof \WP_Error) { - $this->fail($createdSite->get_error_message()); - } - - $siteId = $createdSite; - switch_to_blog($siteId); - $siteTables = [$migrationTable->name(), $lockTable->name()]; - - $this->assertNotSame($mainTables, $siteTables); - $migrator->initialize(); - $this->assertSame([$migration->id()], $migrator->run()->ran); - - restore_current_blog(); - $this->assertSame($mainSiteId, get_current_blog_id()); - $this->assertSame($mainTables, [$migrationTable->name(), $lockTable->name()]); - $this->assertSame([$migration->id()], $migrator->run()->skipped); - } finally { - if (get_current_blog_id() !== $mainSiteId) { - restore_current_blog(); - } - - foreach ($mainTables as $table) { - $database->execute('DROP TABLE IF EXISTS %i', $table); - } - - if ($siteId !== 0) { - switch_to_blog($siteId); - - foreach ($siteTables as $table) { - $database->execute('DROP TABLE IF EXISTS %i', $table); - } - - restore_current_blog(); - wp_delete_site($siteId); - } - } - } - - public function test_it_applies_configured_lock_policy_to_the_migration_store(): void { - $configurations = [ - [['database' => ['lock_name' => ' ']], 'lock name cannot be empty'], - [['database' => ['lock_ttl' => 0]], 'TTL must be at least one second'], - ]; - - foreach ($configurations as [$config, $message]) { - $container = $this->newContainer($config); - $container->register(WPCliProvider::class); - $container->register(DatabaseProvider::class); - - try { - $container->get(Migrator::class); - $this->fail('Expected invalid migration lock configuration to be rejected.'); - } catch (ContainerException $exception) { - $this->assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious()); - $this->assertStringContainsString($message, $exception->getMessage()); - } - } - } - - public function test_it_preserves_preconfigured_migrations(): void { - $later = new TestMigration('2026_06_23_000002_add_example_status'); - $earlier = new TestMigration('2026_06_23_000001_create_example'); - $container = $this->newContainer(); - $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$later]); - $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$earlier]); - - $container->register(WPCliProvider::class); - $container->register(DatabaseProvider::class); - - $this->assertSame([$later, $earlier], $container->get(DatabaseProvider::MIGRATIONS)); - $this->assertSame([ - $earlier->id() => $earlier, - $later->id() => $later, - ], $container->get(Collection::class)->all()); - $this->assertSame([$earlier, $later], $container->get(Collection::class)->values()); - } - - public function test_it_collects_migrations_added_after_provider_registration(): void { - $migration = new TestMigration('2026_06_23_000001_create_example'); - $container = $this->newContainer(); - - $container->register(WPCliProvider::class); - $container->register(DatabaseProvider::class); - $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); - - $this->assertSame([$migration->id() => $migration], $container->get(Collection::class)->all()); - $this->assertSame([$migration], $container->get(Collection::class)->values()); - } - - public function test_provider_built_migrator_executes_against_wordpress(): void { - $suffix = str_replace('.', '_', uniqid('', true)); - $migrationsTableName = 'foundation_provider_migrations_' . $suffix; - $locksTableName = 'foundation_provider_locks_' . $suffix; - $migrationsTable = $GLOBALS['wpdb']->prefix . $migrationsTableName; - $locksTable = $GLOBALS['wpdb']->prefix . $locksTableName; - $migration = new NoopMigration('2026_08_20_000001_provider_migration'); - $container = $this->newContainer([ - 'database' => [ - 'migrations_table' => $migrationsTableName, - 'locks_table' => $locksTableName, - ], - ]); - $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); - $container->register(WPCliProvider::class); - $container->register(DatabaseProvider::class); - - $database = $container->get(Database::class); - $migrationTableObject = $container->get(MigrationTable::class); - $lockTableObject = $container->get(LockTable::class); - - try { - $migrator = $container->get(Migrator::class); - $migrator->initialize(); - $result = $migrator->run(); - - $this->assertSame([$migration->id()], $result->ran); - $this->assertTrue($database->tableExists($migrationTableObject)); - $this->assertTrue($database->tableExists($lockTableObject)); - $this->assertTrue($migrator->status()[0]->isApplied()); - } finally { - $database->execute('DROP TABLE IF EXISTS %i', $migrationsTable); - $database->execute('DROP TABLE IF EXISTS %i', $locksTable); - } - } - - /** - * @param array $config - */ - private function newContainer(array $config = []): Container { - return (new ContainerFactory())->create(new ArrayConfiguration($config)); - } - - /** - * @param array $commands - */ - private function containsMigrateCommand(array $commands): bool { - foreach ($commands as $command) { - if ($command instanceof Migrate) { - return true; - } - } - - return false; - } -} diff --git a/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php b/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php new file mode 100644 index 0000000..b0fe18f --- /dev/null +++ b/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php @@ -0,0 +1,235 @@ + ['migrations_table' => $this->suffix . '_history']]; + } + + protected function setUp(): void { + parent::setUp(); + $this->quotedEntries = $this->privateTable($this->suffix . '_entries'); + $this->tagsName = $this->source->prefix . $this->suffix . '_tags'; + $this->privateTable($this->suffix . '_tags'); + $this->historyName = $this->source->prefix . $this->suffix . '_history'; + $this->privateTable($this->suffix . '_history'); + + $this->container->singleton(EntriesTable::class, new EntriesTable($this->suffix . '_entries')); + $this->container->singleton(TagsTable::class, new TagsTable($this->suffix . '_tags')); + $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ + $c->get(ReduceCreatedAtPrecision::class), + $c->get(TrackEntryUpdates::class), + $c->get(WidenEntryName::class), + $c->get(AddEntryArchivedFlag::class), + $c->get(ReplaceNoteLookup::class), + $c->get(IndexEntryNote::class), + $c->get(AddEntryNote::class), + $c->get(RecreateEntryTags::class), + $c->get(DropEntryTags::class), + $c->get(CreateEntryTags::class), + $c->get(CreateEntries::class), + ]); + } + + private function migrator(): Migrator { + return $this->container->get(Migrator::class); + } + + /** + * @return list + */ + private function history(): array { + return $this->observer->fetchFirstColumn('SELECT version FROM `' . $this->historyName . '` ORDER BY version'); + } + + private function createTableSql(): string { + $row = $this->observer->fetchAssociative('SHOW CREATE TABLE ' . $this->quotedEntries); + self::assertIsArray($row); + + return strtolower((string) $row['Create Table']); + } + + public function test_rollback_honors_an_irreversible_down(): void { + $this->migrator()->migrate(AddEntryArchivedFlag::ID); + self::assertStringContainsString('`archived`', $this->createTableSql()); + + try { + $this->migrator()->rollback(); + self::fail('Expected the author\'s refusal to reverse.'); + } catch (IrreversibleMigration $failure) { + self::assertStringContainsString(AddEntryArchivedFlag::ID, $failure->getMessage()); + } + self::assertContains(AddEntryArchivedFlag::ID, $this->history(), 'The refused migration stays recorded'); + self::assertStringContainsString('`archived`', $this->createTableSql(), 'No destructive DDL was reconstructed from up()'); + } + + public function test_unrelated_foreign_key_survives_an_alteration(): void { + $this->migrator()->migrate(CreateEntries::ID); + $this->observer->executeStatement("ALTER TABLE {$this->quotedEntries} ADD parent_id INT NULL, ADD CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES {$this->table} (id)"); + + $steps = $this->migrator()->migrate(AddEntryNote::ID); + + foreach ($steps as $step) { + self::assertStringNotContainsStringIgnoringCase('foreign key', implode("\n", $step->sql)); + } + self::assertStringContainsString('constraint `fk_parent` foreign key', $this->createTableSql()); + self::assertStringContainsString('`note`', $this->createTableSql()); + } + + public function test_change_replaces_the_complete_column_definition_and_down_restores_it(): void { + $this->migrator()->migrate(WidenEntryName::ID); + $column = $this->observer->createSchemaManager()->introspectTable(trim($this->quotedEntries, '`'))->getColumn('name'); + self::assertSame(120, $column->getLength()); + self::assertFalse($column->getNotnull()); + self::assertStringContainsString("comment='imported entries'", $this->createTableSql()); + + $steps = $this->migrator()->rollback(); + + self::assertSame([WidenEntryName::ID], array_map(static fn (Step $step): string => $step->id, $steps)); + $column = $this->observer->createSchemaManager()->introspectTable(trim($this->quotedEntries, '`'))->getColumn('name'); + self::assertSame(50, $column->getLength()); + self::assertTrue($column->getNotnull()); + self::assertStringNotContainsString('imported entries', $this->createTableSql()); + } + + public function test_timestamp_attribute_changes_emit_sql_and_rerun_cleanly(): void { + $this->migrator()->migrate(WidenEntryName::ID); + self::assertStringNotContainsString('`created_at` datetime(6) not null default current_timestamp(6) on update', $this->createTableSql()); + + $steps = $this->migrator()->migrate(TrackEntryUpdates::ID); + + $last = end($steps); + self::assertInstanceOf(Step::class, $last); + self::assertSame(TrackEntryUpdates::ID, $last->id); + self::assertCount(1, $last->sql); + self::assertStringContainsStringIgnoringCase('MODIFY', $last->sql[0]); + self::assertStringContainsString('`created_at` datetime(6) not null default current_timestamp(6) on update current_timestamp(6)', $this->createTableSql()); + self::assertSame([], $this->migrator()->preview(TrackEntryUpdates::ID), 'Attributes DBAL cannot compare still produce no DDL on rerun'); + + $this->migrator()->rollback(); + self::assertStringContainsString('`created_at` datetime(6) not null default current_timestamp(6),', $this->createTableSql()); + } + + public function test_undeclared_timestamp_drift_is_rejected(): void { + $this->migrator()->migrate(AddEntryArchivedFlag::ID); + $this->observer->executeStatement("ALTER TABLE {$this->quotedEntries} MODIFY created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)"); + + try { + $this->migrator()->migrate(WidenEntryName::ID); + self::fail('Expected the undeclared ON UPDATE to be rejected.'); + } catch (IncompatibleSchema $failure) { + self::assertStringContainsString('created_at', $failure->getMessage()); + self::assertStringContainsString('ON UPDATE', $failure->getMessage()); + } + self::assertNotContains(WidenEntryName::ID, $this->history()); + } + + public function test_preview_sees_existing_tables_from_later_steps_and_creates_no_ledger(): void { + // An interrupted earlier attempt left the tags table behind, exactly as declared. + $this->observer->executeStatement("CREATE TABLE `{$this->tagsName}` (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, label VARCHAR(50) NOT NULL, PRIMARY KEY (id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + + $preview = $this->migrator()->preview(); + + self::assertSame( + [CreateEntries::ID, CreateEntryTags::ID, DropEntryTags::ID, RecreateEntryTags::ID, AddEntryNote::ID, IndexEntryNote::ID, ReplaceNoteLookup::ID, AddEntryArchivedFlag::ID, WidenEntryName::ID, TrackEntryUpdates::ID, ReduceCreatedAtPrecision::ID], + array_map(static fn (Step $step): string => $step->id, $preview), + ); + self::assertCount(1, $preview[0]->sql, 'Entries is created'); + self::assertSame([], $preview[1]->sql, 'Tags already matches its declaration'); + self::assertStringContainsStringIgnoringCase('DROP TABLE', $preview[2]->sql[0]); + self::assertStringContainsStringIgnoringCase('CREATE TABLE', $preview[3]->sql[0], 'Preview remembers the simulated drop and does not reload the old table'); + self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName]), 'Preview executes nothing, not even ledger creation'); + + $executed = $this->migrator()->migrate(); + self::assertSame( + array_map(static fn (Step $step): array => $step->sql, $preview), + array_map(static fn (Step $step): array => $step->sql, $executed), + ); + } + + public function test_interrupted_index_replacement_recovers_on_retry(): void { + $this->migrator()->migrate(IndexEntryNote::ID); + // A previous attempt dropped the old index and died before creating the new one. + $this->observer->executeStatement("DROP INDEX note_lookup ON {$this->quotedEntries}"); + + $steps = $this->migrator()->migrate(ReplaceNoteLookup::ID); + + $last = end($steps); + self::assertInstanceOf(Step::class, $last); + self::assertCount(1, $last->sql, 'Only the creation half remains'); + self::assertStringContainsString('unique key `note_lookup` (`note`)', $this->createTableSql()); + self::assertContains(ReplaceNoteLookup::ID, $this->history()); + } + + public function test_conflicting_existing_index_stops_an_addition(): void { + $this->migrator()->migrate(AddEntryNote::ID); + $this->observer->executeStatement("CREATE UNIQUE INDEX note_lookup ON {$this->quotedEntries} (name)"); + + try { + $this->migrator()->migrate(IndexEntryNote::ID); + self::fail('Expected the conflicting index to be rejected.'); + } catch (IncompatibleSchema $failure) { + self::assertStringContainsString('existing index note_lookup conflicts', $failure->getMessage()); + } + self::assertStringContainsString('unique key `note_lookup` (`name`)', $this->createTableSql(), 'The uniqueness guarantee was not replaced'); + self::assertNotContains(IndexEntryNote::ID, $this->history()); + } + + public function test_precision_and_default_change_together_emit_one_statement(): void { + $this->migrator()->migrate(TrackEntryUpdates::ID); + + $steps = $this->migrator()->migrate(ReduceCreatedAtPrecision::ID); + + $last = end($steps); + self::assertInstanceOf(Step::class, $last); + self::assertCount(1, $last->sql, 'Doctrine\'s CHANGE already carries the complete definition; no supplemental MODIFY'); + self::assertStringContainsString('`created_at` datetime(3) not null default current_timestamp(3) on update current_timestamp(3)', $this->createTableSql()); + self::assertSame([], $this->migrator()->preview()); + } + + public function test_rollback_selects_its_target_under_the_lock(): void { + $this->migrator()->migrate(AddEntryNote::ID); + $name = hash('sha256', constant('DB_NAME') . "\0" . $this->historyName); + self::assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [$name])); + + try { + $this->migrator()->rollback(); + self::fail('Expected contention before any history read.'); + } catch (MigrationAlreadyRunning) { + self::assertContains(AddEntryNote::ID, $this->history()); + } finally { + $this->observer->fetchOne('SELECT RELEASE_LOCK(?)', [$name]); + } + } +} diff --git a/tests/integration/Database/DeclarativeMigratorTest.php b/tests/integration/Database/DeclarativeMigratorTest.php new file mode 100644 index 0000000..7138e05 --- /dev/null +++ b/tests/integration/Database/DeclarativeMigratorTest.php @@ -0,0 +1,354 @@ + ['migrations_table' => $this->suffix . '_history']]; + } + + protected function setUp(): void { + parent::setUp(); + $this->entries = new EntriesTable($this->suffix . '_entries'); + $this->entriesName = $this->source->prefix . $this->suffix . '_entries'; + $this->quotedEntries = $this->privateTable($this->suffix . '_entries'); + $this->historyName = $this->source->prefix . $this->suffix . '_history'; + $this->quotedHistory = $this->privateTable($this->suffix . '_history'); + + // What an application's Database_Provider does: contribute migrations lazily. + $this->container->singleton(EntriesTable::class, $this->entries); + $this->container->when(BackfillEntryStatus::class)->needs('$physicalName')->give($this->entriesName); + $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ + $c->get(BackfillEntryStatus::class), + $c->get(AddEntryNote::class), + $c->get(CreateEntries::class), + ]); + } + + private function migrator(): Migrator { + return $this->container->get(Migrator::class); + } + + /** + * @return list + */ + private function history(): array { + return $this->observer->fetchFirstColumn('SELECT version FROM ' . $this->quotedHistory . ' ORDER BY version'); + } + + /** + * @param list $steps + * + * @return list + */ + private static function ids(array $steps): array { + return array_map(static fn (Step $step): string => $step->id, $steps); + } + + private function createTableSql(): string { + $row = $this->observer->fetchAssociative('SHOW CREATE TABLE ' . $this->quotedEntries); + self::assertIsArray($row); + + return strtolower((string) $row['Create Table']); + } + + private function rejectHistoryWrite(string $event): string { + $trigger = $this->observer->getDatabasePlatform()->quoteSingleIdentifier($this->suffix . '_reject_history'); + $this->observer->executeStatement("CREATE TRIGGER {$trigger} BEFORE {$event} ON {$this->quotedHistory} FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'History write failure'"); + + return $trigger; + } + + public function test_missing_history_is_visible_and_prevents_execution(): void { + $this->migrator()->migrate(); + $this->observer->insert($this->quotedHistory, ['version' => '20270101000100']); + $statuses = $this->migrator()->status(); + $last = end($statuses); + self::assertInstanceOf(\StellarWP\Foundation\Database\Migration\ValueObjects\MigrationStatus::class, $last); + self::assertSame('missing', $last->state()); + $this->expectException(MigrationInterrupted::class); + $this->migrator()->migrate(); + } + + public function test_rollback_on_an_empty_history_has_no_work(): void { + self::assertSame([], $this->migrator()->rollback()); + self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); + } + + public function test_nonpositive_rollback_steps_are_rejected_without_initializing_history(): void { + try { + $this->migrator()->rollback(0); + self::fail('Invalid rollback count must fail.'); + } catch (\InvalidArgumentException) { + self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); + } + } + + public function test_unknown_target_is_rejected_before_creating_history(): void { + try { + $this->migrator()->migrate('unknown'); + self::fail('Unknown target must fail.'); + } catch (\InvalidArgumentException) { + self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); + } + } + + public function test_step_rollback_does_not_apply_an_older_pending_migration(): void { + $this->migrator()->migrate(); + $this->observer->delete($this->quotedHistory, ['version' => self::ALTER]); + $steps = $this->migrator()->rollback(); + self::assertSame([self::BACKFILL], self::ids($steps)); + self::assertTrue($steps[0]->reverse); + self::assertSame([self::CREATE], $this->history()); + } + + public function test_target_reconciles_reversals_and_older_pending_migrations(): void { + $this->migrator()->migrate(); + $this->observer->delete($this->quotedHistory, ['version' => self::ALTER]); + $steps = $this->migrator()->migrate(self::ALTER); + self::assertSame([self::BACKFILL, self::ALTER], self::ids($steps)); + self::assertTrue($steps[0]->reverse); + self::assertFalse($steps[1]->reverse); + self::assertSame([self::CREATE, self::ALTER], $this->history()); + } + + public function test_public_migration_workflow_uses_no_deprecated_dbal_api(): void { + \Doctrine\Deprecations\Deprecation::enableTrackingDeprecations(); + + try { + $this->migrator()->preview(); + $this->migrator()->migrate(); + $this->migrator()->status(); + $this->migrator()->preview(Migrator::NONE); + $this->migrator()->refresh(); + $this->migrator()->rollback(3); + self::assertSame([], \Doctrine\Deprecations\Deprecation::getTriggeredDeprecations()); + } finally { + \Doctrine\Deprecations\Deprecation::disable(); + } + } + + public function test_guard_free_create_alter_and_data_step_apply_once(): void { + $steps = $this->migrator()->migrate(); + + self::assertSame([self::CREATE, self::ALTER, self::BACKFILL], self::ids($steps)); + self::assertCount(1, $steps[0]->sql, 'One CREATE TABLE statement'); + self::assertCount(1, $steps[1]->sql, 'One ALTER TABLE statement'); + self::assertSame([], $steps[2]->sql); + self::assertTrue($steps[2]->hasDataStep); + self::assertSame([self::CREATE, self::ALTER, self::BACKFILL], $this->history()); + + $sql = $this->createTableSql(); + self::assertStringContainsString('`id` bigint unsigned not null auto_increment', str_replace('bigint(20)', 'bigint', $sql)); + self::assertStringContainsString('`amount` decimal(12,4) not null default', $sql); + self::assertStringContainsString('`token` varbinary(16) default null', $sql); + self::assertStringContainsString('`created_at` datetime(6) not null default current_timestamp(6)', $sql); + self::assertStringContainsString('`updated_at` datetime(6) not null default current_timestamp(6) on update current_timestamp(6)', $sql); + self::assertStringContainsString('`note` varchar(100)', $sql); + self::assertStringContainsString('key `status` (`status`)', $sql); + self::assertStringContainsString('charset=utf8mb4', $sql); + + self::assertSame([], $this->migrator()->migrate(), 'Nothing pending'); + self::assertSame([], $this->migrator()->preview(), 'A second run declares no DDL for timestamps, decimals, or binary columns'); + + $status = $this->migrator()->status(); + self::assertSame(['applied', 'applied', 'applied'], array_map(static fn ($row): string => $row->state(), $status)); + self::assertSame('Add an optional note to entries', $status[1]->description); + self::assertNotNull($status[0]->appliedAt); + } + + public function test_created_tables_use_the_wordpress_collation(): void { + // Choose a non-default collation so ignoring the option cannot pass by accident. + $this->source->charset = 'utf8mb4'; + $this->source->collate = 'utf8mb4_bin'; + $this->migrator()->migrate(self::CREATE); + + self::assertSame('utf8mb4_bin', $this->observer->fetchOne( + 'SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', + [$this->entriesName], + )); + self::assertSame([], $this->migrator()->preview(self::CREATE)); + } + + public function test_data_step_runs_after_ddl_and_is_repeat_safe(): void { + $this->migrator()->migrate(self::ALTER); + $this->observer->insert($this->quotedEntries, ['name' => 'Blank', 'status' => '']); + $this->observer->insert($this->quotedEntries, ['name' => 'Kept', 'status' => 'archived']); + + $this->migrator()->migrate(); + + self::assertSame(['active', 'archived'], $this->observer->fetchFirstColumn('SELECT status FROM ' . $this->quotedEntries . ' ORDER BY id')); + } + + public function test_retry_after_ledger_failure_records_without_repeating_ddl(): void { + $this->migrator()->migrate(self::CREATE); + $trigger = $this->rejectHistoryWrite('INSERT'); + + try { + $this->migrator()->migrate(self::ALTER); + self::fail('Expected the ledger insert to fail.'); + } catch (\StellarWP\Foundation\Database\Migration\Exceptions\LedgerFailure $failure) { + self::assertStringContainsString('History write failure', $failure->getPrevious()?->getMessage() ?? ''); + } finally { + $this->observer->executeStatement('DROP TRIGGER ' . $trigger); + } + self::assertSame([self::CREATE], $this->history(), 'DDL committed but the ledger did not'); + $this->observer->insert($this->quotedEntries, ['name' => 'Survivor', 'status' => 'active', 'note' => 'Keep this']); + + $steps = $this->migrator()->migrate(self::ALTER); + + self::assertSame([self::ALTER], self::ids($steps)); + self::assertSame([], $steps[0]->sql, 'The retry detects the completed column and issues no DDL'); + self::assertSame([self::CREATE, self::ALTER], $this->history()); + self::assertSame('Keep this', $this->observer->fetchOne('SELECT note FROM ' . $this->quotedEntries)); + } + + public function test_create_retry_after_ledger_failure_preserves_rows(): void { + $this->migrator()->migrate(Migrator::NONE); // Nothing to do, but creates the ledger so the trigger can be attached. + $trigger = $this->rejectHistoryWrite('INSERT'); + + try { + $this->migrator()->migrate(self::CREATE); + self::fail('Expected the ledger insert to fail.'); + } catch (\StellarWP\Foundation\Database\Migration\Exceptions\LedgerFailure) { + // The table exists; history is empty. + } finally { + $this->observer->executeStatement('DROP TRIGGER ' . $trigger); + } + self::assertSame([], $this->history()); + $this->observer->insert($this->quotedEntries, ['name' => 'Survivor', 'status' => 'active']); + + $steps = $this->migrator()->migrate(self::CREATE); + + self::assertSame([], $steps[0]->sql); + self::assertSame([self::CREATE], $this->history()); + self::assertSame(['Survivor'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->quotedEntries)); + } + + public function test_undeclared_column_change_stops_the_run_and_names_the_column(): void { + $this->migrator()->migrate(self::CREATE); + $this->observer->executeStatement('ALTER TABLE ' . $this->quotedEntries . ' ADD note INT NOT NULL'); + + try { + $this->migrator()->migrate(self::ALTER); + self::fail('Expected drift to be rejected.'); + } catch (IncompatibleSchema $failure) { + self::assertStringContainsString(self::ALTER, $failure->getMessage()); + self::assertStringContainsString('column note differs from its declaration', $failure->getMessage()); + } + self::assertSame([self::CREATE], $this->history()); + self::assertStringContainsString('`note` int', $this->createTableSql(), 'The runner never repaired the column silently'); + } + + public function test_incompatible_existing_table_stops_a_create_migration(): void { + $this->observer->executeStatement("CREATE TABLE {$this->quotedEntries} (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(49) NOT NULL) ENGINE=InnoDB"); + + try { + $this->migrator()->migrate(self::CREATE); + self::fail('Expected the existing table to be rejected.'); + } catch (IncompatibleSchema $failure) { + self::assertStringContainsString('differs from the declared initial definition', $failure->getMessage()); + } + self::assertSame([], $this->history()); + } + + public function test_unrelated_columns_and_indexes_survive(): void { + $this->migrator()->migrate(self::CREATE); + $this->observer->executeStatement('ALTER TABLE ' . $this->quotedEntries . ' ADD external_value INT, ADD INDEX external_lookup (external_value)'); + + $this->migrator()->migrate(); + + $sql = $this->createTableSql(); + self::assertStringContainsString('`external_value` int', $sql); + self::assertStringContainsString('key `external_lookup`', $sql); + self::assertStringContainsString('`note` varchar(100)', $sql); + self::assertSame([], $this->migrator()->preview()); + } + + public function test_preview_reports_exactly_the_sql_that_migrate_executes(): void { + $preview = $this->migrator()->preview(); + $executed = $this->migrator()->migrate(); + + self::assertSame(self::ids($preview), self::ids($executed)); + self::assertSame( + array_map(static fn (Step $step): array => $step->sql, $preview), + array_map(static fn (Step $step): array => $step->sql, $executed), + ); + self::assertSame([], $this->migrator()->preview()); + } + + public function test_rollback_by_step_and_by_target(): void { + $this->migrator()->migrate(); + $this->observer->insert($this->quotedEntries, ['name' => 'Original', 'status' => 'active', 'note' => 'n']); + + $steps = $this->migrator()->rollback(); + self::assertSame([self::BACKFILL], self::ids($steps)); + self::assertTrue($steps[0]->reverse); + self::assertSame([self::CREATE, self::ALTER], $this->history()); + + $steps = $this->migrator()->migrate(self::CREATE); + self::assertSame([self::ALTER], self::ids($steps)); + self::assertStringNotContainsString('`note`', $this->createTableSql()); + self::assertSame(['Original'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->quotedEntries)); + self::assertSame([self::CREATE], $this->history()); + + $this->migrator()->migrate(Migrator::NONE); + self::assertSame([], $this->history()); + self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->entriesName])); + } + + public function test_history_missing_a_dependency_is_reported_not_repaired(): void { + $this->migrator()->migrate(); + $this->observer->delete($this->quotedHistory, ['version' => self::CREATE]); + + try { + $this->migrator()->migrate(self::CREATE); + self::fail('Expected the inconsistent ledger to be reported.'); + } catch (MigrationInterrupted $failure) { + self::assertStringContainsString(self::ALTER, $failure->getMessage()); + self::assertStringContainsString('repair the ledger', $failure->getMessage()); + } + self::assertSame([self::ALTER, self::BACKFILL], $this->history(), 'Nothing was executed or recorded'); + } + + public function test_another_session_holding_the_lock_blocks_the_run(): void { + $name = hash('sha256', constant('DB_NAME') . "\0" . $this->historyName); + self::assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [$name])); + + try { + $this->migrator()->migrate(); + self::fail('Expected contention.'); + } catch (MigrationAlreadyRunning) { + self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); + } finally { + $this->observer->fetchOne('SELECT RELEASE_LOCK(?)', [$name]); + } + } +} diff --git a/tests/integration/Database/ImporterTest.php b/tests/integration/Database/ImporterTest.php new file mode 100644 index 0000000..e13035e --- /dev/null +++ b/tests/integration/Database/ImporterTest.php @@ -0,0 +1,32 @@ +container->when(EntryRepository::class)->needs('$tableName')->give($this->suffix); + $records = array_map(static fn (int $id): array => ['id' => $id, 'name' => 'Imported'], range(1, 501)); + self::assertSame(501, $this->container->get(DatasetImporter::class)->replace($records)); + self::assertSame(501, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->table)); + self::assertCount(50, $this->container->get(EntryRepository::class)->findByName('Imported')); + } + + public function test_a_failure_in_the_second_batch_restores_the_original_dataset(): void { + $this->container->when(EntryRepository::class)->needs('$tableName')->give($this->suffix); + $records = array_map(static fn (int $id): array => ['id' => $id, 'name' => 'Imported'], range(1, 500)); + $records[] = ['id' => 1, 'name' => 'Duplicate in second batch']; + + try { + $this->container->get(DatasetImporter::class)->replace($records); + self::fail('Expected a second-batch constraint violation.'); + } catch (UniqueConstraintViolationException) { + $this->assertOriginal(); + } + } +} diff --git a/tests/integration/Database/MigrationRecoveryTest.php b/tests/integration/Database/MigrationRecoveryTest.php deleted file mode 100644 index 69747dc..0000000 --- a/tests/integration/Database/MigrationRecoveryTest.php +++ /dev/null @@ -1,118 +0,0 @@ -create(new ArrayConfiguration([ - 'database' => [ - 'migrations_table' => 'recovery_migrations_' . $suffix, - 'locks_table' => 'recovery_locks_' . $suffix, - ], - ])); - $container->register(DatabaseProvider::class); - $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ - $c->get(AddRecoveryStatusMigration::class), - ]); - - $database = $container->get(Database::class); - $schema = $container->get(Schema::class); - $table = $container->get(RecoveryTable::class); - $migration = $container->get(AddRecoveryStatusMigration::class); - $migrationTable = $container->get(MigrationTable::class); - $lockTable = $container->get(LockTable::class); - $ledger = $container->get(Repository::class); - $failure = LedgerFailure::notInsertedAfterRun($migration->id()); - $attempts = 0; - $repository = $this->createMock(Repository::class); - $repository->method('all')->willReturnCallback($ledger->all(...)); - $repository->method('latestBatch')->willReturnCallback($ledger->latestBatch(...)); - $repository->expects($this->exactly(2))->method('recordRun') - ->with($migration->id(), 1) - ->willReturnCallback(static function (string $id, int $batch) use ($ledger, $failure, &$attempts): void { - if (++$attempts === 1) { - throw $failure; - } - - $ledger->recordRun($id, $batch); - }); - $container->singleton(Repository::class, $repository); - $migrator = $container->get(Migrator::class); - - $statements = []; - $recordDdl = static function (string $sql) use (&$statements): string { - if (preg_match('/\A\s*(?:CREATE|ALTER|DROP)\b/i', $sql) === 1) { - $statements[] = $sql; - } - - return $sql; - }; - - try { - $initial = Blueprint::for($table); - $initial->bigIncrements('id'); - $initial->string('name', 50); - $initial->index('lookup', 'name'); - $schema->create($initial); - $id = $table->insertGetId(['name' => 'Existing item']); - $migrator->initialize(); - - try { - $migrator->run(); - $this->fail('Expected the ledger write to fail after the schema alteration.'); - } catch (LedgerFailure $exception) { - $this->assertSame($failure, $exception); - } - - $expected = [['id' => (string) $id, 'name' => 'Existing item', 'status' => 'pending']]; - $this->assertSame($expected, $table->query()->get()); - $this->assertSame(['status', 'name'], array_column( - $database->rows('SHOW INDEX FROM %i WHERE Key_name = %s', $table->name(), 'lookup'), - 'Column_name' - )); - $this->assertSame([], $migrationTable->query()->get()); - $this->assertTrue($migrator->status()[0]->isPending()); - - add_filter('query', $recordDdl); - $result = $migrator->run(); - - $this->assertSame([$migration->id()], $result->ran); - $this->assertSame([], $statements); - $this->assertSame($expected, $table->query()->get()); - $this->assertSame([['migration' => $migration->id(), 'batch' => '1']], $migrationTable->query()->select('migration', 'batch')->get()); - $this->assertTrue($migrator->status()[0]->isApplied()); - - $next = $migrator->run(); - - $this->assertSame([], $next->ran); - $this->assertSame([$migration->id()], $next->skipped); - $this->assertCount(1, $migrationTable->query()->get()); - $this->assertSame([], $statements); - $this->assertSame($expected, $table->query()->get()); - } finally { - remove_filter('query', $recordDdl); - - foreach ([$table, $migrationTable, $lockTable] as $createdTable) { - $database->execute('DROP TABLE IF EXISTS %i', $createdTable->name()); - } - } - } -} diff --git a/tests/integration/Database/NativeBehaviorTest.php b/tests/integration/Database/NativeBehaviorTest.php new file mode 100644 index 0000000..3824729 --- /dev/null +++ b/tests/integration/Database/NativeBehaviorTest.php @@ -0,0 +1,58 @@ +create($this->native($this->source), constant('DB_NAME')); + $connection->transactional(function () use ($connection): void { + $connection->update($this->table, ['name' => 'Committed despite failure'], ['id' => 1]); + + try { + $connection->insert($this->table, ['id' => 1, 'name' => 'Duplicate']); + } catch (UniqueConstraintViolationException) { + } + }); + self::assertSame('Committed despite failure', $this->observer->fetchOne('SELECT name FROM ' . $this->table)); + } + + public function test_native_dbal_rejects_unacknowledged_commit(): void { + $connection = (new NativeConnectionFactory())->create($this->native($this->source), constant('DB_NAME')); + + try { + @$connection->transactional(function () use ($connection): string { + $connection->update($this->table, ['name' => 'Lost'], ['id' => 1]); + $this->killConnection(); + + return 'success'; + }); + self::fail('Expected DBAL to reject the unacknowledged commit.'); + } catch (ConnectionLost) { + $this->assertOriginal(); + } + } + + public function test_native_cleanup_can_replace_the_business_exception(): void { + $connection = (new NativeConnectionFactory())->create($this->native($this->source), constant('DB_NAME')); + $business = new RuntimeException('Business failure'); + + try { + $connection->transactional(function () use ($business): void { + $this->native($this->source)->close(); + + throw $business; + }); + } catch (Throwable $caught) { + self::assertNotSame($business, $caught); + self::assertStringContainsString('closed', $caught->getMessage()); + } + } +} diff --git a/tests/integration/Database/TableTest.php b/tests/integration/Database/TableTest.php new file mode 100644 index 0000000..98a5f39 --- /dev/null +++ b/tests/integration/Database/TableTest.php @@ -0,0 +1,87 @@ +entries = $this->container->get(Entry_Table::class); + $this->tables[] = $this->entries->quotedName(); + $this->observer->executeStatement('CREATE TABLE ' . $this->entries->quotedName() . ' (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(191), active BOOLEAN NOT NULL DEFAULT 0) ENGINE=InnoDB'); + } + + public function test_ordinary_table_calls_share_the_transaction_and_bind_values(): void { + $id = $this->entries->insertGetId(['name' => "O'Reilly", 'active' => true], ['active' => Types::BOOLEAN]); + self::assertSame("O'Reilly", $this->entries->query()->select('name')->where('id = :id')->setParameter('id', $id)->fetchOne()); + self::assertSame(1, (int) $this->entries->update(['name' => 'Changed'], ['id' => $id])); + self::assertSame(1, (int) $this->entries->query()->select('active')->fetchOne()); + + try { + $this->connection->transactional(function (): void { + $this->entries->insert(['name' => 'Rolled back']); + + throw new \RuntimeException('Cancel'); + }); + } catch (\RuntimeException $failure) { + self::assertSame('Cancel', $failure->getMessage()); + } + self::assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->entries->quotedName())); + self::assertSame(1, (int) $this->entries->delete(['id' => $id])); + } + + public function test_queries_are_fresh_and_capture_the_site_when_built(): void { + $old = $this->entries->query('entry')->select('entry.id'); + $fresh = $this->entries->query(); + self::assertNotSame($old, $fresh); + $oldName = $this->entries->name(); + $this->source->set_prefix($this->source->prefix . 'other_'); + self::assertStringContainsString($oldName, $old->getSQL()); + self::assertNotSame($oldName, $this->entries->name()); + self::assertStringContainsString($this->entries->name(), $this->entries->query()->select('id')->getSQL()); + } + + public function test_empty_mutation_criteria_are_rejected(): void { + foreach (['update', 'delete'] as $method) { + try { + $method === 'update' ? $this->entries->update(['name' => 'All'], []) : $this->entries->delete([]); + self::fail('Expected empty criteria rejection.'); + } catch (InvalidArgumentException) { + self::assertSame(0, (int) $this->entries->query()->select('COUNT(*)')->fetchOne()); + } + } + } + + public function test_idle_connection_replacement_supports_queries_and_transactions(): void { + $old = $this->native($this->source); + $oldStatement = $this->connection->prepare('SELECT CONNECTION_ID()'); + $this->source->__set('dbh', $this->native($this->observerSource)); + + try { + self::assertSame($this->native($this->observerSource)->thread_id, (int) $this->connection->fetchOne('SELECT CONNECTION_ID()')); + self::assertSame('updated', $this->connection->transactional(function (Connection $connection): string { + $this->entries->insert(['name' => 'Reconnected']); + + return 'updated'; + })); + self::assertSame('Reconnected', $this->entries->query()->select('name')->fetchOne()); + + try { + $oldStatement->executeQuery(); + self::fail('An old statement must not run on a replaced session.'); + } catch (\RuntimeException $failure) { + self::assertStringContainsString('connection changed', $failure->getMessage()); + } + } finally { + $this->source->__set('dbh', $old); + } + } +} diff --git a/tests/integration/Database/TransactionTest.php b/tests/integration/Database/TransactionTest.php new file mode 100644 index 0000000..d4c77a5 --- /dev/null +++ b/tests/integration/Database/TransactionTest.php @@ -0,0 +1,453 @@ +source->prefix; + + try { + $this->connection->transactional(function () use ($prefix): void { + $this->connection->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); + $this->source->set_prefix($prefix . 'changed_'); + + try { + $this->connection->fetchOne('SELECT 1'); + } catch (\RuntimeException) { + $this->source->set_prefix($prefix); + } + }); + self::fail('A caught scope failure must remain terminal.'); + } catch (\StellarWP\Foundation\Database\Exceptions\TransactionFailed) { + $this->assertOriginal(); + } finally { + $this->source->set_prefix($prefix); + } + } + + public function test_provider_shares_one_connection_and_borrows_wordpress_session(): void { + self::assertSame($this->connection, $this->container->get(Connection::class)); + self::assertSame($this->native($this->source), $this->connection->getNativeConnection()); + } + + public function test_commit_publishes_all_work_at_once_and_returns_the_callback_result(): void { + $result = $this->connection->transactional(function (): int { + $this->connection->executeStatement('DELETE FROM ' . $this->table); + $this->connection->insert($this->table, ['id' => 2, 'name' => 'First']); + $this->assertOriginal(); + $this->connection->insert($this->table, ['id' => 3, 'name' => 'Second']); + $this->assertOriginal(); + + return 2; + }); + self::assertSame(2, $result); + self::assertSame(['First', 'Second'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table . ' ORDER BY id')); + } + + public function test_manual_transactions_use_void_and_publish_only_at_outer_commit(): void { + foreach (['beginTransaction', 'commit', 'rollBack'] as $method) { + self::assertSame('void', (string) (new \ReflectionMethod($this->connection, $method))->getReturnType()); + } + + $this->connection->beginTransaction(); + $this->connection->update($this->table, ['name' => 'Outer'], ['id' => 1]); + $this->connection->beginTransaction(); + $this->connection->update($this->table, ['name' => 'Nested'], ['id' => 1]); + $this->connection->commit(); + $this->assertOriginal(); + $this->connection->rollBack(); + $this->assertOriginal(); + + $this->connection->beginTransaction(); + $this->connection->update($this->table, ['name' => 'Committed'], ['id' => 1]); + $this->connection->commit(); + self::assertSame('Committed', $this->observer->fetchOne('SELECT name FROM ' . $this->table)); + self::assertFalse($this->connection->isTransactionActive()); + } + + public function test_false_and_null_callback_results_are_successful_committed_work(): void { + foreach ([false, null] as $expected) { + $name = $expected === false ? 'False result' : 'Null result'; + $result = $this->connection->transactional(function () use ($expected, $name): ?bool { + $this->connection->update($this->table, ['name' => $name], ['id' => 1]); + + return $expected; + }); + + self::assertSame($expected, $result); + self::assertSame($name, $this->observer->fetchOne('SELECT name FROM ' . $this->table)); + self::assertFalse($this->connection->isTransactionActive()); + } + } + + public function test_late_failure_restores_existing_data(): void { + $failure = new RuntimeException('Invalid later batch'); + + try { + $this->connection->transactional(function () use ($failure): void { + $this->connection->executeStatement('DELETE FROM ' . $this->table); + $this->connection->insert($this->table, ['id' => 2, 'name' => 'Provisional']); + + throw $failure; + }); + } catch (Throwable $caught) { + self::assertSame($failure, $caught); + } + $this->assertOriginal(); + self::assertSame(42, $this->connection->transactional(static fn (): int => 42)); + } + + public function test_caught_prepared_statement_failure_is_terminal(): void { + $statement = $this->connection->prepare('INSERT INTO ' . $this->table . ' (id, name) VALUES (?, ?)'); + + try { + $this->connection->transactional(function () use ($statement): void { + $this->connection->update($this->table, ['name' => 'Provisional'], ['id' => 1]); + + try { + $statement->bindValue(1, 1); + $statement->bindValue(2, 'Duplicate'); + $statement->executeStatement(); + } catch (UniqueConstraintViolationException) { + } + }); + self::fail('A caught database failure must prevent commit.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + public function test_caught_query_failure_blocks_further_queries(): void { + try { + $this->connection->transactional(function (): void { + try { + $this->connection->executeQuery('SELECT missing_column FROM ' . $this->table); + } catch (Throwable) { + } + + try { + $this->connection->insert($this->table, ['id' => 2, 'name' => 'Must not execute']); + self::fail('A failed transaction must reject further SQL.'); + } catch (TransactionFailed) { + } + }); + self::fail('Expected terminal failure.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + public function test_nested_application_failure_uses_savepoint_and_outer_can_continue(): void { + $this->connection->transactional(function (): void { + $this->connection->insert($this->table, ['id' => 2, 'name' => 'Outer']); + + try { + $this->connection->transactional(function (): void { + $this->connection->insert($this->table, ['id' => 3, 'name' => 'Inner']); + + throw new RuntimeException('Skip inner work'); + }); + } catch (RuntimeException) { + } + $this->connection->transactional(function (): void { + $this->connection->insert($this->table, ['id' => 4, 'name' => 'Nested success']); + }); + }); + self::assertSame([1, 2, 4], array_map('intval', $this->observer->fetchFirstColumn('SELECT id FROM ' . $this->table . ' ORDER BY id'))); + } + + public function test_outer_rollback_includes_successful_nested_work(): void { + try { + $this->connection->transactional(function (): void { + $this->connection->transactional(function (): void { + $this->connection->insert($this->table, ['id' => 2, 'name' => 'Nested']); + }); + + throw new RuntimeException('Outer failure'); + }); + } catch (RuntimeException) { + $this->assertOriginal(); + } + } + + public function test_ambient_transaction_is_rejected_without_committing_or_rolling_it_back(): void { + $native = $this->native($this->source); + $native->begin_transaction(); + $native->query('UPDATE ' . $this->table . " SET name = 'Ambient'"); + + try { + $this->connection->transactional(static function (): void { + self::fail('Callback must not run.'); + }); + } catch (Throwable $failure) { + self::assertStringContainsString('Transaction characteristics', $failure->getMessage()); + } + $this->assertOriginal(); + $result = $native->query('SELECT name FROM ' . $this->table); + self::assertInstanceOf(\mysqli_result::class, $result); + self::assertSame(['Ambient'], $result->fetch_row()); + $native->rollback(); + } + + public function test_autocommit_disabled_is_rejected(): void { + $native = $this->native($this->source); + $native->autocommit(false); + + try { + $this->connection->transactional(static function (): void { + self::fail('Callback must not run.'); + }); + } catch (RuntimeException $failure) { + self::assertStringContainsString('autocommit', $failure->getMessage()); + } finally { + $native->autocommit(true); + } + } + + public function test_lost_connection_cannot_replay_a_write_even_if_wordpress_reconnects(): void { + try { + @$this->connection->transactional(function (): void { + $this->connection->executeStatement('DELETE FROM ' . $this->table); + $this->killConnection(); + + try { + $this->connection->insert($this->table, ['id' => 2, 'name' => 'Must not replay']); + } catch (Throwable) { + $this->source->db_connect(false); + } + + try { + $this->connection->insert($this->table, ['id' => 3, 'name' => 'Must not adopt']); + } catch (Throwable) { + } + }); + self::fail('Expected terminal connection loss.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + self::assertSame(7, $this->connection->transactional(static fn (): int => 7)); + self::assertSame($this->native($this->source), $this->connection->getNativeConnection()); + } + + public function test_lost_commit_acknowledgement_reports_uncertain_outcome(): void { + try { + @$this->connection->transactional(function (): void { + $this->connection->update($this->table, ['name' => 'Lost'], ['id' => 1]); + $this->killConnection(); + }); + self::fail('Expected uncertain commit.'); + } catch (CommitOutcomeUnknown) { + $this->assertOriginal(); + } + } + + public function test_cleanup_failure_preserves_the_original_business_exception(): void { + $failure = new RuntimeException('Original business failure'); + + try { + $this->connection->transactional(function () use ($failure): void { + $this->native($this->source)->close(); + + throw $failure; + }); + } catch (Throwable $caught) { + self::assertSame($failure, $caught); + } + } + + public function test_site_change_prevents_commit_and_restoration_does_not_erase_failure(): void { + $otherSite = self::factory()->blog->create(); + self::assertIsInt($otherSite); + + try { + $this->connection->transactional(function () use ($otherSite): void { + $this->connection->update($this->table, ['name' => 'Wrong scope'], ['id' => 1]); + switch_to_blog($otherSite); + + try { + $this->connection->executeQuery('SELECT 1'); + } catch (Throwable) { + } finally { + restore_current_blog(); + } + }); + self::fail('Expected terminal scope failure.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + public function test_caught_nested_database_failure_still_aborts_outer_work(): void { + try { + $this->connection->transactional(function (): void { + $this->connection->update($this->table, ['name' => 'Provisional'], ['id' => 1]); + + try { + $this->connection->transactional(function (): void { + $this->connection->insert($this->table, ['id' => 1, 'name' => 'Duplicate']); + }); + } catch (UniqueConstraintViolationException) { + } + }); + self::fail('Caught nested database failure must abort the outer transaction.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + public function test_a_business_exception_takes_precedence_over_a_caught_database_failure(): void { + $business = new RuntimeException('Application failure'); + + try { + $this->connection->transactional(function () use ($business): void { + try { + $this->connection->executeQuery('SELECT missing_column FROM ' . $this->table); + } catch (Throwable) { + } + + throw $business; + }); + } catch (Throwable $failure) { + self::assertSame($business, $failure); + } + $this->assertOriginal(); + } + + public function test_replacing_a_live_wordpress_session_prevents_commit(): void { + try { + $this->connection->transactional(function (): void { + $this->connection->executeStatement('DELETE FROM ' . $this->table); + $this->source->db_connect(false); + }); + self::fail('Expected connection replacement to abort the operation.'); + } catch (RuntimeException $failure) { + self::assertStringContainsString('connection changed', $failure->getMessage()); + $this->assertOriginal(); + } + self::assertSame(9, $this->connection->transactional(static fn (): int => 9)); + } + + public function test_native_logging_middleware_can_wrap_the_wordpress_driver(): void { + $logger = new \Monolog\Logger('doctrine-evaluation'); + $handler = new \Monolog\Handler\TestHandler(); + $logger->pushHandler($handler); + $connection = $this->withLogging($logger); + self::assertSame(1, (int) $connection->transactional(static fn (Connection $connection): mixed => $connection->fetchOne('SELECT 1'))); + self::assertTrue($handler->hasDebugThatContains('Committing transaction')); + } + + public function test_connection_loss_before_begin_rejects_the_callback(): void { + $logger = new \Monolog\Logger('doctrine-evaluation'); + $logger->pushHandler(new \Monolog\Handler\TestHandler()); + $logger->pushProcessor(function (array $record): array { + if ($record['message'] === 'Beginning transaction') { + $this->killConnection(); + } + + return $record; + }); + $connection = $this->withLogging($logger); + $called = false; + + try { + @$connection->transactional(static function () use (&$called): void { + $called = true; + }); + self::fail('Expected begin failure.'); + } catch (RuntimeException $failure) { + self::assertStringContainsString('Unable to start', $failure->getMessage()); + } + self::assertFalse($called); + $this->assertOriginal(); + } + + public function test_caught_savepoint_release_failure_remains_terminal(): void { + $logger = new \Monolog\Logger('doctrine-evaluation'); + $logger->pushHandler(new \Monolog\Handler\TestHandler()); + $logger->pushProcessor(function (array $record): array { + if (str_starts_with($record['context']['sql'] ?? '', 'RELEASE SAVEPOINT')) { + $this->killConnection(); + } + + return $record; + }); + $connection = $this->withLogging($logger); + + try { + @$connection->transactional(function () use ($connection): void { + try { + $connection->transactional(function () use ($connection): void { + $connection->update($this->table, ['name' => 'Nested'], ['id' => 1]); + }); + } catch (Throwable) { + } + }); + self::fail('Expected terminal savepoint failure.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + public function test_stale_prepared_statement_cannot_write_to_an_old_session(): void { + $statement = $this->connection->prepare('UPDATE ' . $this->table . ' SET name = ?'); + $this->source->db_connect(false); + + try { + $this->connection->transactional(static function () use ($statement): void { + try { + $statement->bindValue(1, 'Stale'); + $statement->executeStatement(); + } catch (Throwable) { + } + }); + self::fail('Expected stale statement failure.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + public function test_a_closed_wordpress_source_is_rejected(): void { + $this->source->close(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('WordPress must have an open mysqli connection.'); + $this->connection->transactional(static function (): void { + self::fail('Must not invoke work.'); + }); + } + + public function test_native_manual_rollback_remains_available(): void { + $this->connection->beginTransaction(); + $this->connection->update($this->table, ['name' => 'Provisional'], ['id' => 1]); + $this->connection->rollBack(); + $this->assertOriginal(); + $this->connection->beginTransaction(); + $this->killConnection(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The database did not acknowledge rollback.'); + @$this->connection->rollBack(); + } + + private function withLogging(\Psr\Log\LoggerInterface $logger): Connection { + $configuration = new \Doctrine\DBAL\Configuration(); + $configuration->setSchemaManagerFactory(new \Doctrine\DBAL\Schema\DefaultSchemaManagerFactory()); + $configuration->setMiddlewares([ + new \StellarWP\Foundation\Database\Connection\WordPressMiddleware($this->container->get(\StellarWP\Foundation\Database\Connection\WordPressSession::class)), + new \Doctrine\DBAL\Logging\Middleware($logger), + ]); + $connection = \Doctrine\DBAL\DriverManager::getConnection([ + 'driver' => 'mysqli', + 'wrapperClass' => \StellarWP\Foundation\Database\Connection\WordPressConnection::class, + ], $configuration); + + return $connection; + } +} diff --git a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php index c1f0250..1c8bb82 100644 --- a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -1,7 +1,5 @@ dropTables($I); } - public function test_it_runs_database_migrations_through_wp_cli(WPCLITester $I): void { - $I->cli(['foundation', 'migrate', '--initialize']); + public function test_it_previews_runs_and_rolls_back_migrations(WPCLITester $I): void { + $I->cli(['foundation', 'migrate']); $I->seeResultCodeIs(0); - $I->seeInShellOutput('Foundation migration storage is initialized.'); - - $I->cli(['foundation', 'migrate', '--run']); + $I->seeInShellOutput('pending'); + $I->cli(['foundation', 'migrate', '--run', '--dry-run']); $I->seeResultCodeIs(0); - $I->seeInShellOutput('Ran 1 migrations.'); - + $I->seeInShellOutput('CREATE TABLE'); + $I->seeInShellOutput('Previewed 1 migration steps.'); $I->cli(['foundation', 'migrate']); + $I->seeInShellOutput('pending'); + $I->cli(['foundation', 'migrate', '--run']); $I->seeResultCodeIs(0); - $I->seeInShellOutput('2026_06_23_000001_create_foundation_cli_example'); + $I->seeInShellOutput('Completed 1 migration steps.'); + $I->cli(['foundation', 'migrate']); + $I->seeInShellOutput('20260623000001'); $I->seeInShellOutput('applied'); - - $I->cli(['foundation', 'migrate', '--rollback']); + $I->cli(['foundation', 'migrate', '--rollback', '--step=1']); $I->seeResultCodeIs(0); - $I->seeInShellOutput('Rolled back 1 migrations.'); + $I->seeInShellOutput('Down 20260623000001'); + $I->cli(['foundation', 'migrate']); + $I->seeInShellOutput('pending'); } - public function test_it_refreshes_and_drops_database_tables_through_wp_cli(WPCLITester $I): void { - $I->cli(['foundation', 'migrate', '--initialize']); - $I->seeResultCodeIs(0); - - $I->cli(['foundation', 'migrate', '--run']); + public function test_it_refreshes_and_targets_versions(WPCLITester $I): void { + $I->cli(['foundation', 'migrate', '--run', '--to=20260623000001']); $I->seeResultCodeIs(0); - $I->seeInShellOutput('Ran 1 migrations.'); - $I->cli(['foundation', 'migrate', '--refresh', '--yes']); $I->seeResultCodeIs(0); - $I->seeInShellOutput('Rolled back 1 migrations and ran 1 migrations.'); - - $I->cli(['foundation', 'migrate', '--drop-store', '--yes']); + $I->seeInShellOutput('Completed 2 migration steps.'); + $I->cli(['foundation', 'migrate', '--rollback', '--to=0']); $I->seeResultCodeIs(0); - $I->seeInShellOutput('The migration ledger was dropped. Application tables were not changed, and shared lock storage remains available.'); - - $I->cli([ - 'db', - 'query', - sprintf("SHOW TABLES LIKE '%sfoundation_cli_example'", $this->prefix($I)), - ]); - $I->seeResultCodeIs(0); - $I->seeInShellOutput('foundation_cli_example'); - - $I->cli([ - 'db', - 'query', - sprintf("SHOW TABLES LIKE '%sfoundation_cli_locks'", $this->prefix($I)), - ]); - $I->seeResultCodeIs(0); - $I->seeInShellOutput('foundation_cli_locks'); - $I->cli(['foundation', 'migrate']); - $I->seeResultCodeIs(0); - Assert::assertStringContainsString('Migration storage is not initialized.', $I->grabLastShellErrorOutput()); + $I->seeInShellOutput('pending'); } - public function test_it_warns_when_showing_status_before_tables_exist(WPCLITester $I): void { - $I->cli(['foundation', 'migrate', '--run', '--initialize']); - $I->seeResultCodeIs(1); - Assert::assertStringContainsString('Only one migration operation can be used at a time.', $I->grabLastShellErrorOutput()); - + public function test_invalid_options_fail_before_migration_execution(WPCLITester $I): void { + foreach ([['--run', '--refresh'], ['--dry-run'], ['--rollback', '--step=0'], ['--rollback', '--step=1', '--to=0']] as $flags) { + $I->cli(['foundation', 'migrate', ...$flags]); + $I->seeResultCodeIs(1); + } $I->cli(['foundation', 'migrate']); $I->seeResultCodeIs(0); - Assert::assertStringContainsString('Migration storage is not initialized.', $I->grabLastShellErrorOutput()); - Assert::assertStringContainsString('wp foundation migrate --initialize', $I->grabLastShellErrorOutput()); - - $I->cli(['foundation', 'migrate', '--run']); - $I->seeResultCodeIs(1); - Assert::assertStringContainsString('wp foundation migrate --initialize', $I->grabLastShellErrorOutput()); + $I->seeInShellOutput('pending'); } private function dropTables(WPCLITester $I): void { - $prefix = $this->prefix($I); - - $I->cli([ - 'db', - 'query', - sprintf( - 'DROP TABLE IF EXISTS %sfoundation_cli_migrations, %sfoundation_cli_locks, %sfoundation_cli_example', - $prefix, - $prefix, - $prefix - ), - ]); - $I->seeResultCodeIs(0); - } - - private function prefix(WPCLITester $I): string { $I->cli(['db', 'prefix']); $I->seeResultCodeIs(0); - - return trim($I->grabLastShellOutput()); + $prefix = trim($I->grabLastShellOutput()); + $I->cli(['db', 'query', sprintf('DROP TABLE IF EXISTS %sfoundation_cli_migrations, %sfoundation_cli_example', $prefix, $prefix)]); + $I->seeResultCodeIs(0); } } diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php deleted file mode 100644 index 1c3d654..0000000 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ /dev/null @@ -1,1480 +0,0 @@ - - */ - private array $tables = []; - - /** - * @throws RuntimeException When WordPress is not loaded. - */ - protected function setUp(): void { - parent::setUp(); - - if (! defined('ABSPATH')) { - throw new RuntimeException('WordPress must be loaded before running database integration tests.'); - } - - $this->database = new Database($GLOBALS['wpdb'], new SiteScope($GLOBALS['wpdb'])); - $this->reconciler = new Reconciler($this->database, new DbDelta()); - $this->schema = new Schema($this->database, $this->reconciler, new Editor($this->database, $this->reconciler)); - } - - protected function tearDown(): void { - foreach (array_reverse($this->tables) as $table) { - $this->database->execute(sprintf( - 'DROP TABLE IF EXISTS %s', - $this->database->quoteIdentifier($table) - )); - } - - parent::tearDown(); - } - - public function test_database_executes_and_reads_rows_through_wpdb(): void { - $tableObject = new TestTable($this->unprefixedTable('database')); - $table = $this->database->tableName($tableObject); - - $this->database->execute(sprintf( - 'CREATE TABLE %s ( - id bigint(20) unsigned NOT NULL AUTO_INCREMENT, - name varchar(191) NOT NULL, - PRIMARY KEY (id) - ) %s', - $this->database->quoteIdentifier($table), - $this->database->charsetCollate() - )); - - $this->database->execute( - 'INSERT INTO %i (name) VALUES (%s), (%s)', - $table, - 'first', - 'second' - ); - - $this->assertSame($GLOBALS['wpdb']->prefix . 'example', $this->database->tableName(new TestTable('example'))); - $this->assertSame($GLOBALS['wpdb']->prefix . 'wp_reports', $this->database->tableName(new TestTable('wp_reports'))); - $this->assertSame(['name' => 'first'], $this->database->row(sprintf( - 'SELECT name FROM %s WHERE id = 1', - $this->database->quoteIdentifier($table) - ))); - $this->assertSame([ - ['name' => 'first'], - ['name' => 'second'], - ], $this->database->rows(sprintf( - 'SELECT name FROM %s ORDER BY id ASC', - $this->database->quoteIdentifier($table) - ))); - $this->assertSame([ - ['name' => 'first'], - ], (new QueryBuilder($this->database, $tableObject))->select('name')->where('id', '=', 1)->get()); - } - - public function test_database_rejects_table_names_beyond_the_mysql_identifier_limit(): void { - $maximum = str_repeat('a', 64 - strlen($GLOBALS['wpdb']->prefix)); - - $this->assertSame( - $GLOBALS['wpdb']->prefix . $maximum, - $this->database->tableName(new TestTable($maximum)) - ); - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('64-character identifier limit'); - - $this->database->tableName(new TestTable(str_repeat('a', 65 - strlen($GLOBALS['wpdb']->prefix)))); - } - - /** - * @dataProvider invalidTableNameProvider - */ - #[DataProvider('invalidTableNameProvider')] - public function test_database_rejects_invalid_unprefixed_table_names(string $tableName, string $message): void { - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage($message); - - $this->database->tableName(new TestTable($tableName)); - } - - /** - * @return array - */ - public static function invalidTableNameProvider(): array { - return [ - 'empty' => ['', 'cannot be blank or contain surrounding whitespace'], - 'padded' => [' reports ', 'cannot be blank or contain surrounding whitespace'], - 'unsafe quoting' => ['reports`archive', 'may contain only ASCII letters, numbers, and underscores'], - ]; - } - - public function test_schema_rejects_table_objects_beyond_the_mysql_identifier_limit(): void { - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('64-character identifier limit'); - - $this->schema->create((new TestDatabaseTable(str_repeat('a', 65), $this->database))->blueprint()); - } - - public function test_database_crud_helpers_and_schema_inspection_use_wordpress(): void { - $tableObject = new TestTable($this->unprefixedTable('crud')); - $table = $this->database->tableName($tableObject); - - $this->database->execute(sprintf( - 'CREATE TABLE %s ( - id bigint(20) unsigned NOT NULL AUTO_INCREMENT, - name varchar(191) NOT NULL, - status varchar(20) NOT NULL, - PRIMARY KEY (id), - KEY status (status) - ) %s', - $this->database->quoteIdentifier($table), - $this->database->charsetCollate() - )); - - $this->assertTrue($this->database->tableExists($tableObject)); - $this->assertTrue($this->database->columnExists($tableObject, 'status')); - $this->assertTrue($this->database->indexExists($tableObject, 'status')); - $this->assertFalse($this->database->columnExists($tableObject, 'missing')); - $this->assertFalse($this->database->indexExists($tableObject, 'missing')); - - $id = $this->database->insertGetId($tableObject, [ - 'name' => 'draft report', - 'status' => 'draft', - ]); - - $this->assertGreaterThan(0, $id); - $this->assertSame('draft', $this->database->value('SELECT status FROM %i WHERE id = %d', $table, $id)); - $this->assertSame(1, $this->database->update($tableObject, ['status' => 'published'], ['id' => $id])); - $this->assertSame('published', $this->database->value('SELECT status FROM %i WHERE id = %d', $table, $id)); - $this->assertSame(1, $this->database->delete($tableObject, ['id' => $id])); - $this->assertSame('0', (string) $this->database->value('SELECT COUNT(*) FROM %i', $table)); - } - - public function test_database_insert_returns_affected_rows_for_string_identifiers(): void { - $tableObject = new TestTable($this->unprefixedTable('string_ids')); - $table = $this->database->tableName($tableObject); - - $this->database->execute(sprintf( - 'CREATE TABLE %s ( - id varchar(26) NOT NULL, - name varchar(191) NOT NULL, - PRIMARY KEY (id) - ) %s', - $this->database->quoteIdentifier($table), - $this->database->charsetCollate() - )); - - $this->assertSame(1, $this->database->insert($tableObject, [ - 'id' => '01J2Z3Y4X5W6V7T8S9R0Q1P2N3', - 'name' => 'report', - ])); - } - - public function test_database_returns_empty_results_without_query_errors(): void { - $table = $this->table('missing_value'); - - $this->database->execute(sprintf( - 'CREATE TABLE %s ( - id bigint(20) unsigned NOT NULL AUTO_INCREMENT, - name varchar(191) NOT NULL, - PRIMARY KEY (id) - ) %s', - $this->database->quoteIdentifier($table), - $this->database->charsetCollate() - )); - - $this->assertNull($this->database->value('SELECT name FROM %i WHERE id = %d', $table, 999)); - $this->assertSame([], $this->database->rows('SELECT name FROM %i WHERE id = %d', $table, 999)); - } - - public function test_database_rejects_blank_sql(): void { - $this->expectException(QueryException::class); - $this->expectExceptionMessage('SQL statement cannot be empty.'); - - $this->database->prepare(' '); - } - - public function test_database_quotes_identifiers_and_escapes_embedded_backticks(): void { - $this->assertSame('`report``status`', $this->database->quoteIdentifier('report`status')); - } - - public function test_prepare_failure_does_not_report_an_unrelated_database_error(): void { - $previousError = $GLOBALS['wpdb']->last_error; - $GLOBALS['wpdb']->last_error = 'Previous query failed.'; - $this->setExpectedIncorrectUsage('wpdb::prepare'); - - try { - $this->database->prepare('SELECT %s, %s', 'first'); - $this->fail('Expected SQL preparation to fail.'); - } catch (QueryException $exception) { - $this->assertNull($exception->databaseError()); - } finally { - $GLOBALS['wpdb']->last_error = $previousError; - } - } - - public function test_database_wraps_wordpress_query_failures(): void { - $previous = $GLOBALS['wpdb']->suppress_errors(true); - $table = new TestTable('missing_foundation_table'); - - try { - $exception = $this->assertQueryFails(fn (): mixed => $this->database->rows('SELECT * FROM %i', 'missing_foundation_table')); - - $this->assertSame('SELECT * FROM %i', $exception->sql()); - $this->assertSame(['missing_foundation_table'], $exception->bindings()); - $this->assertNotNull($exception->databaseError()); - - $this->assertQueryFails(fn (): mixed => $this->database->row('SELECT * FROM %i', 'missing_foundation_table')); - $this->assertQueryFails(fn (): mixed => $this->database->execute('SELECT * FROM %i', 'missing_foundation_table')); - - $tableName = $this->database->tableName($table); - - $this->assertSame( - sprintf('INSERT INTO `%s`', $tableName), - $this->assertQueryFails(fn (): mixed => $this->database->insert($table, ['name' => 'test']))->sql() - ); - $this->assertSame( - sprintf('UPDATE `%s`', $tableName), - $this->assertQueryFails(fn (): mixed => $this->database->update($table, ['name' => 'updated'], ['id' => 1]))->sql() - ); - $this->assertSame( - sprintf('DELETE FROM `%s`', $tableName), - $this->assertQueryFails(fn (): mixed => $this->database->delete($table, ['id' => 1]))->sql() - ); - } finally { - $GLOBALS['wpdb']->suppress_errors($previous); - } - } - - public function test_db_delta_reports_query_failures(): void { - $table = $this->table('invalid_schema'); - $previous = $GLOBALS['wpdb']->suppress_errors(true); - - try { - $this->assertQueryFails(function () use ($table): void { - (new DbDelta())->execute(sprintf( - 'CREATE TABLE %s ( - id definitely_invalid NOT NULL, - PRIMARY KEY (id) - ) %s;', - $this->database->quoteIdentifier($table), - $this->database->charsetCollate() - )); - }); - } finally { - $GLOBALS['wpdb']->suppress_errors($previous); - } - } - - public function test_schema_creates_inspects_and_changes_tables_through_wordpress(): void { - $tableObject = new TestTable($this->unprefixedTable('schema')); - $table = $this->database->tableName($tableObject); - $schema = $this->schema; - $definition = Blueprint::for($tableObject); - $definition->bigIncrements('id'); - $definition->string('legacy_name'); - - $schema->create($definition); - $schema->execute(sprintf( - 'ALTER TABLE %s ADD KEY %s (%s)', - $this->database->quoteIdentifier($table), - $this->database->quoteIdentifier('name'), - $this->database->quoteIdentifier('legacy_name') - )); - $this->assertTrue($schema->hasTable($tableObject)); - $this->assertTrue($schema->hasIndex($tableObject, 'name')); - $this->assertTrue($this->database->columnExists($tableObject, 'legacy_name')); - - $change = Blueprint::for($tableObject); - $change->dropIndex('name'); - $change->dropColumn('legacy_name'); - $schema->alter($change); - - $this->assertFalse($schema->hasIndex($tableObject, 'name')); - $this->assertFalse($this->database->columnExists($tableObject, 'legacy_name')); - - $schema->execute(sprintf( - 'DROP TABLE IF EXISTS %s', - $this->database->quoteIdentifier($table) - )); - - $this->assertFalse($schema->hasTable($tableObject)); - } - - public function test_schema_creates_queue_style_table_definitions_through_wordpress(): void { - $table = $this->unprefixedTable('queue_schema'); - $schema = $this->schema; - $queue = new class($table) implements Table { - public function __construct( - private string $unprefixedName - ) { - } - - public function unprefixedName(): string { - return $this->unprefixedName; - } - }; - $definition = Blueprint::for($queue); - - $definition->bigIncrements('id'); - $definition->string('queue', 255); - $definition->string('task_handler', 255); - $definition->longText('args'); - $definition->integer('priority', 3)->nullable(); - $definition->dateTime('run_after')->default('0000-00-00 00:00:00'); - $definition->integer('taken')->default(0); - $definition->integer('done')->nullable()->default(0); - $definition->tinyInteger('tries')->unsigned()->default(0); - $definition->tinyInteger('failed', 1)->unsigned()->default(false); - $definition->index('done', 'done'); - $definition->index('taken_failed', 'taken', 'failed'); - $definition->index('taken_failed_done', 'taken', 'failed', 'done'); - - $schema->create($definition); - - $this->assertTrue($schema->hasTable($queue)); - $this->assertTrue($this->database->columnExists($queue, 'args')); - $this->assertTrue($this->database->columnExists($queue, 'priority')); - $this->assertTrue($this->database->columnExists($queue, 'failed')); - $this->assertTrue($schema->hasIndex($queue, 'taken_failed')); - $this->assertTrue($schema->hasIndex($queue, 'taken_failed_done')); - } - - public function test_schema_canonicalizes_supported_custom_type_spellings(): void { - $table = new TestTable($this->unprefixedTable('custom_types')); - $blueprint = Blueprint::for($table); - - $blueprint->bigIncrements('id'); - $blueprint->column(new Column('measurement', 'DOUBLE PRECISION')); - $blueprint->column(new Column('amount', 'decimal(10, 2)')); - - $this->schema->create($blueprint); - - $measurement = $this->database->row('SHOW FULL COLUMNS FROM %i WHERE Field = %s', $this->database->tableName($table), 'measurement'); - $amount = $this->database->row('SHOW FULL COLUMNS FROM %i WHERE Field = %s', $this->database->tableName($table), 'amount'); - - $this->assertSame('double', strtolower((string) ($measurement['Type'] ?? ''))); - $this->assertSame('decimal(10,2)', strtolower((string) ($amount['Type'] ?? ''))); - } - - public function test_schema_creates_and_retries_decimal_declarations_without_changing_stored_data(): void { - $table = new TestTable($this->unprefixedTable('decimal_retry')); - $blueprint = Blueprint::for($table); - $columns = [ - new Column('amount', 'decimal(12)'), - new Column('numeric_amount', 'NUMERIC(12)'), - new Column('dec_amount', 'dec(12)'), - new Column('separate_precision', 'decimal', 12), - new Column('numeric_precision', 'numeric', 12), - new Column('dec_precision', 'dec', 12), - new Column('default_precision', 'decimal'), - new Column('numeric_default', 'numeric'), - new Column('dec_default', 'dec'), - ]; - $data = []; - - foreach ($columns as $column) { - $blueprint->column($column); - $data[$column->name] = 42; - } - - $this->schema->create($blueprint); - $this->database->insert($table, $data); - - $statements = []; - $recordDdl = static function (string $sql) use (&$statements): string { - if (preg_match('/\A\s*(?:CREATE|ALTER|DROP)\b/i', $sql) === 1) { - $statements[] = $sql; - } - - return $sql; - }; - add_filter('query', $recordDdl); - - try { - $this->schema->create($blueprint); - } finally { - remove_filter('query', $recordDdl); - } - - $this->assertSame([], $statements); - $this->assertSame([array_map(strval(...), $data)], $this->database->rows('SELECT * FROM %i', $this->database->tableName($table))); - } - - /** - * @dataProvider omittedSchemaDeclarations - */ - #[DataProvider('omittedSchemaDeclarations')] - public function test_schema_detects_unapplied_declarations_after_db_delta_returns(string $omitted, string $message): void { - $table = new TestTable($this->unprefixedTable('unapplied_schema')); - $blueprint = Blueprint::for($table); - $blueprint->bigIncrements('id'); - $blueprint->string('status', 20); - $blueprint->index('id_lookup', 'id'); - - // Simulate WordPress applying only part of the requested definition. - $omitDeclaration = static fn (array $queries): array => array_map( - static fn (string $sql): string => str_replace($omitted, '', $sql), - $queries - ); - add_filter('dbdelta_create_queries', $omitDeclaration); - - try { - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage($message); - $this->schema->create($blueprint); - } finally { - remove_filter('dbdelta_create_queries', $omitDeclaration); - } - } - - /** - * @return array - */ - public static function omittedSchemaDeclarations(): array { - return [ - 'column' => [" `status` varchar(20) NOT NULL,\n", '.status'], - 'index' => [",\n KEY `id_lookup` (`id`)", 'index id_lookup expected KEY (id), found missing'], - ]; - } - - public function test_schema_rejects_an_existing_table_with_an_incompatible_charset(): void { - $table = new TestTable($this->unprefixedTable('wrong_charset')); - $tableName = $this->database->tableName($table); - $blueprint = Blueprint::for($table); - - $blueprint->bigIncrements('id'); - $blueprint->string('label', 20); - $this->database->execute(sprintf( - 'CREATE TABLE %s ( - id bigint(20) unsigned NOT NULL AUTO_INCREMENT, - label varchar(20) NOT NULL, - PRIMARY KEY (id) - ) DEFAULT CHARACTER SET latin1 COLLATE latin1_swedish_ci', - $this->database->quoteIdentifier($tableName) - )); - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('table expected collation'); - $this->expectExceptionMessage('found latin1_swedish_ci'); - - $this->schema->create($blueprint); - } - - public function test_schema_rejects_an_existing_text_column_with_an_incompatible_charset(): void { - $table = new TestTable($this->unprefixedTable('wrong_column_charset')); - $tableName = $this->database->tableName($table); - $blueprint = Blueprint::for($table); - - $blueprint->bigIncrements('id'); - $blueprint->string('label', 20); - $this->database->execute(sprintf( - 'CREATE TABLE %s ( - id bigint(20) unsigned NOT NULL AUTO_INCREMENT, - label varchar(20) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL, - PRIMARY KEY (id) - ) %s', - $this->database->quoteIdentifier($tableName), - $this->database->charsetCollate() - )); - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('column label expected collation'); - $this->expectExceptionMessage('found latin1_swedish_ci'); - - $this->schema->create($blueprint); - } - - public function test_schema_does_not_reapply_an_old_creation_blueprint_to_an_evolved_table(): void { - $table = new TestTable($this->unprefixedTable('historical_create')); - $initial = Blueprint::for($table); - - $initial->bigIncrements('id'); - $initial->string('status', 20); - $this->schema->create($initial); - - $change = Blueprint::for($table); - $change->string('status', 40)->change(); - $this->schema->alter($change); - - try { - $this->schema->create($initial); - $this->fail('Expected the historical creation blueprint to reject the evolved column.'); - } catch (DatabaseException $exception) { - $this->assertStringContainsString('column status expected type varchar(20), found varchar(40)', $exception->getMessage()); - } - - $status = $this->database->row( - 'SHOW FULL COLUMNS FROM %i WHERE Field = %s', - $this->database->tableName($table), - 'status' - ); - - $this->assertSame('varchar(40)', strtolower((string) ($status['Type'] ?? ''))); - } - - public function test_datetime_zero_precision_is_canonical_and_idempotent(): void { - $table = new DateTimePrecisionTable($this->unprefixedTable('datetime_zero')); - - $this->schema->create($table->blueprint()); - $this->schema->create($table->blueprint()); - - $column = $this->database->row( - 'SHOW COLUMNS FROM %i WHERE Field = %s', - $this->database->tableName($table), - 'occurred_at' - ); - - $this->assertSame('datetime', strtolower((string) ($column['Type'] ?? ''))); - } - - /** - * @dataProvider managedTimestampPrecisions - */ - #[DataProvider('managedTimestampPrecisions')] - public function test_database_manages_temporal_values_without_overriding_explicit_assignments(int $precision, string $fraction): void { - $this->withControlledTimestamp(function () use ($precision, $fraction): void { - $table = new ManagedTimestampTable($this->unprefixedTable('managed_time'), $precision); - $tableName = $this->database->tableName($table); - $this->schema->create($table->blueprint()); - $this->database->execute('SET SESSION timestamp = 1704067200.125'); - $this->database->insert($table, ['status' => 'draft']); - - $initial = '2024-01-01 00:00:00' . $fraction; - $row = [ - 'id' => '1', - 'status' => 'draft', - 'created_at' => $initial, - 'updated_at' => $initial, - 'created_stamp' => $initial, - 'updated_stamp' => $initial, - 'processed_at' => null, - ]; - $this->assertSame($row, $this->database->row('SELECT * FROM %i WHERE id = 1', $tableName)); - - $this->database->execute('SET SESSION timestamp = 1704153600.125'); - $this->database->update($table, ['status' => 'draft'], ['id' => 1]); - $this->assertSame($row, $this->database->row('SELECT * FROM %i WHERE id = 1', $tableName)); - - $this->database->update($table, ['status' => 'published'], ['id' => 1]); - $row['status'] = 'published'; - $row['updated_at'] = '2024-01-02 00:00:00' . $fraction; - $row['updated_stamp'] = '2024-01-02 00:00:00' . $fraction; - $row['processed_at'] = '2024-01-02 00:00:00' . $fraction; - $this->assertSame($row, $this->database->row('SELECT * FROM %i WHERE id = 1', $tableName)); - - $explicit = '2020-05-06 07:08:09' . $fraction; - $this->database->insert($table, [ - 'status' => 'imported', - 'created_at' => $explicit, - 'updated_at' => null, - 'created_stamp' => $explicit, - 'updated_stamp' => null, - 'processed_at' => null, - ]); - $imported = [ - 'id' => '2', - 'status' => 'imported', - 'created_at' => $explicit, - 'updated_at' => null, - 'created_stamp' => $explicit, - 'updated_stamp' => null, - 'processed_at' => null, - ]; - $this->assertSame($imported, $this->database->row('SELECT * FROM %i WHERE id = 2', $tableName)); - - $this->database->execute('SET SESSION timestamp = 1704240000.125'); - $this->database->update($table, [ - 'status' => 'edited', - 'updated_at' => $explicit, - 'updated_stamp' => $explicit, - 'processed_at' => null, - ], ['id' => 2]); - $imported['status'] = 'edited'; - $imported['updated_at'] = $explicit; - $imported['updated_stamp'] = $explicit; - $this->assertSame($imported, $this->database->row('SELECT * FROM %i WHERE id = 2', $tableName)); - - $this->database->update($table, [ - 'status' => 'cleared', - 'updated_at' => null, - 'updated_stamp' => null, - 'processed_at' => null, - ], ['id' => 2]); - $imported['status'] = 'cleared'; - $imported['updated_at'] = null; - $imported['updated_stamp'] = null; - $this->assertSame($imported, $this->database->row('SELECT * FROM %i WHERE id = 2', $tableName)); - - $this->database->execute("SET SESSION time_zone = '+02:00'"); - $this->assertSame([ - 'created_at' => $initial, - 'created_stamp' => '2024-01-01 02:00:00' . $fraction, - ], $this->database->row('SELECT created_at, created_stamp FROM %i WHERE id = 1', $tableName)); - }); - } - - /** - * @return array - */ - public static function managedTimestampPrecisions(): array { - return [ - 'zero' => [0, ''], - 'fractional' => [6, '.125000'], - ]; - } - - /** - * @dataProvider alteredTimestampTypes - */ - #[DataProvider('alteredTimestampTypes')] - public function test_timestamp_attributes_can_be_added_removed_and_retried_without_rewriting_rows(string $type): void { - // MariaDB temporary tables do not activate ON UPDATE added by ALTER TABLE. - // Use persistent tables; our teardown drops them before the parent restores query hooks. - foreach ($GLOBALS['wp_filter']['query']->callbacks[10] as $hook) { - $callback = $hook['function']; - - if (is_array($callback) && in_array($callback[1], ['_create_temporary_tables', '_drop_temporary_tables'], true)) { - remove_filter('query', $callback); - } - } - - $this->withControlledTimestamp(function () use ($type): void { - $table = new TestTable($this->unprefixedTable('time_alter')); - $tableName = $this->database->tableName($table); - $initial = Blueprint::for($table); - $initial->string('status', 20); - $initial->column(new Column('occurred_at', $type))->nullable()->default(null)->comment('Event time'); - $this->schema->create($initial); - $this->database->insert($table, ['status' => 'draft']); - $original = [['status' => 'draft', 'occurred_at' => null]]; - - $change = Blueprint::for($table); - $change->column(new Column('occurred_at', $type))->nullable()->useCurrent()->useCurrentOnUpdate()->comment('Event time')->change(); - $this->schema->alter($change); - $this->assertSame($original, $this->database->rows('SELECT * FROM %i', $tableName)); - - $current = Blueprint::for($table); - $current->string('status', 20); - $current->column(new Column('occurred_at', $type))->nullable()->useCurrent()->useCurrentOnUpdate()->comment('Event time'); - $this->assertNoTimestampDdl(function () use ($table, $current, $change): void { - $this->schema->create($current); - $this->schema->alter($change); - // Retrying an addition also verifies the existing column without DDL. - $addition = Blueprint::for($table); - $addition->column($current->columns()[1]); - $this->schema->alter($addition); - }); - $this->assertSame($original, $this->database->rows('SELECT * FROM %i', $tableName)); - - $this->database->execute('SET SESSION timestamp = 1704153600'); - $this->database->update($table, ['status' => 'published'], ['status' => 'draft']); - $value = str_contains($type, '(6)') ? '2024-01-02 00:00:00.000000' : '2024-01-02 00:00:00'; - $rows = [['status' => 'published', 'occurred_at' => $value]]; - $this->assertSame($rows, $this->database->rows('SELECT * FROM %i', $tableName)); - - $remove = Blueprint::for($table); - $remove->column(new Column('occurred_at', $type))->nullable()->default(null)->comment('Event time')->change(); - $this->schema->alter($remove); - $this->assertSame($rows, $this->database->rows('SELECT * FROM %i', $tableName)); - $this->assertNoTimestampDdl(function () use ($initial, $remove): void { - $this->schema->create($initial); - $this->schema->alter($remove); - }); - - $this->database->execute('SET SESSION timestamp = 1704240000'); - $this->database->update($table, ['status' => 'archived'], ['status' => 'published']); - $this->database->insert($table, ['status' => 'new']); - $this->assertSame([ - ['status' => 'archived', 'occurred_at' => $value], - ['status' => 'new', 'occurred_at' => null], - ], $this->database->rows('SELECT * FROM %i ORDER BY status', $tableName)); - }); - } - - /** - * @return array - */ - public static function alteredTimestampTypes(): array { - return [ - 'fractional datetime' => ['datetime(6)'], - 'zero timestamp' => ['timestamp(0)'], - ]; - } - - /** - * @dataProvider incompatibleTimestampAttributes - */ - #[DataProvider('incompatibleTimestampAttributes')] - public function test_timestamp_retries_reject_missing_or_unexpected_attributes(string $actual, bool $expectsUpdate): void { - $table = new TestTable($this->unprefixedTable('time_mismatch')); - $tableName = $this->database->tableName($table); - $blueprint = Blueprint::for($table); - $column = $blueprint->dateTime('occurred_at')->nullable()->useCurrent(); - - if ($expectsUpdate) { - $column->useCurrentOnUpdate(); - } - - $this->schema->create($blueprint); - $this->database->insert($table, ['occurred_at' => '2020-01-02 03:04:05']); - $this->database->execute(sprintf('ALTER TABLE %s MODIFY COLUMN occurred_at datetime NULL %s', $this->database->quoteIdentifier($tableName), $actual)); - $this->assertNoTimestampDdl(function () use ($blueprint): void { - foreach (['create', 'alter'] as $operation) { - try { - $this->schema->{$operation}($blueprint); - $this->fail('Expected incompatible automatic timestamp attributes to fail verification.'); - } catch (DatabaseException $exception) { - $this->assertStringContainsString('occurred_at', $exception->getMessage()); - } - } - }); - $this->assertSame([['occurred_at' => '2020-01-02 03:04:05']], $this->database->rows('SELECT * FROM %i', $tableName)); - } - - /** - * @return array - */ - public static function incompatibleTimestampAttributes(): array { - return [ - 'missing default' => ['DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP', true], - 'missing update' => ['DEFAULT CURRENT_TIMESTAMP', true], - 'unexpected update' => ['DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', false], - ]; - } - - public function test_timestamp_creation_detects_a_default_omitted_by_db_delta(): void { - $table = new TestTable($this->unprefixedTable('time_create_failure')); - $blueprint = Blueprint::for($table); - $blueprint->dateTime('occurred_at')->nullable()->useCurrent()->useCurrentOnUpdate(); - $omitDefault = static fn (array $queries): array => array_map( - static fn (string $sql): string => str_replace('DEFAULT CURRENT_TIMESTAMP', 'DEFAULT NULL', $sql), - $queries - ); - add_filter('dbdelta_create_queries', $omitDefault); - - try { - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('occurred_at'); - $this->schema->create($blueprint); - } finally { - remove_filter('dbdelta_create_queries', $omitDefault); - } - } - - public function test_timestamp_alteration_detects_an_update_clause_not_applied_by_the_database(): void { - $table = new TestTable($this->unprefixedTable('time_alter_failure')); - $tableName = $this->database->tableName($table); - $initial = Blueprint::for($table); - $initial->dateTime('occurred_at')->nullable()->default(null); - $this->schema->create($initial); - $this->database->insert($table, ['occurred_at' => '2020-01-02 03:04:05']); - $change = Blueprint::for($table); - $change->dateTime('occurred_at')->nullable()->useCurrent()->useCurrentOnUpdate()->change(); - $omitUpdate = static function (string $sql) use ($tableName): string { - if (str_starts_with($sql, 'ALTER TABLE') && str_contains($sql, $tableName)) { - return str_replace(' ON UPDATE CURRENT_TIMESTAMP', '', $sql); - } - - return $sql; - }; - add_filter('query', $omitUpdate); - - try { - try { - $this->schema->alter($change); - $this->fail('Expected the unapplied timestamp update clause to fail post-DDL verification.'); - } catch (DatabaseException $exception) { - $this->assertStringContainsString('occurred_at', $exception->getMessage()); - } - } finally { - remove_filter('query', $omitUpdate); - } - - $this->assertSame([['occurred_at' => '2020-01-02 03:04:05']], $this->database->rows('SELECT * FROM %i', $tableName)); - } - - public function test_legacy_timestamp_defaults_are_rejected_unless_explicitly_declared(): void { - $original = (int) $this->database->value('SELECT @@SESSION.explicit_defaults_for_timestamp'); - - try { - $this->database->execute('SET SESSION explicit_defaults_for_timestamp = OFF'); - $table = new TestTable($this->unprefixedTable('legacy_time')); - $blueprint = Blueprint::for($table); - $blueprint->timestamp('occurred_at'); - - try { - $this->schema->create($blueprint); - $this->fail('Expected undeclared legacy automatic timestamp attributes to fail verification.'); - } catch (DatabaseException $exception) { - $this->assertStringContainsString('occurred_at', $exception->getMessage()); - } - - $this->assertSame(0, (int) $this->database->value('SELECT @@SESSION.explicit_defaults_for_timestamp')); - $explicit = new TestTable($this->unprefixedTable('explicit_time')); - $safe = Blueprint::for($explicit); - $safe->string('status', 20); - $safe->timestamp('occurred_at')->nullable()->default(null); - $this->schema->create($safe); - $this->schema->create($safe); - $this->database->insert($explicit, ['status' => 'draft']); - $this->database->update($explicit, ['status' => 'published'], ['status' => 'draft']); - $this->assertSame([ - 'status' => 'published', - 'occurred_at' => null, - ], $this->database->row('SELECT * FROM %i', $this->database->tableName($explicit))); - $this->assertSame(0, (int) $this->database->value('SELECT @@SESSION.explicit_defaults_for_timestamp')); - } finally { - $this->database->execute('SET SESSION explicit_defaults_for_timestamp = %d', $original); - } - } - - public function test_schema_creates_and_verifies_column_comments_in_each_backslash_escaping_mode(): void { - $originalSqlMode = (string) $this->database->value('SELECT @@SESSION.sql_mode'); - $sqlModes = array_values(array_filter(explode(',', $originalSqlMode), static fn (string $mode): bool => $mode !== 'NO_BACKSLASH_ESCAPES')); - $testModes = [implode(',', $sqlModes), implode(',', [...$sqlModes, 'NO_BACKSLASH_ESCAPES'])]; - - foreach ($testModes as $index => $sqlMode) { - $this->database->execute('SET SESSION sql_mode = %s', $sqlMode); - $activeSqlMode = (string) $this->database->value('SELECT @@SESSION.sql_mode'); - - try { - $unprefixedTableName = $this->unprefixedTable('column_comment_' . $index); - $comment = "Customer's updated description; internal metadata"; - $table = new CommentedTable($unprefixedTableName, $comment); - - $this->schema->create((new CommentedTable($unprefixedTableName, null))->blueprint()); - $this->alterDescriptionComment($table, "Customer's description"); - $this->alterDescriptionComment($table, $comment); - $this->alterDescriptionComment($table, $comment); - - $column = $this->database->row( - 'SHOW FULL COLUMNS FROM %i WHERE Field = %s', - $this->database->tableName($table), - 'description' - ); - - $this->assertSame($activeSqlMode, $this->database->value('SELECT @@SESSION.sql_mode')); - $this->assertSame($comment, $column['Comment'] ?? null); - - $this->alterDescriptionComment($table, null); - - $column = $this->database->row( - 'SHOW FULL COLUMNS FROM %i WHERE Field = %s', - $this->database->tableName($table), - 'description' - ); - - $this->assertSame('', $column['Comment'] ?? null); - } finally { - $this->database->execute('SET SESSION sql_mode = %s', $originalSqlMode); - } - } - - $this->assertSame($originalSqlMode, $this->database->value('SELECT @@SESSION.sql_mode')); - } - - public function test_schema_changes_and_removes_comments_while_preserving_supported_column_attributes(): void { - $unprefixedTableName = $this->unprefixedTable('reconciled_comment'); - $table = new CommentReconciliationTable($unprefixedTableName, null); - - $this->schema->create($table->blueprint()); - $this->alterReconciliationComments($table, 'Initial comment'); - $this->alterReconciliationComments($table, 'Updated comment'); - $this->alterReconciliationComments($table, 'Updated comment'); - - $this->assertCommentReconciliationTable($unprefixedTableName, 'Updated comment'); - - $this->alterReconciliationComments($table, null); - $this->alterReconciliationComments($table, null); - - $this->assertCommentReconciliationTable($unprefixedTableName, ''); - } - - public function test_schema_preserves_quote_and_backslash_string_defaults(): void { - $unprefixedTableName = $this->unprefixedTable('string_default'); - $tableObject = new TestTable($unprefixedTableName); - $tableName = $this->database->tableName($tableObject); - $default = "customer's \\ path"; - $definition = Blueprint::for($tableObject); - $definition->bigIncrements('id'); - $definition->string('label', 100)->default($default); - $this->schema->create($definition); - $this->schema->create($definition); - $this->database->execute('INSERT INTO %i () VALUES ()', $tableName); - $this->assertSame($default, $this->database->value('SELECT label FROM %i WHERE id = 1', $tableName)); - - $default = "updated customer's \\ path"; - $change = Blueprint::for($tableObject); - $change->string('label', 100)->default($default)->change(); - $this->schema->alter($change); - $this->schema->alter($change); - $this->database->execute('INSERT INTO %i () VALUES ()', $tableName); - - $this->assertSame($default, $this->database->value('SELECT label FROM %i WHERE id = 2', $tableName)); - } - - public function test_schema_explicitly_changes_numeric_defaults_and_nullability(): void { - $unprefixedTableName = $this->unprefixedTable('column_properties'); - $tableObject = new SchemaReconciliationTable($unprefixedTableName, 1, false); - $table = $this->database->tableName($tableObject); - - $this->schema->create($tableObject->blueprint()); - - $change = Blueprint::for($tableObject); - $change->integer('attempts')->default(5)->change(); - $change->dateTime('completed_at')->nullable()->change(); - $this->schema->alter($change); - $this->database->execute('INSERT INTO %i (completed_at) VALUES (NULL)', $table); - - $row = $this->database->row('SELECT attempts, completed_at FROM %i LIMIT 1', $table); - - $this->assertSame('5', $row['attempts'] ?? null); - $this->assertNull($row['completed_at'] ?? null); - } - - public function test_schema_removes_declared_indexes_without_rejecting_external_indexes(): void { - $unprefixedTableName = $this->unprefixedTable('removed_index'); - $table = new IndexReconciliationTable($unprefixedTableName, true); - - $this->schema->create($table->blueprint()); - $this->schema->execute(sprintf( - 'ALTER TABLE %s ADD INDEX %s (%s)', - $this->database->quoteIdentifier($this->database->tableName($table)), - $this->database->quoteIdentifier('external_tenant'), - $this->database->quoteIdentifier('tenant') - )); - $this->assertTrue($this->schema->hasIndex($table, 'email_unique')); - - $change = Blueprint::for($table); - $change->dropIndex('email_unique'); - $this->schema->alter($change); - - $this->assertFalse($this->schema->hasIndex($table, 'email_unique')); - $this->assertTrue($this->schema->hasIndex($table, 'external_tenant')); - } - - /** - * @dataProvider replacementIndexes - * - * @param list $originalColumns - * @param list $replacementColumns - */ - #[DataProvider('replacementIndexes')] - public function test_schema_replaces_indexes_and_skips_completed_retries( - ?string $originalType, - array $originalColumns, - string $replacementType, - array $replacementColumns - ): void { - $table = new TestTable($this->unprefixedTable('index_retry')); - $initial = Blueprint::for($table); - $initial->string('status', 20); - $initial->string('category', 20); - - if ($originalType === IndexType::UNIQUE) { - $initial->unique('lookup', ...$originalColumns); - } elseif ($originalType !== null) { - $initial->index('lookup', ...$originalColumns); - } - - $this->schema->create($initial); - $this->database->insert($table, ['status' => 'draft', 'category' => 'report']); - - $change = Blueprint::for($table); - $change->dropIndex('LOOKUP'); - - if ($replacementType === IndexType::UNIQUE) { - $change->unique('lookup', ...$replacementColumns); - } else { - $change->index('lookup', ...$replacementColumns); - } - - $statements = []; - $recordDdl = static function (string $sql) use (&$statements): string { - if (preg_match('/\A\s*(?:CREATE|ALTER|DROP)\b/i', $sql) === 1) { - $statements[] = $sql; - } - - return $sql; - }; - add_filter('query', $recordDdl); - - try { - $this->schema->alter($change); - $alreadyMatches = $originalType === $replacementType && $originalColumns === $replacementColumns; - $this->assertCount($alreadyMatches ? 0 : 1, $statements); - - $statements = []; - $this->schema->alter($change); - $this->assertSame([], $statements); - } finally { - remove_filter('query', $recordDdl); - } - - $indexes = $this->database->rows('SHOW INDEX FROM %i WHERE Key_name = %s', $this->database->tableName($table), 'lookup'); - $this->assertSame($replacementColumns, array_column($indexes, 'Column_name')); - $this->assertSame($replacementType === IndexType::UNIQUE ? 0 : 1, (int) $indexes[0]['Non_unique']); - $this->assertSame([ - ['status' => 'draft', 'category' => 'report'], - ], $this->database->rows('SELECT * FROM %i', $this->database->tableName($table))); - } - - /** - * @return array, string, list}> - */ - public static function replacementIndexes(): array { - return [ - 'add column to index' => [IndexType::KEY, ['status'], IndexType::KEY, ['status', 'category']], - 'change uniqueness' => [IndexType::UNIQUE, ['status'], IndexType::KEY, ['status']], - 'add uniqueness' => [IndexType::KEY, ['status'], IndexType::UNIQUE, ['status']], - 'change column order' => [IndexType::KEY, ['category', 'status'], IndexType::KEY, ['status', 'category']], - 'missing index' => [null, [], IndexType::KEY, ['status']], - 'already matches' => [IndexType::KEY, ['status'], IndexType::KEY, ['status']], - ]; - } - - public function test_schema_explicitly_adds_an_auto_increment_attribute(): void { - $unprefixedTableName = $this->unprefixedTable('column_extra'); - $tableObject = new TestTable($unprefixedTableName); - $table = $this->database->tableName($tableObject); - - $this->database->execute(sprintf( - 'CREATE TABLE %s (id bigint(20) unsigned NOT NULL, PRIMARY KEY (id)) %s', - $this->database->quoteIdentifier($table), - $this->database->charsetCollate() - )); - - $change = Blueprint::for($tableObject); - $change->column(new Column('id', 'bigint', 20, unsigned: true))->autoIncrement()->change(); - $this->schema->alter($change); - - $column = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'id'); - - $this->assertSame('auto_increment', strtolower((string) ($column['Extra'] ?? ''))); - } - - public function test_migration_repository_persists_records_in_wordpress(): void { - $tableName = $this->unprefixedTable('migrations'); - $schema = $this->schema; - $migrationTable = new MigrationTable($tableName, $this->database); - $table = $this->database->tableName($migrationTable); - $repository = new Repository($migrationTable); - - $this->assertFalse($schema->hasTable($migrationTable)); - - (new StoreSchema( - $schema, - $this->reconciler, - $migrationTable, - new LockTable($this->unprefixedTable('repository_locks'), $this->database) - ))->initializeLedger(); - - $this->assertTrue($schema->hasTable($migrationTable)); - $this->assertSame($table, $migrationTable->name()); - $this->assertNull($repository->latestBatch()); - - $repository->recordRun('2026_06_23_000001_create_example_table', 1); - $record = $repository->all()['2026_06_23_000001_create_example_table']; - - $this->assertGreaterThan(0, $record->id); - $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example_table')); - $this->assertSame(1, $repository->latestBatch()); - $this->assertArrayHasKey('2026_06_23_000001_create_example_table', $repository->all()); - $this->assertCount(1, $repository->recordsForBatch(1)); - $this->assertTrue($repository->deleteRun('2026_06_23_000001_create_example_table')); - $this->assertFalse($repository->hasRun('2026_06_23_000001_create_example_table')); - - $repository->recordRun('CreateReports', 2); - $repository->recordRun('createreports', 2); - - $this->assertTrue($repository->hasRun('CreateReports')); - $this->assertTrue($repository->hasRun('createreports')); - $this->assertSame( - ['CreateReports', 'createreports'], - array_map( - static fn (Record $record): string => $record->migration, - $repository->recordsForBatch(2) - ) - ); - - $schema->drop($migrationTable); - - $this->assertFalse($schema->hasTable($migrationTable)); - } - - public function test_database_lock_coordinates_ownership_in_wordpress(): void { - $tableName = $this->unprefixedTable('locks'); - $wpSchema = $this->schema; - $lockTable = new LockTable($tableName, $this->database); - $table = $this->database->tableName($lockTable); - $lock = new DatabaseLock($this->database, $lockTable); - - $this->assertFalse($wpSchema->hasTable($lockTable)); - - (new StoreSchema( - $wpSchema, - $this->reconciler, - new MigrationTable($this->unprefixedTable('lock_migrations'), $this->database), - $lockTable - ))->initializeLock(); - - $this->assertTrue($wpSchema->hasTable($lockTable)); - $this->assertSame($table, $lockTable->name()); - - $token = $lock->acquire('foundation:database:test', 60); - - $this->assertNotNull($token); - $this->assertNull($lock->acquire('foundation:database:test', 60)); - $this->assertTrue($lock->isAcquired('foundation:database:test')); - $this->assertNotNull($lock->refresh($token, 120)); - $this->assertTrue($lock->release($token)); - $this->assertFalse($lock->isAcquired('foundation:database:test')); - - $wpSchema->drop($lockTable); - - $this->assertFalse($wpSchema->hasTable($lockTable)); - } - - public function test_database_lock_replaces_expired_ownership_without_allowing_the_previous_owner_to_release_it(): void { - $tableName = $this->unprefixedTable('expired_locks'); - $wpSchema = $this->schema; - $lockTable = new LockTable($tableName, $this->database); - $table = $this->database->tableName($lockTable); - $lock = new DatabaseLock($this->database, $lockTable); - - (new StoreSchema( - $wpSchema, - $this->reconciler, - new MigrationTable($this->unprefixedTable('expired_lock_migrations'), $this->database), - $lockTable - ))->initializeLock(); - - $first = $lock->acquire('foundation:database:takeover', 60); - - $this->assertNotNull($first); - - $this->database->execute( - 'UPDATE %i SET expires_at = TIMESTAMPADD(SECOND, -1, UTC_TIMESTAMP(6)) WHERE name = %s', - $table, - 'foundation:database:takeover' - ); - - $second = $lock->acquire('foundation:database:takeover', 60); - - $this->assertNotNull($second); - $this->assertNotSame($first->owner, $second->owner); - $this->assertFalse($lock->release($first)); - $this->assertTrue($lock->isAcquired('foundation:database:takeover')); - $this->assertTrue($lock->release($second)); - - $wpSchema->drop($lockTable); - } - - public function test_database_lock_compares_names_and_owners_by_exact_bytes(): void { - $tableName = $this->unprefixedTable('exact_locks'); - $wpSchema = $this->schema; - $lockTable = new LockTable($tableName, $this->database); - $table = $this->database->tableName($lockTable); - $lock = new DatabaseLock($this->database, $lockTable); - - (new StoreSchema( - $wpSchema, - $this->reconciler, - new MigrationTable($this->unprefixedTable('exact_lock_migrations'), $this->database), - $lockTable - ))->initializeLock(); - - $upper = $lock->acquire('Catalog:1', 60); - $lower = $lock->acquire('catalog:1', 60); - - $this->assertNotNull($upper); - $this->assertNotNull($lower); - - $this->database->execute( - 'UPDATE %i SET owner = %s WHERE name = %s', - $table, - 'owner', - $lower->name - ); - - $this->assertFalse($lock->release(new LockToken($lower->name, 'OWNER', $lower->expiresAt))); - $this->assertTrue($lock->release(new LockToken($lower->name, 'owner', $lower->expiresAt))); - $this->assertTrue($lock->release($upper)); - - $wpSchema->drop($lockTable); - } - - public function test_lock_table_reconciles_an_existing_previous_definition(): void { - $tableName = $this->unprefixedTable('previous_lock_schema'); - $wpSchema = $this->schema; - $lockTable = new LockTable($tableName, $this->database); - $table = $this->database->tableName($lockTable); - - $this->database->execute(sprintf( - 'CREATE TABLE %s ( - name varchar(191) NOT NULL, - owner varchar(64) NOT NULL, - expires_at datetime NOT NULL, - created_at datetime NOT NULL, - updated_at datetime NOT NULL, - PRIMARY KEY (name), - KEY expires_at (expires_at) - ) %s', - $this->database->quoteIdentifier($table), - $this->database->charsetCollate() - )); - - (new StoreSchema( - $wpSchema, - $this->reconciler, - new MigrationTable($this->unprefixedTable('previous_lock_migrations'), $this->database), - $lockTable - ))->initializeLock(); - - $name = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'name'); - $owner = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'owner'); - $expiration = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'expires_at'); - - $this->assertSame('varbinary(191)', strtolower((string) ($name['Type'] ?? ''))); - $this->assertSame('varbinary(64)', strtolower((string) ($owner['Type'] ?? ''))); - $this->assertSame('datetime(6)', strtolower((string) ($expiration['Type'] ?? ''))); - - $wpSchema->drop($lockTable); - } - - public function test_migration_table_reconciles_case_insensitive_identifiers(): void { - $tableName = $this->unprefixedTable('previous_migration_schema'); - $wpSchema = $this->schema; - $migrationTable = new MigrationTable($tableName, $this->database); - $table = $this->database->tableName($migrationTable); - - $this->database->execute(sprintf( - 'CREATE TABLE %s ( - id bigint(20) unsigned NOT NULL AUTO_INCREMENT, - migration varchar(191) NOT NULL, - batch int(10) unsigned NOT NULL, - ran_at datetime NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY migration (migration), - KEY batch (batch) - ) %s', - $this->database->quoteIdentifier($table), - $this->database->charsetCollate() - )); - - (new StoreSchema( - $wpSchema, - $this->reconciler, - $migrationTable, - new LockTable($this->unprefixedTable('previous_migration_locks'), $this->database) - ))->initializeLedger(); - - $migration = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'migration'); - - $this->assertSame('varbinary(191)', strtolower((string) ($migration['Type'] ?? ''))); - - $wpSchema->drop($migrationTable); - } - - public function test_provider_registers_wordpress_prefixed_database_services(): void { - $container = $this->newContainer(); - - $container->register(DatabaseProvider::class); - - $this->assertInstanceOf(Database::class, $container->get(Database::class)); - $this->assertInstanceOf(Database::class, $container->get(DatabaseContract::class)); - $this->assertInstanceOf(Schema::class, $container->get(Schema::class)); - $this->assertSame($GLOBALS['wpdb']->prefix . 'nx_foundation_migrations', $container->get(MigrationTable::class)->name()); - $this->assertSame($GLOBALS['wpdb']->prefix . 'nx_foundation_locks', $container->get(LockTable::class)->name()); - $this->assertInstanceOf(Repository::class, $container->get(MigrationRecordRepositoryContract::class)); - $this->assertInstanceOf(Migrator::class, $container->get(Migrator::class)); - $this->assertFalse($container->has(Lock::class)); - } - - /** - * Apply a complete replacement declaration for the test description column. - */ - private function alterDescriptionComment(CommentedTable $table, ?string $comment): void { - $change = Blueprint::for($table); - $description = $change->text('description'); - - if ($comment !== null) { - $description->comment($comment); - } - - $description->change(); - $this->schema->alter($change); - } - - /** - * Change comments while retaining every modeled column attribute. - */ - private function alterReconciliationComments(CommentReconciliationTable $table, ?string $comment): void { - $change = Blueprint::for($table); - $id = $change->column(new Column('id', 'bigint', 20, unsigned: true))->autoIncrement(); - $description = $change->string('description', 100)->nullable()->default('fallback'); - - if ($comment !== null) { - $id->comment($comment); - $description->comment($comment); - } - - $id->change(); - $description->change(); - $this->schema->alter($change); - } - - private function assertCommentReconciliationTable(string $unprefixedTableName, string $comment): void { - $table = new CommentReconciliationTable($unprefixedTableName, null); - $id = $this->database->row( - 'SHOW FULL COLUMNS FROM %i WHERE Field = %s', - $this->database->tableName($table), - 'id' - ); - $description = $this->database->row( - 'SHOW FULL COLUMNS FROM %i WHERE Field = %s', - $this->database->tableName($table), - 'description' - ); - - $this->assertStringContainsString('bigint', strtolower((string) ($id['Type'] ?? ''))); - $this->assertStringContainsString('unsigned', strtolower((string) ($id['Type'] ?? ''))); - $this->assertSame('NO', $id['Null'] ?? null); - $this->assertSame('auto_increment', strtolower((string) ($id['Extra'] ?? ''))); - $this->assertSame($comment, $id['Comment'] ?? null); - $this->assertSame('varchar(100)', strtolower((string) ($description['Type'] ?? ''))); - $this->assertSame('YES', $description['Null'] ?? null); - $this->assertSame('fallback', $description['Default'] ?? null); - $this->assertSame($comment, $description['Comment'] ?? null); - } - - /** - * Freeze database time in UTC while preserving a pre-existing frozen or live session clock. - * - * @param callable(): void $callback - */ - private function withControlledTimestamp(callable $callback): void { - $timezone = (string) $this->database->value('SELECT @@SESSION.time_zone'); - $timestamp = (string) $this->database->value('SELECT @@SESSION.timestamp'); - $next = (string) $this->database->value('SELECT @@SESSION.timestamp'); - // Reading timestamp=0 returns the changing statement time, not the zero sentinel. - $restore = $timestamp === $next ? $timestamp : '0'; - - try { - $this->database->execute("SET SESSION time_zone = '+00:00'"); - $this->database->execute('SET SESSION timestamp = 1704067200'); - $callback(); - } finally { - try { - $this->database->execute('SET SESSION timestamp = %f', (float) $restore); - } finally { - $this->database->execute('SET SESSION time_zone = %s', $timezone); - } - } - } - - /** - * Assert timestamp migration retries only inspect the existing schema. - * - * @param callable(): void $callback - */ - private function assertNoTimestampDdl(callable $callback): void { - $statements = []; - $recordDdl = static function (string $sql) use (&$statements): string { - if (preg_match('/\A\s*(?:CREATE|ALTER|DROP)\b/i', $sql) === 1) { - $statements[] = $sql; - } - - return $sql; - }; - add_filter('query', $recordDdl); - - try { - $callback(); - } finally { - remove_filter('query', $recordDdl); - } - - $this->assertSame([], $statements); - } - - private function table(string $suffix): string { - return $this->database->tableName(new TestTable($this->unprefixedTable($suffix))); - } - - private function unprefixedTable(string $suffix): string { - $table = 'foundation_' . $suffix . '_' . str_replace('.', '_', uniqid('', true)); - - $this->tables[] = $this->database->tableName(new TestTable($table)); - - return $table; - } - - /** - * @param callable(): mixed $callback - */ - private function assertQueryFails(callable $callback): QueryException { - try { - $callback(); - } catch (QueryException $exception) { - $this->assertNotSame('', $exception->getMessage()); - - return $exception; - } - - $this->fail('Expected the database operation to throw a query exception.'); - } - - private function newContainer(): Container { - return (new ContainerFactory())->create(new ArrayConfiguration()); - } -} diff --git a/tests/wpunit/Database/Schema/DbDeltaTest.php b/tests/wpunit/Database/Schema/DbDeltaTest.php deleted file mode 100644 index cc15078..0000000 --- a/tests/wpunit/Database/Schema/DbDeltaTest.php +++ /dev/null @@ -1,69 +0,0 @@ -originalLastError = $GLOBALS['wpdb']->last_error; - $GLOBALS['wpdb']->last_error = ''; - } - - protected function tearDown(): void { - $GLOBALS['wpdb']->last_error = $this->originalLastError; - - parent::tearDown(); - } - - public function test_it_executes_the_schema_definition_once(): void { - $dbDelta = PHPMockery::mock('StellarWP\Foundation\Database\Schema', 'dbDelta'); - $dbDelta->with([self::SQL], true)->once()->andReturn([]); - - (new DbDelta())->execute(self::SQL); - - $this->addToAssertionCount(1); - } - - public function test_it_translates_wordpress_database_errors(): void { - $dbDelta = PHPMockery::mock('StellarWP\Foundation\Database\Schema', 'dbDelta'); - $dbDelta->with([self::SQL], true)->once()->andReturnUsing(static function (): array { - $GLOBALS['wpdb']->last_error = 'Could not alter the table.'; - - return []; - }); - - $this->expectException(QueryException::class); - $this->expectExceptionMessage('Could not alter the table.'); - - (new DbDelta())->execute(self::SQL); - } - - public function test_it_fails_when_the_global_wordpress_database_is_unavailable(): void { - $wpdb = $GLOBALS['wpdb']; - unset($GLOBALS['wpdb']); - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('The global wpdb instance is not available.'); - - try { - (new DbDelta())->execute(self::SQL); - } finally { - $GLOBALS['wpdb'] = $wpdb; - } - } -} From 3313167f6ce807eb81f9578ddd7cdbc8ef1c1494 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 22 Sep 2026 17:34:14 -0600 Subject: [PATCH 03/53] Document Doctrine database workflows and extension boundaries --- AGENTS.md | 26 +- .../src/content/docs/components/database.mdx | 184 ++--- .../content/docs/components/database/lock.mdx | 8 +- .../docs/components/database/migrations.mdx | 749 +++--------------- .../components/database/query-builder.mdx | 264 ++---- 5 files changed, 251 insertions(+), 980 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f111791..e949c78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Generate WordPress code with Snake_Case classes and WordPress formatting. Detect Migration generators must not offer a force-overwrite option. Existing migrations are identity-bearing history: edit a migration only before it has been applied anywhere, or create a new migration for a new schema change. -Database migration generators must distinguish table ownership from a table dependency through explicit options. Only `make:database-migration --create=` may use the create-table stub whose migration-owned blueprint defines the complete initial schema and whose rollback drops the complete table. `make:database-migration --table=` selects an alteration migration: inject the existing table, declare only the explicit additions, changes, and removals owned by that migration, and default `down()` to `IrreversibleMigration` until the developer supplies a safe inverse. The options are mutually exclusive and accept either a short class from the default table namespace or a fully qualified class. Never infer destructive behavior from a migration name. `make:database-table --migration` must reuse the same create-migration factory so combined and standalone generation remain consistent. +Database migration generators must distinguish table ownership from a table dependency through explicit options. Only `make:database-migration --create=` may use the create-table stub whose migration-owned schema declaration defines the complete initial schema and whose rollback drops the complete table. `make:database-migration --table=` selects an alteration migration: inject the existing table, declare only the explicit additions, changes, and removals owned by that migration, and default `down()` to `IrreversibleMigration` until the developer supplies a safe inverse. The options are mutually exclusive and accept either a short class from the default table namespace or a fully qualified class. Never infer destructive behavior from a migration name. `make:database-table --migration` must reuse the same create-migration factory so combined and standalone generation remain consistent. ## CLI Tooling Boundary @@ -127,7 +127,7 @@ When a package provides container integration, bind autowireable classes and int All providers extend `Provider`. Its final constructor receives `Container` and resolves the application-lifetime `Configuration` snapshot; subclasses use `$this->config` when needed and must not declare constructors. Keep one provider shape. Bind `Configuration` before registering providers, and keep third-party implementations such as `Adbar\Dot` behind a Foundation-owned adapter. -Foundation providers have one eager `register()` lifecycle. Keep deferred construction in lazy service bindings rather than adding provider-level `provides()`, `isDeferred()`, or `boot()` phases tied to a particular container engine. Providers that append to additive collections or register hooks must make repeated registration idempotent so a second `register()` call does not duplicate contributions or callbacks. +Foundation providers have one eager `register()` lifecycle. Keep deferred construction in lazy service bindings rather than adding provider-level `provides()`, `isDeferred()`, or `boot()` phases tied to a particular container engine. The composition root registers each provider once per container, in dependency order, before resolving application services. Provider registration establishes bootstrap wiring; it is not a supported way to reset or reconfigure a running container. Developers own a correct provider list; accidental repetition may replace bindings or duplicate contributions and callbacks. Do not add per-provider registration flags or central deduplication without a demonstrated supported workflow that requires repeated registration. Use `StellarWP\Foundation\Container\ContainerFactory` at composition roots for the default container; it owns DI52 construction and core contract bindings. Construct `ContainerAdapter` directly only in adapter-specific tests or deliberate integration with a separately owned DI52 instance. @@ -147,21 +147,21 @@ Register infrastructure providers and top-level feature providers from the appli ## Database -`stellarwp/foundation-database` is a WordPress-backed database package. Keep its runtime implementation centered on `wpdb`, `dbDelta()`, WordPress table prefixes, and WP-CLI integration. If the project later needs file storage, Redis storage, PDO database support, or another non-WordPress backend, prefer a separate package or explicit driver package instead of making `foundation-database` a generic DBAL-style abstraction. +`stellarwp/foundation-database` provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress migration and transaction behavior. Developers use Doctrine directly for database expressions and advanced operations. Doctrine owns query construction, parameter binding, execution, result handling, schema inspection, and platform SQL generation. Foundation owns provider defaults, WordPress connection integration, resource naming, and operational guarantees. Foundation 2.0 targets Doctrine DBAL `^4.4` on PHP 8.3. DBAL 3 support is deferred until the Rector downgrade feature is implemented; it is not a Foundation 2.0 release requirement. Keep dependency-major differences confined to internal connection and schema boundaries, and validate dependency behavior separately from Rector syntax conversion before widening constraints. Future DBAL major lines require a deliberate compatibility decision. -Application table classes should extend `StellarWP\Foundation\Database\Table\Table`. Inject the concrete table into repositories and other classes that perform ordinary table-scoped queries or writes; the table provides `query()`, `insert()`, `insertGetId()`, `update()`, and `delete()` without requiring callers to coordinate a separate database service and table name. Inject the smallest additional database capability, such as `QueryExecutor`, only for specialized raw or cross-table SQL that does not fit the table gateway or query builder. Use the aggregate `Database` contract only when a collaborator genuinely needs several capabilities. Implement the minimal `Contracts\Table` directly only for custom table implementations that do not need the supplied gateway. Every table implementation must return a stable name without a WordPress prefix from `unprefixedName()`. Foundation SQL services must resolve and validate the physical name through `Database::tableName($table)` at the point of use; custom table implementations must not apply `$wpdb->prefix` or enforce physical-name constraints themselves. +Use Doctrine's native `Connection`, query builder, results, exceptions, and schema objects where they supply the required behavior. Retire Foundation query builders, execution facades, SQL renderers, and gateway/capability contracts that merely duplicate those APIs as their callers are converted. Keep contracts for meaningful Foundation-owned policies and supported variations. Exposed Doctrine types and behavior are intentional compatibility commitments; do not imply that replacing Doctrine will be transparent to consumers. Preserve Foundation-specific exceptions for scope, migration, and managed-transaction failures. -Generated table classes must not repeat the base table's infrastructure constructor. The base class owns its container-resolved `TableGateway`, and generated subclasses provide only table identity through `unprefixedName()`. Migration-owned `Blueprint` instances define historical schema operations; do not place a mutable current-schema definition on the table class. Migration identity and schema history belong to each migration, not to the table it changes. This keeps generated application code stable if Foundation changes its internal construction dependencies or a table evolves over time. +Application table classes should extend `StellarWP\Foundation\Database\Table\Table`. Inject the concrete table for ordinary table-scoped queries and writes; retain `query()`, `insert()`, `insertGetId()`, `update()`, and `delete()` conveniences with deliberate return and failure semantics. `query()` creates a fresh native Doctrine builder with its starting table configured. Do not inject a reusable mutable builder into a repository. Inject the shared Doctrine `Connection` for transaction boundaries, specialized SQL, or cross-table work that needs it. Add table conveniences when they own demonstrated reusable behavior, not merely to forward every Doctrine method. Repositories are application query organization, not a required layer for every table. -Use `Schema::create()` with a complete migration-owned blueprint for a table's initial migration. Use `Schema::alter()` with only the explicit additions, full column modifications marked by `change()`, and removals owned by a later migration. Alterations must be safe to retry after DDL succeeds without a ledger write: existing additions and absent removals are treated as completed work, while incompatible existing declarations fail verification. Do not continuously compare a table against one mutable desired definition. Physical indexes not declared by the current migration may be owned by another migration or external system and must not cause reconciliation to fail. +Every table implementation returns a stable unprefixed name from `unprefixedName()`. Resolve and validate physical names through `TableNameResolver` at the point of use, applying the current `DatabaseScope`; application tables and repositories must not repeat WordPress prefix handling or physical-name validation. Foundation table and schema entry points accept table objects. Native Doctrine and raw SQL use their resolved, quoted names. Generated table classes inherit the base class's infrastructure constructor and declare only application concerns. Keep historical schema definitions in migrations, independent of runtime table classes. -Keep the concrete `Database` as the convenient complete facade, but add new implementable behavior through narrow capability contracts. Raw read-only collaborators should depend on `QueryReader`, table-scoped query builders should depend on `QueryGateway`, and the supplied table base may depend on `TableGateway` because it exposes both reads and writes. Do not add optional features such as upserts, transactions, streaming reads, or bulk writes directly to an existing aggregate when alternate implementations would be forced to support them. Bind default capability contracts through the aggregate `Database` contract so an application replacement remains coherent. +Providers supply one shared managed Doctrine connection per container so tables, repositories, and transaction-owning services participate in the same session. Keep WordPress's native mysqli access inside the connection integration; route Foundation database execution through Doctrine rather than `wpdb::query()` or `dbDelta()`. Applications choose the transaction boundary; Foundation owns cleanup, terminal failure tracking, and commit acknowledgement. Never replay part of a failed transaction. A caught database execution failure must prevent successful completion, an escaping business exception must survive cleanup failure, and an uncertain commit must be reported distinctly. Nested success remains provisional until the outer transaction commits. A replacement connection must satisfy these documented guarantees, not merely extend Doctrine's connection class. -Foundation Database and Schema operations should accept `Table` instances rather than offering parallel string table-name arguments. Keep unprefixed strings at the table construction and prefix-resolution boundary instead of exposing them as an alternative developer workflow. +Use Doctrine schema objects and platform tools for schema declarations, inspection, comparison, and DDL generation. Retain Foundation policy only where it enforces migration ownership, safe recovery, or demonstrated normalization gaps. An initial migration owns the complete table definition; later migrations own explicit additions, full column modifications, and removals. After DDL succeeds without a ledger write, compatible additions and absent removals count as completed work, while incompatible existing declarations fail verification. Do not reconcile continuously against a mutable current-schema definition or remove unrelated columns and indexes. Preserve decimal and timestamp semantics when replacing schema machinery. DBAL adoption does not by itself establish Doctrine Migrations as a suitable replacement runner. -Migration IDs determine forward execution order. Collect all provider contributions into one migration collection, sort globally in ascending byte-exact ID order, and run pending migrations. Contribution order must not affect execution. Rollbacks reverse actual ledger execution order. Generated timestamps provide sortable IDs; custom IDs remain valid and assume responsibility for their lexical position. +Migration IDs determine forward execution order and must remain stable across PHP namespace changes and Strauss scoping. Collect all provider contributions, sort globally in ascending byte-exact ID order, and run pending migrations under migration locking. Contribution order must not affect execution. Use target-version rollback in descending byte-exact ID order. Developers own migration dependencies and selecting a safe rollback target; document that behavior rather than adding execution-order tracking or batch semantics solely to handle migrations introduced out of order. Generated timestamps provide sortable IDs; custom IDs remain valid and assume responsibility for their lexical position. Foundation owns migration registration, stable identities, WordPress integration, locking, and its documented recovery guarantees so application migrations declare their changes without assembling that infrastructure. Foundation owns declarative migration planning, recovery, execution, and history. Doctrine supplies schema comparison and platform SQL. Keep DBAL-specific schema translation internal so a future DBAL 3 downgrade does not change application migration declarations. -Foundation Database stores unprefixed table names and applies the active WordPress site's prefix through `DatabaseScope` on every access. The same container and database services may be reused after `switch_to_blog()` between complete `Migrator` calls. Each migration operation must capture its starting scope and reject a context change before lock renewal, ledger writes, or lock release; when the context is uncertain, leave the original lock to expire rather than touching another site's lock table. A migration must never call `switch_to_blog()` itself, even temporarily, because restoring the original site before a boundary check would hide schema writes made in the wrong scope. Keep developer-authored `Migration` and `Schema` contracts independent of site scope so a future network-wide implementation can use `$wpdb->base_prefix` and network-specific state without requiring migration rewrites. +The same container and database services may be reused after `switch_to_blog()` between complete operations; already-built queries retain the physical names resolved when built. Managed transactions and migration operations capture their starting scope and reject site or connection changes at their operation boundaries. Migrations hold a MySQL/MariaDB named lock on their captured session, check scope and ownership before execution and ledger writes, and release only on the original session. A failed release discards that session. Migration execution never creates the optional application database-lock table. Applications must not issue DDL, manual transaction control, direct WordPress queries, or `switch_to_blog()` inside a managed transaction. Migrations must never switch sites, even temporarily. Keep application migration declarations independent of site scope so another supported scope does not require migration rewrites. ## Split Packages @@ -169,7 +169,9 @@ Split packages live in `src//` and are split to read-only repositories When adding a new split package, set its package `composer.json` PHP constraint to `>=8.3` unless the user explicitly says otherwise. PHP 7.4 release compatibility will be handled later by an automated Rector downgrade workflow, not by lowering the package PHP constraint during development. -When adding external dependencies for split packages, choose version constraints whose package line supports PHP 7.4. Use `>=` constraints for those dependencies instead of caret constraints when preserving the PHP 7.4-compatible floor matters. For example, use a Symfony component version such as `>=5.4` rather than a newer line that requires PHP 8+. +Doctrine DBAL is an explicit exception to the legacy dependency floor: Foundation 2.0 uses `^4.4`, as described in Database above. + +When adding other external dependencies for split packages, choose version constraints whose package line supports PHP 7.4. Use `>=` constraints for those dependencies instead of caret constraints when preserving the PHP 7.4-compatible floor matters. For example, use a Symfony component version such as `>=5.4` rather than a newer line that requires PHP 8+. Important exception: dependencies on this monorepo's own split packages, such as `stellarwp/foundation-container`, should use the correct Composer release constraint like `^1.0`. Do not use `>=` for internal Foundation package dependencies; Monorepo Builder commands such as `composer monorepo bump-interdependency` are expected to bump those constraints during releases. @@ -278,7 +280,7 @@ Codeception tests run through SLIC. Use SLIC 2.3.0 or newer so PCOV-backed cover GitHub workflows should check out SLIC from `main`; do not pin SLIC to a release tag or commit. -Test suite meanings: `Unit` is isolated class/package behavior, `Feature` is Foundation feature behavior without bootstrapping WordPress, `redis` is real Redis behavior shared across packages and run against SLIC's Redis service, `integration` is multi-provider/container behavior that may require WordPress runtime APIs such as hooks, `wpdb`, `dbDelta()`, or globals, `wpunit` is lower-level WordPress-loaded behavior through wp-browser, and `wpcli` is the shared monorepo suite for testing WP-CLI commands through wp-browser's WPCLI module. If a PHPUnit test uses `#[DataProvider]` and must run under Codeception, also include the matching `@dataProvider` docblock because Codeception's PHPUnit loader reads docblock providers for these tests. +Test suite meanings: `Unit` is isolated class/package behavior, `Feature` is Foundation feature behavior without bootstrapping WordPress, `redis` is real Redis behavior shared across packages and run against SLIC's Redis service, `integration` is multi-provider/container behavior that may require WordPress runtime APIs such as hooks, the WordPress database connection, or globals, `wpunit` is lower-level WordPress-loaded behavior through wp-browser, and `wpcli` is the shared monorepo suite for testing WP-CLI commands through wp-browser's WPCLI module. If a PHPUnit test uses `#[DataProvider]` and must run under Codeception, also include the matching `@dataProvider` docblock because Codeception's PHPUnit loader reads docblock providers for these tests. The `integration` suite boots WordPress in multisite mode so cross-provider behavior can exercise real site creation and `switch_to_blog()` lifecycles. diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index 5efd373..20bf9a0 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -1,60 +1,25 @@ --- title: Database -description: WordPress-backed migrations, queries, and distributed locks built on wpdb and dbDelta. +description: A shared Doctrine connection, application tables, managed transactions, and WordPress migrations. sidebar: order: 2 --- import { CardGrid, LinkCard } from '@astrojs/starlight/components'; -Foundation Database provides three focused capabilities for WordPress applications: versioned schema migrations, a small inspectable query API, and a database-backed implementation of the Foundation Lock contract. It intentionally builds on `wpdb`, `dbDelta()`, and WordPress table prefixes instead of acting as a generic database abstraction. +Foundation Database provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress migration and transaction behavior. Use Doctrine directly for expressions, joins, parameter binding, results, and advanced operations. -## Installation +## Get started -Install the runtime package in the application: +Install the runtime package: ```shell composer require stellarwp/foundation-database ``` -Foundation Database installs its Container, Lock, and WP-CLI runtime dependencies automatically. Install `stellarwp/foundation-cli` separately with `--dev` only when the project uses its migration and table generators. +Foundation requires PHP 8.3, mysqli, and Doctrine DBAL 4.4. Database services borrow the active WordPress connection. Use the primary MySQL or MariaDB database and InnoDB tables for transactional application data. -:::note[Runtime requirements] -Foundation Database requires WordPress 6.2 or newer because prepared identifier placeholders use `%i`. Database locks require MySQL 5.6.4 or newer, or MariaDB 5.3 or newer, for fractional-second timestamps. -::: - -### Prepare the application - -Database services use the application's existing container, provider graph, Foundation prefix, and WP-CLI command prefix: - - - - - - - - -## Configuration - -### Scope database resources - -Set a stable application prefix in the root `config.php` for a standalone plugin: +Set a stable, unique application prefix in your root configuration: ```php title="config.php" your_plugin_foundation_migrations` | -| Lock table | `your_plugin_foundation_locks` | -| Migration lock | `your-plugin-foundation-database-migrations` | -| WP-CLI command | `wp your-plugin migrate` | +Create your container using [ContainerFactory](/start/configure-the-container/), then register providers once, in dependency order: -Complete WordPress applications that own the entire installation can keep the zero-configuration `nx` prefix. Standalone plugins should not share the default because another Foundation consumer could otherwise read the same migration ledger or contend for the same lock. +```php +use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\WPCli\WPCliProvider; +use Plugin\Database\Provider; -Keep the prefix stable after migrations have run. Changing it points the application at a different ledger and lock table, making every configured migration appear pending. - -Application table names are chosen by the developer. When [generating a table](/components/database/migrations/#create-and-change-a-table) for a standalone plugin, use a stable name unique to that plugin, such as `--table-name=your_plugin_reports`. WordPress adds its site prefix to produce `wp_your_plugin_reports`; `foundation.prefix` scopes the migration infrastructure listed above. - -### Override individual resources - -Package-specific settings in the root `config.php` override values derived from `foundation.prefix`: - -```php title="config.php" -return [ - 'foundation' => [ - 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', - ], - 'database' => [ - 'migrations_table' => $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'] ?? '', - 'locks_table' => $_ENV['FOUNDATION_DATABASE_LOCKS_TABLE'] ?? '', - 'lock_name' => $_ENV['FOUNDATION_DATABASE_LOCK_NAME'] ?? null, - 'lock_ttl' => (int) ( $_ENV['FOUNDATION_DATABASE_LOCK_TTL'] ?? 300 ), - ], -]; +$container->register( WPCliProvider::class ); +$container->register( DatabaseProvider::class ); +$container->register( Provider::class ); // Your application tables and migrations. ``` -Leave table names empty to use the scoped defaults. Override values are table names without a WordPress prefix; the active database scope applies the correct site prefix. For example, `your_plugin_migrations` becomes `wp_2_your_plugin_migrations` on site 2 of a typical multisite installation. All resulting prefixed table names must fit MySQL's 64-character identifier limit. +`DatabaseProvider` supplies one shared `Doctrine\DBAL\Connection`. Tables and repositories resolved from the container participate in that connection's transactions. Registration is lazy: it creates no database tables. -The migration lock settings coordinate migration execution only. Applications selecting `DatabaseLock` for their own work choose each lock name and TTL when calling `acquire()`. +### Query an application table -Set `database.lock_ttl` longer than the longest uninterrupted operation between renewals. Foundation renews the migration lock immediately before and after every `up()` and `down()` call, but it cannot renew the lease while a blocking migration method or ledger update is still running. Split unusually long work into separate migrations or increase `FOUNDATION_DATABASE_LOCK_TTL` before deployment. +Application tables declare their stable name and inherit their infrastructure constructor: -### Use database services on multisite +```php title="src/Database/Tables/Reports_Table.php" +prefix` whenever they are used. You can therefore reuse the same container and `Migrator` while iterating over sites, provided each migration operation completes before the blog changes: +namespace Plugin\Database\Tables; -```php title="Migrate_Network.php" -foreach ( $site_ids as $site_id ) { - switch_to_blog( $site_id ); +use StellarWP\Foundation\Database\Table\Table; - try { - $migrator->initialize(); - $migrator->run(); - } finally { - restore_current_blog(); +final readonly class Reports_Table extends Table { + public function unprefixedName(): string { + return 'your_plugin_reports'; } } ``` -Each `initialize()`, `run()`, `rollback()`, `refresh()`, and `dropStore()` operation captures the active blog ID when it starts. Foundation confirms that context before renewing the migration lock, writing to the ledger, and releasing the lock. - -:::caution[Do not switch blogs inside a migration] -A migration must not call `switch_to_blog()`, even if it would restore the original blog before returning. Foundation can detect a changed context at its lock and ledger boundaries, but it cannot detect a temporary switch that modifies another site's schema and returns to the original site. Switch blogs only around complete `Migrator` operations as shown above. -::: +Inject `Reports_Table` into your service and call its operations: -WP-CLI can also select each site without an in-process loop: +```php +$id = $this->reports->insertGetId( [ 'title' => 'Weekly report' ] ); -```shell -wp --url=https://site.example your-plugin migrate --initialize -wp --url=https://site.example your-plugin migrate --run +$report = $this->reports->query() + ->select( 'id', 'title' ) + ->where( 'id = :id' ) + ->setParameter( 'id', $id ) + ->fetchAssociative(); ``` -Applications that create sites dynamically can use WordPress's `wp_initialize_site` action to schedule the same initialization and migration workflow. Do not run migrations during every normal request. - -:::note[Network-wide migrations] -Foundation Database currently manages site-specific migration state. It does not provide a network-wide migration ledger for shared global tables. -::: +[Create the table through a migration](/components/database/migrations/), then use the [query and transaction guide](/components/database/query-builder/) for results, writes, and failure handling. -### Register the providers +## Configure resource names -In `src/App.php`, register `WPCliProvider` before `DatabaseProvider`, then register application providers that contribute migrations or select the database lock: +`foundation.prefix = your-plugin` gives the migration ledger the unprefixed name `your_plugin_foundation_migrations`. The optional database lock uses `your_plugin_foundation_locks`. WordPress adds its current site prefix. The migration advisory lock is derived from the database and ledger names, so applications with different ledgers migrate independently. -```php title="App.php" -use StellarWP\Foundation\Container\Contracts\Provider; -use StellarWP\Foundation\Database; -use StellarWP\Foundation\WPCli; -use Plugin\Database as ApplicationDatabase; +Override table names when necessary: -/** @var list> */ -private const array PROVIDERS = [ - WPCli\WPCliProvider::class, - Database\DatabaseProvider::class, - ApplicationDatabase\Provider::class, +```php title="config.php" +return [ + 'foundation' => [ + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], + 'database' => [ + 'migrations_table' => $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'] ?? 'your_plugin_migrations', + 'locks_table' => $_ENV['FOUNDATION_DATABASE_LOCKS_TABLE'] ?? 'your_plugin_locks', + ], ]; ``` -`DatabaseProvider` configures `wpdb`, schema services, migration storage, the migration lock, and the `migrate` command. It does not create tables, run migrations, or select `DatabaseLock` as the application's general `Lock` implementation during WordPress bootstrap. +These are names without a WordPress prefix. Physical names must contain only ASCII letters, numbers, and underscores, and fit within 64 bytes including the WordPress prefix. Application tables choose their own unique names, such as `your_plugin_reports`. + +Keep the Foundation prefix and configured storage names stable across releases. Changing a ledger name makes recorded migrations appear pending. PHP namespace changes and Strauss scoping do not change migration IDs or these resource names. + +## Multisite and customization + +A shared container may be reused after `switch_to_blog()` between complete operations. Tables resolve the active prefix when called; a query already built retains its original table name. Build a fresh query after switching sites. Run migrations separately for each site, for example `wp --url=https://site.example your-plugin migrate --run`. + +Transactions and migrations capture their starting scope and reject detected site or connection changes. Never switch sites, even temporarily, during those operations. -## Choose a feature +Applications can replace `DatabaseScope` and `TableNameResolver` when they own a different naming policy. Register replacements after `DatabaseProvider`, before resolving database services. Replacing the Doctrine connection also requires preserving Foundation's documented transaction and migration guarantees, including terminal failure tracking, acknowledged commits, and the shared WordPress session. A plain DBAL connection alone does not provide those guarantees. - - - + + + diff --git a/src/Docs/src/content/docs/components/database/lock.mdx b/src/Docs/src/content/docs/components/database/lock.mdx index 29f5dcd..20ff382 100644 --- a/src/Docs/src/content/docs/components/database/lock.mdx +++ b/src/Docs/src/content/docs/components/database/lock.mdx @@ -76,13 +76,13 @@ private const array PROVIDERS = [ ### Initialize lock storage -Create or reconcile the database lock table during deployment: +Create the configured lock table during activation or deployment by resolving `DatabaseLock` and calling `initialize()` after registering the providers: -```shell -wp your-plugin migrate --initialize +```php +$container->get( DatabaseLock::class )->initialize(); ``` -Replace `your-plugin` with the configured command prefix. The command is idempotent, so deployment automation can run it before migrations without first checking whether the table exists. +Subsequent calls reuse the existing table. Initialize storage before services acquire locks. Normal lock operations perform no DDL and report `LockUnavailableException` if storage is unavailable. ## Usage diff --git a/src/Docs/src/content/docs/components/database/migrations.mdx b/src/Docs/src/content/docs/components/database/migrations.mdx index 1e578f1..b16d85e 100644 --- a/src/Docs/src/content/docs/components/database/migrations.mdx +++ b/src/Docs/src/content/docs/components/database/migrations.mdx @@ -1,733 +1,200 @@ --- title: Migrations -description: Define WordPress tables, register versioned schema changes, and run them safely during deployment. +description: Generate declarative schema changes, preview SQL, and recover interrupted deployments. sidebar: order: 1 --- -import { LinkCard, Steps } from '@astrojs/starlight/components'; - -Foundation migrations apply ordered database changes and record each successful run in a WordPress-backed ledger. Prefer the bundled WP-CLI command during deployment so initialization, locking, execution, and status reporting follow one path. - - +Foundation migrations describe historical changes to application tables. Foundation orders registered migrations, acquires a database advisory lock, applies the remaining schema changes, and records each successful migration. Doctrine supplies schema inspection, comparison, and platform SQL. ## Create and change a table - - -1. **Create the database provider** - - Install the generator as a development dependency, then create the provider that will collect the application's tables and migrations: - - ```shell - composer require --dev stellarwp/foundation-cli - vendor/bin/foundation make:database-provider - ``` - - Register the generated `Plugin\Database\Provider` in the application's ordered provider list. See [Register the database providers](/components/database/#register-the-providers). - -2. **Generate the table and its initial migration** - - ```shell - vendor/bin/foundation make:database-table Reports_Table \ - --table-name=your_plugin_reports --migration - ``` - - This creates the table class and `Create_Reports_Table` migration, then adds both registrations to the conventional database provider. Review the migration before running it: its `down()` method drops the complete table and its data. - - Choose a stable table name unique to your plugin. WordPress adds its site prefix, producing a name such as `wp_your_plugin_reports`. Keep the application table name fixed after deployment. - -3. **Define the complete initial schema** - - Edit `src/Database/Migrations/Create_Reports_Table.php` and describe every column and index the table initially requires: - - ```php - public function up( Schema $schema ): void { - $blueprint = Blueprint::for( $this->table ); - - $blueprint->bigIncrements( 'id' ); - $blueprint->string( 'status', 20 )->default( 'draft' ); - $blueprint->dateTime( 'created_at' ); - $blueprint->index( 'status', 'status' ); - - $schema->create( $blueprint ); - } - ``` - - Once this migration has been applied anywhere, do not change its blueprint or permanent migration ID. - -4. **Initialize storage and run the migration** - - ```shell - wp your-plugin migrate --initialize - wp your-plugin migrate --run - ``` - - Use the application's configured WP-CLI prefix in place of `your-plugin`. Run both idempotent commands during deployment. - -5. **Create a new migration for every later change** - - Do not edit the table class or original migration. Generate an alteration migration against the existing table class: - - ```shell - vendor/bin/foundation make:database-migration Add_Publishing_To_Reports \ - --table=Reports_Table - ``` - - In the new migration, declare only its additions, complete column changes, and removals, then call `Schema::alter()`. Deploy it with the same `--initialize` and `--run` commands. See [Alter an existing table](#alter-an-existing-table) for the complete API and rollback behavior. - - - -The table class remains the stable identity and query gateway throughout this lifecycle. The creation migration owns the original schema, and every later migration owns one subsequent change. - -## Generate migration files - -### Understand generated files - -The generators use the project's Composer namespace and create this feature structure by default: - -```text -src/Database/ - Provider.php - Migrations/ - Create_Reports_Table.php - Tables/ - Reports_Table.php -``` - -When the conventional provider was created by `make:database-provider`, the table and migration generators add their registrations automatically. Register that provider in the application's ordered provider list as shown in [Database configuration](/components/database/#register-the-providers). - -For a provider generated at another location, pass the same file to later commands: +After [configuring DatabaseProvider](/components/database/), install the development CLI and generate an application provider: ```shell -vendor/bin/foundation make:database-provider Reporting_Provider \ - --namespace=Plugin\\Reporting \ - --path=src/Reporting -vendor/bin/foundation make:database-table Reports_Table --migration \ - --table-name=your_plugin_reports \ - --provider=src/Reporting/Reporting_Provider.php +composer require --dev stellarwp/foundation-cli +vendor/bin/foundation make:database-provider +vendor/bin/foundation make:database-table reports --table-name=your_plugin_reports --migration ``` -### Understand migration classes +The generator creates a table and its initial migration, and adds their registrations to the generated provider. Register that provider after `DatabaseProvider` in your application's composition root. -Every migration has one permanent identifier and two operations: +The table owns only its stable name. The migration owns the initial schema: -| Member | Purpose | -| --- | --- | -| `id()` | Returns the byte-exact identifier stored in the migration ledger. Never change it after deployment. | -| `up()` | Applies the schema or data change. | -| `down()` | Reverses the change, or throws `IrreversibleMigration` when no safe inverse exists. | - -Constructor injection is available for application tables and other services. Foundation resolves each registered migration through the container when a migration operation runs. - -### Customize generated output - -The conventional generator paths and class names require no extra options. When a project uses a different structure, the table generator's `--namespace` and `--path` options customize the table class; its migration uses the project-configured migration namespace and path, falling back to `Database\\Migrations`. Configure persistent defaults for providers, tables, and migrations in [generator locations](/tooling/foundation-cli/#configure-generator-locations). Pass an explicit identifier such as `--migration-id=2026_09_04_143200_create_reports_table` only when the generated timestamp identifier must be replaced. - -The table generator derives the unprefixed WordPress table name from the class name unless `--table-name` is supplied. For a standalone plugin, choose a name unique to that plugin, such as `--table-name=your_plugin_reports`. The value may contain only ASCII letters, numbers, and underscores. Foundation applies the active WordPress prefix at runtime, producing a physical name such as `wp_your_plugin_reports`. Supply the application table name without the WordPress site prefix. The migration generator's `--table` option has a separate, class-oriented meaning: it selects the existing table class that an alteration migration changes. - -An application that owns the entire installation can use the class-derived default, such as `reports`. A wrapper for an existing table should use that table's established name without its WordPress site prefix. - -Project-specific stubs can override the defaults at: - -```text -foundation/stubs/database/provider.stub -foundation/stubs/database/table.stub -foundation/stubs/database/create-table-migration.stub -foundation/stubs/database/alter-table-migration.stub -foundation/stubs/database/migration.stub -``` - -## Define table schemas - -### Identify the table - -The generated `src/Database/Tables/Reports_Table.php` owns the table's stable identity and table-scoped query gateway. Its inherited `name()` method asks the database service to apply the current WordPress table prefix and validate the resulting physical name when the table is used. - -```php title="Reports_Table.php" -table ); - - $blueprint->bigIncrements( 'id' ); - $blueprint->string( 'status', 20 )->default( 'draft' ); - $blueprint->longText( 'payload' )->comment( 'Serialized report payload' ); - $blueprint->dateTime( 'created_at' ); - $blueprint->dateTime( 'updated_at' )->nullable(); - $blueprint->index( 'status', 'status' ); - - $schema->create( $blueprint ); -} -``` - -### Choose column types - -Use the named helpers for common WordPress table columns: - -| Method | Database definition | Typical use | -| --- | --- | --- | -| `bigIncrements( 'id' )` | Unsigned `BIGINT`, auto-incrementing primary key | Numeric row identifiers | -| `string( 'name', 191 )` | `VARCHAR` with a configurable length | Names, states, and short values | -| `unsignedInteger( 'count' )` | Unsigned `INT` | Non-negative counters and identifiers | -| `integer( 'position' )` | Signed `INT` | Counts and positions | -| `tinyInteger( 'enabled', 1 )` | `TINYINT` | Flags and small numeric values | -| `bigInteger( 'external_id' )` | Signed `BIGINT` | Large numeric values | -| `dateTime( 'created_at' )` | `DATETIME`, optionally with precision from `1` to `6` | WordPress-compatible timestamps | -| `timestamp( 'occurred_at' )` | `TIMESTAMP`, optionally with precision from `1` to `6` | Times stored with database session time-zone conversion | -| `text( 'excerpt' )` | `TEXT` | Medium text values | -| `longText( 'payload' )` | `LONGTEXT` | Serialized payloads and large text values | - -Column modifiers can be combined on the declaration being configured. Inside `src/Database/Migrations/Create_Reports_Table.php`, for example: - -```php title="Create_Reports_Table.php" -$blueprint->string( 'status', 20 )->default( 'draft' ); -$blueprint->unsignedInteger( 'attempts' )->default( 0 ); -$blueprint->tinyInteger( 'enabled', 1 )->unsigned()->default( true ); -$blueprint->dateTime( 'published_at' )->nullable()->default( null ); -$blueprint->longText( 'payload' )->comment( 'Serialized report payload' ); -``` - -Available modifiers are `unsigned()`, `nullable()`, `notNull()`, `default()`, `useCurrent()`, `useCurrentOnUpdate()`, `autoIncrement()`, and `comment()`. An explicit `default( null )` is valid only on a nullable column. - -Prefer `bigIncrements()` for the usual generated primary key. When applying `autoIncrement()` manually, use an integer column without a default, define only one auto-increment column in the table, and make it the first column in a primary, unique, or regular index. Foundation validates these requirements before executing schema SQL. - -:::note[Comment changes use the complete column] -An initial column can declare its comment directly. Changing or removing a comment on an existing column requires a declaration marked with `change()`. MySQL replaces that physical column from its complete declaration. Do not add unmodeled attributes to the same column through separate SQL because a later change may replace attributes absent from the migration declaration. -::: - -:::caution -Column comments may contain apostrophes and semicolons, but they cannot contain backslashes or control characters. Foundation rejects those comments because their SQL meaning can change with the database connection's SQL mode. -::: - -Use `column()` when the named helpers do not cover the required MySQL type. In `src/Database/Migrations/Create_Reports_Table.php`, import `StellarWP\Foundation\Database\Table\Column` with the other imports, then add the custom columns to its blueprint: - -```php title="Create_Reports_Table.php" -$blueprint->column( new Column( 'amount', 'decimal(10,2)' ) )->default( 0 ); -$blueprint->column( new Column( 'checksum', 'varbinary', 32 ) )->nullable(); -``` - -For decimal columns, specify both precision and scale when fractional values are needed, such as `decimal(10,2)`. Omitting the scale uses zero fractional digits: `decimal(10)` and `new Column( 'amount', 'decimal', 10 )` both declare `decimal(10,0)`. Omitting precision as well uses `decimal(10,0)`. The `numeric` and `dec` aliases follow the same rules. - -### Let the database manage timestamps - -Use `useCurrent()` for a current-time default and `useCurrentOnUpdate()` for automatic updates when another stored value in the row changes. Both work with `dateTime()` and `timestamp()`. Neither modifier changes nullability, and automatic updates do not imply a current-time default. - -In `src/Database/Migrations/Create_Reports_Table.php`, define creation and modification times in the migration's blueprint: - -```php title="Create_Reports_Table.php" -public function up( Schema $schema ): void { - $blueprint = Blueprint::for( $this->table ); - $blueprint->bigIncrements( 'id' ); - $blueprint->string( 'status', 20 )->default( 'draft' ); - $blueprint->dateTime( 'created_at' )->useCurrent(); - $blueprint->dateTime( 'updated_at' )->useCurrent()->useCurrentOnUpdate(); - - $schema->create( $blueprint ); -} -``` - -An insert that omits these values receives the database's current time. A later update changes `updated_at` but leaves `created_at` alone. An update that leaves every stored value unchanged does not advance the timestamp. Explicit timestamp assignments take precedence; explicitly assigning null stores null on a nullable column. - -The column controls fractional-second precision. Omitted precision and `0` mean whole seconds; `1` through `6` retain that many fractional digits. The generated default and update expressions use the same precision. In `src/Database/Migrations/Create_Reports_Table.php`, replace the earlier `updated_at` declaration and optionally add a nullable processing time: - -```php title="Create_Reports_Table.php" -$blueprint->dateTime( 'updated_at', 6 )->useCurrent()->useCurrentOnUpdate(); -$blueprint->timestamp( 'processed_at', 3 )->nullable()->default( null )->useCurrentOnUpdate(); -``` - -The first declaration emits `DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)`. The second stays null on insertion and receives the current time when another stored value changes. - -`useCurrent()` and `default()` replace the same default: the last call wins. A string such as `default( 'CURRENT_TIMESTAMP' )` remains literal text, not executable SQL. Foundation rejects the temporal modifiers on incompatible types or invalid precision before executing schema SQL. - -:::caution[Database time is not WordPress time] -These values use the database session's clock and time zone, not PHP time or the WordPress timezone setting. `TIMESTAMP` converts between the session time zone and UTC storage; `DATETIME` does not perform that conversion. Keep existing column types unless a migration deliberately changes their meaning. -::: - -To change automatic behavior, create a new alteration migration and declare the complete column with `change()`. In `src/Database/Migrations/Stop_Automatic_Report_Updates.php`, for example: - -```php title="Stop_Automatic_Report_Updates.php" -public function up( Schema $schema ): void { - $blueprint = Blueprint::for( $this->table ); - $blueprint->dateTime( 'updated_at' )->useCurrent()->change(); - - $schema->alter( $blueprint ); -} -``` - -This keeps the insertion default but removes automatic updates. Include any existing nullability, precision, and comments that must survive the change. To remove current-time initialization too, omit `useCurrent()` and declare the intended literal or nullable null default. Matching retries verify the attributes without replaying DDL or rewriting stored timestamps. - -:::caution[Legacy implicit TIMESTAMP defaults] -With `explicit_defaults_for_timestamp=OFF`, the database can add automatic initialization and updates to the first bare `TIMESTAMP` column. Foundation rejects those undeclared attributes rather than changing the connection setting or accepting a different schema. Enable explicit defaults in the database configuration, or declare an explicit default or a nullable column, such as `timestamp( 'processed_at' )->nullable()->default( null )`, then retry. If partial creation left an incompatible table, repair it through an explicit alteration; creation retries only verify existing tables. -::: - -### Add indexes - -In `src/Database/Migrations/Create_Reports_Table.php`, declare indexes after their columns. Index names must be unique within the table, and composite index columns are stored in the order provided: - -```php title="Create_Reports_Table.php" -$blueprint->bigIncrements( 'id' ); -$blueprint->string( 'uuid', 26 ); -$blueprint->string( 'status', 20 ); -$blueprint->dateTime( 'created_at' ); - -$blueprint->unique( 'uuid_unique', 'uuid' ); -$blueprint->index( 'status_created_at', 'status', 'created_at' ); -``` - -Use `primary()` only for a custom primary key. `bigIncrements()` already creates the table's primary key: - -```php title="Create_Report_Lookup_Table.php" -$blueprint->unsignedInteger( 'site_id' ); -$blueprint->bigInteger( 'external_id' )->unsigned(); -$blueprint->string( 'status', 20 ); - -$blueprint->primary( 'site_id', 'external_id' ); -``` - -### Apply an initial table definition - -The generated `src/Database/Migrations/Create_Reports_Table.php` passes its complete blueprint to `Schema`. For a missing table, the schema service uses `dbDelta()` and verifies the requested columns and indexes before the migration is recorded as successful. If the table already exists, Foundation verifies the historical creation blueprint without modifying the table. - -```php title="Create_Reports_Table.php" +```php title="src/Database/Migrations/Create_Reports_Table.php" table ); - - $blueprint->bigIncrements( 'id' ); - $blueprint->string( 'status', 20 )->default( 'draft' ); - $blueprint->longText( 'payload' )->comment( 'Serialized report payload' ); - $blueprint->dateTime( 'created_at' ); - $blueprint->dateTime( 'updated_at' )->nullable(); - $blueprint->index( 'status', 'status' ); - - $schema->create( $blueprint ); + public function up( Blueprint $schema ): void { + $table = $schema->create( $this->table ); + $table->bigIncrements( 'id' ); + $table->string( 'title' ); + $table->string( 'status', 20 )->default( 'draft' ); + $table->decimal( 'amount', 12, 4 )->default( '0' ); + $table->dateTime( 'created_at', 6 )->useCurrent(); + $table->index( 'status_lookup', 'status' ); } - public function down( Schema $schema ): void { + public function down( Blueprint $schema ): void { $schema->drop( $this->table ); } } ``` -The `--migration` flag explicitly selects a create-table migration. Its generated `down()` method therefore drops the table and all of its data when the migration is rolled back. - -You can generate the initial migration separately when the table class already exists: - -```shell -vendor/bin/foundation make:database-migration Create_Reports_Table \ - --create=Reports_Table -``` - -Pass a fully qualified class when the table is outside the default `Database\\Tables` namespace: - -```shell -vendor/bin/foundation make:database-migration Create_Reports_Table \ - --create=Plugin\\Reporting\\Tables\\Reports_Table -``` - -Foundation never infers table ownership from the migration name. Only `--create` selects the destructive create-table rollback, so a migration named `Create_Reports_Table` without that option remains a generic, irreversible migration. - -Migration IDs are permanent, byte-exact identifiers that determine forward execution order. Foundation sorts every configured migration globally from the lowest ID to the highest ID, regardless of which provider contributed it. The generator prefixes IDs with a sortable timestamp so migrations normally follow creation time. IDs generated within the same second use their class-name suffix to determine their exact order. Do not change an ID after the migration has been deployed. - -An explicit ID such as `--id=2026_09_04_143200_create_reports_table` remains supported when a project needs to preserve an established identifier. Custom IDs participate in the same bytewise lexical ordering, so use a consistently sortable convention. A migration introduced with an ID lower than an already-applied migration still runs as the next pending migration on an existing installation; Foundation never inserts work into previously completed history. - -### Alter an existing table - -Create a new migration for every later schema change. The table class remains unchanged because the new migration owns the next step in its schema history: +Review the generated schema before running it: ```shell -vendor/bin/foundation make:database-migration Add_Publishing_To_Reports \ - --table=Reports_Table -``` - -The generated `src/Database/Migrations/Add_Publishing_To_Reports.php` receives `Reports_Table` and starts with an empty blueprint. Declare only the operations owned by this migration, then pass them to `Schema::alter()`: - -```php title="Add_Publishing_To_Reports.php" -public function up( Schema $schema ): void { - $blueprint = Blueprint::for( $this->table ); - - $blueprint->dateTime( 'published_at' )->nullable(); - $blueprint->string( 'status', 40 )->default( 'draft' )->change(); - $blueprint->dropIndex( 'status' ); - $blueprint->index( 'status_published_at', 'status', 'published_at' ); - $blueprint->dropColumn( 'legacy_payload' ); - - $schema->alter( $blueprint ); -} - -/** - * @throws IrreversibleMigration Until a safe inverse is implemented. - */ -public function down( Schema $schema ): void { - throw IrreversibleMigration::forMigration( self::ID ); -} -``` - -Declarations without `change()` add a missing column. Mark a declaration with `change()` only when it replaces an existing column. Because MySQL requires `MODIFY COLUMN` to restate the complete column declaration, include every supported attribute that must remain, including length, nullability, default, unsigned state, auto-increment, and comment. - -`dropColumn()` and `dropIndex()` express destructive removals. The generated `down()` remains irreversible until you provide a safe inverse; implement it only when rollback can restore the intended schema and data. - -Foundation treats an existing addition and an absent removal as already completed. This makes the migration safe to retry when MySQL applied the DDL but Foundation could not write its ledger record. An existing column or index with a different requested definition fails verification instead of being silently accepted. - -To replace an index, declare `dropIndex()` and the new index definition under the same name in one blueprint. Foundation replaces the index when its definition differs and skips the replacement when it already matches, so retrying the migration does not rebuild a completed index. - -:::caution[Changing a column replaces its declaration] -Foundation can preserve only attributes represented by `Column`. A `change()` operation can remove an attribute added through raw SQL or another tool when that attribute is absent from the migration declaration. Use trusted `Schema::execute()` SQL instead when the physical column contains behavior Foundation cannot describe. -::: - -### Use specialized or externally managed indexes - -Foundation verifies indexes declared by the migration being applied. Other physical indexes are left alone because they may belong to an earlier migration, a plugin integration, or a database administrator. - -The blueprint supports primary, unique, and regular indexes over complete columns. Use `Schema::execute()` for trusted schema SQL when an upgrade requires prefix lengths, descending columns, FULLTEXT, SPATIAL, primary-key changes, or another shape the blueprint does not represent. A later blueprint will not reject that external index unless the migration explicitly declares or removes the same name. - -In `src/Database/Migrations/Add_Report_Search.php`, the generated table base resolves the active WordPress prefix through `name()`. Quote that physical name and every trusted identifier before executing specialized SQL: - -```php title="Add_Report_Search.php" -public function up( Schema $schema ): void { - if ( $schema->hasIndex( $this->table, 'report_search' ) ) { - return; - } - - $schema->execute( sprintf( - 'ALTER TABLE %s ADD FULLTEXT KEY %s (%s)', - $schema->quoteIdentifier( $this->table->name() ), - $schema->quoteIdentifier( 'report_search' ), - $schema->quoteIdentifier( 'payload' ) - ) ); -} -``` - -Raw schema SQL bypasses blueprint retry handling. Check whether its requested state already exists before executing it so a migration can run again after the DDL succeeds but the ledger write fails. - -This example assumes the injected table extends Foundation's `Table` base. A custom implementation of the minimal `Table` contract should inject `TableNameResolver` when it needs the active physical name. - -## Write data migrations - -For equality-based data changes, use the table's write methods so the migration needs only the table it changes. For example, `src/Database/Migrations/Backfill_Report_Status.php` can update existing rows without coordinating a separate database service: - -Generate a generic migration without `--create` or `--table`, then add the table constructor dependency manually. Use `--table` only when the generated migration should start with an explicit `Schema::alter()` blueprint. - -```php title="Backfill_Report_Status.php" -table->update( - [ 'status' => 'active' ], - [ 'status' => 'legacy' ] - ); - } - - /** - * @throws IrreversibleMigration Until a safe inverse is implemented. - */ - public function down( Schema $schema ): void { - throw IrreversibleMigration::forMigration( self::ID ); - } -} +wp your-plugin migrate --run --dry-run +wp your-plugin migrate --run +wp your-plugin migrate ``` -Choose the raw SQL API based on the statement. `Database::execute()` accepts WordPress placeholders followed by their bindings, so use it when a data migration includes request, configuration, or stored values. `Schema::execute()` accepts only a complete SQL string and does not bind placeholders; reserve it for trusted schema SQL whose identifiers and literals are fully controlled by the application. +The first run creates the migration ledger automatically. The command prefix comes from your WP-CLI configuration. Programmatic installation or upgrade code injects `Migrator` and calls `$migrator->migrate()` at its chosen upgrade boundary. A public plugin should run this after WordPress and its providers are ready, and record its installed application version only after migration succeeds. Run the same upgrade workflow for each affected site. -## Register migrations +### Register migrations explicitly -The generators update an existing database provider automatically. When registering classes by hand, bind table services and contribute them from `src/Database/Provider.php`: +Providers contribute migration objects lazily. Registration order does not determine execution order: -```php title="Provider.php" +```php title="src/Database/Provider.php" registered ) { - return; - } - - $this->register_tables(); - $this->register_migrations(); - - $this->registered = true; - } - - private function register_tables(): void { $this->container->singleton( Reports_Table::class ); - // foundation:database-tables - } - - private function register_migrations(): void { - $this->container->mergeArrayVar( - DatabaseProvider::MIGRATIONS, - static fn ( C $c ): array => [ - $c->get( Create_Reports_Table::class ), - $c->get( Add_Publishing_To_Reports::class ), - $c->get( Backfill_Report_Status::class ), - ] - ); + $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + $c->get( Create_Reports_Table::class ), + ] ); } } ``` -Foundation combines migrations contributed by every provider and executes pending migrations in ascending byte-exact ID order. Provider registration order does not control migration execution. Give schema prerequisites lower IDs than data migrations that depend on them. +All providers contribute to one globally sorted collection. IDs are compared in ascending byte order and stored exactly, independently of class names and Strauss scoping. IDs must be unique, nonblank, unpadded, and no more than 191 bytes. `0` and `latest` are reserved targets. Generated timestamps provide ordering; custom IDs, including numeric timestamps, must occupy the lexical position their dependencies require. -## Run migrations +### Add a later change -The migration command accepts one operation at a time: - -| Goal | Command | -| --- | --- | -| Show migration status | `wp your-plugin migrate` | -| Create or reconcile migration storage | `wp your-plugin migrate --initialize` | -| Run every pending migration | `wp your-plugin migrate --run` | -| Roll back the latest batch | `wp your-plugin migrate --rollback` | -| Roll back and rerun all configured migrations | `wp your-plugin migrate --refresh` | -| Remove only the migration ledger | `wp your-plugin migrate --drop-store` | - -The destructive `--refresh` and `--drop-store` operations prompt for confirmation. Add `--yes` only in an environment where the operation has already been approved. - -### Initialize migration storage - -Create or reconcile Foundation's migration ledger and lock table before running migrations: - -```shell -wp your-plugin migrate --initialize -``` - -Run this idempotent command during every deployment. Replace `your-plugin` with the configured command prefix; applications using the default prefix run `wp nx migrate --initialize`. - -On WordPress multisite, run the command once for each site by passing WP-CLI's `--url` global argument. Each site owns its migration ledger and lock table. See [Use database services on multisite](/components/database/#use-database-services-on-multisite) before migrating from code that calls `switch_to_blog()`. - -### Apply pending migrations - -```shell -wp your-plugin migrate --run -``` - -Running the command without an operation displays migration status: - -```shell -wp your-plugin migrate -``` - -The `migration` column lists every configured or recorded identifier with its current status, batch, and run time: - -```text -+-------------------------------------------------+---------+-------+---------------------+ -| migration | status | batch | ran_at | -+-------------------------------------------------+---------+-------+---------------------+ -| 2026_09_04_143200_create_reports_table | applied | 1 | 2026-09-04 20:32:10 | -| 2026_09_04_151500_add_status_to_reports | pending | | | -+-------------------------------------------------+---------+-------+---------------------+ -``` - -The runner acquires the configured migration lock, executes pending migrations in ascending byte-exact ID order, and records each successful migration in one batch. Rollback follows the reverse of the ledger's actual execution order, including when a newly introduced migration has an ID lower than migrations that were already applied. - -### Run during deployment - -A typical deployment initializes the Foundation tables, reviews pending work, runs it, and then confirms the final status: - -```shell -wp your-plugin migrate --initialize -wp your-plugin migrate -wp your-plugin migrate --run -wp your-plugin migrate -``` - -`--initialize` is idempotent, so keep it in every deployment rather than branching between first installs and upgrades. Treat a failed command as a failed deployment step; do not continue serving code that expects a migration which did not complete. - -:::caution[Allow enough time for each migration] -Foundation renews its lock immediately before and after each migration. The configured `database.lock_ttl` must exceed the longest uninterrupted `up()`, `down()`, or ledger operation because blocking work cannot renew its lease while it is running. Increase the TTL or split long work into separate migrations before deploying it. - -If a migration finishes but the following lock renewal or ledger write fails, Foundation leaves that migration unrecorded and stops. A later command may therefore call it again. Write `up()` and `down()` so repeated execution can safely reconcile the intended state. -::: - -### Roll back or rebuild - -Roll back the latest applied batch: +Generate an alteration for an existing table: ```shell -wp your-plugin migrate --rollback +vendor/bin/foundation make:database-migration add-published-at --table=Reports_Table ``` -Roll back every configured migration and run them again: +Its `up()` declares only this migration's changes: -```shell -wp your-plugin migrate --refresh --yes +```php +public function up( Blueprint $schema ): void { + $table = $schema->table( $this->table ); + $table->dateTime( 'published_at', 6 )->nullable(); + $table->string( 'title', 255 )->change(); +} ``` -:::caution[Rollback requires reversible migrations] -The generic and alteration migration stubs throw `IrreversibleMigration` from `down()`. Implement a safe inverse before relying on rollback, or keep the migration explicitly irreversible. Foundation does not pretend a destructive data change can be undone. - -Rollback processes a batch in reverse order and is not transactional. If a later `down()` fails or reaches an irreversible migration, migrations already reversed in that command remain reversed and their ledger records remain removed. Inspect migration status and repair the failure before rerunning rollback. - -Refresh first runs rollback and only reapplies migrations after rollback completes. Every applied migration must provide a safe `down()` implementation. If rollback fails, the run phase does not begin. -::: - -Drop only Foundation's migration ledger when intentionally resetting migration history: +`change()` replaces a column's complete definition. Restate any default, nullability, unsigned flag, comment, or other attribute you want to retain. Use `dropColumn('name')`, `dropIndex('name')`, `index('name', ...$columns)`, and `unique('name', ...$columns)` for explicit removals and index changes. An index replacement declares the old index's removal and the new definition under the same name. -```shell -wp your-plugin migrate --drop-store --yes -``` +The generated `down()` throws `IrreversibleMigration` until you supply a safe inverse. A generic migration does the same. Only an explicit `--create=Reports_Table` generates an initial-table declaration whose inverse drops the entire table. `--create` and `--table` are mutually exclusive; a migration's name never implies destructive behavior. -:::danger[`--drop-store` does not drop application tables] -This removes migration history, not tables created by migrations and not the Foundation lock table. The next `--run` treats every configured migration as pending. A historical create migration verifies an existing table without changing it, so replay stops if later migrations evolved that table beyond the original creation blueprint. Use `--drop-store` only when the remaining schema is compatible with replaying the complete history; it is not a general-purpose database reset. +:::caution[Keep applied history immutable] +Edit a migration only before it has been applied anywhere. Otherwise, create another migration. Foundation replays historical declarations to compute each change, so editing or removing applied declarations changes the meaning of recorded history. Generators never overwrite migration files. ::: -## Run migrations from PHP - -WP-CLI is the preferred deployment interface. For controlled environments that cannot invoke WP-CLI, resolve the same `Migrator` service from the application container: +### Column declarations -```php -use StellarWP\Foundation\Database\Migration\Migrator; - -$migrator = $container->get( Migrator::class ); - -$migrator->initialize(); -$result = $migrator->run(); -``` - -The result exposes the migration IDs that were run, rolled back, or skipped through its `ran`, `rolledBack`, and `skipped` properties. The programmatic API follows the same ledger and lock rules as the command. Do not run migrations during every normal WordPress request. +| Declaration | Meaning | +| --- | --- | +| `bigIncrements('id')` | Unsigned auto-incrementing BIGINT primary key | +| `string('name', 191)` | VARCHAR with an explicit maximum length | +| `text('body')`, `longText('body')` | TEXT or LONGTEXT | +| `integer('count')`, `bigInteger('count')` | Integer columns | +| `unsignedInteger('count')`, `unsignedBigInteger('count')` | Unsigned integers | +| `boolean('active')` | Boolean storage | +| `decimal('amount', 12, 4)` | Exact decimal precision and scale | +| `binary('token', 16)` | VARBINARY with an explicit length | +| `dateTime('updated_at', 6)` | DATETIME with fractional precision from 0 to 6 | -### Inspect migration status from PHP +Columns support `nullable()`, `notNull()`, `unsigned()`, `default()`, `comment()`, and `change()`. Date/time declarations additionally support `useCurrent()` and `useCurrentOnUpdate()`. Use strings for exact decimal defaults. Table declarations support `primary(...$columns)` and `comment()`. -`Migrator::status()` returns one `Status` object for each configured or recorded migration. Each status is in exactly one state: +`up()` and `down()` must be pure schema declarations. Foundation may replay them many times, including during previews. Do not query the live database, perform application work, or put existence guards in them. -- `isPending()` identifies a configured migration with no ledger record. -- `isApplied()` identifies a configured migration with a ledger record. -- `isUnavailable()` identifies a ledger record whose migration is not registered in the current deployment. +### Transform existing data -An unavailable migration has a ledger record and may have been applied by an earlier deployment. `isApplied()` checks the exact configured-and-recorded state; it does not answer whether any ledger record exists. Use these methods for application logic instead of comparing raw status values: +Implement the optional `MigratesData` contract alongside `Migration` when a forward migration must transform rows: ```php -use StellarWP\Foundation\Database\Migration\Migrator; -$migrator = $container->get( Migrator::class ); - -foreach ( $migrator->status() as $migration_status ) { - if ( $migration_status->isUnavailable() ) { - // The ledger contains a migration that is not registered in this deployment. - } +use Doctrine\DBAL\Connection; +use StellarWP\Foundation\Database\Migration\Contracts\MigratesData; + +// Add MigratesData to the migration's implements list. +public function migrate( Connection $connection ): void { + $connection->executeStatement( + 'UPDATE ' . $this->table->quotedName() . ' SET status = ? WHERE status IS NULL', + [ 'draft' ], + ); } ``` -Every status includes the migration identifier. Applied and unavailable migrations also include the ledger `batch` and `ranAt` timestamp; pending migrations expose `null` for both values. The `state()` method returns the textual state name when it is needed for presentation. +Foundation runs this callback after schema changes and before writing history. It must be safe to repeat if the process stops or recording fails. You may use the shared connection's `transactional()` for a bounded data operation. Complete that transaction before returning; an open transaction interrupts the migration and is rolled back. DDL remains outside that transaction. Preview reports that a data callback exists but does not execute it. The data contract has no automatic reverse callback: supply schema rollback only when reversing remains safe for the resulting data, or throw `IrreversibleMigration`. -## Testing +## Deploy and recover -Use `wpunit` tests for migration blueprints, schema operations, and migrations that execute against WordPress. Use `integration` when the test proves contributions from multiple providers, and use `wpcli` for the real migration command lifecycle. +| Operation | Command | +| --- | --- | +| Inspect pending, applied, and missing migrations | `wp your-plugin migrate` | +| Preview pending SQL | `wp your-plugin migrate --run --dry-run` | +| Apply pending migrations | `wp your-plugin migrate --run` | +| Reconcile to a registered ID | `wp your-plugin migrate --run --to=` | +| Reverse the highest applied ID | `wp your-plugin migrate --rollback` | +| Reverse several applied IDs | `wp your-plugin migrate --rollback --step=2` | +| Reconcile to a rollback target | `wp your-plugin migrate --rollback --to=` | +| Reverse all applied migrations | `wp your-plugin migrate --rollback --to=0` | +| Reverse and rerun everything | `wp your-plugin migrate --refresh` | + +Programmatic equivalents are `Migrator::status()`, `preview($target)`, `migrate($target)`, `rollback($steps)`, and `refresh()`. Migration operations return step objects containing the stable ID, direction, SQL, and whether a forward data callback was involved. Optional `DescribesMigration::describe()` supplies a status description. + +An explicit target reverses applied IDs above it in descending order, then applies pending IDs through it in ascending order. `latest` applies all pending migrations. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied migration classes must be restored before execution can continue. + +:::danger[Rollback can delete data] +`down()` is application code. Reversing an initial migration drops its complete table, including rows and additions made later. Review the inverse and take the application's required backup before rolling back. `--refresh` prompts for confirmation because it reverses and recreates everything; use `--yes` only when that data loss is intended. +::: -Create and remove application tables within the test lifecycle so tests exercise the real `wpdb` and `dbDelta()` behavior rather than a PHP fake. +### Interrupted schema changes -For example, a project base test case that exposes the application container can resolve the real schema and table services in `tests/wpunit/Database/Reports_Table_Test.php`: +MySQL DDL can commit before the history write. A retry compares the actual schema with the migration's declared change: compatible existing additions and already-absent removals count as completed work. An interrupted index replacement resumes its missing work. Undeclared columns, indexes, and constraints are retained during alterations. -```php title="Reports_Table_Test.php" -container->get( Schema::class ); - $table = $this->container->get( Reports_Table::class ); - $migration = $this->container->get( Create_Reports_Table::class ); +All migration participants must reach the same primary database server. Advisory locks are local to that server; a proxy that moves statements between sessions or servers cannot provide this guarantee. Preview and status are observations and can become stale before a later run. - try { - $migration->up( $schema ); +## Customize generation and test - $this->assertTrue( $schema->hasTable( $table ) ); - $this->assertTrue( $schema->hasIndex( $table, 'status' ) ); - } finally { - $schema->drop( $table ); - } - } -} -``` +The defaults are `Database\Tables`, `Database\Migrations`, and `Database` beneath your Composer application namespace. Use the [CLI generator configuration](/tooling/foundation-cli/) to choose project namespaces, or command options `--namespace` and `--path` for individual output. Stub overrides live under `foundation/stubs/database/`. Keep migrations in production autoloaded code even when the generator itself is installed with `--dev`. -Keep migration orchestration tests separate from schema-operation tests. An orchestration test should initialize an isolated ledger, run the configured migration through `Migrator`, and assert both the schema effect and recorded status. Use the `wpcli` suite when the behavior under test is the command output, confirmation, or exit status. +Test create, alteration, rollback, and retry against real database tables. Include a failure after successful DDL but before history recording, then verify that retry preserves existing rows and records the migration once. Register a fresh container per test and use application-specific test table names. diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index d533dce..c207a68 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -1,44 +1,17 @@ --- -title: Query Builder -description: Read and write WordPress tables through a small query API with inspectable SQL and bindings. +title: Queries and Transactions +description: Query application tables with Doctrine DBAL and choose reliable transaction boundaries. sidebar: order: 2 --- -import { LinkCard } from '@astrojs/starlight/components'; +[Register DatabaseProvider](/components/database/) and inject your concrete table into the service that owns its queries. A table can wrap an existing WordPress or third-party table without owning its migrations. -Foundation Database wraps common `wpdb` operations with prepared bindings, quoted identifiers, consistent exceptions, and a small fluent query builder. It remains intentionally close to SQL so developers can inspect exactly what WordPress will execute. +## Read and write rows - +A repository is an optional way to organize reusable application queries: -Tables used for queries do not need to be managed by Foundation migrations. A wrapper around an existing WordPress, WooCommerce, or legacy table can extend the supplied `Table` base class and provide only its stable name. For example, `src/Database/Tables/Import_Log_Table.php` can target an externally managed table without declaring ownership of its schema: - -```php title="Import_Log_Table.php" -> - */ - public function published( int $limit = 100 ): array { - return $this->table - ->query( 'r' ) - ->select( 'r.id', 'r.status', 'r.payload', 'r.created_at' ) - ->where( 'r.status', '=', 'published' ) - ->orderBy( 'r.created_at', 'DESC' ) - ->limit( $limit ) - ->get(); + private Reports_Table $reports, + ) {} + + /** @return array|false */ + public function find( int $id ): array|false { + return $this->reports->query() + ->select( 'id', 'title' ) + ->where( 'id = :id' ) + ->setParameter( 'id', $id ) + ->fetchAssociative(); } } ``` -`first()` returns one row or `null`. `get()` returns a list of associative rows. Qualified identifiers such as `r.created_at` are quoted as `` `r`.`created_at` `` rather than as one identifier. - -`max( 'column' )` returns the maximum value matching the builder's `where()` predicates. Aggregate queries ignore ordering and pagination because those clauses do not constrain the rows being aggregated. - -Use `null` with equality operators for SQL null checks: +`query()` returns a fresh native Doctrine builder with the table configured as its source. Use `query('report')` for an alias. Supply SQL expressions yourself and bind external values using `setParameter()` or `setParameters()`. SQL fragments, column names, sorting, and aliases must come from application-owned choices, not request input. ```php -$unpublished = $this->table - ->query() - ->where( 'published_at', '=', null ) - ->get(); - -$published = $this->table - ->query() - ->where( 'published_at', '!=', null ) - ->get(); +$rows = $this->reports->query() + ->select( 'id', 'title' ) + ->where( 'status = :status' ) + ->setParameter( 'status', 'ready' ) + ->orderBy( 'id', 'DESC' ) + ->setMaxResults( 50 ) + ->fetchAllAssociative(); + +$count = $this->reports->query()->select( 'COUNT(*)' )->fetchOne(); ``` -These comparisons compile to `IS NULL` and `IS NOT NULL`. Other operators with `null` are rejected because they do not have useful SQL semantics. +Doctrine supplies `fetchAssociative()` (one row or `false`), `fetchAllAssociative()` (rows), `fetchOne()` (one value or `false`), and `executeQuery()` for a native result. Database drivers may return numeric values as strings. Convert results according to your application contract. -## Write rows - -Use the table object for inserts, updates, and deletes so physical table naming stays in one place: +Common writes stay table-scoped: ```php -$reportId = $this->table->insertGetId( [ - 'status' => 'draft', - 'payload' => wp_json_encode( $payload ), - 'created_at' => current_time( 'mysql', true ), -] ); - -$updated = $this->table->update( - [ - 'status' => 'published', - 'updated_at' => current_time( 'mysql', true ), - ], - [ 'id' => $reportId ] -); - -$deleted = $this->table->delete( [ 'id' => $reportId ] ); -``` - -`insert()` returns the affected row count, while `insertGetId()` returns the generated integer ID. `update()` and `delete()` return affected row counts. - -### Add table-specific operations - -Add an intent-revealing method to the table class when specialized SQL belongs to that table and should be reused by its consumers. The base `Table` exposes its table-scoped database gateway to subclasses through the protected `database()` method. - -For example, add `archive_status()` to `src/Database/Tables/Reports_Table.php`: - -```php title="Reports_Table.php" -/** - * Archive reports with the supplied status. - * - * @throws \StellarWP\Foundation\Database\Exceptions\DatabaseException When the physical table name is invalid. - * @throws \StellarWP\Foundation\Database\Exceptions\QueryException When the update fails. - */ -public function archive_status( string $status ): int { - return $this->database()->execute( - 'UPDATE %i SET status = %s WHERE status = %s', - $this->name(), - 'archived', - $status - ); -} +$id = $this->reports->insertGetId( [ 'title' => 'Weekly', 'status' => 'draft' ] ); +$this->reports->update( [ 'status' => 'ready' ], [ 'id' => $id ] ); +$this->reports->delete( [ 'id' => $id ] ); ``` -The method uses the same database service as the inherited table operations. Calling `name()` resolves and validates the physical table name for the active WordPress site when the operation runs. +`insert()`, `update()`, and `delete()` return Doctrine's affected-row count (`int|string`). `insertGetId()` returns the generated identifier (`int|string`) without truncation. Each accepts an optional `$types` map for Doctrine parameter types. Update and delete criteria must be nonempty; use the connection for an intentional whole-table operation. -Keep these methods scoped to one table. Cross-table queries, multi-step workflows, and business rules belong in a repository or feature service. - -## Inspect and execute SQL - -Build a query before executing it when logging or diagnostics need the SQL shape and separate bindings: +Inject `Doctrine\DBAL\Connection` for joins, specialized SQL, and operations spanning tables. Resolve physical names through each table's `quotedName()`: ```php -$query = $this->table - ->query( 'r' ) - ->select( 'r.id', 'r.status' ) - ->where( 'r.status', '=', 'failed' ) - ->limit( 25 ) - ->toQuery(); - -$sql = $query->toSql(); -$bindings = $query->bindings(); -$preparedSql = $query->toPreparedSql(); -$rows = $query->get(); +$connection->executeStatement( + 'UPDATE ' . $reports->quotedName() . ' SET status = ? WHERE id = ?', + [ 'ready', $id ], +); ``` -Prefer `toSql()` plus `bindings()` for structured diagnostics. A fully prepared SQL string may contain customer or application data and should not be logged without considering its sensitivity. - -:::caution[Rebuild materialized queries after changing sites] -`toQuery()` captures SQL for the active WordPress database scope. If code calls `switch_to_blog()`, build a new query before executing it so the table name uses the new site's prefix. Calling `get()`, `first()`, or `max()` directly on the table query builder resolves the active scope as part of that operation. For a scalar result, call `value()` on the `Query` returned by `toQuery()`. -::: - -Inject `QueryReader` in addition to the table when specialized read SQL belongs to a repository workflow rather than one table, or when a query spans multiple tables. Add it explicitly to that repository's constructor: - -```php -use StellarWP\Foundation\Database\Contracts\QueryReader; +Queries, results, types, and DBAL exceptions are supported native Doctrine APIs. See [Doctrine's query builder reference](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.4/reference/query-builder.html) for expressions and advanced usage. -public function __construct( - private Reports_Table $table, - private QueryReader $database -) { -} -``` +## Choose a transaction boundary -The contract exposes prepared low-level read operations: +Inject the shared connection into the service that owns the complete unit of work. Collaborating repositories can continue to use their injected tables. ```php -$row = $this->database->row( - 'SELECT * FROM %i WHERE id = %d', - $this->table->name(), - $reportId -); - -$count = $this->database->value( - 'SELECT COUNT(*) FROM %i WHERE status = %s', - $this->table->name(), - 'published' -); -``` - -Use `QueryExecutor` instead when the same repository must also execute raw writes: - -```php -use StellarWP\Foundation\Database\Contracts\QueryExecutor; +use Doctrine\DBAL\Connection; +use Plugin\Database\Tables\Reports_Table; -public function __construct( - private Reports_Table $table, - private QueryExecutor $database -) { +final readonly class Create_Report { + public function __construct( + private Connection $connection, + private Reports_Table $reports, + ) {} + + public function create( string $title ): int|string { + return $this->connection->transactional( function () use ( $title ): int|string { + return $this->reports->insertGetId( [ 'title' => $title, 'status' => 'draft' ] ); + } ); + } } - -$affected = $this->database->execute( - 'UPDATE %i SET status = %s WHERE status = %s', - $this->table->name(), - 'archived', - 'published' -); ``` -Use `prepare()` when another WordPress API requires the prepared SQL string. Keep values in placeholders instead of concatenating untrusted input. - -For normal application code, follow one of these paths: +`transactional()` returns the callback's value only after commit is acknowledged. `false` and `null` are ordinary callback results, not rollback signals. Throw an exception to cancel work. The original escaping exception is preserved if cleanup also fails. -1. Inject the concrete table for queries and writes scoped to that table. -2. Inject `QueryReader` for specialized raw or cross-table reads. -3. Inject `QueryExecutor` when the same raw workflow also executes writes. +Nested transactions use savepoints. An inner success is provisional until the outer transaction commits. A database execution failure is terminal for the entire managed transaction, even if application code catches its exception. An inner business exception can be caught after its savepoint is rolled back. -The aggregate `Database` contract is available when one infrastructure collaborator genuinely needs the complete API. The narrower contracts are public extension capabilities for specialized raw-SQL repositories, adapters, and decorators. They are not setup choices every application must make: routine table-scoped repositories should continue injecting their concrete table. +### Handle failures deliberately -## Handle query failures +- Ordinary SQL failures use Doctrine exceptions, such as `Doctrine\DBAL\Exception\UniqueConstraintViolationException`. +- `TransactionFailed` means a caught database failure prevented successful completion. Start fresh work at an application boundary after cleanup. +- `CommitOutcomeUnknown` means the server did not confirm commit. The data may already be committed. Inspect durable application state or use an idempotency key before retrying. +- A detected site or connection change interrupts managed work. Foundation never replays failed SQL or reconnects in the middle of a transaction. -Database operations throw `QueryException` when `wpdb` reports an error. The exception retains the SQL template, bindings, and database error separately: +The Foundation exceptions above live under `StellarWP\Foundation\Database\Exceptions`. -```php -use Psr\Log\LoggerInterface; -use StellarWP\Foundation\Database\Exceptions\QueryException; - -try { - $rows = $query->get(); -} catch ( QueryException $exception ) { - $this->logger->error( 'Report query failed.', [ - 'sql' => $exception->sql(), - 'bindings' => $exception->bindings(), - 'database_error' => $exception->databaseError(), - ] ); - - throw $exception; -} -``` +:::caution[Keep managed work on the shared connection] +Use transactional tables. Do not issue DDL, manual transaction-control SQL, direct WordPress queries, or `switch_to_blog()` inside a managed transaction. DDL can implicitly commit MySQL transactions, and writes through other sessions are outside this transaction. External HTTP calls cannot be rolled back with database writes. +::: -Avoid exposing database errors or bindings to end users. They may contain schema details or sensitive values. +Between completed operations, WordPress may replace its mysqli connection. New queries and transactions use the replacement automatically. Prepared statements from the old connection are rejected; prepare them again for the new operation. -## Testing +## Test application behavior -Use `wpunit` tests for repositories and query behavior. Create the real table, exercise the real WordPress database, and remove the table during cleanup. This catches placeholder, collation, identifier, and MariaDB behavior that a mocked `wpdb` cannot reproduce. +Unit-test application decisions against your own repository contracts when useful. Use integration tests with real InnoDB tables for transaction boundaries, SQL behavior, and recovery. Test that an observer on a second connection cannot see uncommitted rows, that a failed operation leaves prior rows intact, and that caught database failures cannot produce a successful result. From 3a0e8c7f4fc75aa2512fbd14b9b91c64de9fcf31 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 22 Sep 2026 17:34:17 -0600 Subject: [PATCH 04/53] Test database workflows against MySQL and MariaDB --- .github/workflows/quality.yml | 2 +- .github/workflows/tests.yml | 20 ++++++++++++++++---- phpstan.neon.dist | 1 + 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 8459de8..696d538 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -19,7 +19,7 @@ jobs: with: php-version: '8.3' coverage: pcov - extensions: redis + extensions: redis, mysqli - name: Detect File Changes uses: dorny/paths-filter@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7dd87c2..94b88f9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,7 @@ jobs: with: php-version: ${{ matrix.php }} coverage: none - extensions: redis + extensions: redis, mysqli - name: Install Composer dependencies uses: ramsey/composer-install@v4 @@ -39,9 +39,21 @@ jobs: slic: runs-on: ubuntu-latest - name: SLIC Tests + name: SLIC Tests (${{ matrix.database.name }}) + strategy: + fail-fast: false + matrix: + database: + - name: MariaDB + image: mariadb:11.8.3 + healthcheck: healthcheck.sh --connect --innodb_initialized + - name: MySQL + image: mysql:8.0 + healthcheck: mysqladmin ping --host=127.0.0.1 --silent env: + SLIC_DB_IMAGE: ${{ matrix.database.image }} + SLIC_DB_HEALTHCHECK: ${{ matrix.database.healthcheck }} COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"}}' steps: @@ -150,7 +162,7 @@ jobs: if: github.event_name == 'pull_request' uses: actions/upload-artifact@v7 with: - name: coverage-report + name: coverage-report-${{ matrix.database.name }} path: clover.xml if-no-files-found: error retention-days: 1 @@ -170,7 +182,7 @@ jobs: - name: Download coverage report uses: actions/download-artifact@v8 with: - name: coverage-report + name: coverage-report-MariaDB - name: Monitor coverage uses: slavcodev/coverage-monitor-action@v1 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 3916aa1..151155c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -13,3 +13,4 @@ parameters: analyse: - src/*/vendor/* - tests/CodeceptionSupport/* + - tests/_data/temp/* From fa5b3147eb884e338b6c76a645e067c7cb11791e Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 22 Sep 2026 17:40:15 -0600 Subject: [PATCH 05/53] Make database regression tests portable across CI environments --- tests/integration/Database/AdvisoryMigrationTest.php | 3 ++- tests/integration/Database/TransactionTest.php | 3 ++- tests/wpcli/Database/Cli/DatabaseMigrateCest.php | 11 +++++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/integration/Database/AdvisoryMigrationTest.php b/tests/integration/Database/AdvisoryMigrationTest.php index ec07597..c1d9989 100644 --- a/tests/integration/Database/AdvisoryMigrationTest.php +++ b/tests/integration/Database/AdvisoryMigrationTest.php @@ -254,7 +254,8 @@ public function test_replacing_the_wordpress_connection_is_terminal_even_if_rest try { $connection->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); - } catch (MigrationInterrupted) { + } catch (Throwable $failure) { + self::assertInstanceOf(MigrationInterrupted::class, $failure); $this->source->__set('dbh', $native); } })->migrate(); diff --git a/tests/integration/Database/TransactionTest.php b/tests/integration/Database/TransactionTest.php index d4c77a5..dd83a0a 100644 --- a/tests/integration/Database/TransactionTest.php +++ b/tests/integration/Database/TransactionTest.php @@ -137,7 +137,8 @@ public function test_caught_query_failure_blocks_further_queries(): void { try { $this->connection->insert($this->table, ['id' => 2, 'name' => 'Must not execute']); self::fail('A failed transaction must reject further SQL.'); - } catch (TransactionFailed) { + } catch (Throwable $failure) { + self::assertInstanceOf(TransactionFailed::class, $failure); } }); self::fail('Expected terminal failure.'); diff --git a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php index 1c8bb82..a764542 100644 --- a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -56,10 +56,13 @@ public function test_invalid_options_fail_before_migration_execution(WPCLITester } private function dropTables(WPCLITester $I): void { - $I->cli(['db', 'prefix']); - $I->seeResultCodeIs(0); - $prefix = trim($I->grabLastShellOutput()); - $I->cli(['db', 'query', sprintf('DROP TABLE IF EXISTS %sfoundation_cli_migrations, %sfoundation_cli_example', $prefix, $prefix)]); + $I->cli(['eval', <<<'PHP' + global $wpdb; + $prefix = $wpdb->prefix; + if ($wpdb->query("DROP TABLE IF EXISTS {$prefix}foundation_cli_migrations, {$prefix}foundation_cli_example") === false) { + WP_CLI::error($wpdb->last_error); + } + PHP]); $I->seeResultCodeIs(0); } } From 3786e84a7b21b4b85bb0de7b8a08134369b3df68 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 22 Sep 2026 17:56:52 -0600 Subject: [PATCH 06/53] Rename database lock connection property to db --- src/Database/Lock/DatabaseLock.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Database/Lock/DatabaseLock.php b/src/Database/Lock/DatabaseLock.php index 512e2d2..7b2fa8f 100644 --- a/src/Database/Lock/DatabaseLock.php +++ b/src/Database/Lock/DatabaseLock.php @@ -32,7 +32,7 @@ * Create a lock backend using the configured WordPress lock table. */ public function __construct( - private Connection $database, + private Connection $db, private LockTable $table ) { } @@ -43,7 +43,7 @@ public function __construct( * @throws Exception When storage cannot be inspected or created. */ public function initialize(): void { - $manager = $this->database->createSchemaManager(); + $manager = $this->db->createSchemaManager(); if ($manager->tablesExist([$this->table->name()])) { return; @@ -80,7 +80,7 @@ public function acquire(string $name, int $ttl): ?LockToken { try { $table = $this->table->quotedName(); - $this->database->executeStatement( + $this->db->executeStatement( "INSERT INTO {$table} (name, owner, expires_at, created_at, updated_at) VALUES (?, ?, TIMESTAMPADD(SECOND, ?, UTC_TIMESTAMP(6)), UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) ON DUPLICATE KEY UPDATE @@ -94,7 +94,7 @@ public function acquire(string $name, int $ttl): ?LockToken { [$name, $owner, $ttl, $owner, $ttl] ); - $row = $this->database->fetchAssociative( + $row = $this->db->fetchAssociative( "SELECT expires_at FROM {$table} WHERE name = ? AND owner = ? AND expires_at > UTC_TIMESTAMP(6) LIMIT 1", @@ -122,7 +122,7 @@ public function acquire(string $name, int $ttl): ?LockToken { */ public function release(LockToken $token): bool { try { - return $this->database->executeStatement( + return $this->db->executeStatement( 'DELETE FROM ' . $this->table->quotedName() . ' WHERE name = ? AND owner = ? AND expires_at > UTC_TIMESTAMP(6)', [$token->name, $token->owner] ) > 0; @@ -143,13 +143,13 @@ public function refresh(LockToken $token, int $ttl): ?LockToken { try { $table = $this->table->quotedName(); - $this->database->executeStatement( + $this->db->executeStatement( "UPDATE {$table} SET expires_at = TIMESTAMPADD(SECOND, ?, UTC_TIMESTAMP(6)), updated_at = UTC_TIMESTAMP(6) WHERE name = ? AND owner = ? AND expires_at > UTC_TIMESTAMP(6)", [$ttl, $token->name, $token->owner] ); - $row = $this->database->fetchAssociative( + $row = $this->db->fetchAssociative( "SELECT expires_at FROM {$table} WHERE name = ? AND owner = ? AND expires_at > UTC_TIMESTAMP(6) LIMIT 1", [$token->name, $token->owner] ); @@ -174,7 +174,7 @@ public function isAcquired(string $name): bool { $this->assertValidName($name); try { - return $this->database->fetchAssociative( + return $this->db->fetchAssociative( 'SELECT name FROM ' . $this->table->quotedName() . ' WHERE name = ? AND expires_at > UTC_TIMESTAMP(6) LIMIT 1', [$name] ) !== false; From cf3cfb250c496442ba88bb0e58e93d6aaab4509a Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 22 Sep 2026 18:14:20 -0600 Subject: [PATCH 07/53] Standardize Doctrine connection variables as db --- .../Migration/Contracts/MigratesData.php | 2 +- src/Database/Migration/History.php | 12 +- src/Database/Migration/Migrator.php | 12 +- .../Migration/Schema/SchemaPlanner.php | 14 +- src/Database/Table/Table.php | 14 +- src/Database/Table/Tables/LockTable.php | 4 +- src/Database/Table/Tables/MigrationTable.php | 4 +- .../docs/components/database/migrations.mdx | 4 +- .../components/database/query-builder.mdx | 6 +- .../Fixtures/Database/DatabaseTestCase.php | 8 +- .../Declarative/BackfillEntryStatus.php | 4 +- .../Database/Import/DatasetImporter.php | 4 +- .../Database/Import/EntryRepository.php | 10 +- .../Database/NativeConnectionFactory.php | 4 +- .../Database/AdvisoryMigrationTest.php | 56 +++--- .../integration/Database/DatabaseLockTest.php | 2 +- .../Database/NativeBehaviorTest.php | 20 +-- tests/integration/Database/TableTest.php | 8 +- .../integration/Database/TransactionTest.php | 168 +++++++++--------- 19 files changed, 178 insertions(+), 178 deletions(-) diff --git a/src/Database/Migration/Contracts/MigratesData.php b/src/Database/Migration/Contracts/MigratesData.php index 6e5c1c5..0358192 100644 --- a/src/Database/Migration/Contracts/MigratesData.php +++ b/src/Database/Migration/Contracts/MigratesData.php @@ -14,5 +14,5 @@ interface MigratesData /** * Transform rows using the shared connection. */ - public function migrate(Connection $connection): void; + public function migrate(Connection $db): void; } diff --git a/src/Database/Migration/History.php b/src/Database/Migration/History.php index 749e780..7eaf98c 100644 --- a/src/Database/Migration/History.php +++ b/src/Database/Migration/History.php @@ -21,7 +21,7 @@ * Receive the shared connection and configured ledger table. */ public function __construct( - private Connection $connection, + private Connection $db, private MigrationTable $table, ) { } @@ -41,7 +41,7 @@ public function table(): string { * @throws Exception When inspection or creation fails. */ public function initialize(): void { - $manager = $this->connection->createSchemaManager(); + $manager = $this->db->createSchemaManager(); if ($manager->tablesExist([$this->table()])) { return; @@ -67,10 +67,10 @@ public function initialize(): void { * @return array */ public function applied(): array { - if (! $this->connection->createSchemaManager()->tablesExist([$this->table()])) { + if (! $this->db->createSchemaManager()->tablesExist([$this->table()])) { return []; } - $rows = $this->connection->fetchAllKeyValue('SELECT version, applied_at FROM ' . $this->quoted() . ' ORDER BY version'); + $rows = $this->db->fetchAllKeyValue('SELECT version, applied_at FROM ' . $this->quoted() . ' ORDER BY version'); return array_map('strval', $rows); } @@ -81,7 +81,7 @@ public function applied(): array { * @throws Exception When the ledger write fails; the schema change has already committed. */ public function record(string $id): void { - $this->connection->insert($this->quoted(), ['version' => $id]); + $this->db->insert($this->quoted(), ['version' => $id]); } /** @@ -90,7 +90,7 @@ public function record(string $id): void { * @throws Exception When the ledger delete fails; the inverse has already committed. */ public function remove(string $id): void { - $this->connection->delete($this->quoted(), ['version' => $id]); + $this->db->delete($this->quoted(), ['version' => $id]); } private function quoted(): string { diff --git a/src/Database/Migration/Migrator.php b/src/Database/Migration/Migrator.php index ac5ab66..fbd8845 100644 --- a/src/Database/Migration/Migrator.php +++ b/src/Database/Migration/Migrator.php @@ -35,7 +35,7 @@ final class Migrator * @internal Constructed by Foundation; applications receive this object through provider wiring or migration callbacks. */ public function __construct( - private readonly Connection $connection, + private readonly Connection $db, private readonly WordPressSession $session, private readonly DatabaseScope $scope, private readonly History $history, @@ -108,8 +108,8 @@ private function locked(Closure $operation): mixed { return $operation(); } catch (Throwable $failure) { try { - while ($this->connection->isTransactionActive()) { - $this->connection->rollBack(); + while ($this->db->isTransactionActive()) { + $this->db->rollBack(); } } catch (Throwable) { // Preserve the migration failure when transaction cleanup also fails. @@ -182,15 +182,15 @@ private function run(string $target, bool $execute, bool $applyPending = true): if ($execute) { foreach ($sql as $statement) { - $this->connection->executeStatement($statement); + $this->db->executeStatement($statement); } $this->session->check(); if (! $reverse && $migration instanceof MigratesData) { - $migration->migrate($this->connection); + $migration->migrate($this->db); $this->session->check(); - if ($this->connection->isTransactionActive()) { + if ($this->db->isTransactionActive()) { throw new MigrationInterrupted('A migration data callback left a transaction open; complete its transaction before returning.'); } } diff --git a/src/Database/Migration/Schema/SchemaPlanner.php b/src/Database/Migration/Schema/SchemaPlanner.php index 60629dd..a8487cb 100644 --- a/src/Database/Migration/Schema/SchemaPlanner.php +++ b/src/Database/Migration/Schema/SchemaPlanner.php @@ -33,7 +33,7 @@ * @param array $tableOptions Defaults such as charset/collation applied to created tables. */ public function __construct( - private Connection $connection, + private Connection $db, private TableNameResolver $names, private MigrationCollection $migrations, private array $tableOptions = [], @@ -121,7 +121,7 @@ private function reverse(SchemaState $before, string $id): SchemaState { * @param list $simulated Lower-cased names of every table an earlier preview step decided on. */ private function introspect(array $names, ?SchemaState $overlay = null, array $simulated = []): SchemaState { - $manager = $this->connection->createSchemaManager(); + $manager = $this->db->createSchemaManager(); $tables = []; $timestamps = $this->timestampAttributes($names); @@ -166,7 +166,7 @@ private function timestampAttributes(array $names): array { if ($names === []) { return []; } - $rows = $this->connection->fetchAllAssociative( + $rows = $this->db->fetchAllAssociative( 'SELECT TABLE_NAME, COLUMN_NAME, DATETIME_PRECISION, EXTRA FROM information_schema.COLUMNS' . ' WHERE TABLE_SCHEMA = DATABASE() AND DATA_TYPE IN (\'datetime\', \'timestamp\') AND TABLE_NAME IN (' . implode(', ', array_fill(0, count($names), '?')) . ')', @@ -264,7 +264,7 @@ private function preserveUndeclared(SchemaState $actual, SchemaState $before, Sc * @return list SQL statements for attributes DBAL does not compare. */ private function timestampSql(SchemaState $actual, SchemaState $desired, SchemaDiff $remaining, array $declaredChanges, string $id): array { - $platform = $this->connection->getDatabasePlatform(); + $platform = $this->db->getDatabasePlatform(); $sql = []; $covered = $this->columnsRewrittenBy($remaining); @@ -299,7 +299,7 @@ private function timestampSql(SchemaState $actual, SchemaState $desired, SchemaD */ private function commentSql(SchemaState $actual, SchemaState $before, SchemaState $after, string $id): array { $sql = []; - $platform = $this->connection->getDatabasePlatform(); + $platform = $this->db->getDatabasePlatform(); foreach ($after->schema->getTables() as $table) { $name = $table->getObjectName()->toString(); @@ -359,7 +359,7 @@ private function timestampChanges(SchemaState $before, SchemaState $after): arra * Compare two snapshots using the configured Doctrine platform. */ private function compare(SchemaState $from, SchemaState $to): SchemaDiff { - return $this->connection->createSchemaManager()->createComparator((new ComparatorConfig())->withReportModifiedIndexes(false))->compareSchemas($from->schema, $to->schema); + return $this->db->createSchemaManager()->createComparator((new ComparatorConfig())->withReportModifiedIndexes(false))->compareSchemas($from->schema, $to->schema); } /** @@ -368,7 +368,7 @@ private function compare(SchemaState $from, SchemaState $to): SchemaDiff { * @return list */ private function sql(SchemaDiff $diff): array { - return $this->connection->getDatabasePlatform()->getAlterSchemaSQL($diff); + return $this->db->getDatabasePlatform()->getAlterSchemaSQL($diff); } /** diff --git a/src/Database/Table/Table.php b/src/Database/Table/Table.php index bef6fd5..e817c4d 100644 --- a/src/Database/Table/Table.php +++ b/src/Database/Table/Table.php @@ -23,7 +23,7 @@ * Receive the application's shared connection and table-name policy. */ public function __construct( - private Connection $connection, + private Connection $db, private TableNameResolver $names, ) { } @@ -51,7 +51,7 @@ final public function name(): string { * @throws Exception When the platform cannot be determined. */ final public function quotedName(): string { - return $this->connection->getDatabasePlatform()->quoteSingleIdentifier($this->name()); + return $this->db->getDatabasePlatform()->quoteSingleIdentifier($this->name()); } /** @@ -61,7 +61,7 @@ final public function quotedName(): string { * @throws Exception When the platform cannot be determined. */ final public function query(?string $alias = null): QueryBuilder { - return $this->connection->createQueryBuilder()->from($this->quotedName(), $alias); + return $this->db->createQueryBuilder()->from($this->quotedName(), $alias); } /** @@ -74,7 +74,7 @@ final public function query(?string $alias = null): QueryBuilder { * @throws Exception When insertion fails. */ final public function insert(array $data, array $types = []): int|string { - return $this->connection->insert($this->quotedName(), $data, $types); + return $this->db->insert($this->quotedName(), $data, $types); } /** @@ -89,7 +89,7 @@ final public function insert(array $data, array $types = []): int|string { final public function insertGetId(array $data, array $types = []): int|string { $this->insert($data, $types); - return $this->connection->lastInsertId(); + return $this->db->lastInsertId(); } /** @@ -108,7 +108,7 @@ final public function update(array $data, array $where, array $types = []): int| throw new InvalidArgumentException('Table updates require criteria; use the connection for an intentional whole-table update.'); } - return $this->connection->update($this->quotedName(), $data, $where, $types); + return $this->db->update($this->quotedName(), $data, $where, $types); } /** @@ -126,6 +126,6 @@ final public function delete(array $where, array $types = []): int|string { throw new InvalidArgumentException('Table deletions require criteria; use the connection for an intentional whole-table delete.'); } - return $this->connection->delete($this->quotedName(), $where, $types); + return $this->db->delete($this->quotedName(), $where, $types); } } diff --git a/src/Database/Table/Tables/LockTable.php b/src/Database/Table/Tables/LockTable.php index 7e78e86..8b58b19 100644 --- a/src/Database/Table/Tables/LockTable.php +++ b/src/Database/Table/Tables/LockTable.php @@ -18,10 +18,10 @@ */ public function __construct( private string $unprefixedTableName, - Connection $connection, + Connection $db, TableNameResolver $names, ) { - parent::__construct($connection, $names); + parent::__construct($db, $names); } /** diff --git a/src/Database/Table/Tables/MigrationTable.php b/src/Database/Table/Tables/MigrationTable.php index a453f0c..358ca96 100644 --- a/src/Database/Table/Tables/MigrationTable.php +++ b/src/Database/Table/Tables/MigrationTable.php @@ -18,10 +18,10 @@ */ public function __construct( private string $unprefixedTableName, - Connection $connection, + Connection $db, TableNameResolver $names, ) { - parent::__construct($connection, $names); + parent::__construct($db, $names); } /** diff --git a/src/Docs/src/content/docs/components/database/migrations.mdx b/src/Docs/src/content/docs/components/database/migrations.mdx index b16d85e..874ea12 100644 --- a/src/Docs/src/content/docs/components/database/migrations.mdx +++ b/src/Docs/src/content/docs/components/database/migrations.mdx @@ -147,8 +147,8 @@ use Doctrine\DBAL\Connection; use StellarWP\Foundation\Database\Migration\Contracts\MigratesData; // Add MigratesData to the migration's implements list. -public function migrate( Connection $connection ): void { - $connection->executeStatement( +public function migrate( Connection $db ): void { + $db->executeStatement( 'UPDATE ' . $this->table->quotedName() . ' SET status = ? WHERE status IS NULL', [ 'draft' ], ); diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index c207a68..5d0aa22 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -63,7 +63,7 @@ $this->reports->delete( [ 'id' => $id ] ); Inject `Doctrine\DBAL\Connection` for joins, specialized SQL, and operations spanning tables. Resolve physical names through each table's `quotedName()`: ```php -$connection->executeStatement( +$db->executeStatement( 'UPDATE ' . $reports->quotedName() . ' SET status = ? WHERE id = ?', [ 'ready', $id ], ); @@ -81,12 +81,12 @@ use Plugin\Database\Tables\Reports_Table; final readonly class Create_Report { public function __construct( - private Connection $connection, + private Connection $db, private Reports_Table $reports, ) {} public function create( string $title ): int|string { - return $this->connection->transactional( function () use ( $title ): int|string { + return $this->db->transactional( function () use ( $title ): int|string { return $this->reports->insertGetId( [ 'title' => $title, 'status' => 'draft' ] ); } ); } diff --git a/tests/Support/Fixtures/Database/DatabaseTestCase.php b/tests/Support/Fixtures/Database/DatabaseTestCase.php index da26141..91878e4 100644 --- a/tests/Support/Fixtures/Database/DatabaseTestCase.php +++ b/tests/Support/Fixtures/Database/DatabaseTestCase.php @@ -18,7 +18,7 @@ abstract class DatabaseTestCase extends WPTestCase { protected wpdb $source; protected wpdb $observerSource; - protected Connection $connection; + protected Connection $db; protected Connection $observer; protected string $table; protected string $suffix; @@ -41,9 +41,9 @@ protected function setUp(): void { $this->observerSource = new wpdb(constant('DB_USER'), constant('DB_PASSWORD'), constant('DB_NAME'), constant('DB_HOST')); $this->container->register(DatabaseProvider::class); $this->container->singleton(wpdb::class, $this->source); - $this->connection = $this->container->get(Connection::class); - $this->observer = (new NativeConnectionFactory())->create($this->native($this->observerSource), constant('DB_NAME')); - $this->table = $this->privateTable($this->suffix); + $this->db = $this->container->get(Connection::class); + $this->observer = (new NativeConnectionFactory())->create($this->native($this->observerSource), constant('DB_NAME')); + $this->table = $this->privateTable($this->suffix); $this->observer->executeStatement("CREATE TABLE {$this->table} (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL) ENGINE=InnoDB"); $this->observer->insert($this->table, ['id' => 1, 'name' => 'Original']); } diff --git a/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php b/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php index 50c94e9..cbc1d27 100644 --- a/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php +++ b/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php @@ -29,7 +29,7 @@ public function up(Blueprint $schema): void { public function down(Blueprint $schema): void { } - public function migrate(Connection $connection): void { - $connection->executeStatement("UPDATE `{$this->physicalName}` SET status = 'active' WHERE status = ''"); + public function migrate(Connection $db): void { + $db->executeStatement("UPDATE `{$this->physicalName}` SET status = 'active' WHERE status = ''"); } } diff --git a/tests/Support/Fixtures/Database/Import/DatasetImporter.php b/tests/Support/Fixtures/Database/Import/DatasetImporter.php index 544468f..1519a21 100644 --- a/tests/Support/Fixtures/Database/Import/DatasetImporter.php +++ b/tests/Support/Fixtures/Database/Import/DatasetImporter.php @@ -16,7 +16,7 @@ * Share the same connection with the repository participating in the transaction. */ public function __construct( - private Connection $connection, + private Connection $db, private EntryRepository $entries, ) { } @@ -29,7 +29,7 @@ public function __construct( * @throws Throwable When the replacement fails or commit is uncertain. */ public function replace(array $records): int { - return $this->connection->transactional(function () use ($records): int { + return $this->db->transactional(function () use ($records): int { $this->entries->deleteAll(); return $this->entries->insertBatch($records); diff --git a/tests/Support/Fixtures/Database/Import/EntryRepository.php b/tests/Support/Fixtures/Database/Import/EntryRepository.php index 13f2b4c..a4b0834 100644 --- a/tests/Support/Fixtures/Database/Import/EntryRepository.php +++ b/tests/Support/Fixtures/Database/Import/EntryRepository.php @@ -17,7 +17,7 @@ * Configure the dataset's stable, unprefixed table name. */ public function __construct( - private Connection $connection, + private Connection $db, private DatabaseScope $scope, private string $tableName, ) { @@ -29,7 +29,7 @@ public function __construct( * @throws Exception When the database cannot delete the rows. */ public function deleteAll(): void { - $this->connection->executeStatement('DELETE FROM ' . $this->table()); + $this->db->executeStatement('DELETE FROM ' . $this->table()); } /** @@ -49,7 +49,7 @@ public function insertBatch(array $records): int { $parameters[] = $record['id']; $parameters[] = $record['name']; } - $count += (int) $this->connection->executeStatement("INSERT INTO {$table} (id, name) VALUES {$values}", $parameters); + $count += (int) $this->db->executeStatement("INSERT INTO {$table} (id, name) VALUES {$values}", $parameters); } return $count; @@ -63,7 +63,7 @@ public function insertBatch(array $records): int { * @return list> */ public function findByName(string $name): array { - return $this->connection->createQueryBuilder() + return $this->db->createQueryBuilder() ->select('id', 'name') ->from($this->table()) ->where('name = :name') @@ -75,6 +75,6 @@ public function findByName(string $name): array { } private function table(): string { - return $this->connection->getDatabasePlatform()->quoteSingleIdentifier($this->scope->resolveTableName($this->tableName)); + return $this->db->getDatabasePlatform()->quoteSingleIdentifier($this->scope->resolveTableName($this->tableName)); } } diff --git a/tests/Support/Fixtures/Database/NativeConnectionFactory.php b/tests/Support/Fixtures/Database/NativeConnectionFactory.php index ed6dc7f..12b3467 100644 --- a/tests/Support/Fixtures/Database/NativeConnectionFactory.php +++ b/tests/Support/Fixtures/Database/NativeConnectionFactory.php @@ -42,8 +42,8 @@ public function connect(array $params): Driver\Connection { }; } }]); - $connection = DriverManager::getConnection(['driver' => 'mysqli', 'dbname' => $database], $configuration); + $db = DriverManager::getConnection(['driver' => 'mysqli', 'dbname' => $database], $configuration); - return $connection; + return $db; } } diff --git a/tests/integration/Database/AdvisoryMigrationTest.php b/tests/integration/Database/AdvisoryMigrationTest.php index c1d9989..5d37b69 100644 --- a/tests/integration/Database/AdvisoryMigrationTest.php +++ b/tests/integration/Database/AdvisoryMigrationTest.php @@ -48,11 +48,11 @@ protected function setUp(): void { * @param Closure(Connection): void $operation */ private function runner(Container $container, Closure $operation, ?string $resource = null): Migrator { - $connection = $container->get(Connection::class); - $scope = $container->get(DatabaseScope::class); - $names = $container->get(TableNameResolver::class); + $db = $container->get(Connection::class); + $scope = $container->get(DatabaseScope::class); + $names = $container->get(TableNameResolver::class); $resource ??= $scope->resolveTableName($this->suffix . '_ledger'); - $table = new MigrationTable(substr($resource, strlen($scope->resolveTableName(''))), $connection, $names); + $table = new MigrationTable(substr($resource, strlen($scope->resolveTableName(''))), $db, $names); $migration = new class($operation) implements MigratesData, Migration { /** @param Closure(Connection): void $operation */ public function __construct(private readonly Closure $operation) { @@ -64,14 +64,14 @@ public function up(Blueprint $schema): void { } public function down(Blueprint $schema): void { } - public function migrate(Connection $connection): void { - ($this->operation)($connection); + public function migrate(Connection $db): void { + ($this->operation)($db); } }; $migrations = new MigrationCollection([$migration]); - return new Migrator($connection, $container->get(WordPressSession::class), $scope, - new History($connection, $table), new SchemaPlanner($connection, $names, $migrations), $migrations); + return new Migrator($db, $container->get(WordPressSession::class), $scope, + new History($db, $table), new SchemaPlanner($db, $names, $migrations), $migrations); } private function lockName(?string $resource = null): string { @@ -84,27 +84,27 @@ private function assertLockAvailable(?string $resource = null): void { public function test_an_unclosed_data_transaction_is_rolled_back_without_recording_success(): void { try { - $this->runner($this->container, function (Connection $connection): void { - $connection->beginTransaction(); - $connection->executeStatement("UPDATE {$this->table} SET name = 'Uncommitted'"); + $this->runner($this->container, function (Connection $db): void { + $db->beginTransaction(); + $db->executeStatement("UPDATE {$this->table} SET name = 'Uncommitted'"); })->migrate(); self::fail('A callback cannot return success with an open transaction.'); } catch (MigrationInterrupted $failure) { self::assertStringContainsString('left a transaction open', $failure->getMessage()); $this->assertOriginal(); - self::assertFalse($this->connection->isTransactionActive()); + self::assertFalse($this->db->isTransactionActive()); self::assertSame(0, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->ledger)); $this->assertLockAvailable(); } } public function test_competing_runner_cannot_plan_until_ddl_and_history_finish(): void { - $second = $this->runner($this->contender, function (Connection $connection): void { - $connection->executeStatement("UPDATE {$this->table} SET name = CONCAT(name, '|duplicate')"); + $second = $this->runner($this->contender, function (Connection $db): void { + $db->executeStatement("UPDATE {$this->table} SET name = CONCAT(name, '|duplicate')"); }); - $first = $this->runner($this->container, function (Connection $connection) use ($second): void { - $connection->executeStatement("ALTER TABLE {$this->table} ADD migrated INT NOT NULL DEFAULT 1"); - $connection->executeStatement("UPDATE {$this->table} SET name = CONCAT(name, '|once')"); + $first = $this->runner($this->container, function (Connection $db) use ($second): void { + $db->executeStatement("ALTER TABLE {$this->table} ADD migrated INT NOT NULL DEFAULT 1"); + $db->executeStatement("UPDATE {$this->table} SET name = CONCAT(name, '|once')"); try { $second->migrate(); @@ -161,14 +161,14 @@ public function test_different_site_prefixes_do_not_share_a_lock(): void { } public function test_lock_survives_commits_and_rollbacks_in_a_migration(): void { - $this->runner($this->container, function (Connection $connection): void { - $connection->transactional(function (Connection $connection): void { - $connection->executeStatement("UPDATE {$this->table} SET name = 'Committed'"); + $this->runner($this->container, function (Connection $db): void { + $db->transactional(function (Connection $db): void { + $db->executeStatement("UPDATE {$this->table} SET name = 'Committed'"); }); self::assertSame(0, (int) $this->observer->fetchOne('SELECT IS_FREE_LOCK(?)', [$this->lockName()])); try { - $connection->transactional(static function (): void { + $db->transactional(static function (): void { throw new RuntimeException('Discard this inner transaction'); }); } catch (RuntimeException) { @@ -180,11 +180,11 @@ public function test_lock_survives_commits_and_rollbacks_in_a_migration(): void public function test_killed_session_cannot_record_success_after_a_caught_error(): void { try { - $this->runner($this->container, function (Connection $connection): void { + $this->runner($this->container, function (Connection $db): void { $this->killConnection(); try { - $connection->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); + $db->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); } catch (Throwable) { // Simulate migration code that catches and ignores a lost connection. } @@ -249,11 +249,11 @@ public function test_replacing_the_wordpress_connection_is_terminal_even_if_rest $native = $this->native($this->source); try { - $this->runner($this->container, function (Connection $connection) use ($native): void { + $this->runner($this->container, function (Connection $db) use ($native): void { $this->source->__set('dbh', $this->native($this->observerSource)); try { - $connection->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); + $db->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); } catch (Throwable $failure) { self::assertInstanceOf(MigrationInterrupted::class, $failure); $this->source->__set('dbh', $native); @@ -271,10 +271,10 @@ public function test_replacing_the_wordpress_connection_is_terminal_even_if_rest public function test_caught_sql_failure_remains_terminal_after_inner_transaction_cleanup(): void { try { - $this->runner($this->container, function (Connection $connection): void { + $this->runner($this->container, function (Connection $db): void { try { - $connection->transactional(function (Connection $connection): void { - $connection->insert(trim($this->table, '`'), ['id' => 1, 'name' => 'Duplicate']); + $db->transactional(function (Connection $db): void { + $db->insert(trim($this->table, '`'), ['id' => 1, 'name' => 'Duplicate']); }); } catch (Throwable) { // The transaction ends, but this migration must remain failed. diff --git a/tests/integration/Database/DatabaseLockTest.php b/tests/integration/Database/DatabaseLockTest.php index dddf4c4..4567144 100644 --- a/tests/integration/Database/DatabaseLockTest.php +++ b/tests/integration/Database/DatabaseLockTest.php @@ -65,7 +65,7 @@ public function test_expired_owner_cannot_release_a_replacement_and_names_are_ca $lock->initialize(); $token = $lock->acquire('resource', 60); self::assertInstanceOf(LockToken::class, $token); - $this->connection->executeStatement('UPDATE ' . $this->container->get(LockTable::class)->quotedName() . ' SET expires_at = UTC_TIMESTAMP(6) - INTERVAL 1 SECOND'); + $this->db->executeStatement('UPDATE ' . $this->container->get(LockTable::class)->quotedName() . ' SET expires_at = UTC_TIMESTAMP(6) - INTERVAL 1 SECOND'); $replacement = $lock->acquire('resource', 60); self::assertInstanceOf(LockToken::class, $replacement); self::assertNotSame($token->owner, $replacement->owner); diff --git a/tests/integration/Database/NativeBehaviorTest.php b/tests/integration/Database/NativeBehaviorTest.php index 3824729..fc6d0b4 100644 --- a/tests/integration/Database/NativeBehaviorTest.php +++ b/tests/integration/Database/NativeBehaviorTest.php @@ -12,12 +12,12 @@ final class NativeBehaviorTest extends DatabaseTestCase { public function test_native_dbal_commits_after_a_caught_statement_failure(): void { - $connection = (new NativeConnectionFactory())->create($this->native($this->source), constant('DB_NAME')); - $connection->transactional(function () use ($connection): void { - $connection->update($this->table, ['name' => 'Committed despite failure'], ['id' => 1]); + $db = (new NativeConnectionFactory())->create($this->native($this->source), constant('DB_NAME')); + $db->transactional(function () use ($db): void { + $db->update($this->table, ['name' => 'Committed despite failure'], ['id' => 1]); try { - $connection->insert($this->table, ['id' => 1, 'name' => 'Duplicate']); + $db->insert($this->table, ['id' => 1, 'name' => 'Duplicate']); } catch (UniqueConstraintViolationException) { } }); @@ -25,11 +25,11 @@ public function test_native_dbal_commits_after_a_caught_statement_failure(): voi } public function test_native_dbal_rejects_unacknowledged_commit(): void { - $connection = (new NativeConnectionFactory())->create($this->native($this->source), constant('DB_NAME')); + $db = (new NativeConnectionFactory())->create($this->native($this->source), constant('DB_NAME')); try { - @$connection->transactional(function () use ($connection): string { - $connection->update($this->table, ['name' => 'Lost'], ['id' => 1]); + @$db->transactional(function () use ($db): string { + $db->update($this->table, ['name' => 'Lost'], ['id' => 1]); $this->killConnection(); return 'success'; @@ -41,11 +41,11 @@ public function test_native_dbal_rejects_unacknowledged_commit(): void { } public function test_native_cleanup_can_replace_the_business_exception(): void { - $connection = (new NativeConnectionFactory())->create($this->native($this->source), constant('DB_NAME')); - $business = new RuntimeException('Business failure'); + $db = (new NativeConnectionFactory())->create($this->native($this->source), constant('DB_NAME')); + $business = new RuntimeException('Business failure'); try { - $connection->transactional(function () use ($business): void { + $db->transactional(function () use ($business): void { $this->native($this->source)->close(); throw $business; diff --git a/tests/integration/Database/TableTest.php b/tests/integration/Database/TableTest.php index 98a5f39..d00e3d2 100644 --- a/tests/integration/Database/TableTest.php +++ b/tests/integration/Database/TableTest.php @@ -26,7 +26,7 @@ public function test_ordinary_table_calls_share_the_transaction_and_bind_values( self::assertSame(1, (int) $this->entries->query()->select('active')->fetchOne()); try { - $this->connection->transactional(function (): void { + $this->db->transactional(function (): void { $this->entries->insert(['name' => 'Rolled back']); throw new \RuntimeException('Cancel'); @@ -62,12 +62,12 @@ public function test_empty_mutation_criteria_are_rejected(): void { public function test_idle_connection_replacement_supports_queries_and_transactions(): void { $old = $this->native($this->source); - $oldStatement = $this->connection->prepare('SELECT CONNECTION_ID()'); + $oldStatement = $this->db->prepare('SELECT CONNECTION_ID()'); $this->source->__set('dbh', $this->native($this->observerSource)); try { - self::assertSame($this->native($this->observerSource)->thread_id, (int) $this->connection->fetchOne('SELECT CONNECTION_ID()')); - self::assertSame('updated', $this->connection->transactional(function (Connection $connection): string { + self::assertSame($this->native($this->observerSource)->thread_id, (int) $this->db->fetchOne('SELECT CONNECTION_ID()')); + self::assertSame('updated', $this->db->transactional(function (Connection $db): string { $this->entries->insert(['name' => 'Reconnected']); return 'updated'; diff --git a/tests/integration/Database/TransactionTest.php b/tests/integration/Database/TransactionTest.php index dd83a0a..c680d0b 100644 --- a/tests/integration/Database/TransactionTest.php +++ b/tests/integration/Database/TransactionTest.php @@ -16,12 +16,12 @@ public function test_a_prefix_change_cannot_commit_even_after_the_error_is_caugh $prefix = $this->source->prefix; try { - $this->connection->transactional(function () use ($prefix): void { - $this->connection->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); + $this->db->transactional(function () use ($prefix): void { + $this->db->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); $this->source->set_prefix($prefix . 'changed_'); try { - $this->connection->fetchOne('SELECT 1'); + $this->db->fetchOne('SELECT 1'); } catch (\RuntimeException) { $this->source->set_prefix($prefix); } @@ -35,16 +35,16 @@ public function test_a_prefix_change_cannot_commit_even_after_the_error_is_caugh } public function test_provider_shares_one_connection_and_borrows_wordpress_session(): void { - self::assertSame($this->connection, $this->container->get(Connection::class)); - self::assertSame($this->native($this->source), $this->connection->getNativeConnection()); + self::assertSame($this->db, $this->container->get(Connection::class)); + self::assertSame($this->native($this->source), $this->db->getNativeConnection()); } public function test_commit_publishes_all_work_at_once_and_returns_the_callback_result(): void { - $result = $this->connection->transactional(function (): int { - $this->connection->executeStatement('DELETE FROM ' . $this->table); - $this->connection->insert($this->table, ['id' => 2, 'name' => 'First']); + $result = $this->db->transactional(function (): int { + $this->db->executeStatement('DELETE FROM ' . $this->table); + $this->db->insert($this->table, ['id' => 2, 'name' => 'First']); $this->assertOriginal(); - $this->connection->insert($this->table, ['id' => 3, 'name' => 'Second']); + $this->db->insert($this->table, ['id' => 3, 'name' => 'Second']); $this->assertOriginal(); return 2; @@ -55,37 +55,37 @@ public function test_commit_publishes_all_work_at_once_and_returns_the_callback_ public function test_manual_transactions_use_void_and_publish_only_at_outer_commit(): void { foreach (['beginTransaction', 'commit', 'rollBack'] as $method) { - self::assertSame('void', (string) (new \ReflectionMethod($this->connection, $method))->getReturnType()); + self::assertSame('void', (string) (new \ReflectionMethod($this->db, $method))->getReturnType()); } - $this->connection->beginTransaction(); - $this->connection->update($this->table, ['name' => 'Outer'], ['id' => 1]); - $this->connection->beginTransaction(); - $this->connection->update($this->table, ['name' => 'Nested'], ['id' => 1]); - $this->connection->commit(); + $this->db->beginTransaction(); + $this->db->update($this->table, ['name' => 'Outer'], ['id' => 1]); + $this->db->beginTransaction(); + $this->db->update($this->table, ['name' => 'Nested'], ['id' => 1]); + $this->db->commit(); $this->assertOriginal(); - $this->connection->rollBack(); + $this->db->rollBack(); $this->assertOriginal(); - $this->connection->beginTransaction(); - $this->connection->update($this->table, ['name' => 'Committed'], ['id' => 1]); - $this->connection->commit(); + $this->db->beginTransaction(); + $this->db->update($this->table, ['name' => 'Committed'], ['id' => 1]); + $this->db->commit(); self::assertSame('Committed', $this->observer->fetchOne('SELECT name FROM ' . $this->table)); - self::assertFalse($this->connection->isTransactionActive()); + self::assertFalse($this->db->isTransactionActive()); } public function test_false_and_null_callback_results_are_successful_committed_work(): void { foreach ([false, null] as $expected) { $name = $expected === false ? 'False result' : 'Null result'; - $result = $this->connection->transactional(function () use ($expected, $name): ?bool { - $this->connection->update($this->table, ['name' => $name], ['id' => 1]); + $result = $this->db->transactional(function () use ($expected, $name): ?bool { + $this->db->update($this->table, ['name' => $name], ['id' => 1]); return $expected; }); self::assertSame($expected, $result); self::assertSame($name, $this->observer->fetchOne('SELECT name FROM ' . $this->table)); - self::assertFalse($this->connection->isTransactionActive()); + self::assertFalse($this->db->isTransactionActive()); } } @@ -93,9 +93,9 @@ public function test_late_failure_restores_existing_data(): void { $failure = new RuntimeException('Invalid later batch'); try { - $this->connection->transactional(function () use ($failure): void { - $this->connection->executeStatement('DELETE FROM ' . $this->table); - $this->connection->insert($this->table, ['id' => 2, 'name' => 'Provisional']); + $this->db->transactional(function () use ($failure): void { + $this->db->executeStatement('DELETE FROM ' . $this->table); + $this->db->insert($this->table, ['id' => 2, 'name' => 'Provisional']); throw $failure; }); @@ -103,15 +103,15 @@ public function test_late_failure_restores_existing_data(): void { self::assertSame($failure, $caught); } $this->assertOriginal(); - self::assertSame(42, $this->connection->transactional(static fn (): int => 42)); + self::assertSame(42, $this->db->transactional(static fn (): int => 42)); } public function test_caught_prepared_statement_failure_is_terminal(): void { - $statement = $this->connection->prepare('INSERT INTO ' . $this->table . ' (id, name) VALUES (?, ?)'); + $statement = $this->db->prepare('INSERT INTO ' . $this->table . ' (id, name) VALUES (?, ?)'); try { - $this->connection->transactional(function () use ($statement): void { - $this->connection->update($this->table, ['name' => 'Provisional'], ['id' => 1]); + $this->db->transactional(function () use ($statement): void { + $this->db->update($this->table, ['name' => 'Provisional'], ['id' => 1]); try { $statement->bindValue(1, 1); @@ -128,14 +128,14 @@ public function test_caught_prepared_statement_failure_is_terminal(): void { public function test_caught_query_failure_blocks_further_queries(): void { try { - $this->connection->transactional(function (): void { + $this->db->transactional(function (): void { try { - $this->connection->executeQuery('SELECT missing_column FROM ' . $this->table); + $this->db->executeQuery('SELECT missing_column FROM ' . $this->table); } catch (Throwable) { } try { - $this->connection->insert($this->table, ['id' => 2, 'name' => 'Must not execute']); + $this->db->insert($this->table, ['id' => 2, 'name' => 'Must not execute']); self::fail('A failed transaction must reject further SQL.'); } catch (Throwable $failure) { self::assertInstanceOf(TransactionFailed::class, $failure); @@ -148,19 +148,19 @@ public function test_caught_query_failure_blocks_further_queries(): void { } public function test_nested_application_failure_uses_savepoint_and_outer_can_continue(): void { - $this->connection->transactional(function (): void { - $this->connection->insert($this->table, ['id' => 2, 'name' => 'Outer']); + $this->db->transactional(function (): void { + $this->db->insert($this->table, ['id' => 2, 'name' => 'Outer']); try { - $this->connection->transactional(function (): void { - $this->connection->insert($this->table, ['id' => 3, 'name' => 'Inner']); + $this->db->transactional(function (): void { + $this->db->insert($this->table, ['id' => 3, 'name' => 'Inner']); throw new RuntimeException('Skip inner work'); }); } catch (RuntimeException) { } - $this->connection->transactional(function (): void { - $this->connection->insert($this->table, ['id' => 4, 'name' => 'Nested success']); + $this->db->transactional(function (): void { + $this->db->insert($this->table, ['id' => 4, 'name' => 'Nested success']); }); }); self::assertSame([1, 2, 4], array_map('intval', $this->observer->fetchFirstColumn('SELECT id FROM ' . $this->table . ' ORDER BY id'))); @@ -168,9 +168,9 @@ public function test_nested_application_failure_uses_savepoint_and_outer_can_con public function test_outer_rollback_includes_successful_nested_work(): void { try { - $this->connection->transactional(function (): void { - $this->connection->transactional(function (): void { - $this->connection->insert($this->table, ['id' => 2, 'name' => 'Nested']); + $this->db->transactional(function (): void { + $this->db->transactional(function (): void { + $this->db->insert($this->table, ['id' => 2, 'name' => 'Nested']); }); throw new RuntimeException('Outer failure'); @@ -186,7 +186,7 @@ public function test_ambient_transaction_is_rejected_without_committing_or_rolli $native->query('UPDATE ' . $this->table . " SET name = 'Ambient'"); try { - $this->connection->transactional(static function (): void { + $this->db->transactional(static function (): void { self::fail('Callback must not run.'); }); } catch (Throwable $failure) { @@ -204,7 +204,7 @@ public function test_autocommit_disabled_is_rejected(): void { $native->autocommit(false); try { - $this->connection->transactional(static function (): void { + $this->db->transactional(static function (): void { self::fail('Callback must not run.'); }); } catch (RuntimeException $failure) { @@ -216,18 +216,18 @@ public function test_autocommit_disabled_is_rejected(): void { public function test_lost_connection_cannot_replay_a_write_even_if_wordpress_reconnects(): void { try { - @$this->connection->transactional(function (): void { - $this->connection->executeStatement('DELETE FROM ' . $this->table); + @$this->db->transactional(function (): void { + $this->db->executeStatement('DELETE FROM ' . $this->table); $this->killConnection(); try { - $this->connection->insert($this->table, ['id' => 2, 'name' => 'Must not replay']); + $this->db->insert($this->table, ['id' => 2, 'name' => 'Must not replay']); } catch (Throwable) { $this->source->db_connect(false); } try { - $this->connection->insert($this->table, ['id' => 3, 'name' => 'Must not adopt']); + $this->db->insert($this->table, ['id' => 3, 'name' => 'Must not adopt']); } catch (Throwable) { } }); @@ -235,14 +235,14 @@ public function test_lost_connection_cannot_replay_a_write_even_if_wordpress_rec } catch (TransactionFailed) { $this->assertOriginal(); } - self::assertSame(7, $this->connection->transactional(static fn (): int => 7)); - self::assertSame($this->native($this->source), $this->connection->getNativeConnection()); + self::assertSame(7, $this->db->transactional(static fn (): int => 7)); + self::assertSame($this->native($this->source), $this->db->getNativeConnection()); } public function test_lost_commit_acknowledgement_reports_uncertain_outcome(): void { try { - @$this->connection->transactional(function (): void { - $this->connection->update($this->table, ['name' => 'Lost'], ['id' => 1]); + @$this->db->transactional(function (): void { + $this->db->update($this->table, ['name' => 'Lost'], ['id' => 1]); $this->killConnection(); }); self::fail('Expected uncertain commit.'); @@ -255,7 +255,7 @@ public function test_cleanup_failure_preserves_the_original_business_exception() $failure = new RuntimeException('Original business failure'); try { - $this->connection->transactional(function () use ($failure): void { + $this->db->transactional(function () use ($failure): void { $this->native($this->source)->close(); throw $failure; @@ -270,12 +270,12 @@ public function test_site_change_prevents_commit_and_restoration_does_not_erase_ self::assertIsInt($otherSite); try { - $this->connection->transactional(function () use ($otherSite): void { - $this->connection->update($this->table, ['name' => 'Wrong scope'], ['id' => 1]); + $this->db->transactional(function () use ($otherSite): void { + $this->db->update($this->table, ['name' => 'Wrong scope'], ['id' => 1]); switch_to_blog($otherSite); try { - $this->connection->executeQuery('SELECT 1'); + $this->db->executeQuery('SELECT 1'); } catch (Throwable) { } finally { restore_current_blog(); @@ -289,12 +289,12 @@ public function test_site_change_prevents_commit_and_restoration_does_not_erase_ public function test_caught_nested_database_failure_still_aborts_outer_work(): void { try { - $this->connection->transactional(function (): void { - $this->connection->update($this->table, ['name' => 'Provisional'], ['id' => 1]); + $this->db->transactional(function (): void { + $this->db->update($this->table, ['name' => 'Provisional'], ['id' => 1]); try { - $this->connection->transactional(function (): void { - $this->connection->insert($this->table, ['id' => 1, 'name' => 'Duplicate']); + $this->db->transactional(function (): void { + $this->db->insert($this->table, ['id' => 1, 'name' => 'Duplicate']); }); } catch (UniqueConstraintViolationException) { } @@ -309,9 +309,9 @@ public function test_a_business_exception_takes_precedence_over_a_caught_databas $business = new RuntimeException('Application failure'); try { - $this->connection->transactional(function () use ($business): void { + $this->db->transactional(function () use ($business): void { try { - $this->connection->executeQuery('SELECT missing_column FROM ' . $this->table); + $this->db->executeQuery('SELECT missing_column FROM ' . $this->table); } catch (Throwable) { } @@ -325,8 +325,8 @@ public function test_a_business_exception_takes_precedence_over_a_caught_databas public function test_replacing_a_live_wordpress_session_prevents_commit(): void { try { - $this->connection->transactional(function (): void { - $this->connection->executeStatement('DELETE FROM ' . $this->table); + $this->db->transactional(function (): void { + $this->db->executeStatement('DELETE FROM ' . $this->table); $this->source->db_connect(false); }); self::fail('Expected connection replacement to abort the operation.'); @@ -334,15 +334,15 @@ public function test_replacing_a_live_wordpress_session_prevents_commit(): void self::assertStringContainsString('connection changed', $failure->getMessage()); $this->assertOriginal(); } - self::assertSame(9, $this->connection->transactional(static fn (): int => 9)); + self::assertSame(9, $this->db->transactional(static fn (): int => 9)); } public function test_native_logging_middleware_can_wrap_the_wordpress_driver(): void { $logger = new \Monolog\Logger('doctrine-evaluation'); $handler = new \Monolog\Handler\TestHandler(); $logger->pushHandler($handler); - $connection = $this->withLogging($logger); - self::assertSame(1, (int) $connection->transactional(static fn (Connection $connection): mixed => $connection->fetchOne('SELECT 1'))); + $db = $this->withLogging($logger); + self::assertSame(1, (int) $db->transactional(static fn (Connection $db): mixed => $db->fetchOne('SELECT 1'))); self::assertTrue($handler->hasDebugThatContains('Committing transaction')); } @@ -356,11 +356,11 @@ public function test_connection_loss_before_begin_rejects_the_callback(): void { return $record; }); - $connection = $this->withLogging($logger); - $called = false; + $db = $this->withLogging($logger); + $called = false; try { - @$connection->transactional(static function () use (&$called): void { + @$db->transactional(static function () use (&$called): void { $called = true; }); self::fail('Expected begin failure.'); @@ -381,13 +381,13 @@ public function test_caught_savepoint_release_failure_remains_terminal(): void { return $record; }); - $connection = $this->withLogging($logger); + $db = $this->withLogging($logger); try { - @$connection->transactional(function () use ($connection): void { + @$db->transactional(function () use ($db): void { try { - $connection->transactional(function () use ($connection): void { - $connection->update($this->table, ['name' => 'Nested'], ['id' => 1]); + $db->transactional(function () use ($db): void { + $db->update($this->table, ['name' => 'Nested'], ['id' => 1]); }); } catch (Throwable) { } @@ -399,11 +399,11 @@ public function test_caught_savepoint_release_failure_remains_terminal(): void { } public function test_stale_prepared_statement_cannot_write_to_an_old_session(): void { - $statement = $this->connection->prepare('UPDATE ' . $this->table . ' SET name = ?'); + $statement = $this->db->prepare('UPDATE ' . $this->table . ' SET name = ?'); $this->source->db_connect(false); try { - $this->connection->transactional(static function () use ($statement): void { + $this->db->transactional(static function () use ($statement): void { try { $statement->bindValue(1, 'Stale'); $statement->executeStatement(); @@ -420,21 +420,21 @@ public function test_a_closed_wordpress_source_is_rejected(): void { $this->source->close(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('WordPress must have an open mysqli connection.'); - $this->connection->transactional(static function (): void { + $this->db->transactional(static function (): void { self::fail('Must not invoke work.'); }); } public function test_native_manual_rollback_remains_available(): void { - $this->connection->beginTransaction(); - $this->connection->update($this->table, ['name' => 'Provisional'], ['id' => 1]); - $this->connection->rollBack(); + $this->db->beginTransaction(); + $this->db->update($this->table, ['name' => 'Provisional'], ['id' => 1]); + $this->db->rollBack(); $this->assertOriginal(); - $this->connection->beginTransaction(); + $this->db->beginTransaction(); $this->killConnection(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('The database did not acknowledge rollback.'); - @$this->connection->rollBack(); + @$this->db->rollBack(); } private function withLogging(\Psr\Log\LoggerInterface $logger): Connection { @@ -444,11 +444,11 @@ private function withLogging(\Psr\Log\LoggerInterface $logger): Connection { new \StellarWP\Foundation\Database\Connection\WordPressMiddleware($this->container->get(\StellarWP\Foundation\Database\Connection\WordPressSession::class)), new \Doctrine\DBAL\Logging\Middleware($logger), ]); - $connection = \Doctrine\DBAL\DriverManager::getConnection([ + $db = \Doctrine\DBAL\DriverManager::getConnection([ 'driver' => 'mysqli', 'wrapperClass' => \StellarWP\Foundation\Database\Connection\WordPressConnection::class, ], $configuration); - return $connection; + return $db; } } From 950aa80601eb2c74b49ab39f5cdd26e8686dab40 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 23 Sep 2026 09:51:24 -0600 Subject: [PATCH 08/53] Fix migration rollback behavior and finalize database failure contracts --- AGENTS.md | 2 +- codeception.dist.yml | 1 + src/Database/Cli/Migrate.php | 8 +++- src/Database/Connection/AdvisoryLock.php | 11 +++-- .../Connection/BorrowedConnection.php | 19 ++++---- .../Connection/WordPressConnection.php | 3 +- src/Database/Connection/WordPressSession.php | 16 +++---- src/Database/DatabaseProvider.php | 3 +- .../Exceptions/CommitOutcomeUnknown.php | 4 +- src/Database/Exceptions/DatabaseException.php | 2 +- .../Exceptions/MigrationAlreadyRunning.php | 12 ----- .../Exceptions/MigrationInterrupted.php | 12 ----- src/Database/Exceptions/TransactionFailed.php | 4 +- .../Exceptions/IncompatibleSchema.php | 6 +-- .../Migration/Exceptions/LedgerFailure.php | 4 +- .../Exceptions/MigrationAlreadyRunning.php | 12 +++++ .../Exceptions/MigrationInterrupted.php | 12 +++++ src/Database/Migration/Migrator.php | 20 ++++++++- .../Migration/Schema/SchemaPlanner.php | 4 +- src/Database/README.md | 3 ++ src/Database/composer.json | 2 +- .../src/content/docs/components/database.mdx | 9 +++- .../docs/components/database/migrations.mdx | 45 +++++++++++++++---- .../components/database/query-builder.mdx | 18 +++++--- tests/CodeceptionSupport/WPCLITester.php | 5 --- .../Database/Declarative/CreateEntries.php | 4 +- .../Database/Declarative/WidenEntryName.php | 2 +- tests/codeception-params.php | 3 ++ .../Database/AdvisoryMigrationTest.php | 4 +- .../DeclarativeMigratorEdgeCaseTest.php | 2 +- .../Database/DeclarativeMigratorTest.php | 36 ++++++++++++--- .../integration/Database/TransactionTest.php | 4 +- tests/wpcli.suite.dist.yml | 2 +- .../Database/Cli/DatabaseMigrateCest.php | 30 ++++++++++++- 34 files changed, 221 insertions(+), 103 deletions(-) delete mode 100644 src/Database/Exceptions/MigrationAlreadyRunning.php delete mode 100644 src/Database/Exceptions/MigrationInterrupted.php create mode 100644 src/Database/Migration/Exceptions/MigrationAlreadyRunning.php create mode 100644 src/Database/Migration/Exceptions/MigrationInterrupted.php create mode 100644 tests/codeception-params.php diff --git a/AGENTS.md b/AGENTS.md index e949c78..200ed7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,7 +159,7 @@ Providers supply one shared managed Doctrine connection per container so tables, Use Doctrine schema objects and platform tools for schema declarations, inspection, comparison, and DDL generation. Retain Foundation policy only where it enforces migration ownership, safe recovery, or demonstrated normalization gaps. An initial migration owns the complete table definition; later migrations own explicit additions, full column modifications, and removals. After DDL succeeds without a ledger write, compatible additions and absent removals count as completed work, while incompatible existing declarations fail verification. Do not reconcile continuously against a mutable current-schema definition or remove unrelated columns and indexes. Preserve decimal and timestamp semantics when replacing schema machinery. DBAL adoption does not by itself establish Doctrine Migrations as a suitable replacement runner. -Migration IDs determine forward execution order and must remain stable across PHP namespace changes and Strauss scoping. Collect all provider contributions, sort globally in ascending byte-exact ID order, and run pending migrations under migration locking. Contribution order must not affect execution. Use target-version rollback in descending byte-exact ID order. Developers own migration dependencies and selecting a safe rollback target; document that behavior rather than adding execution-order tracking or batch semantics solely to handle migrations introduced out of order. Generated timestamps provide sortable IDs; custom IDs remain valid and assume responsibility for their lexical position. Foundation owns migration registration, stable identities, WordPress integration, locking, and its documented recovery guarantees so application migrations declare their changes without assembling that infrastructure. Foundation owns declarative migration planning, recovery, execution, and history. Doctrine supplies schema comparison and platform SQL. Keep DBAL-specific schema translation internal so a future DBAL 3 downgrade does not change application migration declarations. +Migration IDs determine forward execution order and must remain stable across PHP namespace changes and Strauss scoping. Collect all provider contributions, sort globally in ascending byte-exact ID order, and run pending migrations under migration locking. Contribution order must not affect execution. Use target-version rollback in descending byte-exact ID order. Rollback operations only reverse applied migrations; only migration reconciliation may also apply pending IDs through a target. Developers own migration dependencies and selecting a safe rollback target; document that behavior rather than adding execution-order tracking or batch semantics solely to handle migrations introduced out of order. Generated timestamps provide sortable IDs; custom IDs remain valid and assume responsibility for their lexical position. Foundation owns migration registration, stable identities, WordPress integration, locking, and its documented recovery guarantees so application migrations declare their changes without assembling that infrastructure. Foundation owns declarative migration planning, recovery, execution, and history. Doctrine supplies schema comparison and platform SQL. Keep DBAL-specific schema translation internal so a future DBAL 3 downgrade does not change application migration declarations. The same container and database services may be reused after `switch_to_blog()` between complete operations; already-built queries retain the physical names resolved when built. Managed transactions and migration operations capture their starting scope and reject site or connection changes at their operation boundaries. Migrations hold a MySQL/MariaDB named lock on their captured session, check scope and ownership before execution and ledger writes, and release only on the original session. A failed release discards that session. Migration execution never creates the optional application database-lock table. Applications must not issue DDL, manual transaction control, direct WordPress queries, or `switch_to_blog()` inside a managed transaction. Migrations must never switch sites, even temporarily. Keep application migration declarations independent of site scope so another supported scope does not require migration rewrites. diff --git a/codeception.dist.yml b/codeception.dist.yml index a87ebe8..48490fd 100644 --- a/codeception.dist.yml +++ b/codeception.dist.yml @@ -17,3 +17,4 @@ settings: memory_limit: 1024M params: - .env.testing.slic + - tests/codeception-params.php diff --git a/src/Database/Cli/Migrate.php b/src/Database/Cli/Migrate.php index c896d3c..fd95ad7 100644 --- a/src/Database/Cli/Migrate.php +++ b/src/Database/Cli/Migrate.php @@ -67,11 +67,15 @@ public function runCommand(array $args = [], array $assocArgs = []): int { } $target = (string) ($assocArgs['to'] ?? Migrator::LATEST); + if (! $dryRun && $target === Migrator::NONE) { + WP_CLI::confirm('Roll back all migrations? This can permanently delete application data.', $assocArgs); + } + if ($operation === 'refresh') { WP_CLI::confirm('Roll back and rerun all migrations? This can permanently delete application data.', $assocArgs); $result = $this->migrator->refresh(); } elseif ($operation === 'rollback') { - $result = isset($assocArgs['to']) ? $this->migrator->migrate($target) : $this->migrator->rollback($steps); + $result = isset($assocArgs['to']) ? $this->migrator->rollbackTo($target) : $this->migrator->rollback($steps); } else { $result = $dryRun ? $this->migrator->preview($target) : $this->migrator->migrate($target); } @@ -105,7 +109,7 @@ protected function arguments(): array { ['type' => self::FLAG, 'name' => 'dry-run', 'description' => 'Preview --run SQL without executing SQL or data callbacks.', 'optional' => true], ['type' => self::ASSOCIATIVE, 'name' => 'to', 'description' => 'Target migration ID, 0 for none, or latest for all.', 'optional' => true], ['type' => self::ASSOCIATIVE, 'name' => 'step', 'description' => 'Positive number of applied IDs to reverse.', 'optional' => true], - ['type' => self::FLAG, 'name' => 'yes', 'description' => 'Confirm refresh without prompting.', 'optional' => true], + ['type' => self::FLAG, 'name' => 'yes', 'description' => 'Confirm refresh or --to=0 without prompting.', 'optional' => true], ]; } diff --git a/src/Database/Connection/AdvisoryLock.php b/src/Database/Connection/AdvisoryLock.php index 59752b6..1badd37 100644 --- a/src/Database/Connection/AdvisoryLock.php +++ b/src/Database/Connection/AdvisoryLock.php @@ -4,10 +4,9 @@ use Doctrine\DBAL\Driver\Mysqli\Connection; use mysqli; -use RuntimeException; use StellarWP\Foundation\Database\Contracts\DatabaseScope; -use StellarWP\Foundation\Database\Exceptions\MigrationAlreadyRunning; -use StellarWP\Foundation\Database\Exceptions\MigrationInterrupted; +use StellarWP\Foundation\Database\Migration\Exceptions\MigrationAlreadyRunning; +use StellarWP\Foundation\Database\Migration\Exceptions\MigrationInterrupted; use Throwable; /** @@ -49,7 +48,7 @@ public function __construct( */ public function acquire(): void { if ((int) $this->driver->query('SELECT @@session.autocommit')->fetchOne() !== 1) { - throw new RuntimeException('Migrations require autocommit to be enabled.'); + throw new MigrationInterrupted('Migrations require autocommit to be enabled.'); } // Reject an ambient transaction without implicitly committing it. $this->driver->exec('SET TRANSACTION READ WRITE'); @@ -90,11 +89,11 @@ public function check(mixed $current): void { $this->scope->assertCurrent($this->site); if ($current !== $this->native || $this->native->thread_id !== $this->connectionId || $this->scope->resolveTableName('') !== $this->prefix) { - throw new RuntimeException('The migration connection or table prefix changed.'); + throw new MigrationInterrupted('The migration connection or table prefix changed.'); } if ((int) $this->driver->query("SELECT IS_USED_LOCK('{$this->name}')")->fetchOne() !== $this->connectionId) { - throw new RuntimeException('The migration session no longer owns its advisory lock.'); + throw new MigrationInterrupted('The migration session no longer owns its advisory lock.'); } } catch (Throwable $failure) { $this->fail($failure); diff --git a/src/Database/Connection/BorrowedConnection.php b/src/Database/Connection/BorrowedConnection.php index d93eeb6..447fd96 100644 --- a/src/Database/Connection/BorrowedConnection.php +++ b/src/Database/Connection/BorrowedConnection.php @@ -7,7 +7,7 @@ use Doctrine\DBAL\Driver\Result; use Doctrine\DBAL\Driver\Statement; use mysqli; -use RuntimeException; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; /** * Observe native execution failures across Doctrine's query and statement paths. @@ -60,12 +60,12 @@ public function exec(string $sql): int|string { /** * Start the native transaction and require a positive acknowledgement. * - * @throws RuntimeException When mysqli cannot start the transaction. + * @throws DatabaseException When mysqli cannot start the transaction. */ public function beginTransaction(): void { $this->session->execute($this->native, function (): void { if (! $this->native->begin_transaction()) { - throw new RuntimeException('Unable to start the transaction: ' . $this->native->error); + throw new DatabaseException('Unable to start the transaction: ' . $this->native->error); } }); } @@ -73,24 +73,27 @@ public function beginTransaction(): void { /** * Commit the native transaction and require a positive acknowledgement. * - * @throws RuntimeException When mysqli cannot confirm the commit. + * @throws DatabaseException When mysqli cannot confirm the commit. */ public function commit(): void { $this->session->execute($this->native, function (): void { if (! $this->native->commit()) { - throw new RuntimeException('The database did not acknowledge the commit.'); + throw new DatabaseException('The database did not acknowledge the commit.'); } }); } /** - * Roll back when DBAL is used outside the managed callback entry point. + * Roll back the captured native session when DBAL invokes driver cleanup. * - * @throws RuntimeException When mysqli cannot confirm rollback. + * Deliberately bypass the normal execution guard: cleanup must remain possible after + * terminal failure or a WordPress scope change, and must never target a replacement session. + * + * @throws DatabaseException When mysqli cannot confirm rollback. */ public function rollBack(): void { if (! $this->native->rollback()) { - throw new RuntimeException('The database did not acknowledge rollback.'); + throw new DatabaseException('The database did not acknowledge rollback.'); } } } diff --git a/src/Database/Connection/WordPressConnection.php b/src/Database/Connection/WordPressConnection.php index 3769c64..0bca108 100644 --- a/src/Database/Connection/WordPressConnection.php +++ b/src/Database/Connection/WordPressConnection.php @@ -8,6 +8,7 @@ use LogicException; use mysqli; use StellarWP\Foundation\Database\Exceptions\CommitOutcomeUnknown; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; use Throwable; /** @@ -166,7 +167,7 @@ public function rollBack(): void { $session->finish(); if (! $rolledBack && ! $failed) { - throw new \RuntimeException('The database did not acknowledge rollback.'); + throw new DatabaseException('The database did not acknowledge rollback.'); } } diff --git a/src/Database/Connection/WordPressSession.php b/src/Database/Connection/WordPressSession.php index 8a6c502..4ea70c8 100644 --- a/src/Database/Connection/WordPressSession.php +++ b/src/Database/Connection/WordPressSession.php @@ -5,8 +5,8 @@ use Closure; use Doctrine\DBAL\Driver\Mysqli\Connection; use mysqli; -use RuntimeException; use StellarWP\Foundation\Database\Contracts\DatabaseScope; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Exceptions\TransactionFailed; use Throwable; use wpdb; @@ -36,7 +36,7 @@ public function __construct( /** * Return the current native connection without reconnecting or replaying SQL. * - * @throws RuntimeException When WordPress has no mysqli connection. + * @throws DatabaseException When WordPress has no mysqli connection. * @throws TransactionFailed When an active operation has failed. */ public function native(): mysqli { @@ -44,7 +44,7 @@ public function native(): mysqli { $native = $this->wpdb->__get('dbh'); if (! $native instanceof mysqli) { - throw new RuntimeException('WordPress must have an open mysqli connection.'); + throw new DatabaseException('WordPress must have an open mysqli connection.'); } return $native; @@ -70,7 +70,7 @@ public function isActive(): bool { */ public function withAdvisoryLock(string $resource, Closure $operation): mixed { if ($this->advisoryLock !== null || $this->isActive()) { - throw new RuntimeException('Start migrations outside an existing transaction or migration run.'); + throw new DatabaseException('Start migrations outside an existing transaction or migration run.'); } $lock = new AdvisoryLock($this->native(), $this->scope, $resource); $lock->acquire(); @@ -105,7 +105,7 @@ public function start(): void { $driver = new Connection($native); if ((int) $driver->query('SELECT @@session.autocommit')->fetchOne() !== 1) { - throw new RuntimeException('Managed transactions require autocommit to be enabled.'); + throw new DatabaseException('Managed transactions require autocommit to be enabled.'); } // Both MySQL and MariaDB reject this inside an existing transaction. // Unlike START TRANSACTION, it cannot implicitly commit the caller's work. @@ -131,7 +131,7 @@ public function execute(mysqli $native, Closure $operation): mixed { $this->check(); if ($this->wpdb->__get('dbh') !== $native) { - throw new RuntimeException('The WordPress connection changed; start a fresh operation.'); + throw new DatabaseException('The WordPress connection changed; start a fresh operation.'); } return $operation(); @@ -167,11 +167,11 @@ public function check(): void { $this->scope->assertCurrent($this->site); if ($this->scope->resolveTableName('') !== $this->prefix) { - throw new RuntimeException('The WordPress table prefix changed during a transaction.'); + throw new DatabaseException('The WordPress table prefix changed during a transaction.'); } if ($this->wpdb->__get('dbh') !== $this->owned) { - throw new RuntimeException('The WordPress connection changed during a transaction.'); + throw new DatabaseException('The WordPress connection changed during a transaction.'); } } catch (Throwable $failure) { $this->failure = $failure; diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index e2bfcfa..f724ec5 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -32,9 +32,10 @@ */ final class DatabaseProvider extends Provider { - public const string MIGRATIONS = self::class . '.migrations'; use ResolvesFoundationPrefix; + public const string MIGRATIONS = self::class . '.migrations'; + /** * Register lazy database services without executing queries or creating storage. * diff --git a/src/Database/Exceptions/CommitOutcomeUnknown.php b/src/Database/Exceptions/CommitOutcomeUnknown.php index 419ba90..4ad5ab2 100644 --- a/src/Database/Exceptions/CommitOutcomeUnknown.php +++ b/src/Database/Exceptions/CommitOutcomeUnknown.php @@ -2,11 +2,9 @@ namespace StellarWP\Foundation\Database\Exceptions; -use RuntimeException; - /** * Reports an unacknowledged commit whose durable outcome is uncertain. */ -final class CommitOutcomeUnknown extends RuntimeException +final class CommitOutcomeUnknown extends DatabaseException { } diff --git a/src/Database/Exceptions/DatabaseException.php b/src/Database/Exceptions/DatabaseException.php index c4d0280..f3959f4 100644 --- a/src/Database/Exceptions/DatabaseException.php +++ b/src/Database/Exceptions/DatabaseException.php @@ -5,7 +5,7 @@ use RuntimeException; /** - * Base exception for database package failures. + * Base exception for Foundation-owned database failures. Native SQL failures use Doctrine exceptions. */ class DatabaseException extends RuntimeException { diff --git a/src/Database/Exceptions/MigrationAlreadyRunning.php b/src/Database/Exceptions/MigrationAlreadyRunning.php deleted file mode 100644 index fe136a1..0000000 --- a/src/Database/Exceptions/MigrationAlreadyRunning.php +++ /dev/null @@ -1,12 +0,0 @@ - + */ + public function rollbackTo(string $target): array { + $this->assertTarget($target); + + return $this->locked(fn (): array => $this->run($target, true, false)); + } + /** * Reverse all migrations and reapply them under one uninterrupted migration lock. * @@ -248,7 +261,7 @@ private function plan(string $target, array $applied, bool $applyPending): array private function assertKnown(array $ids): void { foreach ($ids as $id) { if (! $this->migrations->has($id)) { - throw new MigrationInterrupted('Restore the missing migration before reversing it: ' . $id); + throw new MigrationInterrupted('Restore the missing migration before running migrations: ' . $id); } } } @@ -271,6 +284,9 @@ private function assertScope(int $site, string $prefix): void { } private static function shortName(Migration $migration): string { + if (str_contains($migration::class, "\0")) { + return '(anonymous)'; + } $parts = explode('\\', $migration::class); return (string) end($parts); diff --git a/src/Database/Migration/Schema/SchemaPlanner.php b/src/Database/Migration/Schema/SchemaPlanner.php index a8487cb..f137541 100644 --- a/src/Database/Migration/Schema/SchemaPlanner.php +++ b/src/Database/Migration/Schema/SchemaPlanner.php @@ -12,8 +12,8 @@ use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; use StellarWP\Foundation\Database\Contracts\TableNameResolver; -use StellarWP\Foundation\Database\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Database\Migration\Exceptions\IncompatibleSchema; +use StellarWP\Foundation\Database\Migration\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Database\Migration\MigrationCollection; use StellarWP\Foundation\Database\Migration\Schema\ValueObjects\SchemaPlan; @@ -285,7 +285,7 @@ private function timestampSql(SchemaState $actual, SchemaState $desired, SchemaD continue; } $this->require(in_array($key, $declaredChanges, true), $id, $table->getObjectName()->toString(), 'column ' . $column->getObjectName()->toString() . ' timestamp precision or ON UPDATE differs from its declaration'); - $sql[] = 'ALTER TABLE ' . $platform->quoteIdentifier($table->getObjectName()->toString()) . ' MODIFY ' . $platform->getColumnDeclarationSQL($platform->quoteIdentifier($column->getObjectName()->toString()), $column->toArray()); + $sql[] = 'ALTER TABLE ' . $platform->quoteSingleIdentifier($table->getObjectName()->toString()) . ' MODIFY ' . $platform->getColumnDeclarationSQL($platform->quoteSingleIdentifier($column->getObjectName()->toString()), $column->toArray()); } } diff --git a/src/Database/README.md b/src/Database/README.md index 12cddef..1b6a0e1 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -13,6 +13,9 @@ for WordPress applications. composer require stellarwp/foundation-database ``` +The package includes `stellarwp/foundation-wpcli` for its migration command. Register +`WPCliProvider` to enable it, or use `Migrator` programmatically. + ## Documentation See the [Foundation Database documentation](https://foundation.nexcess.dev/components/database/) diff --git a/src/Database/composer.json b/src/Database/composer.json index decc4f3..b642c9c 100644 --- a/src/Database/composer.json +++ b/src/Database/composer.json @@ -1,7 +1,7 @@ { "name": "stellarwp/foundation-database", "type": "library", - "description": "Foundation Database package.", + "description": "Doctrine DBAL tables, managed transactions, and declarative migrations for WordPress.", "license": "GPL-2.0-or-later", "config": { "vendor-dir": "vendor", diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index 20bf9a0..36e24b0 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -17,7 +17,9 @@ Install the runtime package: composer require stellarwp/foundation-database ``` -Foundation requires PHP 8.3, mysqli, and Doctrine DBAL 4.4. Database services borrow the active WordPress connection. Use the primary MySQL or MariaDB database and InnoDB tables for transactional application data. +Foundation requires PHP 8.3, mysqli, and Doctrine DBAL 4.4. The package includes `stellarwp/foundation-wpcli` as a runtime dependency for its migration command. Register `WPCliProvider` to enable that command; applications using only programmatic migrations may omit that provider. Command loading is deferred until WP-CLI initializes. + +Database services borrow the active WordPress connection. Use the primary MySQL or MariaDB database and InnoDB tables for transactional application data. Set a stable, unique application prefix in your root configuration: @@ -49,7 +51,9 @@ $container->register( Provider::class ); // Your application tables and migratio Application tables declare their stable name and inherit their infrastructure constructor: -```php title="src/Database/Tables/Reports_Table.php" +In `src/Database/Tables/Reports_Table.php`: + +```php title="Reports_Table.php" container->singleton( Reports_Table::class ); $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ @@ -167,16 +174,25 @@ Foundation runs this callback after schema changes and before writing history. I | Reconcile to a registered ID | `wp your-plugin migrate --run --to=` | | Reverse the highest applied ID | `wp your-plugin migrate --rollback` | | Reverse several applied IDs | `wp your-plugin migrate --rollback --step=2` | -| Reconcile to a rollback target | `wp your-plugin migrate --rollback --to=` | -| Reverse all applied migrations | `wp your-plugin migrate --rollback --to=0` | +| Reverse applied IDs above a target | `wp your-plugin migrate --rollback --to=` | +| Reverse all applied migrations | `wp your-plugin migrate --rollback --to=0 --yes` | | Reverse and rerun everything | `wp your-plugin migrate --refresh` | -Programmatic equivalents are `Migrator::status()`, `preview($target)`, `migrate($target)`, `rollback($steps)`, and `refresh()`. Migration operations return step objects containing the stable ID, direction, SQL, and whether a forward data callback was involved. Optional `DescribesMigration::describe()` supplies a status description. +Programmatic equivalents are `Migrator::status()`, `preview($target)`, `migrate($target)`, `rollback($steps)`, `rollbackTo($target)`, and `refresh()`. Migration operations return step objects containing the stable ID, direction, SQL, and whether a forward data callback was involved. To add a readable status description, implement `DescribesMigration` alongside `Migration`: -An explicit target reverses applied IDs above it in descending order, then applies pending IDs through it in ascending order. `latest` applies all pending migrations. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied migration classes must be restored before execution can continue. +```php +use StellarWP\Foundation\Database\Migration\Contracts\DescribesMigration; + +// Add DescribesMigration to the migration's implements list. +public function describe(): string { + return 'Create report storage'; +} +``` + +`migrate($target)` and `--run --to=` reverse applied IDs above the target in descending order, then apply pending IDs through it in ascending order. `latest` applies all pending migrations. `rollbackTo($target)` and `--rollback --to=` only reverse applied IDs above the target; any pending IDs, including the target itself, remain pending. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied migration classes must be restored before execution can continue. :::danger[Rollback can delete data] -`down()` is application code. Reversing an initial migration drops its complete table, including rows and additions made later. Review the inverse and take the application's required backup before rolling back. `--refresh` prompts for confirmation because it reverses and recreates everything; use `--yes` only when that data loss is intended. +`down()` is application code. Reversing an initial migration drops its complete table, including rows and additions made later. Review the inverse and take the application's required backup before rolling back. `--refresh`, `--run --to=0`, and `--rollback --to=0` prompt for confirmation because they reverse every applied migration. Use `--yes` only when that data loss is intended. `--run --to=0 --dry-run` previews the reversal without prompting or executing it. ::: ### Interrupted schema changes @@ -185,11 +201,22 @@ MySQL DDL can commit before the history write. A retry compares the actual schem An incompatible existing declaration stops the run with `IncompatibleSchema`; Foundation does not silently reconcile unrelated drift. Inspect and correct the mismatch before retrying. `LedgerFailure` means a history write failed after migration work; fix the storage failure and retry with the same declarations. Any data callback must tolerate repetition. +### Repair inconsistent history + +If the ledger itself is wrong, pause all application upgrade triggers and migration workers for the affected site and take a backup. Restore missing migration classes first, then compare the ledger's exact IDs with the live schema and each migration's schema and data effects. + +Use your database administration tool against the configured physical ledger table, including its WordPress site prefix. For a confirmed bookkeeping error: + +- Insert the migration's exact `version` only after verifying that its complete `up()` and any `migrate()` data callback already succeeded. The ledger supplies `applied_at` automatically. +- Delete its `version` row only after verifying that its complete inverse has already been performed and that recorded dependent migrations remain valid. + +These are deliberate manual SQL changes, not schema repairs. Do not change history to suppress a genuine `IncompatibleSchema` mismatch. Restore the intended schema first when history is accurate. After correcting history, inspect status and preview the next run before resuming upgrades. Keep an operational record of the repair. + ### Concurrent upgrades One database advisory lock covers planning, schema execution, data callbacks, and history writes. The lock survives DDL commits and lasts until release or session termination. It has no lease TTL to configure. `MigrationAlreadyRunning` means another session is migrating this application's site ledger: defer and retry after it finishes. This is distinct from a database failure. -`MigrationInterrupted` means ownership or the starting session could not be confirmed. Foundation stops and releases only the original session's lock where possible. Never change sites or sessions during a migration, even temporarily. These two exceptions live under `Database\Exceptions`; schema and history exceptions live under `Database\Migration\Exceptions`. +`MigrationInterrupted` means ownership or the starting session could not be confirmed. Foundation stops and releases only the original session's lock where possible. Never change sites or sessions during a migration, even temporarily. Migration exceptions live under `StellarWP\Foundation\Database\Migration\Exceptions` and extend `StellarWP\Foundation\Database\Exceptions\DatabaseException`. Catch a specific exception when choosing whether to defer, retry, or stop. All migration participants must reach the same primary database server. Advisory locks are local to that server; a proxy that moves statements between sessions or servers cannot provide this guarantee. Preview and status are observations and can become stale before a later run. diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index 5d0aa22..9724083 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -11,7 +11,9 @@ sidebar: A repository is an optional way to organize reusable application queries: -```php title="src/Report/Report_Repository.php" +In `src/Report/Report_Repository.php`: + +```php title="Report_Repository.php" |false */ + /** + * @return array|false + */ public function find( int $id ): array|false { return $this->reports->query() ->select( 'id', 'title' ) @@ -80,10 +86,12 @@ use Doctrine\DBAL\Connection; use Plugin\Database\Tables\Reports_Table; final readonly class Create_Report { + public function __construct( private Connection $db, private Reports_Table $reports, - ) {} + ) { + } public function create( string $title ): int|string { return $this->db->transactional( function () use ( $title ): int|string { @@ -104,7 +112,7 @@ Nested transactions use savepoints. An inner success is provisional until the ou - `CommitOutcomeUnknown` means the server did not confirm commit. The data may already be committed. Inspect durable application state or use an idempotency key before retrying. - A detected site or connection change interrupts managed work. Foundation never replays failed SQL or reconnects in the middle of a transaction. -The Foundation exceptions above live under `StellarWP\Foundation\Database\Exceptions`. +The Foundation exceptions above live under `StellarWP\Foundation\Database\Exceptions` and extend `DatabaseException`. Native Doctrine SQL exceptions retain their own hierarchy. :::caution[Keep managed work on the shared connection] Use transactional tables. Do not issue DDL, manual transaction-control SQL, direct WordPress queries, or `switch_to_blog()` inside a managed transaction. DDL can implicitly commit MySQL transactions, and writes through other sessions are outside this transaction. External HTTP calls cannot be rolled back with database writes. diff --git a/tests/CodeceptionSupport/WPCLITester.php b/tests/CodeceptionSupport/WPCLITester.php index 258e8d2..8e5d15e 100644 --- a/tests/CodeceptionSupport/WPCLITester.php +++ b/tests/CodeceptionSupport/WPCLITester.php @@ -15,11 +15,6 @@ * @method void lookForwardTo($achieveValue) * @method void comment($description) * @method void pause($vars = []) - * @method void cli(string|array $command, array $args = []) - * @method void seeResultCodeIs(int $code) - * @method void seeInShellOutput(string $text) - * @method string grabLastShellOutput() - * @method string grabLastShellErrorOutput() * * @SuppressWarnings(PHPMD) */ diff --git a/tests/Support/Fixtures/Database/Declarative/CreateEntries.php b/tests/Support/Fixtures/Database/Declarative/CreateEntries.php index c959b0f..21a231c 100644 --- a/tests/Support/Fixtures/Database/Declarative/CreateEntries.php +++ b/tests/Support/Fixtures/Database/Declarative/CreateEntries.php @@ -22,11 +22,11 @@ public function id(): string { } public function up(Blueprint $schema): void { - $entries = $schema->create($this->table); + $entries = $schema->create($this->table)->comment('Application entries'); $entries->bigIncrements('id'); $entries->string('name', 50); - $entries->string('status', 20)->default('active'); + $entries->string('status', 20)->default('active')->comment('Current processing state'); $entries->decimal('amount', 12, 4)->default('0'); $entries->binary('token', 16)->nullable(); $entries->dateTime('created_at', 6)->useCurrent(); diff --git a/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php b/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php index 89ccf32..6461559 100644 --- a/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php +++ b/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php @@ -26,6 +26,6 @@ public function up(Blueprint $schema): void { } public function down(Blueprint $schema): void { - $schema->table($this->table)->comment('')->string('name', 50)->change(); + $schema->table($this->table)->comment('Application entries')->string('name', 50)->change(); } } diff --git a/tests/codeception-params.php b/tests/codeception-params.php new file mode 100644 index 0000000..55dc80d --- /dev/null +++ b/tests/codeception-params.php @@ -0,0 +1,3 @@ + dirname(__DIR__)]; diff --git a/tests/integration/Database/AdvisoryMigrationTest.php b/tests/integration/Database/AdvisoryMigrationTest.php index 5d37b69..c38133a 100644 --- a/tests/integration/Database/AdvisoryMigrationTest.php +++ b/tests/integration/Database/AdvisoryMigrationTest.php @@ -12,10 +12,10 @@ use StellarWP\Foundation\Database\Contracts\DatabaseScope; use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Database\DatabaseProvider; -use StellarWP\Foundation\Database\Exceptions\MigrationAlreadyRunning; -use StellarWP\Foundation\Database\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Database\Migration\Contracts\MigratesData; use StellarWP\Foundation\Database\Migration\Contracts\Migration; +use StellarWP\Foundation\Database\Migration\Exceptions\MigrationAlreadyRunning; +use StellarWP\Foundation\Database\Migration\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Database\Migration\History; use StellarWP\Foundation\Database\Migration\MigrationCollection; use StellarWP\Foundation\Database\Migration\Migrator; diff --git a/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php b/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php index b0fe18f..c544e83 100644 --- a/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php +++ b/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php @@ -4,9 +4,9 @@ use StellarWP\Foundation\Container\Contracts\Resolver as C; use StellarWP\Foundation\Database\DatabaseProvider; -use StellarWP\Foundation\Database\Exceptions\MigrationAlreadyRunning; use StellarWP\Foundation\Database\Migration\Exceptions\IncompatibleSchema; use StellarWP\Foundation\Database\Migration\Exceptions\IrreversibleMigration; +use StellarWP\Foundation\Database\Migration\Exceptions\MigrationAlreadyRunning; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\ValueObjects\Step; use StellarWP\Foundation\Tests\Support\Fixtures\Database\DatabaseTestCase; diff --git a/tests/integration/Database/DeclarativeMigratorTest.php b/tests/integration/Database/DeclarativeMigratorTest.php index 7138e05..f58b01e 100644 --- a/tests/integration/Database/DeclarativeMigratorTest.php +++ b/tests/integration/Database/DeclarativeMigratorTest.php @@ -4,9 +4,10 @@ use StellarWP\Foundation\Container\Contracts\Resolver as C; use StellarWP\Foundation\Database\DatabaseProvider; -use StellarWP\Foundation\Database\Exceptions\MigrationAlreadyRunning; -use StellarWP\Foundation\Database\Exceptions\MigrationInterrupted; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Migration\Exceptions\IncompatibleSchema; +use StellarWP\Foundation\Database\Migration\Exceptions\MigrationAlreadyRunning; +use StellarWP\Foundation\Database\Migration\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\ValueObjects\Step; use StellarWP\Foundation\Tests\Support\Fixtures\Database\DatabaseTestCase; @@ -139,6 +140,27 @@ public function test_target_reconciles_reversals_and_older_pending_migrations(): self::assertSame([self::CREATE, self::ALTER], $this->history()); } + public function test_target_rollback_leaves_older_pending_migrations_pending(): void { + $this->migrator()->migrate(); + $this->observer->delete($this->quotedHistory, ['version' => self::ALTER]); + + $steps = $this->migrator()->rollbackTo(self::ALTER); + + self::assertSame([self::BACKFILL], self::ids($steps)); + self::assertTrue($steps[0]->reverse); + self::assertSame([self::CREATE], $this->history()); + self::assertSame([], $this->migrator()->rollbackTo(self::ALTER)); + } + + public function test_unknown_rollback_target_is_rejected_without_creating_history(): void { + try { + $this->migrator()->rollbackTo('unknown'); + self::fail('Unknown rollback target must fail.'); + } catch (\InvalidArgumentException) { + self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); + } + } + public function test_public_migration_workflow_uses_no_deprecated_dbal_api(): void { \Doctrine\Deprecations\Deprecation::enableTrackingDeprecations(); @@ -250,6 +272,9 @@ public function test_create_retry_after_ledger_failure_preserves_rows(): void { self::assertSame([], $steps[0]->sql); self::assertSame([self::CREATE], $this->history()); self::assertSame(['Survivor'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->quotedEntries)); + $table = $this->observer->createSchemaManager()->introspectTable($this->entriesName); + self::assertSame('Application entries', $table->getComment()); + self::assertSame('Current processing state', $table->getColumn('status')->getComment()); } public function test_undeclared_column_change_stops_the_run_and_names_the_column(): void { @@ -313,13 +338,13 @@ public function test_rollback_by_step_and_by_target(): void { self::assertTrue($steps[0]->reverse); self::assertSame([self::CREATE, self::ALTER], $this->history()); - $steps = $this->migrator()->migrate(self::CREATE); + $steps = $this->migrator()->rollbackTo(self::CREATE); self::assertSame([self::ALTER], self::ids($steps)); self::assertStringNotContainsString('`note`', $this->createTableSql()); self::assertSame(['Original'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->quotedEntries)); self::assertSame([self::CREATE], $this->history()); - $this->migrator()->migrate(Migrator::NONE); + $this->migrator()->rollbackTo(Migrator::NONE); self::assertSame([], $this->history()); self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->entriesName])); } @@ -345,7 +370,8 @@ public function test_another_session_holding_the_lock_blocks_the_run(): void { try { $this->migrator()->migrate(); self::fail('Expected contention.'); - } catch (MigrationAlreadyRunning) { + } catch (DatabaseException $failure) { + self::assertInstanceOf(MigrationAlreadyRunning::class, $failure); self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); } finally { $this->observer->fetchOne('SELECT RELEASE_LOCK(?)', [$name]); diff --git a/tests/integration/Database/TransactionTest.php b/tests/integration/Database/TransactionTest.php index c680d0b..dfcc7ae 100644 --- a/tests/integration/Database/TransactionTest.php +++ b/tests/integration/Database/TransactionTest.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use RuntimeException; use StellarWP\Foundation\Database\Exceptions\CommitOutcomeUnknown; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Exceptions\TransactionFailed; use StellarWP\Foundation\Tests\Support\Fixtures\Database\DatabaseTestCase; use Throwable; @@ -246,7 +247,8 @@ public function test_lost_commit_acknowledgement_reports_uncertain_outcome(): vo $this->killConnection(); }); self::fail('Expected uncertain commit.'); - } catch (CommitOutcomeUnknown) { + } catch (DatabaseException $failure) { + self::assertInstanceOf(CommitOutcomeUnknown::class, $failure); $this->assertOriginal(); } } diff --git a/tests/wpcli.suite.dist.yml b/tests/wpcli.suite.dist.yml index f5305da..4272b5d 100644 --- a/tests/wpcli.suite.dist.yml +++ b/tests/wpcli.suite.dist.yml @@ -11,6 +11,6 @@ modules: url: %WP_URL% user: %WP_ADMIN_USERNAME% require: - - /var/www/html/wp-content/plugins/foundation/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php + - '%FOUNDATION_TEST_ROOT%/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php' throw: false allow-root: true diff --git a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php index a764542..d7bcacb 100644 --- a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -14,6 +14,7 @@ public function test_it_previews_runs_and_rolls_back_migrations(WPCLITester $I): $I->cli(['foundation', 'migrate']); $I->seeResultCodeIs(0); $I->seeInShellOutput('pending'); + $I->seeInShellOutput('(anonymous)'); $I->cli(['foundation', 'migrate', '--run', '--dry-run']); $I->seeResultCodeIs(0); $I->seeInShellOutput('CREATE TABLE'); @@ -39,12 +40,39 @@ public function test_it_refreshes_and_targets_versions(WPCLITester $I): void { $I->cli(['foundation', 'migrate', '--refresh', '--yes']); $I->seeResultCodeIs(0); $I->seeInShellOutput('Completed 2 migration steps.'); - $I->cli(['foundation', 'migrate', '--rollback', '--to=0']); + $I->cli(['foundation', 'migrate', '--rollback', '--to=0', '--yes']); $I->seeResultCodeIs(0); $I->cli(['foundation', 'migrate']); $I->seeInShellOutput('pending'); } + public function test_target_rollback_does_not_apply_a_pending_target(WPCLITester $I): void { + $I->cli(['foundation', 'migrate', '--rollback', '--to=20260623000001']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Completed 0 migration steps.'); + $I->cli(['foundation', 'migrate']); + $I->seeInShellOutput('pending'); + } + + public function test_full_reversals_require_confirmation_but_previews_do_not(WPCLITester $I): void { + foreach (['--run', '--rollback'] as $operation) { + $I->cli(['foundation', 'migrate', '--run']); + $I->seeResultCodeIs(0); + $I->cli(['foundation', 'migrate', $operation, '--to=0'], null, "n\n"); + $I->seeInShellOutput('Roll back all migrations?'); + $I->cli(['foundation', 'migrate']); + $I->seeInShellOutput('applied'); + $I->cli(['foundation', 'migrate', '--run', '--to=0', '--dry-run']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Previewed 1 migration steps.'); + $I->cli(['foundation', 'migrate', $operation, '--to=0', '--yes']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Completed 1 migration steps.'); + $I->cli(['foundation', 'migrate']); + $I->seeInShellOutput('pending'); + } + } + public function test_invalid_options_fail_before_migration_execution(WPCLITester $I): void { foreach ([['--run', '--refresh'], ['--dry-run'], ['--rollback', '--step=0'], ['--rollback', '--step=1', '--to=0']] as $flags) { $I->cli(['foundation', 'migrate', ...$flags]); From 6269e5d49c0684df1f66c2af223119a82af2b41c Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 23 Sep 2026 09:58:37 -0600 Subject: [PATCH 09/53] Add more code style rules, test on Migrate.php --- AGENTS.md | 2 ++ src/Database/Cli/Migrate.php | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 200ed7b..faa12b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,8 @@ Encapsulate meaningful closed state behind named factories and predicates such a ## Source Conventions +Separate control statements (`if`, loops, `switch`, and `try`) from surrounding statements with a blank line before and after the complete construct. This includes a blank line between a closing control-flow brace and a following assignment or method call. Do not add blank lines between connected clauses such as `} elseif`, `} else`, `} catch`, or `} finally`, or next to an enclosing block's opening or closing brace solely for this rule. + Do not declare empty constructors when PHP's implicit public constructor suffices. Private empty constructors may prevent instantiation of static utilities or constants holders. Avoid import aliases unless resolving a real collision or ambiguity. The standing exception is `use StellarWP\Foundation\Container\Contracts\Resolver as C;` for container factories. diff --git a/src/Database/Cli/Migrate.php b/src/Database/Cli/Migrate.php index fd95ad7..f31b4da 100644 --- a/src/Database/Cli/Migrate.php +++ b/src/Database/Cli/Migrate.php @@ -38,6 +38,7 @@ public function runCommand(array $args = [], array $assocArgs = []): int { if (count($operations) > 1) { WP_CLI::error('Choose only one of --run, --rollback, or --refresh.'); } + $operation = $operations[0] ?? null; $dryRun = (bool) get_flag_value($assocArgs, 'dry-run', false); @@ -52,6 +53,7 @@ public function runCommand(array $args = [], array $assocArgs = []): int { if (isset($assocArgs['step']) && ($operation !== 'rollback' || isset($assocArgs['to']))) { WP_CLI::error('--step requires --rollback and cannot be combined with --to.'); } + $steps = filter_var($assocArgs['step'] ?? 1, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); if ($steps === false) { @@ -65,6 +67,7 @@ public function runCommand(array $args = [], array $assocArgs = []): int { return self::SUCCESS; } + $target = (string) ($assocArgs['to'] ?? Migrator::LATEST); if (! $dryRun && $target === Migrator::NONE) { @@ -79,6 +82,7 @@ public function runCommand(array $args = [], array $assocArgs = []): int { } else { $result = $dryRun ? $this->migrator->preview($target) : $this->migrator->migrate($target); } + $this->showSteps($result, $dryRun); return self::SUCCESS; @@ -133,6 +137,7 @@ private function showSteps(array $steps, bool $preview): void { if (! $preview) { continue; } + foreach ($step->sql as $sql) { WP_CLI::line($sql . ';'); } @@ -141,6 +146,7 @@ private function showSteps(array $steps, bool $preview): void { WP_CLI::line('Data callback will run after schema changes.'); } } + WP_CLI::success(sprintf('%s %d migration steps.', $preview ? 'Previewed' : 'Completed', count($steps))); } } From 5ffdcff616b164c01e206f9be7743ee982fc9303 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 23 Sep 2026 10:14:14 -0600 Subject: [PATCH 10/53] Add Command::clearRuntimeCache() for long-running cli processes --- .../src/content/docs/components/wp-cli.mdx | 17 +++++ src/WPCli/Command.php | 21 ++++++ tests/Support/Fixtures/WPCli/TestCommand.php | 7 ++ tests/wpunit/WPCli/CommandTest.php | 71 +++++++++++++++++++ 4 files changed, 116 insertions(+) create mode 100644 tests/wpunit/WPCli/CommandTest.php diff --git a/src/Docs/src/content/docs/components/wp-cli.mdx b/src/Docs/src/content/docs/components/wp-cli.mdx index ebc2e18..53b2f59 100644 --- a/src/Docs/src/content/docs/components/wp-cli.mdx +++ b/src/Docs/src/content/docs/components/wp-cli.mdx @@ -317,6 +317,23 @@ wp help your-plugin catalog:sync Use `WP_CLI::error()` for invalid input or an operation that cannot continue; it stops the command with a failing exit code. Use `WP_CLI::warning()` when execution can continue. Returning any nonzero status from `runCommand()` also causes the Foundation command wrapper to halt with that status. ::: +### Release runtime caches between batches + +Long-running commands can call the inherited `clearRuntimeCache()` method after each completed batch. For a command with an injected importer that yields batches, the loop inside `runCommand()` can look like this: + +```php +foreach ( $this->importer->batches( 100 ) as $batch ) { + $this->importer->import( $batch ); + unset( $batch ); + + $this->clearRuntimeCache(); +} +``` + +This clears WordPress's saved query log and flushes the process's runtime object cache when the cache backend supports [`flush_runtime`](https://developer.wordpress.org/reference/functions/wp_cache_supports/). Shared persistent cache entries, action counts, and registered hooks remain intact. If runtime flushing is unsupported, only the query log is cleared. + +Call it after WordPress has loaded and the batch has completed its writes. Release application-held batch and query objects as well; cache cleanup cannot release references your own code still retains. Saved query diagnostics are discarded, so inspect them before cleanup when debugging. + ## Testing ### Test business behavior outside the command diff --git a/src/WPCli/Command.php b/src/WPCli/Command.php index 953f4bf..43771d9 100644 --- a/src/WPCli/Command.php +++ b/src/WPCli/Command.php @@ -101,6 +101,27 @@ final protected function registeredCommandName(): ?string { return $this->context === null ? null : $this->command($this->context); } + /** + * Release saved queries and supported runtime object caches between completed batches. + * + * Requires WordPress to be loaded. Cache backends without runtime-flush support are + * left intact; persistent cache entries and WordPress hook state are preserved. + * + * @throws \Throwable When the cache backend throws during runtime cleanup. + */ + protected function clearRuntimeCache(): void { + global $wpdb; + + // Reset queries. + $wpdb->queries = []; + + if (! wp_cache_supports('flush_runtime')) { + return; + } + + wp_cache_flush_runtime(); + } + /** * Ask a question and retrieve a normalized answer from STDIN. */ diff --git a/tests/Support/Fixtures/WPCli/TestCommand.php b/tests/Support/Fixtures/WPCli/TestCommand.php index e69260c..63549fb 100644 --- a/tests/Support/Fixtures/WPCli/TestCommand.php +++ b/tests/Support/Fixtures/WPCli/TestCommand.php @@ -61,6 +61,13 @@ public function prompt(string $question): string { return $this->ask($question); } + /** + * Expose batch cleanup for WordPress integration tests. + */ + public function clearRuntimeCache(): void { + parent::clearRuntimeCache(); + } + public function defaultInput(): mixed { return $this->input(); } diff --git a/tests/wpunit/WPCli/CommandTest.php b/tests/wpunit/WPCli/CommandTest.php new file mode 100644 index 0000000..0b23efa --- /dev/null +++ b/tests/wpunit/WPCli/CommandTest.php @@ -0,0 +1,71 @@ +queries; + $filter = static fn (): string => 'kept'; + add_filter('foundation_runtime_cleanup_filter', $filter); + do_action('foundation_runtime_cleanup_action'); + + try { + foreach (['first', 'second'] as $batch) { + wp_cache_set('batch', $batch, 'foundation-runtime-cleanup'); + $wpdb->queries = [['SELECT 1', 0.01, 'saved query']]; + + $command->clearRuntimeCache(); + + $this->assertSame([], $wpdb->queries); + $this->assertFalse(wp_cache_get('batch', 'foundation-runtime-cleanup')); + $this->assertSame(1, did_action('foundation_runtime_cleanup_action')); + $this->assertSame('kept', apply_filters('foundation_runtime_cleanup_filter', '')); + } + } finally { + $wpdb->queries = $queries; + remove_filter('foundation_runtime_cleanup_filter', $filter); + } + } + + public function test_an_unsupported_runtime_flush_preserves_cached_data(): void { + global $wpdb; + + PHPMockery::mock('StellarWP\Foundation\WPCli', 'wp_cache_supports') + ->once()->with('flush_runtime')->andReturn(false); + + $queries = $wpdb->queries; + wp_cache_set('keep', 'shared value', 'foundation-runtime-cleanup'); + $wpdb->queries = [['SELECT 1', 0.01, 'saved query']]; + + try { + (new TestCommand())->clearRuntimeCache(); + + $this->assertSame([], $wpdb->queries); + $this->assertSame('shared value', wp_cache_get('keep', 'foundation-runtime-cleanup')); + } finally { + $wpdb->queries = $queries; + wp_cache_delete('keep', 'foundation-runtime-cleanup'); + } + } +} From 6abd505ca578eb76cadfb429697aa77ed41494f2 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 23 Sep 2026 11:27:00 -0600 Subject: [PATCH 11/53] Use colon-separated names for generator commands --- AGENTS.md | 2 +- README.md | 4 +-- .../Make/Database/MigrationCommand.php | 2 +- .../Make/Database/ProviderCommand.php | 2 +- .../Commands/Make/Database/TableCommand.php | 2 +- src/Cli/Commands/Make/WPCliCommand.php | 2 +- .../docs/components/database/migrations.mdx | 6 ++-- .../src/content/docs/components/wp-cli.mdx | 2 +- .../content/docs/tooling/foundation-cli.mdx | 32 +++++++++---------- tests/Unit/Cli/CliProviderTest.php | 8 ++--- tests/Unit/Cli/FoundationBinTest.php | 12 +++---- 11 files changed, 37 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index faa12b8..6441c55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ Generate WordPress code with Snake_Case classes and WordPress formatting. Detect Migration generators must not offer a force-overwrite option. Existing migrations are identity-bearing history: edit a migration only before it has been applied anywhere, or create a new migration for a new schema change. -Database migration generators must distinguish table ownership from a table dependency through explicit options. Only `make:database-migration --create=` may use the create-table stub whose migration-owned schema declaration defines the complete initial schema and whose rollback drops the complete table. `make:database-migration --table=` selects an alteration migration: inject the existing table, declare only the explicit additions, changes, and removals owned by that migration, and default `down()` to `IrreversibleMigration` until the developer supplies a safe inverse. The options are mutually exclusive and accept either a short class from the default table namespace or a fully qualified class. Never infer destructive behavior from a migration name. `make:database-table --migration` must reuse the same create-migration factory so combined and standalone generation remain consistent. +Database migration generators must distinguish table ownership from a table dependency through explicit options. Only `make:database:migration --create=` may use the create-table stub whose migration-owned schema declaration defines the complete initial schema and whose rollback drops the complete table. `make:database:migration --table=` selects an alteration migration: inject the existing table, declare only the explicit additions, changes, and removals owned by that migration, and default `down()` to `IrreversibleMigration` until the developer supplies a safe inverse. The options are mutually exclusive and accept either a short class from the default table namespace or a fully qualified class. Never infer destructive behavior from a migration name. `make:database:table --migration` must reuse the same create-migration factory so combined and standalone generation remain consistent. ## CLI Tooling Boundary diff --git a/README.md b/README.md index 3fa38f9..6f4ac71 100644 --- a/README.md +++ b/README.md @@ -190,12 +190,12 @@ composer require stellarwp/foundation-wpcli Then generate a starter WP-CLI command: ```bash -vendor/bin/foundation make:wpcli-command Sync_Products_Command +vendor/bin/foundation make:wpcli:command Sync_Products_Command ``` The command reads the project's PSR-4 Composer autoload namespaces, writes a Snake_Case command class under `Cli/Commands` inside the selected PSR-4 root, and uses the default WP-CLI command stub from `foundation-wpcli`. -If your project adds a Composer script such as `"foundation": "@php vendor/bin/foundation"`, you may run the command as `composer run foundation -- make:wpcli-command Sync_Products_Command`. +If your project adds a Composer script such as `"foundation": "@php vendor/bin/foundation"`, you may run the command as `composer run foundation -- make:wpcli:command Sync_Products_Command`. `stellarwp/foundation-cli` is build-time developer tooling when installed as a split package. Do not register `StellarWP\Foundation\Cli\CliProvider` in a WordPress plugin application, and do not include the CLI split package in production plugin zips. Production split-package installs should normally run with Composer's `--no-dev` mode so the generator, Symfony Console, and other dev tooling are not packaged. diff --git a/src/Cli/Commands/Make/Database/MigrationCommand.php b/src/Cli/Commands/Make/Database/MigrationCommand.php index 79b16bb..478905a 100644 --- a/src/Cli/Commands/Make/Database/MigrationCommand.php +++ b/src/Cli/Commands/Make/Database/MigrationCommand.php @@ -24,7 +24,7 @@ final class MigrationCommand extends Command { public const string CONFIG_KEY = 'database-migration'; - public const string NAME = 'make:' . self::CONFIG_KEY; + public const string NAME = 'make:database:migration'; public const string DEFAULT_NAMESPACE = 'Database\\Migrations'; /** diff --git a/src/Cli/Commands/Make/Database/ProviderCommand.php b/src/Cli/Commands/Make/Database/ProviderCommand.php index 4400f75..481e7b8 100644 --- a/src/Cli/Commands/Make/Database/ProviderCommand.php +++ b/src/Cli/Commands/Make/Database/ProviderCommand.php @@ -27,7 +27,7 @@ final class ProviderCommand extends Command { public const string CONFIG_KEY = 'database-provider'; - public const string NAME = 'make:' . self::CONFIG_KEY; + public const string NAME = 'make:database:provider'; public const string DEFAULT_NAMESPACE = 'Database'; public function __construct( diff --git a/src/Cli/Commands/Make/Database/TableCommand.php b/src/Cli/Commands/Make/Database/TableCommand.php index c6c9aa6..7a2b7e7 100644 --- a/src/Cli/Commands/Make/Database/TableCommand.php +++ b/src/Cli/Commands/Make/Database/TableCommand.php @@ -30,7 +30,7 @@ final class TableCommand extends Command { public const string CONFIG_KEY = 'database-table'; - public const string NAME = 'make:' . self::CONFIG_KEY; + public const string NAME = 'make:database:table'; public const string DEFAULT_NAMESPACE = 'Database\\Tables'; /** diff --git a/src/Cli/Commands/Make/WPCliCommand.php b/src/Cli/Commands/Make/WPCliCommand.php index 4c9bbf8..3b34b87 100644 --- a/src/Cli/Commands/Make/WPCliCommand.php +++ b/src/Cli/Commands/Make/WPCliCommand.php @@ -27,7 +27,7 @@ final class WPCliCommand extends Command { public const string CONFIG_KEY = 'wpcli-command'; - public const string NAME = 'make:' . self::CONFIG_KEY; + public const string NAME = 'make:wpcli:command'; public const string DEFAULT_NAMESPACE = 'Cli\\Commands'; public function __construct( diff --git a/src/Docs/src/content/docs/components/database/migrations.mdx b/src/Docs/src/content/docs/components/database/migrations.mdx index 7450cff..0d2b00d 100644 --- a/src/Docs/src/content/docs/components/database/migrations.mdx +++ b/src/Docs/src/content/docs/components/database/migrations.mdx @@ -13,8 +13,8 @@ After [configuring DatabaseProvider](/components/database/), install the develop ```shell composer require --dev stellarwp/foundation-cli -vendor/bin/foundation make:database-provider -vendor/bin/foundation make:database-table reports --table-name=your_plugin_reports --migration +vendor/bin/foundation make:database:provider +vendor/bin/foundation make:database:table reports --table-name=your_plugin_reports --migration ``` The generator creates a table and its initial migration, and adds their registrations to the generated provider. Register that provider after `DatabaseProvider` in your application's composition root. @@ -106,7 +106,7 @@ All providers contribute to one globally sorted collection. IDs are compared in Generate an alteration for an existing table: ```shell -vendor/bin/foundation make:database-migration add-published-at --table=Reports_Table +vendor/bin/foundation make:database:migration add-published-at --table=Reports_Table ``` Its `up()` declares only this migration's changes: diff --git a/src/Docs/src/content/docs/components/wp-cli.mdx b/src/Docs/src/content/docs/components/wp-cli.mdx index 53b2f59..54592af 100644 --- a/src/Docs/src/content/docs/components/wp-cli.mdx +++ b/src/Docs/src/content/docs/components/wp-cli.mdx @@ -102,7 +102,7 @@ When the override is absent, WP-CLI uses `foundation.prefix`. Generate the initial class from the project root: ```shell -vendor/bin/foundation make:wpcli-command Sync_Catalog \ +vendor/bin/foundation make:wpcli:command Sync_Catalog \ --namespace="Plugin\\Catalog\\Cli" \ --subcommand="catalog:sync" \ --description="Synchronize the product catalog." diff --git a/src/Docs/src/content/docs/tooling/foundation-cli.mdx b/src/Docs/src/content/docs/tooling/foundation-cli.mdx index 801f955..b05d312 100644 --- a/src/Docs/src/content/docs/tooling/foundation-cli.mdx +++ b/src/Docs/src/content/docs/tooling/foundation-cli.mdx @@ -54,7 +54,7 @@ composer run foundation -- list ### Generate a WP-CLI command ```shell -vendor/bin/foundation make:wpcli-command Sync_Products_Command +vendor/bin/foundation make:wpcli:command Sync_Products_Command ``` The generated class extends Foundation's WP-CLI command base and demonstrates positional arguments, associative options, and flags. A command shipped by the plugin requires the runtime package: @@ -74,13 +74,13 @@ composer require stellarwp/foundation-wpcli Generate the application provider first so later generators can register the table and migration automatically: ```shell -vendor/bin/foundation make:database-provider -vendor/bin/foundation make:database-table Reports_Table --migration +vendor/bin/foundation make:database:provider +vendor/bin/foundation make:database:table Reports_Table --migration ``` The `--migration` flag creates and registers the table's initial migration in the same operation. Table `--namespace` and `--path` options affect only the table class; the migration uses its project-configured namespace and path, falling back to the Foundation convention. Use an explicit identifier such as `--migration-id=2026_09_04_143200_create_reports_table` only when the generated timestamp identifier must be replaced. The explicit ID determines that migration's position in Foundation's ascending execution order. -Use `--table-name=` when the table's unprefixed WordPress name should differ from the name derived from its class. On `make:database-migration`, `--table=` instead selects the existing table class that an alteration migration changes. +Use `--table-name=` when the table's unprefixed WordPress name should differ from the name derived from its class. On `make:database:migration`, `--table=` instead selects the existing table class that an alteration migration changes. Generated database classes require the runtime package: @@ -94,9 +94,9 @@ The migration generator selects one of three modes: | Invocation | Generated behavior | Override stub | | --- | --- | --- | -| `make:database-migration Create_Reports_Table --create=Reports_Table` | Creates the explicitly selected table; `down()` drops the table | `create-table-migration.stub` | -| `make:database-migration Add_Status_To_Reports --table=Reports_Table` | Starts an explicit alteration blueprint; `down()` throws `IrreversibleMigration` until replaced | `alter-table-migration.stub` | -| `make:database-migration Backfill_Report_Status` | Generates a generic migration; add service or table dependencies manually | `migration.stub` | +| `make:database:migration Create_Reports_Table --create=Reports_Table` | Creates the explicitly selected table; `down()` drops the table | `create-table-migration.stub` | +| `make:database:migration Add_Status_To_Reports --table=Reports_Table` | Starts an explicit alteration blueprint; `down()` throws `IrreversibleMigration` until replaced | `alter-table-migration.stub` | +| `make:database:migration Backfill_Report_Status` | Generates a generic migration; add service or table dependencies manually | `migration.stub` | The `--create` and `--table` options are mutually exclusive. Each accepts a short class name from the configured table namespace (`Database\\Tables` by default), or a fully qualified table class. Migration names never select destructive behavior by themselves. @@ -111,10 +111,10 @@ The `--create` and `--table` options are mutually exclusive. Each accepts a shor Use Symfony Console's built-in help for supported names, paths, namespaces, and feature-specific options: ```shell -vendor/bin/foundation help make:wpcli-command -vendor/bin/foundation help make:database-provider -vendor/bin/foundation help make:database-table -vendor/bin/foundation help make:database-migration +vendor/bin/foundation help make:wpcli:command +vendor/bin/foundation help make:database:provider +vendor/bin/foundation help make:database:table +vendor/bin/foundation help make:database:migration ``` Without project overrides, generators use the first `autoload.psr-4` entry in the project's `composer.json` as their root namespace. Output directories follow the most specific Composer mapping for the selected namespace. Explicit `--namespace` and `--path` options override those defaults. @@ -152,14 +152,14 @@ Namespaces are fully qualified application namespaces. With `Plugin\\` mapped to | Generator | Directory | | --- | --- | -| `make:wpcli-command` | `src/Commands/` | -| `make:database-provider` | `src/Persistence/` | -| `make:database-table` | `src/Persistence/Tables/` | -| `make:database-migration` | `src/Persistence/Migrations/` | +| `make:wpcli:command` | `src/Commands/` | +| `make:database:provider` | `src/Persistence/` | +| `make:database:table` | `src/Persistence/Tables/` | +| `make:database:migration` | `src/Persistence/Migrations/` | An explicit `--namespace` takes precedence over the configured namespace. Without either, the conventional suffixes are `Cli\\Commands`, `Database`, `Database\\Tables`, and `Database\\Migrations`, respectively. An explicit `--path` chooses the output directory for that invocation; it does not change the namespace or update Composer autoload mappings. -Table and migration generators look for `Provider.php` in the configured database-provider namespace. Generate that provider first to enable automatic registration. Use `--provider` to select another provider file, including a provider generated with a custom class name. Paired migrations from `make:database-table --migration` use the migration setting independently of table options; short `--create` and `--table` references use the table setting. +Table and migration generators look for `Provider.php` in the configured database-provider namespace. Generate that provider first to enable automatic registration. Use `--provider` to select another provider file, including a provider generated with a custom class name. Paired migrations from `make:database:table --migration` use the migration setting independently of table options; short `--create` and `--table` references use the table setting. Invalid settings for the selected generator or missing Composer mappings stop generation before files are written. Correct the setting or mapping before retrying. If the provider namespace has no mapping, correct `generators.database-provider.namespace` or its Composer mapping, or select an existing provider file with `--provider`. diff --git a/tests/Unit/Cli/CliProviderTest.php b/tests/Unit/Cli/CliProviderTest.php index 4870f1a..4810075 100644 --- a/tests/Unit/Cli/CliProviderTest.php +++ b/tests/Unit/Cli/CliProviderTest.php @@ -57,9 +57,9 @@ public function test_it_registers_cli_services(): void { $this->assertInstanceOf(StubResolver::class, $container->get(StubResolver::class)); $this->assertSame(getcwd(), $container->get(ProjectDirectory::class)->path); $this->assertFalse($container->get(Application::class)->has('package:create')); - $this->assertTrue($container->get(Application::class)->has('make:database-migration')); - $this->assertTrue($container->get(Application::class)->has('make:database-provider')); - $this->assertTrue($container->get(Application::class)->has('make:database-table')); - $this->assertTrue($container->get(Application::class)->has('make:wpcli-command')); + $this->assertTrue($container->get(Application::class)->has('make:database:migration')); + $this->assertTrue($container->get(Application::class)->has('make:database:provider')); + $this->assertTrue($container->get(Application::class)->has('make:database:table')); + $this->assertTrue($container->get(Application::class)->has('make:wpcli:command')); } } diff --git a/tests/Unit/Cli/FoundationBinTest.php b/tests/Unit/Cli/FoundationBinTest.php index ca8dd39..125b24e 100644 --- a/tests/Unit/Cli/FoundationBinTest.php +++ b/tests/Unit/Cli/FoundationBinTest.php @@ -38,7 +38,7 @@ public function test_it_uses_the_composer_bin_proxy_autoload_path(): void { $result = $this->runPhp([$proxy]); $this->assertSame(0, $result['status']); - $this->assertStringContainsString('make:wpcli-command', $result['stdout']); + $this->assertStringContainsString('make:wpcli:command', $result['stdout']); $this->assertStringNotContainsString('package:create', $result['stdout']); } @@ -52,7 +52,7 @@ public function test_it_skips_the_builtin_provider_regardless_of_class_spelling( $result = $this->runPhp([dirname(__DIR__, 3) . '/src/Cli/bin/foundation', 'list', '--raw'], $root); $this->assertSame(0, $result['status'], $provider . ': ' . $result['stderr']); - $this->assertSame(1, substr_count($result['stdout'], 'make:database-migration')); + $this->assertSame(1, substr_count($result['stdout'], 'make:database:migration')); } } @@ -80,7 +80,7 @@ public function test_it_loads_project_generator_configuration_once_per_invocatio $result = $this->runPhp([ dirname(__DIR__, 3) . '/src/Cli/bin/foundation', - 'make:wpcli-command', + 'make:wpcli:command', 'Sync_Products_Command', '--no-ansi', ], $root); @@ -103,9 +103,9 @@ public function test_it_accepts_shared_configuration_for_custom_generators(): vo $list = $this->runPhp([$binary, 'list', '--no-ansi'], $root); $this->assertSame(0, $list['status'], $list['stderr']); - $this->assertStringContainsString('make:wpcli-command', $list['stdout']); + $this->assertStringContainsString('make:wpcli:command', $list['stdout']); - $result = $this->runPhp([$binary, 'make:wpcli-command', 'Sync_Command', '--no-ansi'], $root); + $result = $this->runPhp([$binary, 'make:wpcli:command', 'Sync_Command', '--no-ansi'], $root); $this->assertSame(0, $result['status'], $result['stderr'] . $result['stdout']); $this->assertFileExists($root . '/src/Commands/Sync_Command.php'); } @@ -116,7 +116,7 @@ public function test_the_custom_generator_example_uses_project_defaults_and_over $list = $this->runPhp([$binary, 'list', '--no-ansi'], $root); $this->assertSame(0, $list['status'], $list['stderr']); $this->assertStringContainsString('make:report', $list['stdout']); - $this->assertStringContainsString('make:database-table', $list['stdout']); + $this->assertStringContainsString('make:database:table', $list['stdout']); $result = $this->runPhp([$binary, 'make:report', 'Sales_Report', '--no-ansi'], $root); $this->assertSame(0, $result['status'], $result['stderr'] . $result['stdout']); From 95e4a96c142f2fb56926f13ded6513ebd1de186c Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 23 Sep 2026 15:08:34 -0600 Subject: [PATCH 12/53] Discover anonymous migrations from configurable directories Use stable filename identities and historical table-name literals, decoupling migrations from application table classes and Composer mappings. Update generators, provider wiring, documentation, and regression coverage; document contextual binding formatting. --- AGENTS.md | 14 +- .../Factories/MigrationFileFactory.php | 191 +- .../Make/Database/MigrationCommand.php | 203 +- .../Make/Database/ProviderCommand.php | 4 +- .../Database/ProviderRegistrationEditor.php | 193 +- .../Commands/Make/Database/TableCommand.php | 115 +- .../ValueObjects/GeneratedMigration.php | 21 - src/Database/Contracts/TableNameResolver.php | 4 +- src/Database/DatabaseProvider.php | 31 +- .../Migration/Contracts/MigratesData.php | 5 +- .../Migration/Contracts/Migration.php | 5 - src/Database/Migration/Migration.php | 23 + .../Migration/MigrationCollection.php | 26 +- src/Database/Migration/MigrationDiscovery.php | 71 + src/Database/Migration/Migrator.php | 12 +- src/Database/Migration/Schema/Blueprint.php | 10 +- .../Migration/Schema/SchemaPlanner.php | 9 +- .../ValueObjects/MigrationDirectory.php | 68 + .../ValueObjects/MigrationRegistration.php | 20 + src/Database/Table/TableNameResolver.php | 4 +- src/Database/stubs/alter-table-migration.stub | 30 +- .../stubs/create-table-migration.stub | 31 +- src/Database/stubs/migration.stub | 21 +- src/Database/stubs/provider.stub | 12 +- .../src/content/docs/components/database.mdx | 5 +- .../docs/components/database/migrations.mdx | 312 ++- .../content/docs/tooling/foundation-cli.mdx | 38 +- .../20260923000003_publish_reports.php | 22 + .../Declarative/AddEntryArchivedFlag.php | 4 - .../Database/Declarative/AddEntryNote.php | 4 - .../Declarative/BackfillEntryStatus.php | 7 +- .../Database/Declarative/CreateEntries.php | 4 - .../Database/Declarative/CreateEntryTags.php | 4 - .../Database/Declarative/DropEntryTags.php | 4 - .../Database/Declarative/IndexEntryNote.php | 4 - .../Declarative/RecreateEntryTags.php | 4 - .../Declarative/ReduceCreatedAtPrecision.php | 4 - .../Declarative/ReplaceNoteLookup.php | 4 - .../Declarative/TrackEntryUpdates.php | 4 - .../Database/Declarative/WidenEntryName.php | 4 - .../20260923000001_create_reports_table.php | 16 + .../20260923000002_add_published_at.php | 14 + .../register-wpcli-migrate-command.php | 3 +- .../Cli/Commands/Make/DatabaseCommandTest.php | 1739 +---------------- .../Commands/Make/MigrationGenerationTest.php | 201 ++ .../Migration/MigrationCollectionTest.php | 23 +- .../Migration/MigrationDiscoveryTest.php | 89 + .../Database/Table/TableNameResolverTest.php | 18 + .../Database/AdvisoryMigrationTest.php | 7 +- .../DeclarativeMigratorEdgeCaseTest.php | 23 +- .../Database/DeclarativeMigratorTest.php | 7 +- .../Database/GeneratedMigrationTest.php | 61 + .../Database/MigrationDiscoveryTest.php | 87 + .../Database/Cli/DatabaseMigrateCest.php | 2 +- 54 files changed, 1297 insertions(+), 2544 deletions(-) delete mode 100644 src/Cli/Commands/Make/Database/ValueObjects/GeneratedMigration.php create mode 100644 src/Database/Migration/Migration.php create mode 100644 src/Database/Migration/MigrationDiscovery.php create mode 100644 src/Database/Migration/ValueObjects/MigrationDirectory.php create mode 100644 src/Database/Migration/ValueObjects/MigrationRegistration.php create mode 100644 tests/Support/Fixtures/Database/DataDiscovery/20260923000003_publish_reports.php create mode 100644 tests/Support/Fixtures/Database/Discovery/20260923000001_create_reports_table.php create mode 100644 tests/Support/Fixtures/Database/Discovery/reports/20260923000002_add_published_at.php create mode 100644 tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php create mode 100644 tests/Unit/Database/Migration/MigrationDiscoveryTest.php create mode 100644 tests/integration/Database/GeneratedMigrationTest.php create mode 100644 tests/integration/Database/MigrationDiscoveryTest.php diff --git a/AGENTS.md b/AGENTS.md index 6441c55..842c61c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,9 @@ Generate WordPress code with Snake_Case classes and WordPress formatting. Detect Migration generators must not offer a force-overwrite option. Existing migrations are identity-bearing history: edit a migration only before it has been applied anywhere, or create a new migration for a new schema change. -Database migration generators must distinguish table ownership from a table dependency through explicit options. Only `make:database:migration --create=` may use the create-table stub whose migration-owned schema declaration defines the complete initial schema and whose rollback drops the complete table. `make:database:migration --table=` selects an alteration migration: inject the existing table, declare only the explicit additions, changes, and removals owned by that migration, and default `down()` to `IrreversibleMigration` until the developer supplies a safe inverse. The options are mutually exclusive and accept either a short class from the default table namespace or a fully qualified class. Never infer destructive behavior from a migration name. `make:database:table --migration` must reuse the same create-migration factory so combined and standalone generation remain consistent. +Database migration generators distinguish table ownership from alteration through explicit options. Only `make:database:migration --create=` generates the complete initial schema and a rollback that drops the table. `--table=` selects an alteration migration: declare only the additions, changes, and removals owned by that migration, and default `down()` to `IrreversibleMigration` until the developer supplies a safe inverse. These options are mutually exclusive and take stable database names, not PHP classes. Never infer destructive behavior from a migration name. `make:database:table --migration` reuses the create-migration factory and writes the same unprefixed name into both generated files. + +Generated migration files return anonymous subclasses of `Database\Migration\Migration`. Use `_.php` filenames, with 14-digit timestamps and lowercase letters, numbers, or underscores in the description. The filename basename without `.php` is the permanent ledger identity. Configure discovery and generation together through `database.migrations.path`, defaulting to `db/migrations` relative to the application's `foundation.root`. Discover recursively when migration services are resolved, using a typed file-return boundary. Do not require Composer mappings, container resolution, or per-migration provider entries. Explicit `DatabaseProvider::MIGRATIONS` contributions use `Migration\ValueObjects\MigrationRegistration` to associate an ID with a declaration; `MigrationCollection` owns identity validation and global ordering. Keep historical table names literal and independent of application table classes. Foundation supplies the connection and table-name resolver to optional data callbacks. ## CLI Tooling Boundary @@ -127,6 +129,14 @@ If local scaffolding assets such as `foundation/stubs/` should not be included i When a package provides container integration, bind autowireable classes and interfaces directly. Use contextual `when()->needs()->give()` bindings for scalar arguments, collections, and substitutions. Use closures for computed or resolved dependencies; keep construction in owning providers or factories. +Format contextual bindings with `when()`, `needs()`, and `give()` on separate lines, even for short bindings. Keep `when()` on the container expression's line and indent `->needs()` and `->give()` one level, aligned with each other: + +```php +$this->container->when(MigrationDiscovery::class) + ->needs('$root') + ->give(fn (): ?string => $this->config->get('foundation.root')); +``` + All providers extend `Provider`. Its final constructor receives `Container` and resolves the application-lifetime `Configuration` snapshot; subclasses use `$this->config` when needed and must not declare constructors. Keep one provider shape. Bind `Configuration` before registering providers, and keep third-party implementations such as `Adbar\Dot` behind a Foundation-owned adapter. Foundation providers have one eager `register()` lifecycle. Keep deferred construction in lazy service bindings rather than adding provider-level `provides()`, `isDeferred()`, or `boot()` phases tied to a particular container engine. The composition root registers each provider once per container, in dependency order, before resolving application services. Provider registration establishes bootstrap wiring; it is not a supported way to reset or reconfigure a running container. Developers own a correct provider list; accidental repetition may replace bindings or duplicate contributions and callbacks. Do not add per-provider registration flags or central deduplication without a demonstrated supported workflow that requires repeated registration. @@ -155,7 +165,7 @@ Use Doctrine's native `Connection`, query builder, results, exceptions, and sche Application table classes should extend `StellarWP\Foundation\Database\Table\Table`. Inject the concrete table for ordinary table-scoped queries and writes; retain `query()`, `insert()`, `insertGetId()`, `update()`, and `delete()` conveniences with deliberate return and failure semantics. `query()` creates a fresh native Doctrine builder with its starting table configured. Do not inject a reusable mutable builder into a repository. Inject the shared Doctrine `Connection` for transaction boundaries, specialized SQL, or cross-table work that needs it. Add table conveniences when they own demonstrated reusable behavior, not merely to forward every Doctrine method. Repositories are application query organization, not a required layer for every table. -Every table implementation returns a stable unprefixed name from `unprefixedName()`. Resolve and validate physical names through `TableNameResolver` at the point of use, applying the current `DatabaseScope`; application tables and repositories must not repeat WordPress prefix handling or physical-name validation. Foundation table and schema entry points accept table objects. Native Doctrine and raw SQL use their resolved, quoted names. Generated table classes inherit the base class's infrastructure constructor and declare only application concerns. Keep historical schema definitions in migrations, independent of runtime table classes. +Every table implementation returns a stable unprefixed name from `unprefixedName()`. Resolve and validate physical names through `TableNameResolver` at the point of use, applying the current `DatabaseScope`; application tables and repositories must not repeat WordPress prefix handling or physical-name validation. Application query entry points use table objects. Migration schema entry points accept historical unprefixed strings, resolved and validated by the same naming policy; table objects remain accepted for deliberate programmatic use. Native Doctrine and raw SQL use their resolved, quoted names. Generated table classes inherit the base class's infrastructure constructor and declare only application concerns. Keep historical schema definitions in migrations, independent of runtime table classes. Providers supply one shared managed Doctrine connection per container so tables, repositories, and transaction-owning services participate in the same session. Keep WordPress's native mysqli access inside the connection integration; route Foundation database execution through Doctrine rather than `wpdb::query()` or `dbDelta()`. Applications choose the transaction boundary; Foundation owns cleanup, terminal failure tracking, and commit acknowledgement. Never replay part of a failed transaction. A caught database execution failure must prevent successful completion, an escaping business exception must survive cleanup failure, and an uncertain commit must be reported distinctly. Nested success remains provisional until the outer transaction commits. A replacement connection must satisfy these documented guarantees, not merely extend Doctrine's connection class. diff --git a/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php b/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php index 0ea0f57..ec9133a 100644 --- a/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php +++ b/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php @@ -2,180 +2,135 @@ namespace StellarWP\Foundation\Cli\Commands\Make\Database\Factories; +use DateTimeImmutable; +use DateTimeZone; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use RuntimeException; -use StellarWP\Foundation\Cli\Commands\Make\Database\MigrationCommand; -use StellarWP\Foundation\Cli\Commands\Make\Database\TableCommand; -use StellarWP\Foundation\Cli\Commands\Make\Database\ValueObjects\GeneratedMigration; use StellarWP\Foundation\Cli\Composer\ComposerAutoloadResolver; -use StellarWP\Foundation\Cli\Generation\GeneratorLocationResolver; use StellarWP\Foundation\Cli\Generation\StubRenderer; use StellarWP\Foundation\Cli\Generation\StubResolver; -use StellarWP\Foundation\Cli\Generation\ValueObjects\ComposerProject; use StellarWP\Foundation\Cli\Generation\ValueObjects\GeneratedFile; -use StellarWP\Foundation\Cli\Generation\ValueObjects\PhpNamespace; use StellarWP\Foundation\Cli\Generation\ValueObjects\ProjectDirectory; use StellarWP\Foundation\Cli\Generation\WordPressClassNameResolver; +use StellarWP\Foundation\Container\Contracts\Configuration; use StellarWP\Foundation\Database\DatabaseStubPath; use StellarWP\Foundation\Database\Migration\ValueObjects\Id; +use StellarWP\Foundation\Database\Migration\ValueObjects\MigrationDirectory; /** - * Creates generic, create-table, and alter-table migration artifacts. + * Create anonymous migration files with permanent, timestamped filenames. */ final readonly class MigrationFileFactory { /** - * Create a migration factory rooted in the consuming Composer project. + * Receive project configuration and migration rendering services. */ public function __construct( private ProjectDirectory $projectDirectory, private ComposerAutoloadResolver $autoloadResolver, - private GeneratorLocationResolver $locations, private WordPressClassNameResolver $classNameResolver, private StubResolver $stubResolver, - private StubRenderer $stubRenderer + private StubRenderer $stubRenderer, + private Configuration $config, ) { } /** - * Build a migration artifact without implied table behavior. + * Build a migration without implied table ownership. * - * @throws RuntimeException When project metadata or generator input is invalid. + * @throws RuntimeException When generator input or project metadata is invalid. + * @throws \InvalidArgumentException When the directory or identity is invalid. */ - public function generic(string $name, ?string $namespace = null, ?string $path = null, ?string $id = null): GeneratedMigration { - $context = $this->context($name, $namespace, $path, $id); - $stub = $this->stubResolver->resolve('database', 'migration', DatabaseStubPath::migration()); - - return $this->migration($context, $this->stubRenderer->render($stub, [ - 'namespace' => $context['namespace'], - 'class' => $context['class'], - 'id_php' => $this->stubRenderer->phpStringLiteral($context['id']), - 'foundation_database_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration'), - 'foundation_database_schema' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint'), - 'foundation_database_irreversible_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration'), - ])); + public function generic(string $name): GeneratedFile { + return $this->render($name, 'migration', DatabaseStubPath::migration()); } /** - * Build a migration that creates and owns the selected table. + * Build a migration that creates and owns the named table. * - * @throws RuntimeException When project metadata or generator input is invalid. + * @throws RuntimeException When generator input or project metadata is invalid. + * @throws \InvalidArgumentException When the directory or identity is invalid. */ - public function createTable(string $name, string $tableClass, ?string $namespace = null, ?string $path = null, ?string $id = null): GeneratedMigration { - $context = $this->context($name, $namespace, $path, $id); - $table = $this->tableReference($tableClass, $context['project']); - $stub = $this->stubResolver->resolve('database', 'create-table-migration', DatabaseStubPath::createTableMigration()); - - return $this->migration($context, $this->stubRenderer->render($stub, [ - 'namespace' => $context['namespace'], - 'class' => $context['class'], - 'id_php' => $this->stubRenderer->phpStringLiteral($context['id']), - 'table_class' => $table['class'], - 'table_namespace' => $table['namespace'], - 'foundation_database_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration'), - 'foundation_database_schema' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint'), - ])); + public function createTable(string $name, string $table): GeneratedFile { + return $this->render($name, 'create-table-migration', DatabaseStubPath::createTableMigration(), $table); } /** - * Build a migration that explicitly alters an existing table. + * Build a migration that alters the named table. * - * @throws RuntimeException When project metadata or generator input is invalid. + * @throws RuntimeException When generator input or project metadata is invalid. + * @throws \InvalidArgumentException When the directory or identity is invalid. */ - public function alterTable(string $name, string $tableClass, ?string $namespace = null, ?string $path = null, ?string $id = null): GeneratedMigration { - $context = $this->context($name, $namespace, $path, $id); - $table = $this->tableReference($tableClass, $context['project']); - $stub = $this->stubResolver->resolve('database', 'alter-table-migration', DatabaseStubPath::alterTableMigration()); - - return $this->migration($context, $this->stubRenderer->render($stub, [ - 'namespace' => $context['namespace'], - 'class' => $context['class'], - 'id_php' => $this->stubRenderer->phpStringLiteral($context['id']), - 'table_class' => $table['class'], - 'table_namespace' => $table['namespace'], - 'foundation_database_irreversible_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration'), - 'foundation_database_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration'), - 'foundation_database_schema' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint'), - ])); + public function alterTable(string $name, string $table): GeneratedFile { + return $this->render($name, 'alter-table-migration', DatabaseStubPath::alterTableMigration(), $table); } /** - * Resolve and validate the metadata shared by every generated migration type. - * - * @throws RuntimeException When project metadata or generator input is invalid. - * - * @return array{project: ComposerProject, class: string, namespace: string, path: string, id: string} + * Render a migration without loading application classes or bootstrapping WordPress. */ - private function context(string $name, ?string $namespace, ?string $path, ?string $id): array { - $className = $this->classNameResolver->className($name); - $project = $this->autoloadResolver->project(); - $namespace = $this->locations->namespaceFor(MigrationCommand::CONFIG_KEY, MigrationCommand::DEFAULT_NAMESPACE, $project, $namespace); - $path = $this->locations->directoryFor($namespace, $project, $path); - $id = (new Id($id ?? $this->classNameResolver->migrationId($className)))->value; - - return [ - 'project' => $project, - 'class' => $className, - 'namespace' => $namespace, - 'path' => $path, - 'id' => $id, - ]; - } + private function render(string $name, string $stubName, string $defaultStub, ?string $table = null): GeneratedFile { + if ($table !== null && preg_match('/\A[A-Za-z0-9_]+\z/', $table) !== 1) { + throw new RuntimeException('Use an unprefixed table name containing only ASCII letters, numbers, and underscores.'); + } - /** - * Wrap rendered source and its declared class details as a generated migration. - * - * @param array{project: ComposerProject, class: string, namespace: string, path: string, id: string} $context - */ - private function migration(array $context, string $contents): GeneratedMigration { - $file = new GeneratedFile( - path: $context['path'] . '/' . $context['class'] . '.php', - relativePath: $this->projectDirectory->relativePath($context['path'] . '/' . $context['class'] . '.php'), - contents: $contents + $directory = new MigrationDirectory( + $this->config->get('foundation.root', $this->projectDirectory->path), + $this->config->get('database.migrations.path', MigrationDirectory::DEFAULT_PATH), ); - return new GeneratedMigration($file, $context['class'], $context['namespace']); + $parts = explode('/', $name); + + foreach ($parts as $part) { + if ($part === '' || $part === '.' || $part === '..' || str_contains($part, '\\')) { + throw new RuntimeException('Use a migration name or relative group/name, such as reports/add_published_at.'); + } + } + + $description = strtolower($this->classNameResolver->className(array_pop($parts))); + $groups = array_map(fn (string $part): string => strtolower($this->classNameResolver->className($part)), $parts); + $id = (new Id($this->nextTimestamp($directory->path) . '_' . $description))->value; + $path = implode('/', [$directory->path, ...$groups, $id . '.php']); + $prefix = $this->autoloadResolver->straussNamespacePrefix() ?? ''; + $stub = $this->stubResolver->resolve('database', $stubName, $defaultStub); + + return new GeneratedFile( + path: $path, + relativePath: $this->projectDirectory->relativePath($path), + contents: $this->stubRenderer->render($stub, [ + 'table_php' => $this->stubRenderer->phpStringLiteral($table ?? ''), + 'foundation_database_migration' => $prefix . 'StellarWP\\Foundation\\Database\\Migration\\Migration', + 'foundation_database_schema' => $prefix . 'StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint', + 'foundation_database_irreversible_migration' => $prefix . 'StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration', + ]), + ); } /** - * Resolve a short or fully qualified table class into an importable reference. - * - * @return array{class: string, namespace: string} + * Keep sequentially generated migrations ordered even within the same second. */ - private function tableReference(string $tableClass, ComposerProject $project): array { - $tableClass = trim($tableClass); + private function nextTimestamp(string $directory): string { + $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); + $timestamp = $now->format('YmdHis'); - if ($tableClass === '') { - throw new RuntimeException('The table class cannot be blank.'); + if (! is_dir($directory)) { + return $timestamp; } - if (str_contains($tableClass, '/')) { - throw new RuntimeException(sprintf('Table class "%s" is not a valid PHP class name.', $tableClass)); - } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS)); - $tableClass = ltrim($tableClass, '\\'); - $separator = strrpos($tableClass, '\\'); - - if ($separator === false) { - if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $tableClass) !== 1) { - throw new RuntimeException(sprintf('Table class "%s" is not a valid PHP class name.', $tableClass)); + foreach ($files as $file) { + if (! $file->isFile() || preg_match('/^([0-9]{14})_[a-z0-9_]+\.php$/', $file->getFilename(), $matches) !== 1 || $matches[1] < $timestamp) { + continue; } - return [ - 'class' => $tableClass, - 'namespace' => $this->locations->namespaceFor(TableCommand::CONFIG_KEY, TableCommand::DEFAULT_NAMESPACE, $project), - ]; - } - - $namespace = substr($tableClass, 0, $separator); - $class = substr($tableClass, $separator + 1); + $previous = DateTimeImmutable::createFromFormat('!YmdHis', $matches[1], new DateTimeZone('UTC')); - if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $class) !== 1) { - throw new RuntimeException(sprintf('Table class "%s" is not a valid PHP class name.', $tableClass)); + if ($previous !== false) { + $timestamp = $previous->modify('+1 second')->format('YmdHis'); + } } - return [ - 'class' => $class, - 'namespace' => (new PhpNamespace($namespace))->value, - ]; + return $timestamp; } } diff --git a/src/Cli/Commands/Make/Database/MigrationCommand.php b/src/Cli/Commands/Make/Database/MigrationCommand.php index 478905a..089110c 100644 --- a/src/Cli/Commands/Make/Database/MigrationCommand.php +++ b/src/Cli/Commands/Make/Database/MigrationCommand.php @@ -2,10 +2,9 @@ namespace StellarWP\Foundation\Cli\Commands\Make\Database; +use InvalidArgumentException; use RuntimeException; use StellarWP\Foundation\Cli\Commands\Make\Database\Factories\MigrationFileFactory; -use StellarWP\Foundation\Cli\Commands\Make\Database\ValueObjects\GeneratedMigration; -use StellarWP\Foundation\Cli\Composer\ComposerAutoloadResolver; use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; use StellarWP\Foundation\Cli\Generation\ValueObjects\GeneratedFile; use StellarWP\Foundation\Cli\Generation\ValueObjects\ProjectDirectory; @@ -16,27 +15,22 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Generates a WordPress-style migration class for Foundation Database. + * Generate an anonymous migration file for Foundation Database. * * Use this from a consuming WordPress project when a feature needs a versioned - * database change that can be registered with `DatabaseProvider`. + * database change discovered from the configured migration directory. */ final class MigrationCommand extends Command { - public const string CONFIG_KEY = 'database-migration'; - public const string NAME = 'make:database:migration'; - public const string DEFAULT_NAMESPACE = 'Database\\Migrations'; + public const string NAME = 'make:database:migration'; /** * Create the migration generator for a consuming project root. */ public function __construct( private readonly ProjectDirectory $projectDirectory, - private readonly ComposerAutoloadResolver $autoloadResolver, - private readonly ProviderFileResolver $providerFiles, private readonly MigrationFileFactory $migrationFactory, - private readonly GeneratedFileWriter $fileWriter, - private readonly ProviderRegistrationEditor $providerUpdater + private readonly GeneratedFileWriter $fileWriter ) { parent::__construct(self::NAME); } @@ -46,56 +40,31 @@ public function __construct( */ protected function configure(): void { $this->setDescription('Create a new Foundation database migration.') - ->setHelp('Use --create for a new table, --table for explicit changes to an existing table, or neither for a generic migration. The table options are mutually exclusive and accept short or fully qualified class names.') - ->addArgument('name', InputArgument::REQUIRED, 'Migration class name, e.g. Create_Reports_Table, Bump_Version, or create-reports-table.') - ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated migration, e.g. Plugin\Database\Migrations.') - ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Output directory for the generated migration, e.g. src/Database/Migrations.') - ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'Database provider file to update, e.g. src/Database/Provider.php.') - ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable identifier that determines execution order, e.g. 2026_09_04_143200_create_reports_table.') - ->addOption('create', null, InputOption::VALUE_REQUIRED, 'Table class created and dropped by this migration, e.g. Reports_Table or Plugin\Database\Tables\Reports_Table.') - ->addOption('table', null, InputOption::VALUE_REQUIRED, 'Existing table class altered by this migration, e.g. Reports_Table or Plugin\Database\Tables\Reports_Table.'); + ->setHelp('Use --create for a new table, --table for explicit changes to an existing table, or neither for a generic migration. The table options are mutually exclusive and accept stable, unprefixed table names.') + ->addArgument('name', InputArgument::REQUIRED, 'Migration description, optionally grouped, e.g. add_status or reports/add_status.') + ->addOption('create', null, InputOption::VALUE_REQUIRED, 'Unprefixed table name created and dropped by this migration, e.g. your_plugin_reports.') + ->addOption('table', null, InputOption::VALUE_REQUIRED, 'Unprefixed table name altered by this migration, e.g. your_plugin_reports.'); } /** - * Generate the selected migration and update its database provider when available. + * Generate a migration in the configured discovery directory. */ protected function execute(InputInterface $input, OutputInterface $output): int { - $writtenFiles = []; - try { $migration = $this->migration($input); - $this->validateExplicitProviderUpdate($input, $migration); - - if (file_exists($migration->file->path)) { - throw new RuntimeException(sprintf( - 'Migration already exists: %s. Edit it directly or create a new migration.', - $migration->file->relativePath - )); - } - - $this->fileWriter->write($migration->file); - $writtenFiles[] = $migration->file; - $providerPath = $this->updateProvider($input, $output, $migration); - } catch (RuntimeException $exception) { - $output->writeln('' . $this->failureMessage($exception, $writtenFiles) . ''); + $this->fileWriter->write($migration); + } catch (RuntimeException|InvalidArgumentException $exception) { + $output->writeln('' . $exception->getMessage() . ''); return Command::FAILURE; } - $output->writeln(sprintf('Created: %s', $migration->file->relativePath)); - - if ($providerPath !== null) { - $output->writeln(sprintf('Updated: %s', $this->projectDirectory->relativePath($providerPath))); - } elseif (! $this->providerExists($input)) { - $output->writeln(''); - $output->writeln('Register this migration with DatabaseProvider::MIGRATIONS using mergeArrayVar().'); - } - - $runtimeDependencyWarning = $this->runtimeDependencyWarning(); + $output->writeln(sprintf('Created: %s', $migration->relativePath)); + $output->writeln('Complete up() and down(), then preview with wp migrate --run --dry-run.'); + $warning = $this->runtimeDependencyWarning(); - if ($runtimeDependencyWarning !== null) { - $output->writeln(''); - $output->writeln('Runtime dependency missing: ' . $runtimeDependencyWarning); + if ($warning !== null) { + $output->writeln('Runtime dependency missing: ' . $warning); } return Command::SUCCESS; @@ -104,9 +73,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** * Build the migration artifact selected by the generic, create, or alter mode. * - * @throws RuntimeException When options or project metadata are invalid. + * @throws RuntimeException When options or project metadata are invalid. + * @throws InvalidArgumentException When the migration directory or identity is invalid. */ - private function migration(InputInterface $input): GeneratedMigration { + private function migration(InputInterface $input): GeneratedFile { $create = $this->tableOption($input, 'create'); $table = $this->tableOption($input, 'table'); @@ -114,117 +84,23 @@ private function migration(InputInterface $input): GeneratedMigration { throw new RuntimeException('The --create and --table options cannot be used together.'); } - $name = (string) $input->getArgument('name'); - $namespace = $this->nullableOption($input, 'namespace'); - $path = $this->nullableOption($input, 'path'); - $id = $this->nullableOption($input, 'id'); + $name = (string) $input->getArgument('name'); if ($create !== null) { - return $this->migrationFactory->createTable($name, $create, $namespace, $path, $id); + return $this->migrationFactory->createTable($name, $create); } if ($table !== null) { - return $this->migrationFactory->alterTable($name, $table, $namespace, $path, $id); + return $this->migrationFactory->alterTable($name, $table); } - return $this->migrationFactory->generic($name, $namespace, $path, $id); + return $this->migrationFactory->generic($name); } /** - * Fail before writing the migration when an explicitly selected provider cannot be updated. + * Return a normalized table-name option while rejecting an explicit blank value. * - * @throws RuntimeException When the provider cannot accept the migration registration. - */ - private function validateExplicitProviderUpdate(InputInterface $input, GeneratedMigration $migration): void { - $project = $this->autoloadResolver->project(); - $providerPath = $this->providerFiles->resolve($project, $this->nullableOption($input, 'provider')); - - if (! $this->hasExplicitProvider($input)) { - return; - } - - if (! is_file($providerPath)) { - throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->projectDirectory->relativePath($providerPath))); - } - - $result = $this->providerUpdater->checkMigration($providerPath, $migration->class, $migration->namespace); - - if ($result->succeeded()) { - return; - } - - throw new RuntimeException(sprintf( - 'Could not update database provider "%s": %s.', - $this->projectDirectory->relativePath($providerPath), - $result->failureReason() ?? 'provider could not be updated' - )); - } - - /** - * Update the selected or conventional provider and report non-fatal automatic failures. - * - * @throws RuntimeException When an explicitly selected provider cannot be updated. - */ - private function updateProvider(InputInterface $input, OutputInterface $output, GeneratedMigration $migration): ?string { - $project = $this->autoloadResolver->project(); - $providerPath = $this->providerFiles->resolve($project, $this->nullableOption($input, 'provider')); - $explicit = $this->hasExplicitProvider($input); - - if (! is_file($providerPath)) { - if ($explicit) { - throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->projectDirectory->relativePath($providerPath))); - } - - return null; - } - - $result = $this->providerUpdater->addMigration($providerPath, $migration->class, $migration->namespace); - - if ($result->wasUpdated()) { - return $providerPath; - } - - if ($result->succeeded()) { - return null; - } - - if ($explicit) { - throw new RuntimeException(sprintf( - 'Could not update database provider "%s": %s.', - $this->projectDirectory->relativePath($providerPath), - $result->failureReason() ?? 'provider could not be updated' - )); - } - - $output->writeln(sprintf( - 'Provider not updated: %s (%s). Register %s manually.', - $this->projectDirectory->relativePath($providerPath), - $result->failureReason() ?? 'provider could not be updated', - $migration->class - )); - - return null; - } - - /** - * Remove files written by a failed command and combine any cleanup failure message. - * - * @param list $writtenFiles - */ - private function failureMessage(RuntimeException $exception, array $writtenFiles): string { - try { - $this->fileWriter->remove(...$writtenFiles); - } catch (RuntimeException $cleanupException) { - return $exception->getMessage() . ' ' . $cleanupException->getMessage(); - } - - return $exception->getMessage(); - } - - /** - * Return a normalized table-class option while rejecting an explicit blank value. - * - * @throws RuntimeException When the option was supplied without a class name. + * @throws RuntimeException When the option was supplied without a table name. */ private function tableOption(InputInterface $input, string $option): ?string { $value = $input->getOption($option); @@ -240,31 +116,6 @@ private function tableOption(InputInterface $input, string $option): ?string { return trim($value); } - /** - * Return a string option or null when the option was omitted. - */ - private function nullableOption(InputInterface $input, string $option): ?string { - $value = $input->getOption($option); - - return is_string($value) ? $value : null; - } - - /** - * Determine whether the developer explicitly selected a provider file. - */ - private function hasExplicitProvider(InputInterface $input): bool { - $provider = $input->getOption('provider'); - - return is_string($provider) && trim($provider) !== ''; - } - - /** - * Determine whether the selected or conventional provider file exists. - */ - private function providerExists(InputInterface $input): bool { - return is_file($this->providerFiles->resolve($this->autoloadResolver->project(), $this->nullableOption($input, 'provider'))); - } - /** * Explain when generated runtime code lacks a production Foundation dependency. */ diff --git a/src/Cli/Commands/Make/Database/ProviderCommand.php b/src/Cli/Commands/Make/Database/ProviderCommand.php index 481e7b8..3a23597 100644 --- a/src/Cli/Commands/Make/Database/ProviderCommand.php +++ b/src/Cli/Commands/Make/Database/ProviderCommand.php @@ -21,7 +21,7 @@ /** * Generates a database feature provider for registering Foundation Database classes. * - * Use this before generating database tables or migrations when a consuming + * Use this before generating database tables when a consuming * project wants a single provider where generated database classes are wired. */ final class ProviderCommand extends Command @@ -61,7 +61,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('Created: %s', $file->relativePath)); $output->writeln(''); - $output->writeln('Register this provider in your application provider list before adding generated tables and migrations.'); + $output->writeln('Register this provider in your application provider list to supply table and service wiring.'); $runtimeDependencyWarning = $this->runtimeDependencyWarning(); diff --git a/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php b/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php index 7c1958f..aa422e0 100644 --- a/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php +++ b/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php @@ -6,7 +6,7 @@ use StellarWP\Foundation\Cli\Generation\Php\PhpSourceEditor; /** - * Updates generated database providers with generated table and migration registrations. + * Updates generated database providers with generated table registrations. * * The updater intentionally edits only marker-based provider files, which keeps * silent modifications predictable while still letting developers review or @@ -14,10 +14,7 @@ */ final class ProviderRegistrationEditor { - private const string TABLE_MARKER = '// foundation:database-tables'; - private const string MIGRATION_MARKER = '// foundation:database-migrations'; - private const string MIGRATIONS_CLASS = 'StellarWP\\Foundation\\Database\\DatabaseProvider'; - private const string MIGRATIONS_CONST = 'MIGRATIONS'; + private const string TABLE_MARKER = '// foundation:database-tables'; /** * Create an editor backed by structured PHP source inspection. @@ -41,85 +38,6 @@ classNamespace: $classNamespace, ); } - /** - * Add a migration contribution to a generated database provider. - */ - public function addMigration(string $providerPath, string $class, string $classNamespace): ProviderRegistrationResult { - return $this->addMergeArrayVarRegistration( - providerPath: $providerPath, - class: $class, - classNamespace: $classNamespace, - write: true - ); - } - - /** - * Add a table and its initial migration to one provider replacement. - */ - public function addTableAndMigration( - string $providerPath, - string $tableClass, - string $tableNamespace, - string $migrationClass, - string $migrationNamespace - ): ProviderRegistrationResult { - if (! is_file($providerPath) || ! is_readable($providerPath)) { - return ProviderRegistrationResult::notFound(); - } - - if (! $this->isWritableTarget($providerPath)) { - return ProviderRegistrationResult::notWritable(); - } - - $contents = file_get_contents($providerPath); - - if ($contents === false) { - return ProviderRegistrationResult::readFailed(); - } - - $temporaryPath = tempnam(dirname($providerPath), '.foundation-provider-'); - - if ($temporaryPath === false) { - return ProviderRegistrationResult::writeFailed(); - } - - try { - $written = file_put_contents($temporaryPath, $contents); - - if ($written !== strlen($contents)) { - return ProviderRegistrationResult::writeFailed(); - } - - $tableResult = $this->addTable($temporaryPath, $tableClass, $tableNamespace); - - if (! $tableResult->succeeded()) { - return $tableResult; - } - - $migrationResult = $this->addMigration($temporaryPath, $migrationClass, $migrationNamespace); - - if (! $migrationResult->succeeded()) { - return $migrationResult; - } - - if ($tableResult->wasAlreadyRegistered() && $migrationResult->wasAlreadyRegistered()) { - return ProviderRegistrationResult::alreadyRegistered(); - } - - $updatedContents = file_get_contents($temporaryPath); - - if ($updatedContents === false) { - return ProviderRegistrationResult::writeFailed(); - } - - return $this->writeContents($providerPath, $updatedContents) - ? ProviderRegistrationResult::updated() - : ProviderRegistrationResult::writeFailed(); - } finally { - @unlink($temporaryPath); - } - } - /** * Verify that a table registration can be added without changing the provider. */ @@ -134,45 +52,6 @@ classNamespace: $classNamespace, ); } - /** - * Verify that a migration contribution can be added without changing the provider. - */ - public function checkMigration(string $providerPath, string $class, string $classNamespace): ProviderRegistrationResult { - return $this->addMergeArrayVarRegistration( - providerPath: $providerPath, - class: $class, - classNamespace: $classNamespace, - write: false - ); - } - - /** - * Verify that both registrations can be added without changing the provider. - */ - public function checkTableAndMigration( - string $providerPath, - string $tableClass, - string $tableNamespace, - string $migrationClass, - string $migrationNamespace - ): ProviderRegistrationResult { - $tableResult = $this->checkTable($providerPath, $tableClass, $tableNamespace); - - if (! $tableResult->succeeded()) { - return $tableResult; - } - - $migrationResult = $this->checkMigration($providerPath, $migrationClass, $migrationNamespace); - - if (! $migrationResult->succeeded()) { - return $migrationResult; - } - - return $tableResult->wasAlreadyRegistered() && $migrationResult->wasAlreadyRegistered() - ? ProviderRegistrationResult::alreadyRegistered() - : ProviderRegistrationResult::ready(); - } - /** * Validate and optionally insert one marker-based provider registration. */ @@ -232,74 +111,6 @@ private function addRegistration(string $providerPath, string $class, string $cl return ProviderRegistrationResult::updated(); } - /** - * Validate and optionally add a class to the provider's migration contribution. - */ - private function addMergeArrayVarRegistration(string $providerPath, string $class, string $classNamespace, bool $write): ProviderRegistrationResult { - if (! is_file($providerPath) || ! is_readable($providerPath)) { - return ProviderRegistrationResult::notFound(); - } - - $contents = file_get_contents($providerPath); - - if ($contents === false) { - return ProviderRegistrationResult::readFailed(); - } - - if (! $this->sourceEditor->canParse($contents)) { - return ProviderRegistrationResult::parseFailed(); - } - - $containerExpression = $this->sourceEditor->mergeArrayVarContainerExpression($contents, self::MIGRATIONS_CLASS, self::MIGRATIONS_CONST); - - if ($containerExpression === null || ! $this->sourceEditor->canInsertIntoMergeArrayVar($contents, self::MIGRATIONS_CLASS, self::MIGRATIONS_CONST, self::MIGRATION_MARKER)) { - return ProviderRegistrationResult::missingAnchor(); - } - - $fullyQualifiedClass = $classNamespace . '\\' . $class; - $registration = sprintf('%s->get( %s::class ),', $containerExpression, $class); - - if ($this->sourceEditor->mergeArrayVarContainsClass($contents, self::MIGRATIONS_CLASS, self::MIGRATIONS_CONST, $fullyQualifiedClass)) { - return ProviderRegistrationResult::alreadyRegistered(); - } - - if ($this->sourceEditor->hasImportShortNameCollision($contents, $class, $fullyQualifiedClass)) { - return ProviderRegistrationResult::importCollision(); - } - - if (! $this->isWritableTarget($providerPath)) { - return ProviderRegistrationResult::notWritable(); - } - - if (! $write) { - return ProviderRegistrationResult::ready(); - } - - $contents = $this->sourceEditor->addImport($contents, $fullyQualifiedClass); - - if ($contents === null) { - return ProviderRegistrationResult::parseFailed(); - } - - $contents = $this->sourceEditor->insertIntoMergeArrayVar( - contents: $contents, - class: self::MIGRATIONS_CLASS, - constant: self::MIGRATIONS_CONST, - statement: $registration, - beforeComment: self::MIGRATION_MARKER - ); - - if ($contents === null) { - return ProviderRegistrationResult::missingAnchor(); - } - - if (! $this->writeContents($providerPath, $contents)) { - return ProviderRegistrationResult::writeFailed(); - } - - return ProviderRegistrationResult::updated(); - } - /** * Atomically replace provider source while preserving file permissions. */ diff --git a/src/Cli/Commands/Make/Database/TableCommand.php b/src/Cli/Commands/Make/Database/TableCommand.php index 7a2b7e7..da7a18f 100644 --- a/src/Cli/Commands/Make/Database/TableCommand.php +++ b/src/Cli/Commands/Make/Database/TableCommand.php @@ -2,9 +2,9 @@ namespace StellarWP\Foundation\Cli\Commands\Make\Database; +use InvalidArgumentException; use RuntimeException; use StellarWP\Foundation\Cli\Commands\Make\Database\Factories\MigrationFileFactory; -use StellarWP\Foundation\Cli\Commands\Make\Database\ValueObjects\GeneratedMigration; use StellarWP\Foundation\Cli\Commands\Make\Database\ValueObjects\ProviderRegistrationResult; use StellarWP\Foundation\Cli\Composer\ComposerAutoloadResolver; use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; @@ -22,10 +22,10 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Generates a WordPress-style table class for Foundation Database migrations. + * Generate a WordPress-style application table class for Foundation Database. * * Use this from a consuming WordPress project when a feature needs a stable - * table identity for migrations and table-scoped database operations. + * table identity and table-scoped database operations. */ final class TableCommand extends Command { @@ -61,8 +61,7 @@ protected function configure(): void { ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Output directory for the generated table, e.g. src/Database/Tables.') ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'Database provider file to update, e.g. src/Database/Provider.php.') ->addOption('table-name', null, InputOption::VALUE_REQUIRED, 'Unprefixed WordPress table name using letters, numbers, and underscores, e.g. report_entries.') - ->addOption('migration', 'm', InputOption::VALUE_NONE, 'Also create the table\'s initial migration. Rolling it back drops the complete table and its data.') - ->addOption('migration-id', null, InputOption::VALUE_REQUIRED, 'Stable identifier that determines execution order, e.g. 2026_09_04_143200_create_reports_table. Requires --migration.'); + ->addOption('migration', 'm', InputOption::VALUE_NONE, 'Also create the table\'s initial migration. Rolling it back drops the complete table and its data.'); } /** @@ -72,26 +71,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $writtenFiles = []; try { - $this->validateMigrationOptions($input); $table = $this->generatedFile($input); $migration = $input->getOption('migration') === true ? $this->initialMigration($input) : null; - $this->validateGeneratedFiles($table, $migration); - $this->validateExplicitProviderUpdate($input, $migration); + $files = $migration === null ? [$table] : [$table, $migration]; - if ($migration === null) { - $this->fileWriter->write($table); - $writtenFiles[] = $table; - } else { - $this->fileWriter->writeAll($table, $migration->file); - $writtenFiles[] = $table; - $writtenFiles[] = $migration->file; - } + $this->validateExplicitProviderUpdate($input); + $this->fileWriter->writeAll(...$files); + $writtenFiles = $files; - $providerPath = $this->updateProvider($input, $output, $migration); - } catch (RuntimeException $exception) { + $providerPath = $this->updateProvider($input, $output); + } catch (RuntimeException|InvalidArgumentException $exception) { $output->writeln('' . $this->failureMessage($exception, $writtenFiles) . ''); return Command::FAILURE; @@ -100,7 +92,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('Created: %s', $table->relativePath)); if ($migration !== null) { - $output->writeln(sprintf('Created: %s', $migration->file->relativePath)); + $output->writeln(sprintf('Created: %s', $migration->relativePath)); } $output->writeln(''); @@ -109,9 +101,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln('Create a migration that defines this table with Blueprint::create().'); } elseif ($providerPath === null && ! $this->providerExists($input)) { $output->writeln(sprintf( - 'Register %s and %s with your database provider.', + 'Register %s with your database provider when it needs shared or custom wiring.', $this->classNameResolver->tableClass((string) $input->getArgument('name')), - $migration->class )); } @@ -134,57 +125,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int * * @throws RuntimeException When migration input or project metadata is invalid. */ - private function initialMigration(InputInterface $input): GeneratedMigration { - $project = $this->autoloadResolver->project(); - $tableClass = $this->classNameResolver->tableClass((string) $input->getArgument('name')); - $tableNamespace = $this->locations->namespaceFor(self::CONFIG_KEY, self::DEFAULT_NAMESPACE, $project, (string) $input->getOption('namespace')); + private function initialMigration(InputInterface $input): GeneratedFile { + $tableClass = $this->classNameResolver->tableClass((string) $input->getArgument('name')); return $this->migrationFactory->createTable( - name: 'Create_' . $tableClass, - tableClass: $tableNamespace . '\\' . $tableClass, - id: $this->nullableOption($input, 'migration-id') + name: 'create_' . $tableClass, + table: $this->tableName($input, $tableClass), ); } - /** - * Reject migration-only options unless initial migration generation is enabled. - * - * @throws RuntimeException When --migration-id is used without --migration. - */ - private function validateMigrationOptions(InputInterface $input): void { - if ($input->getOption('migration') === true || $input->getOption('migration-id') === null) { - return; - } - - throw new RuntimeException('The --migration-id option requires --migration.'); - } - - /** - * Validate every artifact and reject existing destinations before writing either file. - * - * @throws RuntimeException When source is invalid or a destination already exists. - */ - private function validateGeneratedFiles(GeneratedFile $table, ?GeneratedMigration $migration): void { - $this->fileWriter->validate($table); - - if (file_exists($table->path)) { - throw new RuntimeException(sprintf('File already exists: %s.', $table->relativePath)); - } - - if ($migration === null) { - return; - } - - $this->fileWriter->validate($migration->file); - - if (file_exists($migration->file->path)) { - throw new RuntimeException(sprintf( - 'Migration already exists: %s. Edit it directly or create a new migration.', - $migration->file->relativePath - )); - } - } - /** * Render the table artifact from normalized project and command input. * @@ -216,7 +165,7 @@ private function generatedFile(InputInterface $input): GeneratedFile { * * @throws RuntimeException When the provider cannot accept the generated registrations. */ - private function validateExplicitProviderUpdate(InputInterface $input, ?GeneratedMigration $migration): void { + private function validateExplicitProviderUpdate(InputInterface $input): void { $project = $this->autoloadResolver->project(); $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); $namespace = $this->locations->namespaceFor(self::CONFIG_KEY, self::DEFAULT_NAMESPACE, $project, (string) $input->getOption('namespace')); @@ -230,15 +179,7 @@ private function validateExplicitProviderUpdate(InputInterface $input, ?Generate throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->projectDirectory->relativePath($providerPath))); } - $result = $migration === null - ? $this->providerUpdater->checkTable($providerPath, $className, $namespace) - : $this->providerUpdater->checkTableAndMigration( - $providerPath, - $className, - $namespace, - $migration->class, - $migration->namespace - ); + $result = $this->providerUpdater->checkTable($providerPath, $className, $namespace); if (! $result->succeeded()) { throw new RuntimeException(sprintf( @@ -254,7 +195,7 @@ private function validateExplicitProviderUpdate(InputInterface $input, ?Generate * * @throws RuntimeException When an explicitly selected provider cannot be updated. */ - private function updateProvider(InputInterface $input, OutputInterface $output, ?GeneratedMigration $migration): ?string { + private function updateProvider(InputInterface $input, OutputInterface $output): ?string { $project = $this->autoloadResolver->project(); $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); $namespace = $this->locations->namespaceFor(self::CONFIG_KEY, self::DEFAULT_NAMESPACE, $project, (string) $input->getOption('namespace')); @@ -269,15 +210,7 @@ private function updateProvider(InputInterface $input, OutputInterface $output, return null; } - $result = $migration === null - ? $this->providerUpdater->addTable($providerPath, $className, $namespace) - : $this->providerUpdater->addTableAndMigration( - $providerPath, - $className, - $namespace, - $migration->class, - $migration->namespace - ); + $result = $this->providerUpdater->addTable($providerPath, $className, $namespace); if (! $result->succeeded() && $explicit) { throw new RuntimeException(sprintf( @@ -288,11 +221,7 @@ private function updateProvider(InputInterface $input, OutputInterface $output, } if (! $result->succeeded()) { - $classes = $migration === null - ? $className - : $className . ' and ' . $migration->class; - - $this->writeProviderWarning($output, $providerPath, $result, $classes); + $this->writeProviderWarning($output, $providerPath, $result, $className); } return $result->wasUpdated() ? $providerPath : null; @@ -303,7 +232,7 @@ private function updateProvider(InputInterface $input, OutputInterface $output, * * @param list $writtenFiles */ - private function failureMessage(RuntimeException $exception, array $writtenFiles): string { + private function failureMessage(RuntimeException|InvalidArgumentException $exception, array $writtenFiles): string { try { $this->fileWriter->remove(...$writtenFiles); } catch (RuntimeException $cleanupException) { diff --git a/src/Cli/Commands/Make/Database/ValueObjects/GeneratedMigration.php b/src/Cli/Commands/Make/Database/ValueObjects/GeneratedMigration.php deleted file mode 100644 index 30eb6a0..0000000 --- a/src/Cli/Commands/Make/Database/ValueObjects/GeneratedMigration.php +++ /dev/null @@ -1,21 +0,0 @@ -container->mergeArrayVar(self::MIGRATIONS, []); - $this->container->when(MigrationCollection::class)->needs('$migrations') + $this->container->when(MigrationDiscovery::class) + ->needs('$root') + ->give(fn (): ?string => $this->config->get('foundation.root')); + + $this->container->when(MigrationDiscovery::class) + ->needs('$path') + ->give(fn (): ?string => $this->config->get('database.migrations.path')); + + $this->container->singleton(MigrationDiscovery::class); + $this->container->mergeArrayVar( + self::MIGRATIONS, + static fn (C $c): array => iterator_to_array($c->get(MigrationDiscovery::class)->migrations(), false) + ); + + $this->container->when(MigrationCollection::class) + ->needs('$migrations') ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); - $this->container->when(SchemaPlanner::class)->needs('$tableOptions')->give(static function (C $c): array { - $source = $c->get(wpdb::class); - return array_filter(['engine' => 'InnoDB', 'charset' => $source->charset, 'collation' => $source->collate]); - }); + $this->container->when(SchemaPlanner::class) + ->needs('$tableOptions') + ->give(static function (C $c): array { + $source = $c->get(wpdb::class); + + return array_filter(['engine' => 'InnoDB', 'charset' => $source->charset, 'collation' => $source->collate]); + }); + $this->container->singleton(MigrationCollection::class); $this->container->singleton(History::class); $this->container->singleton(SchemaPlanner::class); diff --git a/src/Database/Migration/Contracts/MigratesData.php b/src/Database/Migration/Contracts/MigratesData.php index 0358192..a7acd2f 100644 --- a/src/Database/Migration/Contracts/MigratesData.php +++ b/src/Database/Migration/Contracts/MigratesData.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Database\Migration\Contracts; use Doctrine\DBAL\Connection; +use StellarWP\Foundation\Database\Contracts\TableNameResolver; /** * Optional data step executed after a migration's schema change and before it is recorded. @@ -12,7 +13,7 @@ interface MigratesData { /** - * Transform rows using the shared connection. + * Transform rows using the shared connection and scoped historical table names. */ - public function migrate(Connection $db): void; + public function migrate(Connection $db, TableNameResolver $names): void; } diff --git a/src/Database/Migration/Contracts/Migration.php b/src/Database/Migration/Contracts/Migration.php index ccc09cd..d07822c 100644 --- a/src/Database/Migration/Contracts/Migration.php +++ b/src/Database/Migration/Contracts/Migration.php @@ -13,11 +13,6 @@ */ interface Migration { - /** - * Return the persistent identity that orders this migration, independent of its class name. - */ - public function id(): string; - /** * Declare the forward change. */ diff --git a/src/Database/Migration/Migration.php b/src/Database/Migration/Migration.php new file mode 100644 index 0000000..cfc81f8 --- /dev/null +++ b/src/Database/Migration/Migration.php @@ -0,0 +1,23 @@ + $migrations + * @param iterable $migrations * * @throws InvalidArgumentException When an identity is duplicated. * @throws Exceptions\InvalidMigrationId When an identity is invalid. @@ -30,17 +31,26 @@ final class MigrationCollection public function __construct( iterable $migrations = [], ) { - foreach ($migrations as $migration) { - $id = (new ValueObjects\Id($migration->id()))->value; - - if (isset($this->migrations[$id])) { - throw new InvalidArgumentException('Duplicate migration identity: ' . $id); - } - $this->migrations[$id] = $migration; + foreach ($migrations as $registration) { + $this->add($registration); } + ksort($this->migrations, SORT_STRING); } + /** + * Validate and store a contribution without allowing duplicate identities. + */ + private function add(MigrationRegistration $registration): void { + $id = (new ValueObjects\Id($registration->id))->value; + + if (isset($this->migrations[$id])) { + throw new InvalidArgumentException('Duplicate migration identity: ' . $id); + } + + $this->migrations[$id] = $registration->migration; + } + /** * Return migrations keyed by their persistent identities. * diff --git a/src/Database/Migration/MigrationDiscovery.php b/src/Database/Migration/MigrationDiscovery.php new file mode 100644 index 0000000..0d87b42 --- /dev/null +++ b/src/Database/Migration/MigrationDiscovery.php @@ -0,0 +1,71 @@ + + */ + public function migrations(): iterable { + if ($this->root === null && $this->path === null) { + return; + } + + $directory = new MigrationDirectory($this->root, $this->path ?? MigrationDirectory::DEFAULT_PATH); + + if (! file_exists($directory->path)) { + if ($this->path !== null) { + throw new InvalidArgumentException('Configured migration directory does not exist: ' . $directory->path); + } + + return; + } + + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory->path, FilesystemIterator::SKIP_DOTS)); + + /** @var SplFileInfo $file */ + foreach ($files as $file) { + if (! $file->isFile() || preg_match('/^[0-9]{14}_[a-z0-9_]+\.php$/', $file->getFilename()) !== 1) { + continue; + } + + yield new MigrationRegistration($file->getBasename('.php'), self::load($file->getPathname())); + } + } + + /** + * Load a declaration without exposing discovery's instance to the migration file. + */ + private static function load(string $file): Migration { + return require $file; + } +} diff --git a/src/Database/Migration/Migrator.php b/src/Database/Migration/Migrator.php index 98fa274..2db47f1 100644 --- a/src/Database/Migration/Migrator.php +++ b/src/Database/Migration/Migrator.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use StellarWP\Foundation\Database\Connection\WordPressSession; use StellarWP\Foundation\Database\Contracts\DatabaseScope; +use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Database\Migration\Contracts\DescribesMigration; use StellarWP\Foundation\Database\Migration\Contracts\MigratesData; use StellarWP\Foundation\Database\Migration\Contracts\Migration; @@ -41,6 +42,7 @@ public function __construct( private readonly History $history, private readonly SchemaPlanner $planner, private readonly MigrationCollection $migrations, + private readonly TableNameResolver $names, ) { } @@ -157,7 +159,7 @@ public function status(): array { $id = (string) $id; $rows[$id] = new MigrationStatus( $id, - self::shortName($migration), + self::displayName($migration, $id), $migration instanceof DescribesMigration ? $migration->describe() : '', $applied[$id] ?? null, ); @@ -200,7 +202,7 @@ private function run(string $target, bool $execute, bool $applyPending = true): $this->session->check(); if (! $reverse && $migration instanceof MigratesData) { - $migration->migrate($this->db); + $migration->migrate($this->db, $this->names); $this->session->check(); if ($this->db->isTransactionActive()) { @@ -220,7 +222,7 @@ private function run(string $target, bool $execute, bool $applyPending = true): } $applied = $afterIds; - $steps[] = new Step($id, self::shortName($migration), $reverse, $sql, ! $reverse && $migration instanceof MigratesData); + $steps[] = new Step($id, self::displayName($migration, $id), $reverse, $sql, ! $reverse && $migration instanceof MigratesData); } return $steps; @@ -283,9 +285,9 @@ private function assertScope(int $site, string $prefix): void { } } - private static function shortName(Migration $migration): string { + private static function displayName(Migration $migration, string $id): string { if (str_contains($migration::class, "\0")) { - return '(anonymous)'; + return $id; } $parts = explode('\\', $migration::class); diff --git a/src/Database/Migration/Schema/Blueprint.php b/src/Database/Migration/Schema/Blueprint.php index ca2d3f6..aa471bb 100644 --- a/src/Database/Migration/Schema/Blueprint.php +++ b/src/Database/Migration/Schema/Blueprint.php @@ -11,7 +11,7 @@ * The schema handed to up() and down(): declare tables to create, alter, or drop. * * Table names are resolved through the current site scope, so migrations name application - * tables by their Table object and never repeat WordPress prefix handling. + * tables by their historical unprefixed names and never repeat WordPress prefix handling. */ final class Blueprint { @@ -33,7 +33,7 @@ public function __construct( /** * Declare a new table and its complete initial definition. */ - public function create(Table $table): TableBlueprint { + public function create(Table|string $table): TableBlueprint { $blueprint = new TableBlueprint($this->resolve($table)); $this->operations[] = function () use ($blueprint): void { $name = $blueprint->name(); @@ -51,7 +51,7 @@ public function create(Table $table): TableBlueprint { /** * Declare additions, changes, and removals on an existing table. */ - public function table(Table $table): TableBlueprint { + public function table(Table|string $table): TableBlueprint { $blueprint = new TableBlueprint($this->resolve($table)); $this->operations[] = function () use ($blueprint): void { $this->replaceTable($blueprint->applyTo($this->state->schema->getTable($blueprint->name())->edit(), $this->state)); @@ -63,7 +63,7 @@ public function table(Table $table): TableBlueprint { /** * Declare that the table no longer exists after this migration. */ - public function drop(Table $table): void { + public function drop(Table|string $table): void { $name = $this->resolve($table); $this->operations[] = function () use ($name): void { $this->state->schema->dropTable($name); @@ -80,7 +80,7 @@ public function drop(Table $table): void { * * @return non-empty-string */ - public function resolve(Table $table): string { + public function resolve(Table|string $table): string { return $this->names->tableName($table); } diff --git a/src/Database/Migration/Schema/SchemaPlanner.php b/src/Database/Migration/Schema/SchemaPlanner.php index f137541..7eb761c 100644 --- a/src/Database/Migration/Schema/SchemaPlanner.php +++ b/src/Database/Migration/Schema/SchemaPlanner.php @@ -13,6 +13,7 @@ use Doctrine\DBAL\Schema\TableDiff; use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Database\Migration\Exceptions\IncompatibleSchema; +use StellarWP\Foundation\Database\Migration\Exceptions\IrreversibleMigration; use StellarWP\Foundation\Database\Migration\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Database\Migration\MigrationCollection; use StellarWP\Foundation\Database\Migration\Schema\ValueObjects\SchemaPlan; @@ -105,7 +106,13 @@ private function apply(Blueprint $blueprint, string $id): void { private function reverse(SchemaState $before, string $id): SchemaState { $after = clone $before; $blueprint = new Blueprint($after, $this->names, $this->tableOptions); - $this->migrations->get($id)->down($blueprint); + + try { + $this->migrations->get($id)->down($blueprint); + } catch (IrreversibleMigration $failure) { + throw new IrreversibleMigration(sprintf('Migration "%s" cannot be reversed: %s', $id, $failure->getMessage()), 0, $failure); + } + $this->apply($blueprint, $id); return $after; diff --git a/src/Database/Migration/ValueObjects/MigrationDirectory.php b/src/Database/Migration/ValueObjects/MigrationDirectory.php new file mode 100644 index 0000000..660ad32 --- /dev/null +++ b/src/Database/Migration/ValueObjects/MigrationDirectory.php @@ -0,0 +1,68 @@ +path = self::normalize($path); + } + + /** + * Normalize separators and dot segments in the configured path. + */ + private static function normalize(string $path): string { + $path = str_replace('\\', '/', $path); + $parts = []; + + foreach (explode('/', $path) as $part) { + if ($part === '' || $part === '.') { + continue; + } + + if ($part === '..') { + array_pop($parts); + continue; + } + + $parts[] = $part; + } + + return (str_starts_with($path, '/') ? '/' : '') . implode('/', $parts); + } + + private static function isAbsolute(string $path): bool { + return str_starts_with($path, '/') || preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1; + } +} diff --git a/src/Database/Migration/ValueObjects/MigrationRegistration.php b/src/Database/Migration/ValueObjects/MigrationRegistration.php new file mode 100644 index 0000000..9918e81 --- /dev/null +++ b/src/Database/Migration/ValueObjects/MigrationRegistration.php @@ -0,0 +1,20 @@ +unprefixedName(); + public function tableName(Table|string $table): string { + $unprefixed = is_string($table) ? $table : $table->unprefixedName(); if ($unprefixed === '' || trim($unprefixed) !== $unprefixed) { throw new DatabaseException('The unprefixed database table name cannot be blank or contain surrounding whitespace.'); diff --git a/src/Database/stubs/alter-table-migration.stub b/src/Database/stubs/alter-table-migration.stub index ebdbab6..516790b 100644 --- a/src/Database/stubs/alter-table-migration.stub +++ b/src/Database/stubs/alter-table-migration.stub @@ -1,37 +1,27 @@ table( $this->table ); - - // Declare only the additions, changes, and removals owned by this migration. + $table = $schema->table( {{ table_php }} ); + // Declare the columns and indexes this migration adds, changes, or removes. } /** + * Stop rollback until a safe inverse is supplied. + * * @throws IrreversibleMigration Until a safe inverse is implemented. */ public function down( Blueprint $schema ): void { - throw IrreversibleMigration::forMigration( self::ID ); + throw new IrreversibleMigration( 'Implement a safe down() declaration before rolling back this migration.' ); } -} +}; diff --git a/src/Database/stubs/create-table-migration.stub b/src/Database/stubs/create-table-migration.stub index 549d3dc..8172681 100644 --- a/src/Database/stubs/create-table-migration.stub +++ b/src/Database/stubs/create-table-migration.stub @@ -1,35 +1,26 @@ create( $this->table ); + $table = $schema->create( {{ table_php }} ); $table->bigIncrements( 'id' ); - // Define the complete initial schema before running this migration. - + // Add the initial columns and indexes before running this migration. } + /** + * Drop the table and its data. + */ public function down( Blueprint $schema ): void { - $schema->drop( $this->table ); + $schema->drop( {{ table_php }} ); } -} +}; diff --git a/src/Database/stubs/migration.stub b/src/Database/stubs/migration.stub index 9b4e82f..5eff2e3 100644 --- a/src/Database/stubs/migration.stub +++ b/src/Database/stubs/migration.stub @@ -1,28 +1,25 @@ register_tables(); - $this->register_migrations(); } private function register_tables(): void { // foundation:database-tables } - private function register_migrations(): void { - $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ - ] ); - } - } diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index 36e24b0..3298fbd 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -28,6 +28,7 @@ Set a stable, unique application prefix in your root configuration: return [ 'foundation' => [ + 'root' => __DIR__, 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', ], ]; @@ -38,14 +39,12 @@ Create your container using [ContainerFactory](/start/configure-the-container/), ```php use StellarWP\Foundation\Database\DatabaseProvider; use StellarWP\Foundation\WPCli\WPCliProvider; -use Plugin\Database\Provider; $container->register( WPCliProvider::class ); $container->register( DatabaseProvider::class ); -$container->register( Provider::class ); // Your application tables and migrations. ``` -`DatabaseProvider` supplies one shared `Doctrine\DBAL\Connection`. Tables and repositories resolved from the container participate in that connection's transactions. Registration is lazy: it creates no database tables. +`DatabaseProvider` supplies one shared `Doctrine\DBAL\Connection`. Tables and repositories resolved from the container participate in that connection's transactions. Registration is lazy: it creates no database tables. Register an application provider afterward when you need custom service bindings. `foundation.root` supplies the project directory for automatic migration discovery. ### Query an application table diff --git a/src/Docs/src/content/docs/components/database/migrations.mdx b/src/Docs/src/content/docs/components/database/migrations.mdx index 0d2b00d..1f2de04 100644 --- a/src/Docs/src/content/docs/components/database/migrations.mdx +++ b/src/Docs/src/content/docs/components/database/migrations.mdx @@ -1,67 +1,94 @@ --- title: Migrations -description: Generate declarative schema changes, preview SQL, and recover interrupted deployments. +description: Create tables, write later schema changes, preview SQL, and roll back safely. sidebar: order: 1 --- -Foundation migrations describe historical changes to application tables. Foundation orders registered migrations, acquires a database advisory lock, applies the remaining schema changes, and records each successful migration. Doctrine supplies schema inspection, comparison, and platform SQL. +Migrations are the history of your application's database schema. Each migration declares one change in `up()` and its inverse in `down()`. Foundation loads migration files, runs pending changes in order under a database lock, and records successful migrations. ## Create and change a table -After [configuring DatabaseProvider](/components/database/), install the development CLI and generate an application provider: +### Set up migrations + +First [configure DatabaseProvider](/components/database/) and install the development CLI: ```shell composer require --dev stellarwp/foundation-cli -vendor/bin/foundation make:database:provider -vendor/bin/foundation make:database:table reports --table-name=your_plugin_reports --migration ``` -The generator creates a table and its initial migration, and adds their registrations to the generated provider. Register that provider after `DatabaseProvider` in your application's composition root. +Add your project root to the existing root `config.php`. The Foundation CLI reads this file, and your [application bootstrap](/start/configure-the-container/) supplies the same configuration to its container: -The table owns only its stable name. The migration owns the initial schema: +```php title="config.php" + [ + 'root' => __DIR__, + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], +]; +``` -```php title="Create_Reports_Table.php" -create( $this->table ); + $table = $schema->create( 'your_plugin_reports' ); + $table->bigIncrements( 'id' ); $table->string( 'title' ); $table->string( 'status', 20 )->default( 'draft' ); - $table->decimal( 'amount', 12, 4 )->default( '0' ); - $table->dateTime( 'created_at', 6 )->useCurrent(); $table->index( 'status_lookup', 'status' ); } + /** + * Remove report storage and its data. + */ public function down( Blueprint $schema ): void { - $schema->drop( $this->table ); + $schema->drop( 'your_plugin_reports' ); } -} +}; ``` -Review the generated schema before running it: +The filename supplies the persistent ID `20260923000001_create_reports_table`. The migration owns its historical table name and schema. Foundation validates that unprefixed name and adds the active WordPress site prefix when planning the change. Refactoring or removing the application table class does not change this history. + +### Preview and apply ```shell wp your-plugin migrate --run --dry-run @@ -69,63 +96,129 @@ wp your-plugin migrate --run wp your-plugin migrate ``` -The first run creates the migration ledger automatically. The command prefix comes from your WP-CLI configuration. Programmatic installation or upgrade code injects `Migrator` and calls `$migrator->migrate()` at its chosen upgrade boundary. A public plugin should run this after WordPress and its providers are ready, and record its installed application version only after migration succeeds. Run the same upgrade workflow for each affected site. +Review the previewed SQL before applying it. `--run` applies all pending migrations; the last command shows their status. The first run creates the migration ledger automatically. `your-plugin` is the configured WP-CLI command prefix. + +Foundation loads new migration files automatically from the configured directory. You do not add each migration to a provider list. + +### Add a column later -### Register migrations explicitly +Generate a new migration for the existing table: + +```shell +vendor/bin/foundation make:database:migration add_published_at --table=your_plugin_reports +``` -Providers contribute migration objects lazily. Registration order does not determine execution order: +This creates a timestamped file returning an anonymous migration. It selects the table in `up()` and leaves a placeholder for your changes. The argument names the migration; it does not generate column definitions. -In `src/Database/Provider.php`: +Complete `db/migrations/20260924000001_add_published_at.php` like this: -```php title="Provider.php" +```php title="20260924000001_add_published_at.php" table( 'your_plugin_reports' ); - public function register(): void { - $this->container->singleton( Reports_Table::class ); - $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ - $c->get( Create_Reports_Table::class ), - ] ); + $table->dateTime( 'published_at', 6 )->nullable(); } -} + + /** + * Remove publication times when rolling back this change. + */ + public function down( Blueprint $schema ): void { + $table = $schema->table( 'your_plugin_reports' ); + + $table->dropColumn( 'published_at' ); + } +}; ``` -All providers contribute to one globally sorted collection. IDs are compared in ascending byte order and stored exactly, independently of class names and Strauss scoping. IDs must be unique, nonblank, unpadded, and no more than 191 bytes. `0` and `latest` are reserved targets. Generated timestamps provide ordering; custom IDs, including numeric timestamps, must occupy the lexical position their dependencies require. +The generated `down()` initially throws `IrreversibleMigration`. Replace it with the inverse above only if deleting publication times is acceptable; remove its unused exception import and annotation. Otherwise, keep the exception. Forward migration still works, but rollback stops at this migration. -### Add a later change +Leave the original migration and `Reports_Table` unchanged. Preview and apply the new migration with the same commands: + +```shell +wp your-plugin migrate --run --dry-run +wp your-plugin migrate --run +wp your-plugin migrate +``` -Generate an alteration for an existing table: +Existing reports have `NULL` in `published_at` until your application writes a value. + +:::note[Generate migrations before applying them] +`--create=your_plugin_reports` declares ownership of a new table. `--table=your_plugin_reports` scaffolds a later alteration. Both take unprefixed database names, independently of PHP table classes. Generation does not inspect the live database. When applying an alteration, an earlier migration must already declare the table in the schema history. +::: + +### Roll back a change + +To reverse the highest applied migration, inspect its `down()` and run: ```shell -vendor/bin/foundation make:database:migration add-published-at --table=Reports_Table +wp your-plugin migrate --rollback ``` -Its `up()` declares only this migration's changes: +In this example that removes `published_at` while retaining the reports table. Running `--run` afterward reapplies the column; values deleted by rollback do not return. Reversing the initial migration drops the complete reports table. [Deployment commands](#deploy-and-recover) cover targets, multiple reversals, and interrupted operations. + +:::caution[Keep applied history immutable] +Once a migration has been applied anywhere, retain its filename and declarations. Create another migration for a later change. Renaming its filename changes its recorded identity. Moving a file between configured directories preserves identity when its basename stays unchanged. Keep historical table names literal; referencing a constant on a current application class would couple history to later refactors. +::: + +## Write schema changes + +`up()` and `down()` declare schema changes. Foundation may replay them during planning, including previews. Keep queries and application work in the separate [data callback](#transform-existing-data). + +### Change a column + +Declare the complete replacement definition and call `change()`: ```php -public function up( Blueprint $schema ): void { - $table = $schema->table( $this->table ); - $table->dateTime( 'published_at', 6 )->nullable(); - $table->string( 'title', 255 )->change(); -} +$table = $schema->table( 'your_plugin_reports' ); + +$table->string( 'title', 255 )->change(); ``` -`change()` replaces a column's complete definition. Restate any default, nullability, unsigned flag, comment, or other attribute you want to retain. Use `dropColumn('name')`, `dropIndex('name')`, `index('name', ...$columns)`, and `unique('name', ...$columns)` for explicit removals and index changes. An index replacement declares the old index's removal and the new definition under the same name. +Restate any default, nullability, unsigned flag, comment, or other attribute to retain. For this example, a safe inverse can restore the original length only after accounting for values longer than that length. -The generated `down()` throws `IrreversibleMigration` until you supply a safe inverse. A generic migration does the same. Only an explicit `--create=Reports_Table` generates an initial-table declaration whose inverse drops the entire table. `--create` and `--table` are mutually exclusive; a migration's name never implies destructive behavior. +### Remove a column -:::caution[Keep applied history immutable] -Edit a migration only before it has been applied anywhere. Otherwise, create another migration. Foundation replays historical declarations to compute each change, so editing or removing applied declarations changes the meaning of recorded history. Generators never overwrite migration files. -::: +```php +$table = $schema->table( 'your_plugin_reports' ); + +$table->dropColumn( 'published_at' ); +``` + +Removing a column deletes its values. Adding it again in `down()` restores its structure, not the deleted data. Keep `IrreversibleMigration` when no acceptable inverse exists. + +### Add or replace an index + +```php +$table = $schema->table( 'your_plugin_reports' ); + +$table->index( 'publication_lookup', 'published_at' ); +$table->unique( 'report_title', 'title' ); +``` + +Use `dropIndex()` for removal. To replace an index under the same name, declare both the removal and its replacement: + +```php +$table = $schema->table( 'your_plugin_reports' ); + +$table->dropIndex( 'status_lookup' ); +$table->index( 'status_lookup', 'status', 'published_at' ); +``` + +### Choose a scaffold + +Use `--create=your_plugin_reports` to generate an initial migration for that unprefixed table name. Its `up()` declares the complete initial table, and its `down()` drops that table. Use `--table=your_plugin_reports` for a later alteration. The options are mutually exclusive. + +Omitting both produces a generic migration with an empty `up()` and an irreversible `down()`. Declare the required schema changes using stable, unprefixed table names. Migration names alone never select table-dropping behavior. ### Column declarations @@ -147,25 +240,47 @@ Columns support `nullable()`, `notNull()`, `unsigned()`, `default()`, `comment() ### Transform existing data -Implement the optional `MigratesData` contract alongside `Migration` when a forward migration must transform rows: +An anonymous migration can implement `MigratesData`. Foundation supplies the shared connection and table-name resolver to its data callback. Resolve the historical unprefixed name for the active site and quote it with Doctrine: ```php use Doctrine\DBAL\Connection; +use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Database\Migration\Contracts\MigratesData; +use StellarWP\Foundation\Database\Migration\Migration; +use StellarWP\Foundation\Database\Migration\Schema\Blueprint; -// Add MigratesData to the migration's implements list. -public function migrate( Connection $db ): void { - $db->executeStatement( - 'UPDATE ' . $this->table->quotedName() . ' SET status = ? WHERE status IS NULL', - [ 'draft' ], - ); -} +return new class extends Migration implements MigratesData { + + /** + * This migration changes data in an already-declared table. + */ + public function up( Blueprint $schema ): void { + } + + /** + * Fill missing statuses without changing existing values. + */ + public function migrate( Connection $db, TableNameResolver $names ): void { + $table = $db->getDatabasePlatform()->quoteSingleIdentifier( + $names->tableName( 'your_plugin_reports' ), + ); + + $db->executeStatement( + 'UPDATE ' . $table . ' SET status = ? WHERE status IS NULL', + [ 'draft' ], + ); + } +}; ``` +This example inherits the default irreversible `down()`: replacing data has no automatic safe inverse. + Foundation runs this callback after schema changes and before writing history. It must be safe to repeat if the process stops or recording fails. You may use the shared connection's `transactional()` for a bounded data operation. Complete that transaction before returning; an open transaction interrupts the migration and is rolled back. DDL remains outside that transaction. Preview reports that a data callback exists but does not execute it. The data contract has no automatic reverse callback: supply schema rollback only when reversing remains safe for the resulting data, or throw `IrreversibleMigration`. ## Deploy and recover +Programmatic installation or upgrade code injects `Migrator` and calls `$migrator->migrate()` at its chosen upgrade boundary. A public plugin should run this after WordPress and its providers are ready, and record its installed application version only after migration succeeds. Run the same upgrade workflow for each affected site. + | Operation | Command | | --- | --- | | Inspect pending, applied, and missing migrations | `wp your-plugin migrate` | @@ -189,7 +304,7 @@ public function describe(): string { } ``` -`migrate($target)` and `--run --to=` reverse applied IDs above the target in descending order, then apply pending IDs through it in ascending order. `latest` applies all pending migrations. `rollbackTo($target)` and `--rollback --to=` only reverse applied IDs above the target; any pending IDs, including the target itself, remain pending. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied migration classes must be restored before execution can continue. +`migrate($target)` and `--run --to=` reverse applied IDs above the target in descending order, then apply pending IDs through it in ascending order. `latest` applies all pending migrations. `rollbackTo($target)` and `--rollback --to=` only reverse applied IDs above the target; any pending IDs, including the target itself, remain pending. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied migration files must be restored before execution can continue. :::danger[Rollback can delete data] `down()` is application code. Reversing an initial migration drops its complete table, including rows and additions made later. Review the inverse and take the application's required backup before rolling back. `--refresh`, `--run --to=0`, and `--rollback --to=0` prompt for confirmation because they reverse every applied migration. Use `--yes` only when that data loss is intended. `--run --to=0 --dry-run` previews the reversal without prompting or executing it. @@ -203,7 +318,7 @@ An incompatible existing declaration stops the run with `IncompatibleSchema`; Fo ### Repair inconsistent history -If the ledger itself is wrong, pause all application upgrade triggers and migration workers for the affected site and take a backup. Restore missing migration classes first, then compare the ledger's exact IDs with the live schema and each migration's schema and data effects. +If the ledger itself is wrong, pause all application upgrade triggers and migration workers for the affected site and take a backup. Restore missing migration files first, then compare the ledger's exact IDs with the live schema and each migration's schema and data effects. Use your database administration tool against the configured physical ledger table, including its WordPress site prefix. For a confirmed bookkeeping error: @@ -220,8 +335,57 @@ One database advisory lock covers planning, schema execution, data callbacks, an All migration participants must reach the same primary database server. Advisory locks are local to that server; a proxy that moves statements between sessions or servers cannot provide this guarantee. Preview and status are observations and can become stale before a later run. -## Customize generation and test +## Configure discovery + +### Use another directory + +Set the path once in root `config.php`; generation and runtime discovery use the same setting: + +```php title="config.php" +return [ + 'foundation' => [ + 'root' => __DIR__, + ], + 'database' => [ + 'migrations' => [ + 'path' => 'migrations', + ], + ], +]; +``` + +Merge this with your existing configuration. Paths are relative to `foundation.root`; absolute paths such as `__DIR__ . '/db/migrations'` also work. An explicitly configured directory must exist when running migrations. Generation creates the directory when writing its first file. An absent default directory means the application has no discovered migrations yet. + +Migration directories do not need Composer autoload mappings or a classmap rebuild. Include the PHP files in production archives. Files are application code: keep executable work inside the documented methods, and use a top-level `return new class extends Migration` declaration. + +### Group migrations by feature + +Use a slash in the migration description: + +```shell +vendor/bin/foundation make:database:migration reports/add_published_at --table=your_plugin_reports +``` + +With the default location, the file goes into `db/migrations/reports/`. Discovery includes subfolders, but execution is still globally ordered across all folders. + +Generated filenames use `_.php`, such as `20260924000001_add_published_at.php`. Generation chooses a timestamp later than existing generated migrations in the configured tree, so consecutive commands preserve order. Developers still own dependencies when merging independently developed migrations. The filename without `.php` is the persistent ID; description changes after application are identity changes too. Discovery loads files matching 14 digits, an underscore, and a lowercase description containing letters, numbers, or underscores. Keep helper files under other names. + +### Contribute migrations explicitly + +Packages or applications with additional migration sources can contribute objects through a provider: + +```php +$this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + new MigrationRegistration( '20260923000001_package_setup', $c->get( Package_Migration::class ) ), +] ); +``` + +Import `StellarWP\Foundation\Database\DatabaseProvider`, `StellarWP\Foundation\Database\Migration\ValueObjects\MigrationRegistration`, and `StellarWP\Foundation\Container\Contracts\Resolver as C` in that provider. `Package_Migration` is a package-supplied implementation of the migration contract. Explicit contributions and discovered migrations share one ordered collection; contribute each migration once. Registrations keep the ID separate from the declaration object, preserving optional data and description capabilities. + +A declaration implements `StellarWP\Foundation\Database\Migration\Contracts\Migration` through `up()` and `down()`. Its registration supplies the ID. IDs are compared in ascending byte order. They must be unique, nonblank, unpadded, and no more than 191 bytes; `0` and `latest` are reserved targets. Choose IDs whose lexical order respects dependencies. Applications using only explicit contributions can omit discovery configuration. + +### Customize stubs and test migrations -The defaults are `Database\Tables`, `Database\Migrations`, and `Database` beneath your Composer application namespace. Use the [CLI generator configuration](/tooling/foundation-cli/) to choose project namespaces, or command options `--namespace` and `--path` for individual output. Stub overrides live under `foundation/stubs/database/`. Keep migrations in production autoloaded code even when the generator itself is installed with `--dev`. +Copy the package stubs into `foundation/stubs/database/` to customize generated code. Start with the [CLI stub guide](/tooling/foundation-cli/#override-package-stubs). Table namespaces remain configurable through generator settings; migration placement is controlled by `database.migrations.path`. Test create, alteration, rollback, and retry against real database tables. Include a failure after successful DDL but before history recording, then verify that retry preserves existing rows and records the migration once. Register a fresh container per test and use application-specific test table names. diff --git a/src/Docs/src/content/docs/tooling/foundation-cli.mdx b/src/Docs/src/content/docs/tooling/foundation-cli.mdx index b05d312..4a6a792 100644 --- a/src/Docs/src/content/docs/tooling/foundation-cli.mdx +++ b/src/Docs/src/content/docs/tooling/foundation-cli.mdx @@ -71,22 +71,18 @@ composer require stellarwp/foundation-wpcli ### Generate a database feature -Generate the application provider first so later generators can register the table and migration automatically: +Install the runtime package, then generate a table with its initial migration: ```shell -vendor/bin/foundation make:database:provider -vendor/bin/foundation make:database:table Reports_Table --migration +composer require stellarwp/foundation-database +vendor/bin/foundation make:database:table Reports --table-name=your_plugin_reports --migration ``` -The `--migration` flag creates and registers the table's initial migration in the same operation. Table `--namespace` and `--path` options affect only the table class; the migration uses its project-configured namespace and path, falling back to the Foundation convention. Use an explicit identifier such as `--migration-id=2026_09_04_143200_create_reports_table` only when the generated timestamp identifier must be replaced. The explicit ID determines that migration's position in Foundation's ascending execution order. +The command creates `Reports_Table.php` and a timestamped `db/migrations/_create_reports_table.php`. Add the columns to the migration, preview its SQL, and apply it through WP-CLI. Follow the [complete create-and-alter workflow](/components/database/migrations/) to configure discovery and run the generated code. -Use `--table-name=` when the table's unprefixed WordPress name should differ from the name derived from its class. On `make:database:migration`, `--table=` instead selects the existing table class that an alteration migration changes. +`--table-name` supplies the stable name before WordPress adds its site prefix. `--migration` creates an initial migration whose rollback drops the complete table. Table `--namespace` and `--path` options affect only the table class. Migrations use `database.migrations.path` from root `config.php`, defaulting to `db/migrations`. -Generated database classes require the runtime package: - -```shell -composer require stellarwp/foundation-database -``` +If you want a provider for application table bindings, generate one with `make:database:provider` before generating tables. Table generation can update it; migration discovery does not require per-migration provider entries. Database table and migration generators refuse to overwrite existing files. Edit an unapplied migration directly, or create a new migration after the existing one has been deployed. @@ -94,15 +90,15 @@ The migration generator selects one of three modes: | Invocation | Generated behavior | Override stub | | --- | --- | --- | -| `make:database:migration Create_Reports_Table --create=Reports_Table` | Creates the explicitly selected table; `down()` drops the table | `create-table-migration.stub` | -| `make:database:migration Add_Status_To_Reports --table=Reports_Table` | Starts an explicit alteration blueprint; `down()` throws `IrreversibleMigration` until replaced | `alter-table-migration.stub` | -| `make:database:migration Backfill_Report_Status` | Generates a generic migration; add service or table dependencies manually | `migration.stub` | +| `make:database:migration create_reports_table --create=your_plugin_reports` | Creates the explicitly selected table; `down()` drops the table | `create-table-migration.stub` | +| `make:database:migration add_status_to_reports --table=your_plugin_reports` | Starts an explicit alteration blueprint; `down()` throws `IrreversibleMigration` until replaced | `alter-table-migration.stub` | +| `make:database:migration Backfill_Report_Status` | Generates a generic migration; declare changes using historical table names | `migration.stub` | -The `--create` and `--table` options are mutually exclusive. Each accepts a short class name from the configured table namespace (`Database\\Tables` by default), or a fully qualified table class. Migration names never select destructive behavior by themselves. +The `--create` and `--table` options are mutually exclusive. Each accepts a stable, unprefixed database table name such as `your_plugin_reports`. Migration names never select destructive behavior by themselves. @@ -117,7 +113,7 @@ vendor/bin/foundation help make:database:table vendor/bin/foundation help make:database:migration ``` -Without project overrides, generators use the first `autoload.psr-4` entry in the project's `composer.json` as their root namespace. Output directories follow the most specific Composer mapping for the selected namespace. Explicit `--namespace` and `--path` options override those defaults. +For tables, providers, and WP-CLI commands, generators use the first `autoload.psr-4` entry in the project's `composer.json` as their root namespace. Output directories follow the most specific Composer mapping for the selected namespace. Explicit `--namespace` and `--path` options override those defaults. ## Customize generation @@ -139,9 +135,6 @@ return [ 'database-table' => [ 'namespace' => 'Plugin\\Persistence\\Tables', ], - 'database-migration' => [ - 'namespace' => 'Plugin\\Persistence\\Migrations', - ], ], ]; ``` @@ -155,11 +148,12 @@ Namespaces are fully qualified application namespaces. With `Plugin\\` mapped to | `make:wpcli:command` | `src/Commands/` | | `make:database:provider` | `src/Persistence/` | | `make:database:table` | `src/Persistence/Tables/` | -| `make:database:migration` | `src/Persistence/Migrations/` | -An explicit `--namespace` takes precedence over the configured namespace. Without either, the conventional suffixes are `Cli\\Commands`, `Database`, `Database\\Tables`, and `Database\\Migrations`, respectively. An explicit `--path` chooses the output directory for that invocation; it does not change the namespace or update Composer autoload mappings. +An explicit `--namespace` takes precedence over the configured namespace. Without either, the conventional suffixes are `Cli\\Commands`, `Database`, and `Database\\Tables`, respectively. An explicit `--path` chooses the output directory for that invocation; it does not change the namespace or update Composer autoload mappings. + +The table generator looks for `Provider.php` in the configured database-provider namespace. Generate that provider first to enable table registration. Use table `--provider` to select another provider file. Migration `--create` and `--table` options take stable, unprefixed database names. -Table and migration generators look for `Provider.php` in the configured database-provider namespace. Generate that provider first to enable automatic registration. Use `--provider` to select another provider file, including a provider generated with a custom class name. Paired migrations from `make:database:table --migration` use the migration setting independently of table options; short `--create` and `--table` references use the table setting. +Migration placement is shared with runtime discovery through `database.migrations.path`. Configure the [migration directory](/components/database/migrations/#use-another-directory) once. Migration commands have no separate `--path`, `--namespace`, or `--provider` option. Use a name such as `reports/add_status` to generate inside a feature subfolder. Invalid settings for the selected generator or missing Composer mappings stop generation before files are written. Correct the setting or mapping before retrying. If the provider namespace has no mapping, correct `generators.database-provider.namespace` or its Composer mapping, or select an existing provider file with `--provider`. diff --git a/tests/Support/Fixtures/Database/DataDiscovery/20260923000003_publish_reports.php b/tests/Support/Fixtures/Database/DataDiscovery/20260923000003_publish_reports.php new file mode 100644 index 0000000..eb274d4 --- /dev/null +++ b/tests/Support/Fixtures/Database/DataDiscovery/20260923000003_publish_reports.php @@ -0,0 +1,22 @@ +getDatabasePlatform()->quoteSingleIdentifier($names->tableName('discovery_reports')); + $db->executeStatement('UPDATE ' . $table . ' SET title = ? WHERE title = ?', ['Published report', 'Existing report']); + } + + public function describe(): string { + return 'Publish existing reports'; + } +}; diff --git a/tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php b/tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php index a3c2c0f..dee0f03 100644 --- a/tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php +++ b/tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php @@ -18,10 +18,6 @@ public function __construct( ) { } - public function id(): string { - return self::ID; - } - public function up(Blueprint $schema): void { $schema->table($this->table)->boolean('archived')->default(false); } diff --git a/tests/Support/Fixtures/Database/Declarative/AddEntryNote.php b/tests/Support/Fixtures/Database/Declarative/AddEntryNote.php index 5dbb16c..79c522c 100644 --- a/tests/Support/Fixtures/Database/Declarative/AddEntryNote.php +++ b/tests/Support/Fixtures/Database/Declarative/AddEntryNote.php @@ -18,10 +18,6 @@ public function __construct( ) { } - public function id(): string { - return self::ID; - } - public function describe(): string { return 'Add an optional note to entries'; } diff --git a/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php b/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php index cbc1d27..299bfc7 100644 --- a/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php +++ b/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; use Doctrine\DBAL\Connection; +use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Database\Migration\Contracts\MigratesData; use StellarWP\Foundation\Database\Migration\Contracts\Migration; use StellarWP\Foundation\Database\Migration\Schema\Blueprint; @@ -19,17 +20,13 @@ public function __construct( ) { } - public function id(): string { - return self::ID; - } - public function up(Blueprint $schema): void { } public function down(Blueprint $schema): void { } - public function migrate(Connection $db): void { + public function migrate(Connection $db, TableNameResolver $names): void { $db->executeStatement("UPDATE `{$this->physicalName}` SET status = 'active' WHERE status = ''"); } } diff --git a/tests/Support/Fixtures/Database/Declarative/CreateEntries.php b/tests/Support/Fixtures/Database/Declarative/CreateEntries.php index 21a231c..9337b2c 100644 --- a/tests/Support/Fixtures/Database/Declarative/CreateEntries.php +++ b/tests/Support/Fixtures/Database/Declarative/CreateEntries.php @@ -17,10 +17,6 @@ public function __construct( ) { } - public function id(): string { - return self::ID; - } - public function up(Blueprint $schema): void { $entries = $schema->create($this->table)->comment('Application entries'); diff --git a/tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php b/tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php index c0c337d..1755c9e 100644 --- a/tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php +++ b/tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php @@ -17,10 +17,6 @@ public function __construct( ) { } - public function id(): string { - return self::ID; - } - public function up(Blueprint $schema): void { $tags = $schema->create($this->table); $tags->bigIncrements('id'); diff --git a/tests/Support/Fixtures/Database/Declarative/DropEntryTags.php b/tests/Support/Fixtures/Database/Declarative/DropEntryTags.php index c31771d..48bdc43 100644 --- a/tests/Support/Fixtures/Database/Declarative/DropEntryTags.php +++ b/tests/Support/Fixtures/Database/Declarative/DropEntryTags.php @@ -18,10 +18,6 @@ public function __construct( ) { } - public function id(): string { - return self::ID; - } - public function up(Blueprint $schema): void { $schema->drop($this->table); } diff --git a/tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php b/tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php index 068ac46..c8b7ba8 100644 --- a/tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php +++ b/tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php @@ -17,10 +17,6 @@ public function __construct( ) { } - public function id(): string { - return self::ID; - } - public function up(Blueprint $schema): void { $schema->table($this->table)->index('note_lookup', 'note'); } diff --git a/tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php b/tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php index bdeb5ac..f2fc30e 100644 --- a/tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php +++ b/tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php @@ -17,10 +17,6 @@ public function __construct( ) { } - public function id(): string { - return self::ID; - } - public function up(Blueprint $schema): void { $tags = $schema->create($this->table); $tags->bigIncrements('id'); diff --git a/tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php b/tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php index 5fea52c..aad099f 100644 --- a/tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php +++ b/tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php @@ -17,10 +17,6 @@ public function __construct( ) { } - public function id(): string { - return self::ID; - } - public function up(Blueprint $schema): void { $schema->table($this->table)->dateTime('created_at', 3)->useCurrent()->useCurrentOnUpdate()->change(); } diff --git a/tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php b/tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php index 664a26a..b741aed 100644 --- a/tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php +++ b/tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php @@ -17,10 +17,6 @@ public function __construct( ) { } - public function id(): string { - return self::ID; - } - public function up(Blueprint $schema): void { $schema->table($this->table)->dropIndex('note_lookup')->unique('note_lookup', 'note'); } diff --git a/tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php b/tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php index 9a11ce1..857b3f7 100644 --- a/tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php +++ b/tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php @@ -17,10 +17,6 @@ public function __construct( ) { } - public function id(): string { - return self::ID; - } - public function up(Blueprint $schema): void { $schema->table($this->table)->dateTime('created_at', 6)->useCurrent()->useCurrentOnUpdate()->change(); } diff --git a/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php b/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php index 6461559..4218516 100644 --- a/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php +++ b/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php @@ -17,10 +17,6 @@ public function __construct( ) { } - public function id(): string { - return self::ID; - } - public function up(Blueprint $schema): void { $schema->table($this->table)->comment('Imported entries')->string('name', 120)->nullable()->change(); } diff --git a/tests/Support/Fixtures/Database/Discovery/20260923000001_create_reports_table.php b/tests/Support/Fixtures/Database/Discovery/20260923000001_create_reports_table.php new file mode 100644 index 0000000..2d5aea2 --- /dev/null +++ b/tests/Support/Fixtures/Database/Discovery/20260923000001_create_reports_table.php @@ -0,0 +1,16 @@ +create('discovery_reports'); + $table->bigIncrements('id'); + $table->string('title', 255); + } + + public function down(Blueprint $schema): void { + $schema->drop('discovery_reports'); + } +}; diff --git a/tests/Support/Fixtures/Database/Discovery/reports/20260923000002_add_published_at.php b/tests/Support/Fixtures/Database/Discovery/reports/20260923000002_add_published_at.php new file mode 100644 index 0000000..2b786ba --- /dev/null +++ b/tests/Support/Fixtures/Database/Discovery/reports/20260923000002_add_published_at.php @@ -0,0 +1,14 @@ +table('discovery_reports')->dateTime('published_at', 6)->nullable(); + } + + public function down(Blueprint $schema): void { + $schema->table('discovery_reports')->dropColumn('published_at'); + } +}; diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index 8e2243d..0f1ac45 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -7,6 +7,7 @@ use StellarWP\Foundation\Database\DatabaseProvider; use StellarWP\Foundation\Database\Migration\Contracts\Migration; use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Database\Migration\ValueObjects\MigrationRegistration; use StellarWP\Foundation\WPCli\CommandContext; use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; @@ -43,6 +44,6 @@ public function down(Blueprint $schema): void { $schema->drop($this->table); } }; - $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); + $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [new MigrationRegistration($migration->id(), $migration)]); $container->get(Migrate::class)->register(new CommandContext(new CommandPrefix('foundation'))); }); diff --git a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php index d78405d..78766c8 100644 --- a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php +++ b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php @@ -5,12 +5,10 @@ use phpmock\mockery\PHPMockery; use PhpParser\Lexer; use PhpParser\ParserFactory; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use StellarWP\Foundation\Cli\CliProvider; use StellarWP\Foundation\Cli\Commands\Make\Database\Factories\MigrationFileFactory; -use StellarWP\Foundation\Cli\Commands\Make\Database\MigrationCommand; use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderCommand; use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderFileResolver; use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderRegistrationEditor; @@ -84,100 +82,6 @@ public function test_it_generates_a_database_table_from_project_autoload_default $this->assertStringNotContainsString('Blueprint', $contents); } - public function test_database_table_generator_can_create_and_register_its_initial_migration(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^2.0', - ], - ]); - - (new CommandTester($this->providerCommand($root)))->execute([]); - - $tester = new CommandTester($this->tableCommand($root)); - $status = $tester->execute([ - 'name' => 'reports', - '--migration' => true, - ]); - - $tablePath = $root . '/src/Database/Tables/Reports_Table.php'; - $migrationPath = $root . '/src/Database/Migrations/Create_Reports_Table.php'; - $providerPath = $root . '/src/Database/Provider.php'; - - $this->assertSame(Command::SUCCESS, $status); - $this->assertFileExists($tablePath); - $this->assertFileExists($migrationPath); - $this->assertStringContainsString('Created: src/Database/Tables/Reports_Table.php', $tester->getDisplay()); - $this->assertStringContainsString('Created: src/Database/Migrations/Create_Reports_Table.php', $tester->getDisplay()); - $this->assertStringContainsString('Updated: src/Database/Provider.php', $tester->getDisplay()); - - $migration = (string) file_get_contents($migrationPath); - $provider = (string) file_get_contents($providerPath); - - $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $migration); - - $this->assertStringContainsString("\$table->bigIncrements( 'id' );", $migration); - $this->assertStringContainsString('Define the complete initial schema before running this migration.', $migration); - $this->assertStringContainsString('$table = $schema->create( $this->table );', $migration); - $this->assertStringContainsString('$schema->drop( $this->table );', $migration); - $this->assertStringContainsString('$this->container->singleton( Reports_Table::class );', $provider); - $this->assertStringContainsString('$c->get( Create_Reports_Table::class ),', $provider); - } - - public function test_database_table_generator_uses_its_custom_namespace_for_the_initial_migration(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->tableCommand($root)); - - $status = $tester->execute([ - 'name' => 'audit-log', - '--namespace' => 'Acme\\Plugin\\Storage', - '--path' => 'custom/tables', - '--migration' => true, - ]); - - $migration = (string) file_get_contents($root . '/src/Database/Migrations/Create_Audit_Log_Table.php'); - - $this->assertSame(Command::SUCCESS, $status); - $this->assertFileExists($root . '/custom/tables/Audit_Log_Table.php'); - $this->assertStringContainsString('use Acme\\Plugin\\Storage\\Audit_Log_Table;', $migration); - $this->assertStringContainsString( - 'Register Audit_Log_Table and Create_Audit_Log_Table with your database provider.', - $tester->getDisplay() - ); - } - - public function test_database_table_generator_accepts_an_explicit_initial_migration_id(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->tableCommand($root)); - - $status = $tester->execute([ - 'name' => 'reports', - '--migration' => true, - '--migration-id' => '2026_09_01_120000_create_reports_table', - ]); - - $migration = (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php'); - - $this->assertSame(Command::SUCCESS, $status); - $this->assertStringContainsString( - "public const string ID = '2026_09_01_120000_create_reports_table';", - $migration - ); - } - - public function test_database_table_generator_rejects_a_migration_id_without_a_migration(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->tableCommand($root)); - - $status = $tester->execute([ - 'name' => 'reports', - '--migration-id' => '2026_09_01_120000_create_reports_table', - ]); - - $this->assertSame(Command::FAILURE, $status); - $this->assertStringContainsString('The --migration-id option requires --migration.', $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); - } - public function test_database_table_generator_rejects_an_explicit_blank_table_name(): void { $root = $this->temporaryProject(); $tester = new CommandTester($this->tableCommand($root)); @@ -213,302 +117,6 @@ public function test_database_table_generator_rejects_invalid_unprefixed_table_n } } - public function test_database_table_generator_does_not_write_either_file_when_the_initial_migration_exists(): void { - $root = $this->temporaryProject(); - $migrationPath = $root . '/src/Database/Migrations/Create_Reports_Table.php'; - - mkdir(dirname($migrationPath), 0777, true); - file_put_contents($migrationPath, 'existing migration'); - - $tester = new CommandTester($this->tableCommand($root)); - $status = $tester->execute([ - 'name' => 'reports', - '--migration' => true, - ]); - - $this->assertSame(Command::FAILURE, $status); - $this->assertStringContainsString( - 'Migration already exists: src/Database/Migrations/Create_Reports_Table.php. Edit it directly or create a new migration.', - $tester->getDisplay() - ); - $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); - $this->assertSame('existing migration', (string) file_get_contents($migrationPath)); - } - - public function test_it_generates_a_database_migration_from_project_autoload_defaults(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_26_000001_create_reports_table', - ]); - - $path = $root . '/src/Database/Migrations/Create_Reports_Table.php'; - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertFileExists($path); - $this->assertStringContainsString('Created: src/Database/Migrations/Create_Reports_Table.php', $tester->getDisplay()); - - $contents = (string) file_get_contents($path); - - $this->assertStringContainsString('namespace Acme\\Plugin\\Database\\Migrations;', $contents); - $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration;', $contents); - $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $contents); - $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); - $this->assertStringContainsString('final readonly class Create_Reports_Table implements Migration {', $contents); - $this->assertStringContainsString("public const string ID = '2026_06_26_000001_create_reports_table';", $contents); - $this->assertStringContainsString('private Reports_Table $table', $contents); - $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $contents); - - $this->assertStringContainsString('$table = $schema->create( $this->table );', $contents); - $this->assertStringContainsString('$schema->drop( $this->table );', $contents); - $this->assertStringNotContainsString('CreateTable', $contents); - } - - public function test_it_generates_a_generic_database_migration_for_non_table_names(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'bump-version', - '--id' => '2026_06_26_000003_bump_version', - ]); - - $contents = (string) file_get_contents($root . '/src/Database/Migrations/Bump_Version.php'); - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertStringContainsString('final readonly class Bump_Version implements Migration {', $contents); - $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration;', $contents); - $this->assertStringContainsString("public const string ID = '2026_06_26_000003_bump_version';", $contents); - $this->assertStringContainsString('throw IrreversibleMigration::forMigration( self::ID );', $contents); - $this->assertStringNotContainsString('CreateTable', $contents); - $this->assertStringNotContainsString('Bump_Version_Table', $contents); - } - - public function test_it_generates_a_table_alteration_migration_without_destructive_rollback(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'add-status-to-reports', - '--id' => '2026_06_26_000004_add_status_to_reports', - '--table' => 'Reports_Table', - ]); - - $contents = (string) file_get_contents($root . '/src/Database/Migrations/Add_Status_To_Reports.php'); - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); - $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration;', $contents); - $this->assertStringContainsString('private Reports_Table $table', $contents); - - $this->assertStringContainsString('$table = $schema->table( $this->table );', $contents); - $this->assertStringContainsString('throw IrreversibleMigration::forMigration( self::ID );', $contents); - $this->assertStringNotContainsString('$schema->drop( $this->table );', $contents); - } - - public function test_create_named_non_table_migrations_do_not_receive_destructive_table_behavior(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'create-report-index', - '--id' => '2026_06_26_000005_create_report_index', - ]); - - $contents = (string) file_get_contents($root . '/src/Database/Migrations/Create_Report_Index.php'); - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertStringContainsString('throw IrreversibleMigration::forMigration( self::ID );', $contents); - $this->assertStringNotContainsString('$schema->drop(', $contents); - $this->assertStringNotContainsString('Report_Index_Table', $contents); - } - - public function test_migration_names_do_not_infer_destructive_table_behavior(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'create-reports-table', - ]); - - $contents = (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php'); - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertStringContainsString('throw IrreversibleMigration::forMigration( self::ID );', $contents); - $this->assertStringNotContainsString('$schema->drop(', $contents); - $this->assertStringNotContainsString('Reports_Table $table', $contents); - } - - public function test_database_migrations_reject_blank_table_options(): void { - $root = $this->temporaryProject(); - - $createTester = new CommandTester($this->migrationCommand($root)); - $createStatus = $createTester->execute([ - 'name' => 'create-reports-table', - '--create' => '', - ]); - - $tableTester = new CommandTester($this->migrationCommand($root)); - $tableStatus = $tableTester->execute([ - 'name' => 'add-status-to-reports', - '--table' => '', - ]); - - $this->assertSame(Command::FAILURE, $createStatus); - $this->assertStringContainsString('The --create option cannot be blank.', $createTester->getDisplay()); - $this->assertSame(Command::FAILURE, $tableStatus); - $this->assertStringContainsString('The --table option cannot be blank.', $tableTester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Add_Status_To_Reports.php'); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); - } - - public function test_database_migrations_reject_create_and_table_together(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--table' => 'Reports_Table', - ]); - - $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString('The --create and --table options cannot be used together.', $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); - } - - public function test_database_migrations_accept_fully_qualified_table_classes(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Acme\\Plugin\\Storage\\ReportTable', - ]); - - $contents = (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php'); - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertStringContainsString('use Acme\\Plugin\\Storage\\ReportTable;', $contents); - $this->assertStringContainsString('private ReportTable $table', $contents); - $this->assertStringNotContainsString('Report_Table', $contents); - $this->assertStringContainsString('$schema->drop( $this->table );', $contents); - } - - public function test_database_migrations_preserve_short_table_class_names(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'update-report-store', - '--table' => 'ReportTable', - ]); - - $contents = (string) file_get_contents($root . '/src/Database/Migrations/Update_Report_Store.php'); - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\ReportTable;', $contents); - $this->assertStringContainsString('private ReportTable $table', $contents); - $this->assertStringNotContainsString('Report_Table', $contents); - } - - public function test_database_migrations_reject_invalid_fully_qualified_table_classes_before_writing_files(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'add-status-to-reports', - '--table' => 'Acme Plugin\\Database\\Tables\\Reports_Table', - ]); - - $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString( - 'Namespace "Acme Plugin\\Database\\Tables" is not a valid PHP namespace.', - $tester->getDisplay() - ); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Add_Status_To_Reports.php'); - } - - public function test_database_migrations_reject_empty_table_namespace_segments_before_writing_files(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'add-status-to-reports', - '--table' => 'Acme\\\\Reports_Table', - ]); - - $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString('is not a valid PHP namespace', $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Add_Status_To_Reports.php'); - } - - public function test_database_migrations_reject_invalid_table_class_paths(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'add-status-to-reports', - '--table' => 'Acme/Plugin/Database/Tables/Reports_Table', - ]); - - $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString( - 'Table class "Acme/Plugin/Database/Tables/Reports_Table" is not a valid PHP class name.', - $tester->getDisplay() - ); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Add_Status_To_Reports.php'); - } - - public function test_database_migration_command_exposes_explicit_generation_modes(): void { - $command = $this->migrationCommand($this->temporaryProject()); - - $this->assertSame('Create a new Foundation database migration.', $command->getDescription()); - $this->assertTrue($command->getDefinition()->hasOption('create')); - $this->assertTrue($command->getDefinition()->hasOption('table')); - $this->assertFalse($command->getDefinition()->hasOption('table-class')); - $this->assertFalse($command->getDefinition()->hasOption('table-namespace')); - $this->assertStringContainsString('mutually exclusive', $command->getHelp()); - $this->assertStringContainsString('fully qualified class names', $command->getHelp()); - - foreach ([ - 'namespace' => 'Plugin\Database\Migrations', - 'path' => 'src/Database/Migrations', - 'provider' => 'src/Database/Provider.php', - 'id' => '2026_09_04_143200_create_reports_table', - 'create' => 'Reports_Table', - 'table' => 'Reports_Table', - ] as $option => $example) { - $this->assertStringContainsString( - $example, - $command->getDefinition()->getOption($option)->getDescription() - ); - } - } - - public function test_database_table_command_distinguishes_the_physical_table_name_from_a_migration_table_class(): void { - $command = $this->tableCommand($this->temporaryProject()); - - $this->assertTrue($command->getDefinition()->hasOption('table-name')); - $this->assertFalse($command->getDefinition()->hasOption('table')); - - foreach ([ - 'namespace' => 'Plugin\Database\Tables', - 'path' => 'src/Database/Tables', - 'provider' => 'src/Database/Provider.php', - 'table-name' => 'report_entries', - 'migration-id' => '2026_09_04_143200_create_reports_table', - ] as $option => $example) { - $this->assertStringContainsString( - $example, - $command->getDefinition()->getOption($option)->getDescription() - ); - } - } - public function test_database_provider_command_describes_customization_options_with_examples(): void { $command = $this->providerCommand($this->temporaryProject()); @@ -537,65 +145,13 @@ public function test_it_generates_a_database_provider_from_project_autoload_defa $contents = (string) file_get_contents($path); $this->assertStringContainsString('namespace Acme\\Plugin\\Database;', $contents); - $this->assertStringContainsString('use StellarWP\\Foundation\\Container\\Contracts\\Resolver as C;', $contents); - $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\DatabaseProvider;', $contents); $this->assertStringContainsString('use StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;', $contents); $this->assertStringContainsString('final class Provider extends Service_Provider {', $contents); $this->assertStringContainsString('$this->register_tables();', $contents); - $this->assertStringContainsString('$this->register_migrations();', $contents); $this->assertStringContainsString('// foundation:database-tables', $contents); $this->assertStringNotContainsString('// foundation:database-migrations', $contents); } - public function test_database_migrations_default_to_timestamped_ids(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'create-reports-table', - ]); - - $contents = (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php'); - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertMatchesRegularExpression( - "/public const string ID = '\\d{4}_\\d{2}_\\d{2}_\\d{6}_create_reports_table';/", - $contents - ); - } - - public function test_database_migrations_cannot_overwrite_existing_files(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^2.0', - ], - ]); - $command = $this->migrationCommand($root); - $path = $root . '/src/Database/Migrations/Create_Reports_Table.php'; - - (new CommandTester($this->providerCommand($root)))->execute([]); - mkdir(dirname($path), 0777, true); - file_put_contents($path, 'existing migration'); - - $providerPath = $root . '/src/Database/Provider.php'; - $providerContents = (string) file_get_contents($providerPath); - $tester = new CommandTester($command); - $status = $tester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_27_000001_create_reports_table', - ]); - - $this->assertFalse($command->getDefinition()->hasOption('force')); - $this->assertSame(Command::FAILURE, $status); - $this->assertStringContainsString( - 'Migration already exists: src/Database/Migrations/Create_Reports_Table.php. Edit it directly or create a new migration.', - $tester->getDisplay() - ); - $this->assertSame('existing migration', (string) file_get_contents($path)); - $this->assertSame($providerContents, (string) file_get_contents($providerPath)); - } - public function test_database_tables_cannot_overwrite_existing_files(): void { $root = $this->temporaryProject(); $command = $this->tableCommand($root); @@ -613,60 +169,6 @@ public function test_database_tables_cannot_overwrite_existing_files(): void { $this->assertSame('existing table', (string) file_get_contents($path)); } - public function test_database_generators_accept_generation_options(): void { - $root = $this->temporaryProject(); - - $tableTester = new CommandTester($this->tableCommand($root)); - $tableStatus = $tableTester->execute([ - 'name' => 'Audit_Log', - '--namespace' => 'Acme\\Plugin\\Storage', - '--path' => 'custom/tables', - '--table-name' => 'custom_audit_log', - ]); - - $migrationTester = new CommandTester($this->migrationCommand($root)); - $migrationStatus = $migrationTester->execute([ - 'name' => 'Create_Audit_Log_Table', - '--namespace' => 'Acme\\Plugin\\Storage\\Migrations', - '--path' => 'custom/migrations', - '--id' => '2026_06_26_000002_create_audit_log_table', - '--create' => 'Acme\\Plugin\\Storage\\Audit_Log_Table', - ]); - - $tableContents = (string) file_get_contents($root . '/custom/tables/Audit_Log_Table.php'); - $migrationContents = (string) file_get_contents($root . '/custom/migrations/Create_Audit_Log_Table.php'); - - $this->assertSame(Command::SUCCESS, $tableStatus); - $this->assertStringContainsString('namespace Acme\\Plugin\\Storage;', $tableContents); - $this->assertStringContainsString("private const string UNPREFIXED_TABLE_NAME = 'custom_audit_log';", $tableContents); - $this->assertSame(Command::SUCCESS, $migrationStatus); - $this->assertStringContainsString('namespace Acme\\Plugin\\Storage\\Migrations;', $migrationContents); - $this->assertStringContainsString('use Acme\\Plugin\\Storage\\Audit_Log_Table;', $migrationContents); - $this->assertStringContainsString("public const string ID = '2026_06_26_000002_create_audit_log_table';", $migrationContents); - } - - public function test_database_alteration_migrations_accept_generation_options(): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'Add_Status_To_Audit_Log', - '--namespace' => 'Acme\\Plugin\\Storage\\Migrations', - '--path' => 'custom/migrations', - '--id' => '2026_06_26_000003_add_status_to_audit_log', - '--table' => 'Acme\\Plugin\\Storage\\Audit_Log_Table', - ]); - - $contents = (string) file_get_contents($root . '/custom/migrations/Add_Status_To_Audit_Log.php'); - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertStringContainsString('namespace Acme\\Plugin\\Storage\\Migrations;', $contents); - $this->assertStringContainsString('use Acme\\Plugin\\Storage\\Audit_Log_Table;', $contents); - - $this->assertStringContainsString('$table = $schema->table( $this->table );', $contents); - $this->assertStringNotContainsString('$schema->drop( $this->table );', $contents); - } - public function test_database_provider_generator_accepts_generation_options(): void { $root = $this->temporaryProject(); $tester = new CommandTester($this->providerCommand($root)); @@ -714,718 +216,11 @@ public function test_database_provider_generator_accepts_an_absolute_output_path $this->assertSame(Command::SUCCESS, $statusCode); $this->assertFileExists($outputRoot . '/Provider.php'); - $this->assertStringContainsString('Created: ' . $outputRoot . '/Provider.php', $tester->getDisplay()); - $this->assertStringContainsString('namespace Acme\\External\\Database;', (string) file_get_contents($outputRoot . '/Provider.php')); - } - - public function test_table_and_migration_generators_update_the_conventional_database_provider_when_it_exists(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); - - (new CommandTester($this->providerCommand($root)))->execute([]); - - $tableTester = new CommandTester($this->tableCommand($root)); - $tableStatus = $tableTester->execute([ - 'name' => 'reports', - ]); - - $migrationTester = new CommandTester($this->migrationCommand($root)); - $migrationStatus = $migrationTester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_26_000001_create_reports_table', - ]); - - $contents = (string) file_get_contents($root . '/src/Database/Provider.php'); - - $this->assertSame(Command::SUCCESS, $tableStatus); - $this->assertStringContainsString('Updated: src/Database/Provider.php', $tableTester->getDisplay()); - $this->assertSame(Command::SUCCESS, $migrationStatus); - $this->assertStringContainsString('Updated: src/Database/Provider.php', $migrationTester->getDisplay()); - $this->assertStringNotContainsString('Register this migration', $migrationTester->getDisplay()); - $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); - $this->assertStringContainsString('use Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table;', $contents); - $this->assertStringContainsString('$this->container->singleton( Reports_Table::class );', $contents); - $this->assertStringContainsString('$c->get( Create_Reports_Table::class ),', $contents); - $this->assertStringContainsString("\t\t\$this->container->singleton( Reports_Table::class );\n\t\t// foundation:database-tables", $contents); - $this->assertStringContainsString("\t\t\t\$c->get( Create_Reports_Table::class ),\n\t\t] );", $contents); - $this->assertStringNotContainsString('Array$this', $contents); - $this->assertStringNotContainsString('Array$c', $contents); - } - - public function test_database_migration_generator_appends_to_existing_provider_migrations_in_order(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); - - (new CommandTester($this->providerCommand($root)))->execute([]); - - (new CommandTester($this->migrationCommand($root)))->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_26_000001_create_reports_table', - ]); - (new CommandTester($this->migrationCommand($root)))->execute([ - 'name' => 'create-orders-table', - '--create' => 'Orders_Table', - '--id' => '2026_06_26_000002_create_orders_table', - ]); - - $contents = (string) file_get_contents($root . '/src/Database/Provider.php'); - - $reportsOffset = strpos($contents, '$c->get( Create_Reports_Table::class ),'); - $ordersOffset = strpos($contents, '$c->get( Create_Orders_Table::class ),'); - - $this->assertIsInt($reportsOffset); - $this->assertIsInt($ordersOffset); - $this->assertGreaterThan($reportsOffset, $ordersOffset); - } - - public function test_table_and_migration_generators_update_an_explicit_database_provider_when_it_exists(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); - - (new CommandTester($this->providerCommand($root)))->execute([ - '--path' => 'custom/providers', - ]); - - $tableTester = new CommandTester($this->tableCommand($root)); - $tableStatus = $tableTester->execute([ - 'name' => 'reports', - '--provider' => 'custom/providers/Provider.php', - ]); - - $migrationTester = new CommandTester($this->migrationCommand($root)); - $migrationStatus = $migrationTester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_26_000001_create_reports_table', - '--provider' => 'custom/providers/Provider.php', - ]); - - $contents = (string) file_get_contents($root . '/custom/providers/Provider.php'); - - $this->assertSame(Command::SUCCESS, $tableStatus); - $this->assertStringContainsString('Updated: custom/providers/Provider.php', $tableTester->getDisplay()); - $this->assertSame(Command::SUCCESS, $migrationStatus); - $this->assertStringContainsString('Updated: custom/providers/Provider.php', $migrationTester->getDisplay()); - $this->assertStringContainsString('$this->container->singleton( Reports_Table::class );', $contents); - $this->assertStringContainsString('$c->get( Create_Reports_Table::class ),', $contents); - } - - public function test_explicit_database_provider_update_fails_when_the_provider_has_no_markers(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); - - mkdir($root . '/custom/providers', 0777, true); - file_put_contents($root . '/custom/providers/Provider.php', 'tableCommand($root)); - $statusCode = $tester->execute([ - 'name' => 'reports', - '--provider' => 'custom/providers/Provider.php', - ]); - - $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString('file does not contain the generated database provider markers', $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); - } - - public function test_explicit_database_provider_updates_fail_before_writing_when_the_provider_is_not_writable(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); - - (new CommandTester($this->providerCommand($root)))->execute([]); - - $providerPath = $root . '/src/Database/Provider.php'; - chmod($providerPath, 0444); - - $tableTester = new CommandTester($this->tableCommand($root)); - $tableStatus = $tableTester->execute([ - 'name' => 'reports', - '--provider' => 'src/Database/Provider.php', - ]); - - $migrationTester = new CommandTester($this->migrationCommand($root)); - $migrationStatus = $migrationTester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--provider' => 'src/Database/Provider.php', - ]); - - $this->assertSame(Command::FAILURE, $tableStatus); - $this->assertSame(Command::FAILURE, $migrationStatus); - $this->assertStringContainsString('file is not writable', $tableTester->getDisplay()); - $this->assertStringContainsString('file is not writable', $migrationTester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); - } - - public function test_combined_generation_fails_before_writing_when_the_provider_directory_cannot_be_replaced(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^2.0', - ], - ]); - - (new CommandTester($this->providerCommand($root)))->execute([]); - - $providerDirectory = $root . '/src/Database'; - $tester = new CommandTester($this->tableCommand($root)); - - chmod($providerDirectory, 0555); - - try { - $status = $tester->execute([ - 'name' => 'reports', - '--migration' => true, - '--provider' => 'src/Database/Provider.php', - ]); - } finally { - chmod($providerDirectory, 0755); - } - - $this->assertSame(Command::FAILURE, $status); - $this->assertStringContainsString('file is not writable', $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); - } - - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] - public function test_migration_generation_removes_its_file_when_provider_replacement_fails_after_preflight(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^2.0', - ], - ]); - - (new CommandTester($this->providerCommand($root)))->execute([]); - - $providerPath = $root . '/src/Database/Provider.php'; - $original = (string) file_get_contents($providerPath); - - PHPMockery::mock('StellarWP\Foundation\Cli\Commands\Make\Database', 'rename') - ->once() - ->andReturn(false); - - $tester = new CommandTester($this->migrationCommand($root)); - $status = $tester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--provider' => 'src/Database/Provider.php', - ]); - - $this->assertSame(Command::FAILURE, $status); - $this->assertStringContainsString('file could not be written', $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); - $this->assertSame($original, (string) file_get_contents($providerPath)); - } - - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] - public function test_combined_generation_removes_both_files_when_provider_replacement_fails_after_preflight(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^2.0', - ], - ]); - - (new CommandTester($this->providerCommand($root)))->execute([]); - - $providerPath = $root . '/src/Database/Provider.php'; - $original = (string) file_get_contents($providerPath); - - PHPMockery::mock('StellarWP\Foundation\Cli\Commands\Make\Database', 'tempnam') - ->with(dirname($providerPath), '.foundation-provider-') - ->once() - ->andReturn(false); - - $tester = new CommandTester($this->tableCommand($root)); - $status = $tester->execute([ - 'name' => 'reports', - '--migration' => true, - '--provider' => 'src/Database/Provider.php', - ]); - - $this->assertSame(Command::FAILURE, $status); - $this->assertStringContainsString('file could not be written', $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); - $this->assertSame($original, (string) file_get_contents($providerPath)); - } - - public function test_explicit_database_provider_migration_update_fails_when_the_provider_has_no_migration_anchor(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); - - mkdir($root . '/custom/providers', 0777, true); - file_put_contents($root . '/custom/providers/Provider.php', <<<'PHP' -migrationCommand($root)); - $statusCode = $tester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_26_000001_create_reports_table', - '--provider' => 'custom/providers/Provider.php', - ]); - - $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString('file does not contain a generated database provider registration point', $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); - } - - public function test_table_generator_warns_when_the_conventional_provider_cannot_be_updated(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); - - mkdir($root . '/src/Database', 0777, true); - file_put_contents( - $root . '/src/Database/Provider.php', - file_get_contents($this->data_dir('cli/generation/php-source-editor/database-provider-without-registration-points.stub')) - ); - - $tester = new CommandTester($this->tableCommand($root)); - $statusCode = $tester->execute(['name' => 'reports']); - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertFileExists($root . '/src/Database/Tables/Reports_Table.php'); - $this->assertStringContainsString('Provider not updated: src/Database/Provider.php', $tester->getDisplay()); - $this->assertStringContainsString('Register Reports_Table manually.', $tester->getDisplay()); - } - - public function test_migration_generator_warns_when_the_conventional_provider_cannot_be_updated(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); - - mkdir($root . '/src/Database', 0777, true); - file_put_contents( - $root . '/src/Database/Provider.php', - file_get_contents($this->data_dir('cli/generation/php-source-editor/database-provider-without-registration-points.stub')) - ); - - $tester = new CommandTester($this->migrationCommand($root)); - $statusCode = $tester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_26_000001_create_reports_table', - ]); - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertFileExists($root . '/src/Database/Migrations/Create_Reports_Table.php'); - $this->assertStringContainsString('Provider not updated: src/Database/Provider.php', $tester->getDisplay()); - $this->assertStringContainsString('Register Create_Reports_Table manually.', $tester->getDisplay()); - } - - public function test_database_provider_migration_update_preserves_legacy_migration_marker_position(): void { - $root = $this->temporaryProject(); - - mkdir($root . '/src/Database', 0777, true); - - $providerPath = $root . '/src/Database/Provider.php'; - file_put_contents($providerPath, <<<'PHP' -container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ - // foundation:database-migrations - ] ); - } -} -PHP); - - $status = $this->providerUpdater()->addMigration( - providerPath: $providerPath, - class: 'Create_Reports_Table', - classNamespace: 'Acme\\Plugin\\Database\\Migrations' - ); - - $contents = (string) file_get_contents($providerPath); - - $this->assertTrue($status->wasUpdated()); - $this->assertStringContainsString('use Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table;', $contents); - $this->assertStringContainsString("\t\t\t\$c->get( Create_Reports_Table::class ),\n\t\t\t// foundation:database-migrations", $contents); - } - - public function test_database_provider_migration_update_supports_direct_array_registrations(): void { - $root = $this->temporaryProject(); - - mkdir($root . '/src/Database', 0777, true); - - $providerPath = $root . '/src/Database/Provider.php'; - file_put_contents($providerPath, <<<'PHP' -container->mergeArrayVar( DatabaseProvider::MIGRATIONS, [ - ] ); - } -} -PHP); - - $status = $this->providerUpdater()->addMigration( - providerPath: $providerPath, - class: 'Create_Reports_Table', - classNamespace: 'Acme\\Plugin\\Database\\Migrations' - ); - - $contents = (string) file_get_contents($providerPath); - - $this->assertTrue($status->wasUpdated()); - $this->assertStringContainsString('$this->container->get( Create_Reports_Table::class ),', $contents); - } - - public function test_combined_provider_update_does_not_write_the_table_when_the_migration_cannot_be_added(): void { - $root = $this->temporaryProject(); - - mkdir($root . '/src/Database', 0777, true); - - $providerPath = $root . '/src/Database/Provider.php'; - $original = (string) file_get_contents( - $this->data_dir('cli/generation/php-source-editor/database-provider-with-table-marker-only.stub') - ); - - file_put_contents($providerPath, $original); - - $status = $this->providerUpdater()->addTableAndMigration( - providerPath: $providerPath, - tableClass: 'Reports_Table', - tableNamespace: 'Acme\\Plugin\\Database\\Tables', - migrationClass: 'Create_Reports_Table', - migrationNamespace: 'Acme\\Plugin\\Database\\Migrations' - ); - - $this->assertSame('file does not contain a generated database provider registration point', $status->failureReason()); - $this->assertSame($original, (string) file_get_contents($providerPath)); - $this->assertSame( - 'file does not contain a generated database provider registration point', - $this->providerUpdater()->checkTableAndMigration( - providerPath: $providerPath, - tableClass: 'Reports_Table', - tableNamespace: 'Acme\\Plugin\\Database\\Tables', - migrationClass: 'Create_Reports_Table', - migrationNamespace: 'Acme\\Plugin\\Database\\Migrations' - )->failureReason() - ); - } - - public function test_combined_provider_updates_are_idempotent(): void { - $root = $this->temporaryProject(); - - (new CommandTester($this->providerCommand($root)))->execute([]); - - $providerPath = $root . '/src/Database/Provider.php'; - $updater = $this->providerUpdater(); - $arguments = [ - 'providerPath' => $providerPath, - 'tableClass' => 'Reports_Table', - 'tableNamespace' => 'Acme\\Plugin\\Database\\Tables', - 'migrationClass' => 'Create_Reports_Table', - 'migrationNamespace' => 'Acme\\Plugin\\Database\\Migrations', - ]; - - $this->assertTrue($updater->addTableAndMigration(...$arguments)->wasUpdated()); - - $updated = (string) file_get_contents($providerPath); - - $this->assertTrue($updater->addTableAndMigration(...$arguments)->wasAlreadyRegistered()); - $this->assertTrue($updater->checkTableAndMigration(...$arguments)->wasAlreadyRegistered()); - $this->assertSame($updated, (string) file_get_contents($providerPath)); - } - - public function test_provider_registration_checks_report_ready_without_changing_the_provider(): void { - $root = $this->temporaryProject(); - - (new CommandTester($this->providerCommand($root)))->execute([]); - - $providerPath = $root . '/src/Database/Provider.php'; - $updater = $this->providerUpdater(); - $original = (string) file_get_contents($providerPath); - - $tableResult = $updater->checkTable( - $providerPath, - 'Reports_Table', - 'Acme\\Plugin\\Database\\Tables' - ); - $migrationResult = $updater->checkMigration( - $providerPath, - 'Create_Reports_Table', - 'Acme\\Plugin\\Database\\Migrations' - ); - - $this->assertTrue($tableResult->succeeded()); - $this->assertFalse($tableResult->wasUpdated()); - $this->assertFalse($tableResult->wasAlreadyRegistered()); - $this->assertTrue($migrationResult->succeeded()); - $this->assertFalse($migrationResult->wasUpdated()); - $this->assertFalse($migrationResult->wasAlreadyRegistered()); - $this->assertSame($original, (string) file_get_contents($providerPath)); - - $this->assertTrue($updater->addTable( - $providerPath, - 'Reports_Table', - 'Acme\\Plugin\\Database\\Tables' - )->wasUpdated()); - - $partiallyRegistered = (string) file_get_contents($providerPath); - $combinedResult = $updater->checkTableAndMigration( - providerPath: $providerPath, - tableClass: 'Reports_Table', - tableNamespace: 'Acme\\Plugin\\Database\\Tables', - migrationClass: 'Create_Reports_Table', - migrationNamespace: 'Acme\\Plugin\\Database\\Migrations' - ); - - $this->assertTrue($combinedResult->succeeded()); - $this->assertFalse($combinedResult->wasUpdated()); - $this->assertFalse($combinedResult->wasAlreadyRegistered()); - $this->assertSame($partiallyRegistered, (string) file_get_contents($providerPath)); - } - - public function test_provider_updates_preserve_symbolic_links(): void { - $root = $this->temporaryProject(); - - (new CommandTester($this->providerCommand($root)))->execute([]); - - $providerPath = $root . '/src/Database/Provider.php'; - $linkPath = $root . '/DatabaseProvider.php'; - - $this->assertTrue(symlink($providerPath, $linkPath)); - $this->assertTrue( - $this->providerUpdater()->addTable( - $linkPath, - 'Reports_Table', - 'Acme\\Plugin\\Database\\Tables' - )->wasUpdated() - ); - - $this->assertTrue(is_link($linkPath)); - $this->assertStringContainsString( - '$this->container->singleton( Reports_Table::class );', - (string) file_get_contents($providerPath) - ); - } - - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] - public function test_provider_updates_report_transient_read_failures(): void { - $root = $this->temporaryProject(); - - mkdir($root . '/src/Database', 0777, true); - - $providerPath = $root . '/src/Database/Provider.php'; - - file_put_contents($providerPath, 'with($providerPath) - ->times(3) - ->andReturn(false); - - $updater = $this->providerUpdater(); - - $this->assertSame( - 'file could not be read', - $updater->checkTable($providerPath, 'Reports_Table', 'Acme\\Plugin\\Database\\Tables')->failureReason() - ); - $this->assertSame( - 'file could not be read', - $updater->checkMigration($providerPath, 'Create_Reports_Table', 'Acme\\Plugin\\Database\\Migrations')->failureReason() - ); - $this->assertSame( - 'file could not be read', - $updater->addTableAndMigration( - providerPath: $providerPath, - tableClass: 'Reports_Table', - tableNamespace: 'Acme\\Plugin\\Database\\Tables', - migrationClass: 'Create_Reports_Table', - migrationNamespace: 'Acme\\Plugin\\Database\\Migrations' - )->failureReason() - ); - } - - public function test_provider_updates_report_a_missing_provider_file(): void { - $providerPath = $this->tempDir . '/missing/Provider.php'; - $updater = $this->providerUpdater(); - - $this->assertSame( - 'file does not exist or is not readable', - $updater->checkTable($providerPath, 'Reports_Table', 'Acme\\Plugin\\Database\\Tables')->failureReason() - ); - $this->assertSame( - 'file does not exist or is not readable', - $updater->checkMigration($providerPath, 'Create_Reports_Table', 'Acme\\Plugin\\Database\\Migrations')->failureReason() - ); - $this->assertSame( - 'file does not exist or is not readable', - $updater->addTableAndMigration( - providerPath: $providerPath, - tableClass: 'Reports_Table', - tableNamespace: 'Acme\\Plugin\\Database\\Tables', - migrationClass: 'Create_Reports_Table', - migrationNamespace: 'Acme\\Plugin\\Database\\Migrations' - )->failureReason() - ); - } - - public function test_database_provider_migration_update_supports_closure_callbacks_returning_arrays(): void { - $root = $this->temporaryProject(); - - mkdir($root . '/src/Database', 0777, true); - - $providerPath = $root . '/src/Database/Provider.php'; - file_put_contents($providerPath, <<<'PHP' -container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static function ( C $c ): array { - return [ - ]; - } ); - } -} -PHP); - - $status = $this->providerUpdater()->addMigration( - providerPath: $providerPath, - class: 'Create_Reports_Table', - classNamespace: 'Acme\\Plugin\\Database\\Migrations' - ); - - $contents = (string) file_get_contents($providerPath); - - $this->assertTrue($status->wasUpdated()); - $this->assertStringContainsString('use Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table;', $contents); - $this->assertStringContainsString("\t\t\t\t\$c->get( Create_Reports_Table::class ),\n\t\t\t];", $contents); - } - - public function test_database_provider_migration_update_uses_the_callback_parameter_name(): void { - $root = $this->temporaryProject(); - - mkdir($root . '/src/Database', 0777, true); - - $providerPath = $root . '/src/Database/Provider.php'; - file_put_contents($providerPath, <<<'PHP' -container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $container ): array => [ - ] ); - } -} -PHP); - - $status = $this->providerUpdater()->addMigration( - providerPath: $providerPath, - class: 'Create_Reports_Table', - classNamespace: 'Acme\\Plugin\\Database\\Migrations' - ); - - $contents = (string) file_get_contents($providerPath); - - $this->assertTrue($status->wasUpdated()); - $this->assertStringContainsString('$container->get( Create_Reports_Table::class ),', $contents); - } - - public function test_explicit_database_provider_migration_update_fails_before_writing_when_the_array_cannot_be_safely_edited(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); - - mkdir($root . '/custom/providers', 0777, true); - file_put_contents($root . '/custom/providers/Provider.php', <<<'PHP' -container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [] ); - } -} -PHP); - - $tester = new CommandTester($this->migrationCommand($root)); - $statusCode = $tester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_26_000001_create_reports_table', - '--provider' => 'custom/providers/Provider.php', - ]); - - $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString('file does not contain a generated database provider registration point', $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); + $this->assertStringContainsString('Created: ' . $outputRoot . '/Provider.php', $tester->getDisplay()); + $this->assertStringContainsString('namespace Acme\\External\\Database;', (string) file_get_contents($outputRoot . '/Provider.php')); } - public function test_explicit_database_provider_migration_update_fails_when_the_callback_has_no_container_parameter(): void { + public function test_explicit_database_provider_update_fails_when_the_provider_has_no_markers(): void { $root = $this->temporaryProject([ 'require' => [ 'stellarwp/foundation-database' => '^1.2', @@ -1433,66 +228,63 @@ public function test_explicit_database_provider_migration_update_fails_when_the_ ]); mkdir($root . '/custom/providers', 0777, true); - file_put_contents($root . '/custom/providers/Provider.php', <<<'PHP' -container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn (): array => [ - ] ); - } -} -PHP); + file_put_contents($root . '/custom/providers/Provider.php', 'migrationCommand($root)); + $tester = new CommandTester($this->tableCommand($root)); $statusCode = $tester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_26_000001_create_reports_table', + 'name' => 'reports', '--provider' => 'custom/providers/Provider.php', ]); $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString('file does not contain a generated database provider registration point', $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); + $this->assertStringContainsString('file does not contain the generated database provider markers', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); } - public function test_database_provider_migration_update_ignores_unrelated_database_provider_imports(): void { - $root = $this->temporaryProject(); + public function test_table_generator_warns_when_the_conventional_provider_cannot_be_updated(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); mkdir($root . '/src/Database', 0777, true); + file_put_contents( + $root . '/src/Database/Provider.php', + file_get_contents($this->data_dir('cli/generation/php-source-editor/database-provider-without-registration-points.stub')) + ); - $providerPath = $root . '/src/Database/Provider.php'; - file_put_contents($providerPath, <<<'PHP' -tableCommand($root)); + $statusCode = $tester->execute(['name' => 'reports']); -namespace Acme\Plugin\Database; + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($root . '/src/Database/Tables/Reports_Table.php'); + $this->assertStringContainsString('Provider not updated: src/Database/Provider.php', $tester->getDisplay()); + $this->assertStringContainsString('Register Reports_Table manually.', $tester->getDisplay()); + } -use Acme\Other\DatabaseProvider; -use StellarWP\Foundation\Container\Contracts\Resolver as C; + public function test_provider_updates_preserve_symbolic_links(): void { + $root = $this->temporaryProject(); -final class Provider -{ - public function register(): void { - $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ - ] ); - } -} -PHP); + (new CommandTester($this->providerCommand($root)))->execute([]); - $status = $this->providerUpdater()->addMigration( - providerPath: $providerPath, - class: 'Create_Reports_Table', - classNamespace: 'Acme\\Plugin\\Database\\Migrations' + $providerPath = $root . '/src/Database/Provider.php'; + $linkPath = $root . '/DatabaseProvider.php'; + + $this->assertTrue(symlink($providerPath, $linkPath)); + $this->assertTrue( + $this->providerUpdater()->addTable( + $linkPath, + 'Reports_Table', + 'Acme\\Plugin\\Database\\Tables' + )->wasUpdated() ); - $this->assertSame('file does not contain a generated database provider registration point', $status->failureReason()); - $this->assertStringNotContainsString('Create_Reports_Table', (string) file_get_contents($providerPath)); + $this->assertTrue(is_link($linkPath)); + $this->assertStringContainsString( + '$this->container->singleton( Reports_Table::class );', + (string) file_get_contents($providerPath) + ); } public function test_explicit_database_provider_update_fails_when_the_provider_cannot_be_parsed(): void { @@ -1707,56 +499,6 @@ classNamespace: 'Acme\\Plugin\\Database\\Tables' $this->assertSame($contents, (string) file_get_contents($providerPath)); } - public function test_database_provider_updater_is_idempotent_after_wordpress_formatting(): void { - $root = $this->temporaryProject(); - - mkdir($root . '/src/Database', 0777, true); - - $providerPath = $root . '/src/Database/Provider.php'; - $contents = (string) file_get_contents($this->data_dir('cli/generation/php-source-editor/formatted-database-provider.stub')); - file_put_contents($providerPath, $contents); - - $tableStatus = $this->providerUpdater()->addTable( - providerPath: $providerPath, - class: 'Reports_Table', - classNamespace: 'Acme\\Plugin\\Database\\Tables' - ); - $migrationStatus = $this->providerUpdater()->addMigration( - providerPath: $providerPath, - class: 'Create_Reports_Table', - classNamespace: 'Acme\\Plugin\\Database\\Migrations' - ); - - $this->assertTrue($tableStatus->wasAlreadyRegistered()); - $this->assertTrue($migrationStatus->wasAlreadyRegistered()); - $this->assertSame($contents, (string) file_get_contents($providerPath)); - } - - public function test_database_provider_updater_is_idempotent_for_namespace_relative_registrations(): void { - $root = $this->temporaryProject(); - - mkdir($root . '/src/Database', 0777, true); - - $providerPath = $root . '/src/Database/Provider.php'; - $contents = (string) file_get_contents($this->data_dir('cli/generation/php-source-editor/namespace-relative-registrations.stub')); - file_put_contents($providerPath, $contents); - - $tableStatus = $this->providerUpdater()->addTable( - providerPath: $providerPath, - class: 'Reports_Table', - classNamespace: 'Acme\\Plugin\\Database\\Tables' - ); - $migrationStatus = $this->providerUpdater()->addMigration( - providerPath: $providerPath, - class: 'Create_Reports_Table', - classNamespace: 'Acme\\Plugin\\Database\\Migrations' - ); - - $this->assertTrue($tableStatus->wasAlreadyRegistered()); - $this->assertTrue($migrationStatus->wasAlreadyRegistered()); - $this->assertSame($contents, (string) file_get_contents($providerPath)); - } - public function test_explicit_database_provider_update_fails_on_import_short_name_collisions(): void { $root = $this->temporaryProject([ 'require' => [ @@ -1768,8 +510,8 @@ public function test_explicit_database_provider_update_fails_on_import_short_nam $providerPath = $root . '/src/Database/Provider.php'; file_put_contents($providerPath, str_replace( - 'use StellarWP\\Foundation\\Database\\DatabaseProvider;', - "use Acme\\Other\\reports_table;\nuse StellarWP\\Foundation\\Database\\DatabaseProvider;", + 'use StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;', + "use Acme\\Other\\reports_table;\nuse StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;", (string) file_get_contents($providerPath) )); @@ -1795,8 +537,8 @@ public function test_explicit_database_provider_update_fails_on_grouped_import_s $providerPath = $root . '/src/Database/Provider.php'; file_put_contents($providerPath, str_replace( - 'use StellarWP\\Foundation\\Database\\DatabaseProvider;', - "use Acme\\Other\\{Reports as Reports_Table};\nuse StellarWP\\Foundation\\Database\\DatabaseProvider;", + 'use StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;', + "use Acme\\Other\\{Reports as Reports_Table};\nuse StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;", (string) file_get_contents($providerPath) )); @@ -1822,8 +564,8 @@ public function test_explicit_database_provider_update_fails_on_aliased_import_s $providerPath = $root . '/src/Database/Provider.php'; file_put_contents($providerPath, str_replace( - 'use StellarWP\\Foundation\\Database\\DatabaseProvider;', - "use Acme\\Other\\Reports as Reports_Table;\nuse StellarWP\\Foundation\\Database\\DatabaseProvider;", + 'use StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;', + "use Acme\\Other\\Reports as Reports_Table;\nuse StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;", (string) file_get_contents($providerPath) )); @@ -1838,29 +580,6 @@ public function test_explicit_database_provider_update_fails_on_aliased_import_s $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); } - public function test_explicit_database_provider_update_fails_when_the_generated_class_matches_the_provider_class(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); - - (new CommandTester($this->providerCommand($root)))->execute([]); - - $providerPath = $root . '/src/Database/Provider.php'; - $providerContents = (string) file_get_contents($providerPath); - $tester = new CommandTester($this->migrationCommand($root)); - $statusCode = $tester->execute([ - 'name' => 'Provider', - '--provider' => 'src/Database/Provider.php', - ]); - - $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString('another class declaration or import uses the same short class name', $tester->getDisplay()); - $this->assertSame($providerContents, (string) file_get_contents($providerPath)); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Provider.php'); - } - public function test_database_table_generator_accepts_an_absolute_output_path(): void { $root = $this->temporaryProject(); $outputRoot = $this->temporaryRoot('foundation-make-database-output-'); @@ -1876,58 +595,6 @@ public function test_database_table_generator_accepts_an_absolute_output_path(): $this->assertStringContainsString('Created: ' . $outputRoot . '/Reports_Table.php', $tester->getDisplay()); } - public function test_database_generators_use_strauss_namespace_prefix_for_foundation_imports(): void { - $root = $this->temporaryProject([ - 'extra' => [ - 'strauss' => [ - 'namespace_prefix' => 'Acme\\Product\\', - ], - ], - ]); - - $tableTester = new CommandTester($this->tableCommand($root)); - $tableTester->execute([ - 'name' => 'reports', - ]); - - $migrationTester = new CommandTester($this->migrationCommand($root)); - $migrationTester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_26_000001_create_reports_table', - ]); - $migrationTester->execute([ - 'name' => 'add-status-to-reports', - '--id' => '2026_06_26_000002_add_status_to_reports', - '--table' => 'Reports_Table', - ]); - $migrationTester->execute([ - 'name' => 'bump-version', - '--id' => '2026_06_26_000003_bump_version', - ]); - - $tableContents = (string) file_get_contents($root . '/src/Database/Tables/Reports_Table.php'); - $migrationContents = (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php'); - $alterContents = (string) file_get_contents($root . '/src/Database/Migrations/Add_Status_To_Reports.php'); - $genericContents = (string) file_get_contents($root . '/src/Database/Migrations/Bump_Version.php'); - - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Table\\Table;', $tableContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration;', $migrationContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $migrationContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $migrationContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration;', $alterContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration;', $alterContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $alterContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $alterContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration;', $genericContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration;', $genericContents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $genericContents); - $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Database;', $tableContents); - $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Table\\Table;', $tableContents); - $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $tableContents); - $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Migration\\Contracts\\Migration;', $migrationContents); - } - public function test_database_provider_generator_uses_strauss_namespace_prefix_for_foundation_imports(): void { $root = $this->temporaryProject([ 'extra' => [ @@ -1942,62 +609,9 @@ public function test_database_provider_generator_uses_strauss_namespace_prefix_f $contents = (string) file_get_contents($root . '/src/Database/Provider.php'); $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Container\\Contracts\\Resolver as C;', $contents); - $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\DatabaseProvider;', $contents); $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;', $contents); $this->assertStringNotContainsString('use StellarWP\\Foundation\\Container\\Contracts\\Resolver as C;', $contents); - $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\DatabaseProvider;', $contents); - } - - public function test_database_generators_use_project_stub_overrides(): void { - $root = $this->temporaryProject(); - - mkdir($root . '/foundation/stubs/database', 0777, true); - file_put_contents($root . '/foundation/stubs/database/table.stub', 'tableCommand($root)))->execute([ - 'name' => 'reports', - ]); - (new CommandTester($this->migrationCommand($root)))->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_26_000001_create_reports_table', - ]); - (new CommandTester($this->migrationCommand($root)))->execute([ - 'name' => 'bump-version', - '--id' => '2026_06_26_000003_bump_version', - ]); - (new CommandTester($this->migrationCommand($root)))->execute([ - 'name' => 'add-status-to-reports', - '--id' => '2026_06_26_000004_add_status_to_reports', - '--table' => 'Reports_Table', - ]); - (new CommandTester($this->providerCommand($root)))->execute([]); - - $this->assertStringContainsString( - 'Generated table Reports_Table in Acme\\Plugin\\Database\\Tables', - (string) file_get_contents($root . '/src/Database/Tables/Reports_Table.php') - ); - $this->assertStringContainsString( - 'Generated create migration Create_Reports_Table with Reports_Table', - (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php') - ); - $this->assertStringContainsString( - 'Generated alteration migration Add_Status_To_Reports with Reports_Table', - (string) file_get_contents($root . '/src/Database/Migrations/Add_Status_To_Reports.php') - ); - $this->assertStringContainsString( - 'Generated migration Bump_Version', - (string) file_get_contents($root . '/src/Database/Migrations/Bump_Version.php') - ); - $this->assertStringContainsString( - 'Generated provider Provider in Acme\\Plugin\\Database', - (string) file_get_contents($root . '/src/Database/Provider.php') - ); + $this->assertStringNotContainsString('use StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;', $contents); } public function test_database_generators_warn_when_the_runtime_dependency_is_missing_from_production_requirements(): void { @@ -2067,43 +681,6 @@ public function test_database_provider_generator_does_not_warn_when_the_aggregat $this->assertStringNotContainsString('Runtime dependency missing:', $tester->getDisplay()); } - public function test_database_generators_warn_when_the_runtime_dependency_is_only_a_development_dependency(): void { - $root = $this->temporaryProject([ - 'require-dev' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_26_000001_create_reports_table', - ]); - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertStringContainsString('Runtime dependency missing:', $tester->getDisplay()); - $this->assertStringContainsString('only in require-dev', $tester->getDisplay()); - } - - public function test_database_generators_do_not_warn_when_the_runtime_dependency_is_in_production_requirements(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--id' => '2026_06_26_000001_create_reports_table', - ]); - - $this->assertSame(Command::SUCCESS, $statusCode); - $this->assertStringNotContainsString('Runtime dependency missing:', $tester->getDisplay()); - } - public function test_database_generators_reject_invalid_namespaces_before_writing_files(): void { $root = $this->temporaryProject(); $tester = new CommandTester($this->tableCommand($root)); @@ -2133,30 +710,6 @@ public function test_database_provider_generator_rejects_invalid_namespaces_befo $this->assertFileDoesNotExist($root . '/custom/providers/Provider.php'); } - public function test_database_generators_reject_namespaces_outside_the_autoload_root(): void { - $root = $this->temporaryProject(); - $tableTester = new CommandTester($this->tableCommand($root)); - - $tableStatus = $tableTester->execute([ - 'name' => 'reports', - '--namespace' => 'Acme\\PluginTools\\Database\\Tables', - ]); - - $migrationTester = new CommandTester($this->migrationCommand($root)); - $migrationStatus = $migrationTester->execute([ - 'name' => 'create-reports-table', - '--create' => 'Reports_Table', - '--namespace' => 'Acme\\PluginTools\\Database\\Migrations', - ]); - - $this->assertSame(Command::FAILURE, $tableStatus); - $this->assertStringContainsString('Namespace "Acme\\PluginTools\\Database\\Tables" is outside the Composer PSR-4 namespaces in composer.json.', $tableTester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Tools/Database/Tables/Reports_Table.php'); - $this->assertSame(Command::FAILURE, $migrationStatus); - $this->assertStringContainsString('Namespace "Acme\\PluginTools\\Database\\Migrations" is outside the Composer PSR-4 namespaces in composer.json.', $migrationTester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Tools/Database/Migrations/Create_Reports_Table.php'); - } - public function test_database_provider_generator_rejects_namespaces_outside_the_autoload_root(): void { $root = $this->temporaryProject(); $tester = new CommandTester($this->providerCommand($root)); @@ -2170,168 +723,37 @@ public function test_database_provider_generator_rejects_namespaces_outside_the_ $this->assertFileDoesNotExist($root . '/src/Tools/Database/Provider.php'); } - /** - * @dataProvider invalidMigrationIdProvider - */ - #[DataProvider('invalidMigrationIdProvider')] - public function test_database_migration_generator_rejects_runtime_invalid_ids(string $id, string $message): void { - $root = $this->temporaryProject(); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute([ - 'name' => 'bump-version', - '--id' => $id, - ]); - - $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString($message, $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Bump_Version.php'); - } - - /** - * @return iterable - */ - public static function invalidMigrationIdProvider(): iterable { - yield 'blank' => ['', 'cannot be blank']; - - yield 'padded' => [' padded ', 'surrounding whitespace']; - - yield 'reserved zero' => ['0', 'reserved']; - - yield 'reserved latest' => ['latest', 'reserved']; - - yield 'over ledger limit' => [str_repeat('a', 192), 'cannot exceed 191 bytes']; - } - - public function test_database_migration_generator_rejects_an_overlong_generated_id(): void { - $root = $this->temporaryProject(); - $name = str_repeat('a', 180); - $tester = new CommandTester($this->migrationCommand($root)); - - $statusCode = $tester->execute(['name' => $name]); - - $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString('cannot exceed 191 bytes', $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/src/Database/Migrations/' . ucfirst($name) . '.php'); - } - - public function test_database_generators_reject_class_names_that_conflict_with_stub_imports(): void { - $root = $this->temporaryProject(); - $cases = [ - [new CommandTester($this->tableCommand($root)), ['name' => 'Table'], 'src/Database/Tables/Table.php'], - [new CommandTester($this->migrationCommand($root)), ['name' => 'Migration'], 'src/Database/Migrations/Migration.php'], - [ - new CommandTester($this->migrationCommand($root)), - ['name' => 'Reports_Table', '--table' => 'Reports_Table'], - 'src/Database/Migrations/Reports_Table.php', + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_combined_generation_removes_both_files_when_provider_replacement_fails_after_preflight(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^2.0', ], - [new CommandTester($this->providerCommand($root)), ['name' => 'C'], 'src/Database/C.php'], - ]; - - foreach ($cases as [$tester, $input, $relativePath]) { - $this->assertSame(Command::FAILURE, $tester->execute($input)); - $this->assertStringContainsString('declares or imports', $tester->getDisplay()); - $this->assertFileDoesNotExist($root . '/' . $relativePath); - } - } - - public function test_project_defaults_coordinate_database_generation_and_registration(): void { - $this->generatorConfig = require dirname(__DIR__, 4) . '/Support/Fixtures/Cli/generator-config.php'; - $root = $this->temporaryProject([ - 'autoload' => ['psr-4' => ['Acme\\Plugin\\Persistence\\Migrations\\' => 'history']], ]); - $provider = new CommandTester($this->providerCommand($root)); - $this->assertSame(Command::SUCCESS, $provider->execute([]), $provider->getDisplay()); - - $table = new CommandTester($this->tableCommand($root)); - $this->assertSame(Command::SUCCESS, $table->execute(['name' => 'reports', '--migration' => true]), $table->getDisplay()); - - $alteration = new CommandTester($this->migrationCommand($root)); - $this->assertSame(Command::SUCCESS, $alteration->execute(['name' => 'Add_Status', '--table' => 'Reports_Table']), $alteration->getDisplay()); - $creation = new CommandTester($this->migrationCommand($root)); - $this->assertSame(Command::SUCCESS, $creation->execute(['name' => 'Create_Archive', '--create' => 'Reports_Table']), $creation->getDisplay()); - - $this->assertFileExists($root . '/src/Persistence/Tables/Reports_Table.php'); - foreach (['Create_Reports_Table', 'Add_Status', 'Create_Archive'] as $class) { - $contents = (string) file_get_contents($root . '/history/' . $class . '.php'); - $this->assertStringContainsString('namespace Acme\\Plugin\\Persistence\\Migrations;', $contents); - $this->assertStringContainsString('use Acme\\Plugin\\Persistence\\Tables\\Reports_Table;', $contents); - } + (new CommandTester($this->providerCommand($root)))->execute([]); - $contents = (string) file_get_contents($root . '/src/Persistence/Provider.php'); - $this->assertStringContainsString('use Acme\\Plugin\\Persistence\\Tables\\Reports_Table;', $contents); - $this->assertStringContainsString('$this->container->singleton( Reports_Table::class );', $contents); - $this->assertStringContainsString('$c->get( Create_Reports_Table::class ),', $contents); - $this->assertStringContainsString('$c->get( Add_Status::class ),', $contents); - $this->assertStringContainsString('$c->get( Create_Archive::class ),', $contents); - } + $providerPath = $root . '/src/Database/Provider.php'; + $original = (string) file_get_contents($providerPath); - public function test_explicit_database_options_override_project_defaults(): void { - $this->generatorConfig = require dirname(__DIR__, 4) . '/Support/Fixtures/Cli/generator-config.php'; - $root = $this->temporaryProject(); - $provider = new CommandTester($this->providerCommand($root)); - $this->assertSame(Command::SUCCESS, $provider->execute([ - 'name' => 'Storage_Provider', - '--namespace' => 'Acme\\Plugin\\Storage', - '--path' => 'custom/providers', - ]), $provider->getDisplay()); + PHPMockery::mock('StellarWP\Foundation\Cli\Commands\Make\Database', 'tempnam') + ->with(dirname($providerPath), '.foundation-write-') + ->once() + ->andReturn(false); - $table = new CommandTester($this->tableCommand($root)); - $this->assertSame(Command::SUCCESS, $table->execute([ + $tester = new CommandTester($this->tableCommand($root)); + $status = $tester->execute([ 'name' => 'reports', - '--namespace' => 'Acme\\Plugin\\Reports', - '--path' => 'custom/tables', - '--provider' => 'custom/providers/Storage_Provider.php', '--migration' => true, - ]), $table->getDisplay()); - - $paired = (string) file_get_contents($root . '/src/Persistence/Migrations/Create_Reports_Table.php'); - $this->assertStringContainsString('use Acme\\Plugin\\Reports\\Reports_Table;', $paired); - $this->assertFileExists($root . '/custom/tables/Reports_Table.php'); - - $migration = new CommandTester($this->migrationCommand($root)); - $this->assertSame(Command::SUCCESS, $migration->execute([ - 'name' => 'Add_Status', - '--namespace' => 'Acme\\Plugin\\History', - '--path' => 'custom/migrations', - '--provider' => 'custom/providers/Storage_Provider.php', - '--table' => 'Acme\\Plugin\\Reports\\Reports_Table', - ]), $migration->getDisplay()); - $contents = (string) file_get_contents($root . '/custom/migrations/Add_Status.php'); - $this->assertStringContainsString('namespace Acme\\Plugin\\History;', $contents); - $this->assertStringContainsString('use Acme\\Plugin\\Reports\\Reports_Table;', $contents); - $contents = (string) file_get_contents($root . '/custom/providers/Storage_Provider.php'); - $this->assertStringContainsString('$c->get( Add_Status::class ),', $contents); - $this->assertFileDoesNotExist($root . '/src/Persistence/Provider.php'); - } + '--provider' => 'src/Database/Provider.php', + ]); - public function test_an_unmapped_provider_namespace_fails_before_generation(): void { - $this->generatorConfig = ['generators' => ['database-provider' => ['namespace' => 'Other\\Storage']]]; - $root = $this->temporaryProject(); - $table = new CommandTester($this->tableCommand($root)); - $this->assertSame(Command::FAILURE, $table->execute(['name' => 'reports', '--migration' => true])); - $this->assertStringContainsString('Database provider namespace "Other\\Storage"', $table->getDisplay()); - $this->assertStringContainsString('generators.database-provider.namespace', $table->getDisplay()); - $this->assertStringContainsString('--provider', $table->getDisplay()); - $this->assertStringNotContainsString('--path', $table->getDisplay()); - $this->assertDirectoryDoesNotExist($root . '/src'); - - $migration = new CommandTester($this->migrationCommand($root)); - $this->assertSame(Command::FAILURE, $migration->execute(['name' => 'Add_Status'])); - $this->assertStringContainsString('--provider', $migration->getDisplay()); - $this->assertStringNotContainsString('--path', $migration->getDisplay()); - $this->assertDirectoryDoesNotExist($root . '/src'); - - $provider = new CommandTester($this->providerCommand($root)); - $this->assertSame(Command::SUCCESS, $provider->execute(['--namespace' => 'Acme\\Plugin\\Providers'])); - $providerPath = 'src/Providers/Provider.php'; - $this->assertSame(Command::SUCCESS, $table->execute([ - 'name' => 'reports', '--migration' => true, '--provider' => $providerPath, - ]), $table->getDisplay()); - $this->assertSame(Command::SUCCESS, $migration->execute([ - 'name' => 'Add_Status', '--provider' => $providerPath, - ]), $migration->getDisplay()); + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('file could not be written', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + $this->assertSame([], glob($root . '/src/Database/Migrations/Migration_*.php')); + $this->assertSame($original, (string) file_get_contents($providerPath)); } private function generatorLocations(ProjectDirectory $projectDirectory): GeneratorLocationResolver { @@ -2359,29 +781,16 @@ classNameResolver: new WordPressClassNameResolver(), ); } - private function migrationCommand(string $root): MigrationCommand { - $projectDirectory = new ProjectDirectory($root); - - return new MigrationCommand( - providerFiles: new ProviderFileResolver($projectDirectory, $this->generatorLocations($projectDirectory)), - projectDirectory: $projectDirectory, - autoloadResolver: new ComposerAutoloadResolver($projectDirectory), - migrationFactory: $this->migrationFactory($root), - fileWriter: $this->fileWriter(), - providerUpdater: $this->providerUpdater() - ); - } - private function migrationFactory(string $root): MigrationFileFactory { $projectDirectory = new ProjectDirectory($root); return new MigrationFileFactory( projectDirectory: $projectDirectory, autoloadResolver: new ComposerAutoloadResolver($projectDirectory), - locations: $this->generatorLocations($projectDirectory), classNameResolver: new WordPressClassNameResolver(), stubResolver: new StubResolver($projectDirectory), - stubRenderer: new StubRenderer() + stubRenderer: new StubRenderer(), + config: new ArrayConfiguration($this->generatorConfig), ); } diff --git a/tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php b/tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php new file mode 100644 index 0000000..fbca4e6 --- /dev/null +++ b/tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php @@ -0,0 +1,201 @@ +root = $this->prepare_temp_dir('migration-generation'); + $this->configure(); + } + + /** @param array $configuration */ + private function configure(array $configuration = [], string $namespace = 'Example\\', string $path = 'src/'): void { + file_put_contents($this->root . '/composer.json', json_encode([ + 'autoload' => ['psr-4' => [$namespace => $path]], + 'require' => ['stellarwp/foundation-database' => '^2.0'], + ], JSON_THROW_ON_ERROR)); + $this->services = (new ContainerFactory())->create(new ArrayConfiguration($configuration)); + $this->services->register(CliProvider::class); + $this->services->singleton(ProjectDirectory::class, new ProjectDirectory($this->root)); + } + + private function migration(): CommandTester { + return new CommandTester($this->services->get(MigrationCommand::class)); + } + + private function table(): CommandTester { + return new CommandTester($this->services->get(TableCommand::class)); + } + + /** @return list */ + private function files(string $directory = 'db/migrations'): array { + return glob($this->root . '/' . $directory . '/[0-9]*.php') ?: []; + } + + public function test_combined_creation_then_alteration_has_ordered_file_identities_without_provider_edits(): void { + $table = $this->table(); + self::assertSame(0, $table->execute(['name' => 'reports', '--migration' => true]), $table->getDisplay()); + $createFile = $this->files()[0]; + $create = (string) file_get_contents($createFile); + self::assertMatchesRegularExpression('/[0-9]{14}_create_reports_table.php$/', $createFile); + self::assertStringContainsString('extends Migration', $create); + self::assertStringContainsString('return new class extends Migration', $create); + self::assertStringNotContainsString('Reports_Table', $create); + self::assertStringContainsString("\$schema->create( 'reports' )", $create); + self::assertStringContainsString("\$schema->drop( 'reports' )", $create); + self::assertStringNotContainsString('const string ID', $create); + self::assertStringNotContainsString('function id()', $create); + self::assertFileDoesNotExist($this->root . '/src/Database/Provider.php'); + + $command = $this->migration(); + self::assertSame(0, $command->execute(['name' => 'Add_Published_At', '--table' => 'reports']), $command->getDisplay()); + $files = $this->files(); + self::assertCount(2, $files); + self::assertSame($createFile, $files[0]); + $alter = (string) file_get_contents($files[1]); + self::assertStringContainsString("\$schema->table( 'reports' )", $alter); + self::assertStringContainsString('throw new IrreversibleMigration', $alter); + self::assertStringNotContainsString('$schema->drop', $alter); + self::assertSame($create, file_get_contents($createFile)); + } + + public function test_generation_creates_custom_directories_without_composer_namespace_mappings(): void { + $this->configure(['database' => ['migrations' => ['path' => 'migrations']]]); + $command = $this->migration(); + self::assertSame(0, $command->execute(['name' => 'Reports/Add_Status']), $command->getDisplay()); + $file = $this->files('migrations/reports')[0]; + self::assertStringNotContainsString('namespace ', (string) file_get_contents($file)); + self::assertDirectoryDoesNotExist($this->root . '/src'); + } + + public function test_absolute_location_and_foundation_root_use_the_same_configuration(): void { + $this->configure(['foundation' => ['root' => $this->root], 'database' => ['migrations' => ['path' => $this->root . '/src/History']]]); + $command = $this->migration(); + self::assertSame(0, $command->execute(['name' => 'Rebuild']), $command->getDisplay()); + self::assertCount(1, $this->files('src/History')); + } + + public function test_new_timestamps_follow_migrations_in_other_feature_folders(): void { + mkdir($this->root . '/db/migrations/Reports', 0777, true); + file_put_contents($this->root . '/db/migrations/Reports/20990101000000_create.php', 'root . '/db/migrations/Reports/README.md', 'History'); + $command = $this->migration(); + self::assertSame(0, $command->execute(['name' => 'Add_Status'])); + self::assertStringEndsWith('20990101000001_add_status.php', $this->files()[0]); + } + + public function test_repeated_descriptions_create_new_files_and_never_overwrite_history(): void { + $command = $this->migration(); + self::assertSame(0, $command->execute(['name' => 'Change_Status'])); + $file = $this->files()[0]; + $contents = file_get_contents($file); + self::assertSame(0, $command->execute(['name' => 'Change_Status'])); + self::assertCount(2, $this->files()); + self::assertSame($contents, file_get_contents($file)); + self::assertFalse($this->services->get(MigrationCommand::class)->getDefinition()->hasOption('force')); + } + + public function test_standalone_create_and_alter_use_table_names_without_application_classes(): void { + file_put_contents($this->root . '/composer.json', '{"require":{"stellarwp/foundation-database":"^2.0"}}'); + $command = $this->migration(); + self::assertSame(0, $command->execute(['name' => 'create_reports', '--create' => 'your_plugin_reports']), $command->getDisplay()); + self::assertSame(0, $command->execute(['name' => 'add_status', '--table' => 'your_plugin_reports']), $command->getDisplay()); + self::assertCount(2, $this->files()); + self::assertDirectoryDoesNotExist($this->root . '/src'); + } + + public function test_combined_generation_preserves_an_explicit_physical_table_suffix(): void { + self::assertSame(0, $this->table()->execute(['name' => 'reports', '--table-name' => 'your_plugin_reports', '--migration' => true])); + $table = (string) file_get_contents($this->root . '/src/Database/Tables/Reports_Table.php'); + $migration = (string) file_get_contents($this->files()[0]); + self::assertStringContainsString("UNPREFIXED_TABLE_NAME = 'your_plugin_reports';", $table); + self::assertStringContainsString("\$schema->create( 'your_plugin_reports' )", $migration); + } + + public function test_generic_migration_does_not_infer_table_ownership_from_its_name(): void { + $command = $this->migration(); + self::assertSame(0, $command->execute(['name' => 'Create_Reports_Table'])); + $contents = (string) file_get_contents($this->files()[0]); + self::assertStringNotContainsString('$schema->create', $contents); + self::assertStringNotContainsString('$schema->drop', $contents); + self::assertStringContainsString('IrreversibleMigration', $contents); + } + + public function test_invalid_options_and_locations_fail_without_writing(): void { + foreach ([ + ['name' => 'Change', '--table' => 'reports', '--create' => 'reports'], + ['name' => 'Change', '--table' => ''], + ['name' => 'Change', '--create' => ''], + ['name' => 'Change', '--table' => 'Tables/Reports'], + ['name' => 'Change', '--table' => 'reports; DROP TABLE reports'], + ['name' => 'Change', '--table' => 'Example\\Tables\\1Invalid'], + ['name' => 'Change', '--table' => 'Missing\\Reports'], + ['name' => '../Change'], + ['name' => '/Change'], + ['name' => str_repeat('a', 180)], + ] as $input) { + self::assertSame(1, $this->migration()->execute($input)); + self::assertSame([], $this->files()); + } + + $this->configure(['database' => ['migrations' => ['path' => '']]]); + $command = $this->migration(); + self::assertSame(1, $command->execute(['name' => 'Change'])); + self::assertStringContainsString('cannot be blank', $command->getDisplay()); + self::assertDirectoryDoesNotExist($this->root . '/unmapped'); + } + + public function test_combined_generation_writes_neither_file_if_migration_location_is_invalid(): void { + $this->configure(['database' => ['migrations' => ['path' => '']]]); + self::assertSame(1, $this->table()->execute(['name' => 'reports', '--migration' => true])); + self::assertFileDoesNotExist($this->root . '/src/Database/Tables/Reports_Table.php'); + } + public function test_strauss_imports_apply_to_every_generated_migration_kind(): void { + $manifest = json_decode((string) file_get_contents($this->root . '/composer.json'), true, 512, JSON_THROW_ON_ERROR); + $manifest['extra']['strauss']['namespace_prefix'] = 'Scoped\\'; + file_put_contents($this->root . '/composer.json', json_encode($manifest, JSON_THROW_ON_ERROR)); + self::assertSame(0, $this->table()->execute(['name' => 'reports', '--migration' => true])); + self::assertSame(0, $this->migration()->execute(['name' => 'Add_Status', '--table' => 'reports'])); + self::assertSame(0, $this->migration()->execute(['name' => 'Backfill'])); + self::assertCount(3, $this->files()); + + foreach ($this->files() as $file) { + $contents = (string) file_get_contents($file); + self::assertStringContainsString('use Scoped\\StellarWP\\Foundation\\Database\\Migration\\Migration;', $contents); + self::assertStringContainsString('use Scoped\\StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $contents); + } + } + + public function test_each_migration_mode_uses_the_project_stub_override(): void { + mkdir($this->root . '/foundation/stubs/database', 0777, true); + + foreach (['create-table-migration', 'alter-table-migration', 'migration'] as $stub) { + file_put_contents($this->root . '/foundation/stubs/database/' . $stub . '.stub', 'table()->execute(['name' => 'reports', '--migration' => true])); + self::assertSame(0, $this->migration()->execute(['name' => 'Add_Status', '--table' => 'reports'])); + self::assertSame(0, $this->migration()->execute(['name' => 'Backfill'])); + $files = $this->files(); + self::assertCount(3, $files); + + foreach (['create-table-migration', 'alter-table-migration', 'migration'] as $i => $stub) { + self::assertStringContainsString('// custom ' . $stub, (string) file_get_contents($files[$i])); + } + } +} diff --git a/tests/Unit/Database/Migration/MigrationCollectionTest.php b/tests/Unit/Database/Migration/MigrationCollectionTest.php index 408fb28..f12c9b6 100644 --- a/tests/Unit/Database/Migration/MigrationCollectionTest.php +++ b/tests/Unit/Database/Migration/MigrationCollectionTest.php @@ -8,31 +8,26 @@ use StellarWP\Foundation\Database\Migration\Exceptions\InvalidMigrationId; use StellarWP\Foundation\Database\Migration\MigrationCollection; use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Database\Migration\ValueObjects\MigrationRegistration; final class MigrationCollectionTest extends TestCase { - private function migration(string $id): Migration { - return new class($id) implements Migration { - public function __construct( - private readonly string $identity, - ) { - } - public function id(): string { - return $this->identity; - } + private function migration(string $id): MigrationRegistration { + return new MigrationRegistration($id, new class implements Migration { public function up(Blueprint $schema): void { } public function down(Blueprint $schema): void { } - }; + }); } public function test_order_and_identity_are_byte_exact_even_for_numeric_ids(): void { - $ids = ['a', '10', '2', '01', 'A', '20260922000100']; - $collection = new MigrationCollection(array_map($this->migration(...), $ids)); + $ids = ['a', '10', '2', '01', 'A', '20260922000100']; + $registrations = array_map($this->migration(...), $ids); + $collection = new MigrationCollection($registrations); self::assertSame(['01', '10', '2', '20260922000100', 'A', 'a'], $collection->ids()); - foreach ($ids as $id) { - self::assertSame($id, $collection->get($id)->id()); + foreach ($registrations as $registration) { + self::assertSame($registration->migration, $collection->get($registration->id)); } self::assertFalse($collection->has('missing')); } diff --git a/tests/Unit/Database/Migration/MigrationDiscoveryTest.php b/tests/Unit/Database/Migration/MigrationDiscoveryTest.php new file mode 100644 index 0000000..5af473b --- /dev/null +++ b/tests/Unit/Database/Migration/MigrationDiscoveryTest.php @@ -0,0 +1,89 @@ +fixtures = dirname(__DIR__, 3) . '/Support/Fixtures/Database/Discovery'; + } + + public function test_nested_files_are_loaded_and_ordered_without_composer_or_a_container(): void { + $discovery = new MigrationDiscovery(null, $this->fixtures); + $collection = new MigrationCollection($discovery->migrations()); + self::assertSame(['20260923000001_create_reports_table', '20260923000002_add_published_at'], $collection->ids()); + $again = new MigrationCollection($discovery->migrations()); + self::assertSame($collection->ids(), $again->ids()); + self::assertNotSame($collection->get($collection->ids()[0]), $again->get($again->ids()[0])); + } + + public function test_moving_a_file_preserves_identity_and_duplicate_filenames_are_rejected(): void { + $root = $this->prepare_temp_dir('moved-migration'); + $file = '20260923000001_create_reports_table.php'; + copy($this->fixtures . '/' . $file, $root . '/' . $file); + $original = iterator_to_array((new MigrationDiscovery(null, $this->fixtures))->migrations()); + $moved = iterator_to_array((new MigrationDiscovery(null, $root))->migrations()); + self::assertSame(pathinfo($file, PATHINFO_FILENAME), $moved[0]->id); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Duplicate migration identity'); + new MigrationCollection([...$original, ...$moved]); + } + + public function test_no_configuration_or_missing_default_directory_is_empty(): void { + self::assertSame([], iterator_to_array((new MigrationDiscovery(null, null))->migrations())); + $root = $this->prepare_temp_dir('missing-migrations'); + self::assertSame([], iterator_to_array((new MigrationDiscovery($root, null))->migrations())); + self::assertDirectoryDoesNotExist($root . '/db/migrations'); + } + + public function test_an_explicit_missing_directory_is_an_error(): void { + $root = $this->prepare_temp_dir('missing-custom-migrations'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Configured migration directory does not exist'); + iterator_to_array((new MigrationDiscovery($root, 'migrations'))->migrations()); + } + + public function test_relative_locations_require_an_application_root(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('foundation.root'); + iterator_to_array((new MigrationDiscovery(null, 'migrations'))->migrations()); + } + + public function test_matching_files_must_return_a_migration(): void { + $root = $this->prepare_temp_dir('invalid-migration'); + file_put_contents($root . '/20260923000001_invalid.php', 'expectException(TypeError::class); + iterator_to_array((new MigrationDiscovery(null, $root))->migrations()); + } + + public function test_helpers_and_non_migration_files_are_not_executed(): void { + $root = $this->prepare_temp_dir('migration-helpers'); + foreach (['helper.php', 'README.md', '20260923000001_notes.txt'] as $file) { + file_put_contents($root . '/' . $file, 'migrations())); + } + + public function test_default_and_custom_paths_are_normalized_without_namespace_mappings(): void { + self::assertSame('/project/db/migrations', (new MigrationDirectory('/project'))->path); + self::assertSame('/project/history', (new MigrationDirectory('/project', 'db/../history'))->path); + self::assertSame('/shared/history', (new MigrationDirectory(null, '/shared/history'))->path); + } + + public function test_blank_migration_path_is_an_error_instead_of_scanning_the_project_root(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot be blank'); + new MigrationDirectory('/project', ''); + } +} diff --git a/tests/Unit/Database/Table/TableNameResolverTest.php b/tests/Unit/Database/Table/TableNameResolverTest.php index 29d73ef..8d27b57 100644 --- a/tests/Unit/Database/Table/TableNameResolverTest.php +++ b/tests/Unit/Database/Table/TableNameResolverTest.php @@ -38,4 +38,22 @@ public function test_invalid_names_fail_before_sql(string $name, string $prefix) $this->expectException(DatabaseException::class); (new TableNameResolver($scope))->tableName($table); } + /** + * @dataProvider invalidNames + */ + #[DataProvider('invalidNames')] + public function test_historical_string_names_have_the_same_validation(string $name, string $prefix): void { + $scope = $this->createMock(DatabaseScope::class); + $scope->method('resolveTableName')->willReturn($prefix . $name); + $this->expectException(DatabaseException::class); + (new TableNameResolver($scope))->tableName($name); + } + + public function test_a_historical_name_is_resolved_again_for_each_site(): void { + $scope = $this->createMock(DatabaseScope::class); + $scope->expects(self::exactly(2))->method('resolveTableName')->with('reports')->willReturnOnConsecutiveCalls('wp_reports', 'wp_2_reports'); + $names = new TableNameResolver($scope); + self::assertSame('wp_reports', $names->tableName('reports')); + self::assertSame('wp_2_reports', $names->tableName('reports')); + } } diff --git a/tests/integration/Database/AdvisoryMigrationTest.php b/tests/integration/Database/AdvisoryMigrationTest.php index c38133a..0d38dd0 100644 --- a/tests/integration/Database/AdvisoryMigrationTest.php +++ b/tests/integration/Database/AdvisoryMigrationTest.php @@ -21,6 +21,7 @@ use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Schema\Blueprint; use StellarWP\Foundation\Database\Migration\Schema\SchemaPlanner; +use StellarWP\Foundation\Database\Migration\ValueObjects\MigrationRegistration; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Tests\Support\Fixtures\Database\DatabaseTestCase; use StellarWP\Foundation\Tests\Support\Fixtures\Database\LostAdvisoryLockReply; @@ -64,14 +65,14 @@ public function up(Blueprint $schema): void { } public function down(Blueprint $schema): void { } - public function migrate(Connection $db): void { + public function migrate(Connection $db, TableNameResolver $names): void { ($this->operation)($db); } }; - $migrations = new MigrationCollection([$migration]); + $migrations = new MigrationCollection([new MigrationRegistration($migration->id(), $migration)]); return new Migrator($db, $container->get(WordPressSession::class), $scope, - new History($db, $table), new SchemaPlanner($db, $names, $migrations), $migrations); + new History($db, $table), new SchemaPlanner($db, $names, $migrations), $migrations, $names); } private function lockName(?string $resource = null): string { diff --git a/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php b/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php index c544e83..6f2b2fa 100644 --- a/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php +++ b/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php @@ -8,6 +8,7 @@ use StellarWP\Foundation\Database\Migration\Exceptions\IrreversibleMigration; use StellarWP\Foundation\Database\Migration\Exceptions\MigrationAlreadyRunning; use StellarWP\Foundation\Database\Migration\Migrator; +use StellarWP\Foundation\Database\Migration\ValueObjects\MigrationRegistration; use StellarWP\Foundation\Database\Migration\ValueObjects\Step; use StellarWP\Foundation\Tests\Support\Fixtures\Database\DatabaseTestCase; use StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative\AddEntryArchivedFlag; @@ -48,17 +49,17 @@ protected function setUp(): void { $this->container->singleton(EntriesTable::class, new EntriesTable($this->suffix . '_entries')); $this->container->singleton(TagsTable::class, new TagsTable($this->suffix . '_tags')); $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ - $c->get(ReduceCreatedAtPrecision::class), - $c->get(TrackEntryUpdates::class), - $c->get(WidenEntryName::class), - $c->get(AddEntryArchivedFlag::class), - $c->get(ReplaceNoteLookup::class), - $c->get(IndexEntryNote::class), - $c->get(AddEntryNote::class), - $c->get(RecreateEntryTags::class), - $c->get(DropEntryTags::class), - $c->get(CreateEntryTags::class), - $c->get(CreateEntries::class), + new MigrationRegistration(ReduceCreatedAtPrecision::ID, $c->get(ReduceCreatedAtPrecision::class)), + new MigrationRegistration(TrackEntryUpdates::ID, $c->get(TrackEntryUpdates::class)), + new MigrationRegistration(WidenEntryName::ID, $c->get(WidenEntryName::class)), + new MigrationRegistration(AddEntryArchivedFlag::ID, $c->get(AddEntryArchivedFlag::class)), + new MigrationRegistration(ReplaceNoteLookup::ID, $c->get(ReplaceNoteLookup::class)), + new MigrationRegistration(IndexEntryNote::ID, $c->get(IndexEntryNote::class)), + new MigrationRegistration(AddEntryNote::ID, $c->get(AddEntryNote::class)), + new MigrationRegistration(RecreateEntryTags::ID, $c->get(RecreateEntryTags::class)), + new MigrationRegistration(DropEntryTags::ID, $c->get(DropEntryTags::class)), + new MigrationRegistration(CreateEntryTags::ID, $c->get(CreateEntryTags::class)), + new MigrationRegistration(CreateEntries::ID, $c->get(CreateEntries::class)), ]); } diff --git a/tests/integration/Database/DeclarativeMigratorTest.php b/tests/integration/Database/DeclarativeMigratorTest.php index f58b01e..126b70e 100644 --- a/tests/integration/Database/DeclarativeMigratorTest.php +++ b/tests/integration/Database/DeclarativeMigratorTest.php @@ -9,6 +9,7 @@ use StellarWP\Foundation\Database\Migration\Exceptions\MigrationAlreadyRunning; use StellarWP\Foundation\Database\Migration\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Database\Migration\Migrator; +use StellarWP\Foundation\Database\Migration\ValueObjects\MigrationRegistration; use StellarWP\Foundation\Database\Migration\ValueObjects\Step; use StellarWP\Foundation\Tests\Support\Fixtures\Database\DatabaseTestCase; use StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative\AddEntryNote; @@ -47,9 +48,9 @@ protected function setUp(): void { $this->container->singleton(EntriesTable::class, $this->entries); $this->container->when(BackfillEntryStatus::class)->needs('$physicalName')->give($this->entriesName); $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ - $c->get(BackfillEntryStatus::class), - $c->get(AddEntryNote::class), - $c->get(CreateEntries::class), + new MigrationRegistration(BackfillEntryStatus::ID, $c->get(BackfillEntryStatus::class)), + new MigrationRegistration(AddEntryNote::ID, $c->get(AddEntryNote::class)), + new MigrationRegistration(CreateEntries::ID, $c->get(CreateEntries::class)), ]); } diff --git a/tests/integration/Database/GeneratedMigrationTest.php b/tests/integration/Database/GeneratedMigrationTest.php new file mode 100644 index 0000000..09691de --- /dev/null +++ b/tests/integration/Database/GeneratedMigrationTest.php @@ -0,0 +1,61 @@ + */ + public static function locations(): iterable { + yield 'default' => [null]; + + yield 'root migrations directory' => ['migrations']; + + yield 'nested custom directory' => ['src/Database/Migrations']; + } + + /** + * @dataProvider locations + */ + #[DataProvider('locations')] + public function test_generated_migrations_load_without_application_classes_or_migration_autoload_mappings(?string $path): void { + $root = $this->prepare_temp_dir('generated-migration-consumer'); + $namespace = 'Consumer' . bin2hex(random_bytes(5)) . '\\'; + $mappings = [$namespace => 'src/']; + $configuration = ['foundation' => ['root' => $root]]; + + if ($path !== null) { + $configuration['database']['migrations']['path'] = $path; + } + + file_put_contents($root . '/composer.json', json_encode(['autoload' => ['psr-4' => $mappings], 'require' => ['stellarwp/foundation-database' => '^2.0']], JSON_THROW_ON_ERROR)); + $config = new ArrayConfiguration($configuration); + $tooling = (new ContainerFactory())->create($config); + $tooling->register(CliProvider::class); + $tooling->singleton(ProjectDirectory::class, new ProjectDirectory($root)); + $table = new CommandTester($tooling->get(TableCommand::class)); + self::assertSame(0, $table->execute(['name' => 'Reports', '--migration' => true]), $table->getDisplay()); + $migration = new CommandTester($tooling->get(MigrationCommand::class)); + self::assertSame(0, $migration->execute(['name' => 'Reports/Add_Published_At', '--table' => 'reports']), $migration->getDisplay()); + // The generated application table is deliberately not autoloadable at runtime. + $runtime = (new ContainerFactory())->create($config); + $runtime->register(DatabaseProvider::class); + $collection = $runtime->get(MigrationCollection::class); + $ids = $collection->ids(); + self::assertCount(2, $ids); + self::assertStringEndsWith('_create_reports_table', $ids[0]); + self::assertStringEndsWith('_add_published_at', $ids[1]); + self::assertInstanceOf(\StellarWP\Foundation\Database\Migration\Migration::class, $collection->get($ids[1])); + } +} diff --git a/tests/integration/Database/MigrationDiscoveryTest.php b/tests/integration/Database/MigrationDiscoveryTest.php new file mode 100644 index 0000000..0bb2b0b --- /dev/null +++ b/tests/integration/Database/MigrationDiscoveryTest.php @@ -0,0 +1,87 @@ +cleanup_temp_dirs(); + } finally { + parent::tearDown(); + } + } + + protected function configuration(): array { + return ['database' => [ + 'migrations_table' => $this->suffix . '_history', + 'migrations' => ['path' => $this->migrationDirectory()], + ]]; + } + + private function migrationDirectory(): string { + $directory = $this->prepare_temp_dir('anonymous-migration-runtime'); + $this->migrationPath = $directory; + $fixtures = dirname(__DIR__, 2) . '/Support/Fixtures/Database/Discovery'; + foreach ([$fixtures . '/20260923000001_create_reports_table.php', $fixtures . '/reports/20260923000002_add_published_at.php'] as $file) { + file_put_contents($directory . '/' . basename($file), str_replace('discovery_reports', $this->suffix . '_reports', (string) file_get_contents($file))); + } + + return $directory; + } + + public function test_discovered_create_and_alter_migrations_execute_and_rollback_without_provider_lists(): void { + $table = $this->privateTable($this->suffix . '_reports'); + $this->privateTable($this->suffix . '_history'); + $ids = $this->container->get(MigrationCollection::class)->ids(); + self::assertCount(2, $ids); + $migrator = $this->container->get(Migrator::class); + $migrator->migrate($ids[0]); + $this->observer->executeStatement('INSERT INTO ' . $table . ' (title) VALUES (?)', ['Existing report']); + $migrator->migrate(); + self::assertSame([['title' => 'Existing report', 'published_at' => null]], $this->observer->fetchAllAssociative('SELECT title, published_at FROM ' . $table)); + $migrator->rollback(1); + self::assertSame(['Existing report'], $this->observer->fetchFirstColumn('SELECT title FROM ' . $table)); + $migrator->migrate(); + self::assertSame([['title' => 'Existing report', 'published_at' => null]], $this->observer->fetchAllAssociative('SELECT title, published_at FROM ' . $table)); + } + public function test_anonymous_data_callbacks_use_scoped_names_and_keep_their_optional_capabilities(): void { + $fixture = dirname(__DIR__, 2) . '/Support/Fixtures/Database/DataDiscovery/20260923000003_publish_reports.php'; + file_put_contents($this->migrationPath . '/' . basename($fixture), str_replace('discovery_reports', $this->suffix . '_reports', (string) file_get_contents($fixture))); + $table = $this->privateTable($this->suffix . '_reports'); + $this->privateTable($this->suffix . '_history'); + $migrator = $this->container->get(Migrator::class); + $migrator->migrate('20260923000002_add_published_at'); + $this->observer->executeStatement('INSERT INTO ' . $table . ' (title) VALUES (?)', ['Existing report']); + $preview = $migrator->preview(); + self::assertTrue($preview[0]->hasDataStep); + self::assertSame(['Existing report'], $this->observer->fetchFirstColumn('SELECT title FROM ' . $table)); + $migrator->migrate(); + self::assertSame(['Published report'], $this->observer->fetchFirstColumn('SELECT title FROM ' . $table)); + $status = $migrator->status()[2]; + self::assertSame('20260923000003_publish_reports', $status->migration); + self::assertSame('Publish existing reports', $status->description); + + try { + $migrator->rollback(); + self::fail('The inherited down() must stop rollback.'); + } catch (\StellarWP\Foundation\Database\Migration\Exceptions\IrreversibleMigration $failure) { + self::assertStringContainsString('20260923000003_publish_reports', $failure->getMessage()); + self::assertInstanceOf(\StellarWP\Foundation\Database\Migration\Exceptions\IrreversibleMigration::class, $failure->getPrevious()); + } + self::assertSame('applied', $migrator->status()[2]->state()); + } +} diff --git a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php index d7bcacb..18569eb 100644 --- a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -14,7 +14,7 @@ public function test_it_previews_runs_and_rolls_back_migrations(WPCLITester $I): $I->cli(['foundation', 'migrate']); $I->seeResultCodeIs(0); $I->seeInShellOutput('pending'); - $I->seeInShellOutput('(anonymous)'); + $I->seeInShellOutput("20260623000001\t20260623000001\tpending"); $I->cli(['foundation', 'migrate', '--run', '--dry-run']); $I->seeResultCodeIs(0); $I->seeInShellOutput('CREATE TABLE'); From b4c03e0687c034225b7b2ac002822aabd7972669 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 23 Sep 2026 15:32:27 -0600 Subject: [PATCH 13/53] Extract database locks into a dedicated package --- AGENTS.md | 1 + README.md | 3 +- composer.json | 2 + src/Database/DatabaseProvider.php | 9 +-- src/Database/README.md | 5 +- src/Database/composer.json | 1 - src/Docs/astro.config.mjs | 9 ++- .../src/content/docs/components/database.mdx | 5 +- src/Docs/src/content/docs/components/lock.mdx | 8 +-- .../{database/lock.mdx => lock/database.mdx} | 63 +++++++++++++---- src/LockDatabase/.gitattributes | 7 ++ src/LockDatabase/.gitignore | 2 + .../Lock => LockDatabase}/DatabaseLock.php | 8 ++- src/LockDatabase/LockDatabaseProvider.php | 30 +++++++++ src/LockDatabase/README.md | 20 ++++++ .../Tables/LockTable.php | 2 +- src/LockDatabase/composer.json | 26 +++++++ .../LockDatabase/LockDatabaseProviderTest.php | 67 +++++++++++++++++++ .../DatabaseLockTest.php | 34 ++++++++-- 19 files changed, 260 insertions(+), 42 deletions(-) rename src/Docs/src/content/docs/components/{database/lock.mdx => lock/database.mdx} (56%) create mode 100644 src/LockDatabase/.gitattributes create mode 100644 src/LockDatabase/.gitignore rename src/{Database/Lock => LockDatabase}/DatabaseLock.php (97%) create mode 100644 src/LockDatabase/LockDatabaseProvider.php create mode 100644 src/LockDatabase/README.md rename src/{Database/Table => LockDatabase}/Tables/LockTable.php (93%) create mode 100644 src/LockDatabase/composer.json create mode 100644 tests/Unit/LockDatabase/LockDatabaseProviderTest.php rename tests/integration/{Database => LockDatabase}/DatabaseLockTest.php (69%) diff --git a/AGENTS.md b/AGENTS.md index 842c61c..c22435b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Split packages: - `stellarwp/foundation-container` - `stellarwp/foundation-log` - `stellarwp/foundation-lock` +- `stellarwp/foundation-lock-database` - `stellarwp/foundation-lock-redis` - `stellarwp/foundation-database` - `stellarwp/foundation-identifier` diff --git a/README.md b/README.md index 6f4ac71..6e8de8b 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ See the [Foundation documentation](https://foundation.nexcess.dev/) for installa | [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) | Services need a configured PSR logger | Runtime | | [stellarwp/foundation-shutdown](https://github.com/stellarwp/foundation-shutdown) | Deferred work should run during PHP shutdown, optionally after finishing the response | Runtime | | [stellarwp/foundation-lock](https://github.com/stellarwp/foundation-lock) | Code needs the portable lock contract or process-local test implementation | Runtime | +| [stellarwp/foundation-lock-database](https://github.com/stellarwp/foundation-lock-database) | Requests and workers coordinate through the primary WordPress database | Runtime | | [stellarwp/foundation-lock-redis](https://github.com/stellarwp/foundation-lock-redis) | Multiple processes or servers coordinate through dedicated Redis | Runtime | -| [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) | A WordPress application needs queries, migrations, or database-backed locks | Runtime | +| [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) | A WordPress application needs queries, managed transactions, or migrations | Runtime | | [stellarwp/foundation-identifier](https://github.com/stellarwp/foundation-identifier) | Services need injectable ULID generation and validation | Runtime | | [stellarwp/foundation-view](https://github.com/stellarwp/foundation-view) | Services need scoped PHP template rendering without global state | Runtime | | [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) | A shipped WordPress plugin exposes WP-CLI commands | Runtime | diff --git a/composer.json b/composer.json index 6b70589..b455e07 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "stellarwp/foundation-database": "self.version", "stellarwp/foundation-identifier": "self.version", "stellarwp/foundation-lock": "self.version", + "stellarwp/foundation-lock-database": "self.version", "stellarwp/foundation-lock-redis": "self.version", "stellarwp/foundation-log": "self.version", "stellarwp/foundation-pipeline": "self.version", @@ -60,6 +61,7 @@ "StellarWP\\Foundation\\Container\\": "src/Container/", "StellarWP\\Foundation\\Database\\": "src/Database/", "StellarWP\\Foundation\\Identifier\\": "src/Identifier/", + "StellarWP\\Foundation\\LockDatabase\\": "src/LockDatabase/", "StellarWP\\Foundation\\LockRedis\\": "src/LockRedis/", "StellarWP\\Foundation\\Lock\\": "src/Lock/", "StellarWP\\Foundation\\Log\\": "src/Log/", diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index 73d5225..77c3582 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -16,14 +16,12 @@ use StellarWP\Foundation\Database\Contracts\DatabaseScope; use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Database\Exceptions\DatabaseException; -use StellarWP\Foundation\Database\Lock\DatabaseLock; use StellarWP\Foundation\Database\Migration\History; use StellarWP\Foundation\Database\Migration\MigrationCollection; use StellarWP\Foundation\Database\Migration\MigrationDiscovery; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Schema\SchemaPlanner; use StellarWP\Foundation\Database\Scope\SiteScope; -use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\WPCli\WPCliProvider; use wpdb; @@ -46,7 +44,6 @@ public function register(): void { $this->registerConnection(); $this->registerTables(); $this->registerMigrations(); - $this->container->singleton(DatabaseLock::class); $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [$c->get(Migrate::class)]); } @@ -77,12 +74,10 @@ private function registerConnection(): void { private function registerTables(): void { $prefix = str_replace('-', '_', $this->foundationPrefix()); - $this->container->when(MigrationTable::class)->needs('$unprefixedTableName') + $this->container->when(MigrationTable::class) + ->needs('$unprefixedTableName') ->give($this->config->get('database.migrations_table', $prefix . '_foundation_migrations')); - $this->container->when(LockTable::class)->needs('$unprefixedTableName') - ->give($this->config->get('database.locks_table', $prefix . '_foundation_locks')); $this->container->singleton(MigrationTable::class); - $this->container->singleton(LockTable::class); } private function registerMigrations(): void { diff --git a/src/Database/README.md b/src/Database/README.md index 1b6a0e1..aab4290 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -4,8 +4,7 @@ > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). Foundation Database provides a shared Doctrine DBAL connection, application tables, -managed transactions, declarative migrations, and an optional database lock backend -for WordPress applications. +managed transactions, and declarative migrations for WordPress applications. ## Installation @@ -19,4 +18,4 @@ The package includes `stellarwp/foundation-wpcli` for its migration command. Reg ## Documentation See the [Foundation Database documentation](https://foundation.nexcess.dev/components/database/) -for configuration, migrations, query building, database locks, and testing. +for configuration, migrations, query building, and testing. diff --git a/src/Database/composer.json b/src/Database/composer.json index b642c9c..2a164df 100644 --- a/src/Database/composer.json +++ b/src/Database/composer.json @@ -10,7 +10,6 @@ "require": { "php": ">=8.3", "stellarwp/foundation-container": "^2.0", - "stellarwp/foundation-lock": "^2.0", "stellarwp/foundation-wpcli": "^2.0", "doctrine/dbal": "^4.4", "ext-mysqli": "*" diff --git a/src/Docs/astro.config.mjs b/src/Docs/astro.config.mjs index d4226c1..0328787 100644 --- a/src/Docs/astro.config.mjs +++ b/src/Docs/astro.config.mjs @@ -39,10 +39,15 @@ export default defineConfig({ { slug: 'components/database', label: 'Overview' }, { slug: 'components/database/migrations' }, { slug: 'components/database/query-builder' }, - { slug: 'components/database/lock' }, ], }, - { slug: 'components/lock' }, + { + label: 'Lock', + items: [ + { slug: 'components/lock', label: 'Overview' }, + { slug: 'components/lock/database' }, + ], + }, { slug: 'components/log' }, { slug: 'components/identifier' }, { slug: 'components/pipeline' }, diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index 3298fbd..60d472a 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -83,7 +83,7 @@ $report = $this->reports->query() ## Configure resource names -`foundation.prefix = your-plugin` gives the migration ledger the unprefixed name `your_plugin_foundation_migrations`. The optional database lock uses `your_plugin_foundation_locks`. WordPress adds its current site prefix. The migration advisory lock is derived from the database and ledger names, so applications with different ledgers migrate independently. +`foundation.prefix = your-plugin` gives the migration ledger the unprefixed name `your_plugin_foundation_migrations`. WordPress adds its current site prefix. The migration advisory lock is derived from the database and ledger names, so applications with different ledgers migrate independently. Override table names when necessary: @@ -94,7 +94,6 @@ return [ ], 'database' => [ 'migrations_table' => $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'] ?? 'your_plugin_migrations', - 'locks_table' => $_ENV['FOUNDATION_DATABASE_LOCKS_TABLE'] ?? 'your_plugin_locks', ], ]; ``` @@ -114,5 +113,5 @@ Applications can replace `DatabaseScope` and `TableNameResolver` when they own a - + diff --git a/src/Docs/src/content/docs/components/lock.mdx b/src/Docs/src/content/docs/components/lock.mdx index d8e1c1e..30946fe 100644 --- a/src/Docs/src/content/docs/components/lock.mdx +++ b/src/Docs/src/content/docs/components/lock.mdx @@ -17,7 +17,7 @@ For WordPress applications where every participant can reach the same primary da | Implementation | Package | Coordinates work across | | --- | --- | --- | -| `DatabaseLock` | `stellarwp/foundation-database` | Requests and workers sharing the primary WordPress database | +| `DatabaseLock` | `stellarwp/foundation-lock-database` | Requests and workers sharing the primary WordPress database | | `RedisLock` | `stellarwp/foundation-lock-redis` | Requests and workers sharing a Redis endpoint | | `InMemoryLock` | `stellarwp/foundation-lock` | One PHP process | @@ -44,10 +44,10 @@ The examples assume the application has a composition root that registers provid ### Use the WordPress database -Install the database package: +Install the database lock backend: ```shell -composer require stellarwp/foundation-database +composer require stellarwp/foundation-lock-database ``` Follow the database lock guide to register its providers, initialize the lock table, and select `DatabaseLock` as your application's `Lock` implementation. Then continue to [Usage](#usage) below. @@ -55,7 +55,7 @@ Follow the database lock guide to register its providers, initialize the lock ta ### Use Redis diff --git a/src/Docs/src/content/docs/components/database/lock.mdx b/src/Docs/src/content/docs/components/lock/database.mdx similarity index 56% rename from src/Docs/src/content/docs/components/database/lock.mdx rename to src/Docs/src/content/docs/components/lock/database.mdx index 20ff382..599edcf 100644 --- a/src/Docs/src/content/docs/components/database/lock.mdx +++ b/src/Docs/src/content/docs/components/lock/database.mdx @@ -2,12 +2,12 @@ title: Database Lock description: Use the WordPress database as the shared backend for Foundation lock ownership. sidebar: - order: 3 + order: 1 --- import { CardGrid, LinkCard } from '@astrojs/starlight/components'; -`DatabaseLock` implements the shared Foundation `Lock` contract with a WordPress table. Choose it when every process that must coordinate can reach the same primary database and a dedicated Redis service is unnecessary. +`stellarwp/foundation-lock-database` supplies `DatabaseLock`, which implements the shared Foundation `Lock` contract with a WordPress table. Choose it when every process that must coordinate can reach the same primary database and a dedicated Redis service is unnecessary. -## Configuration +## Set up database locks + +Install the backend and its Database and Lock dependencies: + +```shell +composer require stellarwp/foundation-lock-database +``` + +Set a stable application prefix in the root `config.php` supplied to your application container: + +```php title="config.php" +return [ + 'foundation' => [ + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], +]; +``` + +For this prefix, the lock table defaults to `your_plugin_foundation_locks` before WordPress adds its site prefix. Register the providers below before resolving lock services. ### Select the database implementation -`DatabaseProvider` registers `DatabaseLock`, but does not choose it as the application's global `Lock` implementation. Make that application-level decision in `src/Lock/Provider.php`: +`DatabaseProvider` supplies the shared database connection. `LockDatabaseProvider` wires the database lock and its storage table. Select it as the application's `Lock` implementation in your existing application provider, or create `src/Lock/Provider.php`: ```php title="Provider.php" register_lock(); - } - - private function register_lock(): void { $this->container->singleton( Lock::class, static fn ( C $c ): DatabaseLock => $c->get( DatabaseLock::class ) @@ -56,20 +73,20 @@ final class Provider extends Service_Provider { } ``` -The factory aliases the interface to the `DatabaseLock` singleton already owned by `DatabaseProvider`. A direct concrete binding would ask the container to construct a separate instance. +The binding shares the configured `DatabaseLock` instance with application lock consumers. Registering the backend provider preserves any existing application `Lock` selection, so both database and Redis implementations can be available. Register providers in dependency order in `src/App.php`: ```php title="App.php" use StellarWP\Foundation\Container\Contracts\Provider; use StellarWP\Foundation\Database\DatabaseProvider; -use StellarWP\Foundation\WPCli\WPCliProvider; +use StellarWP\Foundation\LockDatabase\LockDatabaseProvider; use Plugin\Lock; /** @var list> */ private const array PROVIDERS = [ - WPCliProvider::class, DatabaseProvider::class, + LockDatabaseProvider::class, Lock\Provider::class, ]; ``` @@ -86,7 +103,7 @@ Subsequent calls reuse the existing table. Initialize storage before services ac ## Usage -Inject the shared `Lock` contract into application services. Acquisition, release, refresh, contention, long-running leases, and remote idempotency are covered in the [Lock guide](/components/lock/). +Inject `LockOperation` into application services to run bounded work using the selected backend. The [Lock guide](/components/lock/#usage) covers complete usage, contention, renewal, and failure handling. Database-backed locks have these operational constraints: @@ -103,6 +120,26 @@ On multisite, acquire, refresh, and release a database lock without changing the A database lock prevents cooperating application processes from overlapping while its lease is valid. It cannot guarantee exactly-once behavior in a remote payment or messaging API. Use the remote system's idempotency key as well. ::: +## Customize storage + +Override the unprefixed table name through `lock.database.table` when an existing deployment needs a specific name. In root `config.php`, add the override only when configured: + +```php title="config.php" +$config = [ + 'foundation' => [ + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], +]; + +if ( isset( $_ENV['FOUNDATION_DATABASE_LOCKS_TABLE'] ) ) { + $config['lock']['database']['table'] = $_ENV['FOUNDATION_DATABASE_LOCKS_TABLE']; +} + +return $config; +``` + +Foundation adds the current WordPress site prefix. Physical names must contain only ASCII letters, numbers, and underscores, and fit within 64 bytes including the site prefix. Keep the configured name stable across deployments so every worker coordinates through the same table. + ## Testing Use `InMemoryLock` in unit and feature tests that only verify application behavior against the shared contract. Use `wpunit` when testing `DatabaseLock` itself or behavior that depends on real lock-table queries and database time. diff --git a/src/LockDatabase/.gitattributes b/src/LockDatabase/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/LockDatabase/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/LockDatabase/.gitignore b/src/LockDatabase/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/LockDatabase/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Database/Lock/DatabaseLock.php b/src/LockDatabase/DatabaseLock.php similarity index 97% rename from src/Database/Lock/DatabaseLock.php rename to src/LockDatabase/DatabaseLock.php index 7b2fa8f..a743638 100644 --- a/src/Database/Lock/DatabaseLock.php +++ b/src/LockDatabase/DatabaseLock.php @@ -1,23 +1,24 @@ tablesExist([$this->table->name()])) { return; } + $table = Table::editor()->setUnquotedName($this->table->name())->setOptions(['engine' => 'InnoDB']) ->setColumns( Column::editor()->setUnquotedName('name')->setTypeName(Types::BINARY)->setLength(191)->setNotNull(true)->create(), @@ -60,7 +62,7 @@ public function initialize(): void { try { $manager->createTable($table); - } catch (\Doctrine\DBAL\Exception\TableExistsException) { + } catch (TableExistsException) { // Another activation created the same configured storage concurrently. } } diff --git a/src/LockDatabase/LockDatabaseProvider.php b/src/LockDatabase/LockDatabaseProvider.php new file mode 100644 index 0000000..ed89fbe --- /dev/null +++ b/src/LockDatabase/LockDatabaseProvider.php @@ -0,0 +1,30 @@ +foundationPrefix()); + $this->container->when(LockTable::class) + ->needs('$unprefixedTableName') + ->give($this->config->get('lock.database.table', $prefix . '_foundation_locks')); + + $this->container->singleton(LockTable::class); + $this->container->singleton(DatabaseLock::class); + } +} diff --git a/src/LockDatabase/README.md b/src/LockDatabase/README.md new file mode 100644 index 0000000..7565e2f --- /dev/null +++ b/src/LockDatabase/README.md @@ -0,0 +1,20 @@ +# Foundation Lock Database + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Database-backed expiring locks for Foundation. Coordinate requests and workers +through the same primary WordPress database using the shared Foundation Lock +contract and managed lock operations. + +## Installation + +```shell +composer require stellarwp/foundation-lock-database +``` + +## Documentation + +See the [Database Lock guide](https://foundation.nexcess.dev/components/lock/database/) +for provider registration, application backend selection, storage initialization, +configuration, and testing. Start with the [Lock guide](https://foundation.nexcess.dev/components/lock/) to compare backends and use `LockOperation`. diff --git a/src/Database/Table/Tables/LockTable.php b/src/LockDatabase/Tables/LockTable.php similarity index 93% rename from src/Database/Table/Tables/LockTable.php rename to src/LockDatabase/Tables/LockTable.php index 8b58b19..8ab2dc6 100644 --- a/src/Database/Table/Tables/LockTable.php +++ b/src/LockDatabase/Tables/LockTable.php @@ -1,6 +1,6 @@ =8.3", + "stellarwp/foundation-container": "^2.0", + "stellarwp/foundation-database": "^2.0", + "stellarwp/foundation-lock": "^2.0" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\LockDatabase\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + } +} diff --git a/tests/Unit/LockDatabase/LockDatabaseProviderTest.php b/tests/Unit/LockDatabase/LockDatabaseProviderTest.php new file mode 100644 index 0000000..10c5544 --- /dev/null +++ b/tests/Unit/LockDatabase/LockDatabaseProviderTest.php @@ -0,0 +1,67 @@ +, string}> + */ + public static function tableNames(): iterable { + yield 'default' => [[], 'wp_nx_foundation_locks']; + + yield 'application prefix' => [['foundation' => ['prefix' => 'your-plugin']], 'wp_your_plugin_foundation_locks']; + + yield 'explicit override' => [[ + 'foundation' => ['prefix' => 'your-plugin'], + 'lock' => ['database' => ['table' => 'Custom_Locks']], + ], 'wp_Custom_Locks']; + } + + /** + * @dataProvider tableNames + * + * @param array $configuration + */ + #[DataProvider('tableNames')] + public function test_it_uses_the_shared_database_services_and_configured_storage(array $configuration, string $name): void { + $db = $this->createMock(Connection::class); + $db->expects(self::never())->method('executeStatement'); + $scope = $this->createMock(DatabaseScope::class); + $scope->method('resolveTableName')->willReturnCallback(static fn (string $suffix): string => 'wp_' . $suffix); + $this->container->singleton(Configuration::class, new ArrayConfiguration($configuration)); + $this->container->singleton(Connection::class, $db); + $this->container->singleton(TableNameResolver::class, new DatabaseTableNameResolver($scope)); + $this->container->register(LockDatabaseProvider::class); + + self::assertSame($name, $this->container->get(LockTable::class)->name()); + $lock = $this->container->get(DatabaseLock::class); + self::assertSame($lock, $this->container->get(DatabaseLock::class)); + self::assertSame($db, $this->container->get(Connection::class)); + self::assertFalse($this->container->has(Lock::class)); + } + + public function test_registration_preserves_an_existing_application_lock_without_resolving_database_services(): void { + $existing = new InMemoryLock(new SystemClock()); + $this->container->singleton(Lock::class, $existing); + $this->container->register(LockDatabaseProvider::class); + + self::assertSame($existing, $this->container->get(Lock::class)); + self::assertNotNull($existing->acquire('resource', 60)); + } +} diff --git a/tests/integration/Database/DatabaseLockTest.php b/tests/integration/LockDatabase/DatabaseLockTest.php similarity index 69% rename from tests/integration/Database/DatabaseLockTest.php rename to tests/integration/LockDatabase/DatabaseLockTest.php index 4567144..3f82906 100644 --- a/tests/integration/Database/DatabaseLockTest.php +++ b/tests/integration/LockDatabase/DatabaseLockTest.php @@ -1,22 +1,31 @@ ['locks_table' => $this->suffix . '_locks', 'migrations_table' => $this->suffix . '_history']]; + return [ + 'database' => ['migrations_table' => $this->suffix . '_history'], + 'lock' => ['database' => ['table' => $this->suffix . '_locks']], + ]; } protected function setUp(): void { parent::setUp(); + $this->container->register(LockDatabaseProvider::class); $this->privateTable($this->suffix . '_locks'); $this->privateTable($this->suffix . '_history'); } @@ -28,6 +37,23 @@ public function test_migrations_do_not_create_application_lock_storage(): void { $this->container->get(DatabaseLock::class)->acquire('test', 60); } + public function test_application_selection_supports_managed_lock_operations(): void { + $this->container->singleton(Lock::class, static fn (C $c): DatabaseLock => $c->get(DatabaseLock::class)); + $lock = $this->container->get(DatabaseLock::class); + $lock->initialize(); + + $result = $this->container->get(LockOperation::class)->run('import', 60, static function (LockLease $lease) use ($lock): string { + self::assertTrue($lock->isAcquired('import')); + self::assertNull($lock->acquire('import', 60)); + $lease->renew(); + + return 'imported'; + }); + + self::assertSame('imported', $result); + self::assertFalse($lock->isAcquired('import')); + } + public function test_a_missing_wordpress_connection_is_reported_through_the_lock_contract(): void { $lock = $this->container->get(DatabaseLock::class); $lock->initialize(); From fabc812931466ac11ea569b2cd3cd8d0d3c44a82 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 23 Sep 2026 20:47:52 -0600 Subject: [PATCH 14/53] Extract migrations into a dedicated Foundation package --- AGENTS.md | 9 +- README.md | 14 ++- composer.json | 4 +- .../Factories/MigrationFileFactory.php | 20 ++-- .../Make/Database/MigrationCommand.php | 10 +- .../Make/Database/ProviderCommand.php | 2 + .../Commands/Make/Database/TableCommand.php | 27 ++++-- src/Cli/Generation/Php/PhpSourceEditor.php | 6 +- src/Cli/README.md | 6 ++ src/Cli/composer.json | 3 +- src/Database/Connection/AdvisoryLock.php | 34 +++---- src/Database/Connection/WordPressSession.php | 9 +- src/Database/Contracts/AdvisorySession.php | 46 +++++++++ src/Database/DatabaseProvider.php | 67 +------------ src/Database/DatabaseStubPath.php | 21 ---- .../Exceptions/AdvisoryLockContended.php | 10 ++ .../Exceptions/AdvisoryLockInterrupted.php | 10 ++ .../Exceptions/InvalidMigrationId.php | 12 --- .../Migration/Exceptions/LedgerFailure.php | 12 --- .../Exceptions/MigrationAlreadyRunning.php | 12 --- .../Exceptions/MigrationInterrupted.php | 12 --- src/Database/README.md | 7 +- src/Database/composer.json | 3 +- src/Docs/astro.config.mjs | 2 +- .../src/content/docs/components/database.mdx | 51 ++++------ .../content/docs/components/lock/database.mdx | 4 + .../components/{database => }/migrations.mdx | 97 +++++++++++++------ src/Docs/src/content/docs/index.mdx | 2 +- .../content/docs/start/install-foundation.md | 30 ++++-- .../content/docs/start/scope-foundation.md | 6 +- .../content/docs/start/what-is-foundation.md | 4 +- .../content/docs/tooling/foundation-cli.mdx | 18 ++-- src/Migrations/.gitattributes | 7 ++ src/Migrations/.gitignore | 2 + src/{Database => Migrations}/Cli/Migrate.php | 8 +- .../Contracts/DescribesMigration.php | 2 +- .../Contracts/MigratesData.php | 7 +- .../Contracts/Migration.php | 6 +- src/Migrations/DataMigrationContext.php | 35 +++++++ .../Exceptions/IncompatibleSchema.php | 6 +- .../Exceptions/InvalidMigrationId.php | 10 ++ .../Exceptions/IrreversibleMigration.php | 6 +- src/Migrations/Exceptions/LedgerFailure.php | 10 ++ .../Exceptions/MigrationAlreadyRunning.php | 10 ++ .../Exceptions/MigrationException.php | 12 +++ .../Exceptions/MigrationInterrupted.php | 10 ++ .../Migration => Migrations}/History.php | 4 +- .../Migration => Migrations}/Migration.php | 6 +- .../MigrationCollection.php | 6 +- .../MigrationDiscovery.php | 8 +- src/Migrations/MigrationStubPath.php | 30 ++++++ src/Migrations/MigrationsProvider.php | 74 ++++++++++++++ .../Migration => Migrations}/Migrator.php | 55 ++++++----- src/Migrations/README.md | 21 ++++ .../Schema/Blueprint.php | 2 +- .../Schema/ColumnDefinition.php | 2 +- .../Schema/SchemaPlanner.php | 12 +-- .../Schema/SchemaState.php | 2 +- .../Schema/TableBlueprint.php | 2 +- .../Schema/ValueObjects/SchemaPlan.php | 4 +- .../Tables/MigrationTable.php | 2 +- .../ValueObjects/Id.php | 4 +- .../ValueObjects/MigrationDirectory.php | 4 +- .../ValueObjects/MigrationRegistration.php | 4 +- .../ValueObjects/MigrationStatus.php | 2 +- .../ValueObjects/Step.php | 2 +- src/Migrations/composer.json | 26 +++++ .../stubs/alter-table-migration.stub | 0 .../stubs/create-table-migration.stub | 0 .../stubs/migration.stub | 0 .../Migrations/MigrationsProviderTest.php | 23 +++++ .../20260923000003_publish_reports.php | 17 ++-- .../Declarative/AddEntryArchivedFlag.php | 6 +- .../Database/Declarative/AddEntryNote.php | 6 +- .../Declarative/BackfillEntryStatus.php | 13 ++- .../Database/Declarative/CreateEntries.php | 4 +- .../Database/Declarative/CreateEntryTags.php | 4 +- .../Database/Declarative/DropEntryTags.php | 6 +- .../Database/Declarative/IndexEntryNote.php | 4 +- .../Declarative/RecreateEntryTags.php | 4 +- .../Declarative/ReduceCreatedAtPrecision.php | 4 +- .../Declarative/ReplaceNoteLookup.php | 4 +- .../Declarative/TrackEntryUpdates.php | 4 +- .../Database/Declarative/WidenEntryName.php | 4 +- .../20260923000001_create_reports_table.php | 4 +- .../20260923000002_add_published_at.php | 4 +- .../register-wpcli-migrate-command.php | 14 +-- .../Commands/Make/MigrationGenerationTest.php | 29 ++++-- .../Cli/Generation/PhpSourceEditorTest.php | 28 +++--- .../ColumnDefinitionTest.php | 4 +- .../MigrationCollectionTest.php | 12 +-- .../MigrationDiscoveryTest.php | 10 +- .../TableBlueprintTest.php | 4 +- ...ow-callback-without-registration-list.stub | 4 +- .../closure-without-array-return.stub | 4 +- .../closure-without-container-parameter.stub | 4 +- .../formatted-database-provider.stub | 4 +- .../import-shadowed-registrations.stub | 4 +- .../imported-prefix-registrations.stub | 4 +- ...-class-reference-without-registration.stub | 4 +- .../multiple-migration-contributions.stub | 6 +- .../namespace-relative-registrations.stub | 4 +- .../nested-migration-registration.stub | 4 +- .../not-container-merge-array-var.stub | 4 +- .../space-indented-registration-list.stub | 4 +- .../strauss-prefixed-database-provider.stub | 2 +- .../wrong-constant-merge-array-var.stub | 4 +- .../LockDatabase/DatabaseLockTest.php | 8 +- .../AdvisoryMigrationTest.php | 43 ++++---- .../DeclarativeMigratorEdgeCaseTest.php | 21 ++-- .../DeclarativeMigratorTest.php | 27 +++--- .../GeneratedMigrationTest.php | 12 ++- .../MigrationDiscoveryTest.php | 23 +++-- 113 files changed, 850 insertions(+), 525 deletions(-) create mode 100644 src/Database/Contracts/AdvisorySession.php create mode 100644 src/Database/Exceptions/AdvisoryLockContended.php create mode 100644 src/Database/Exceptions/AdvisoryLockInterrupted.php delete mode 100644 src/Database/Migration/Exceptions/InvalidMigrationId.php delete mode 100644 src/Database/Migration/Exceptions/LedgerFailure.php delete mode 100644 src/Database/Migration/Exceptions/MigrationAlreadyRunning.php delete mode 100644 src/Database/Migration/Exceptions/MigrationInterrupted.php rename src/Docs/src/content/docs/components/{database => }/migrations.mdx (74%) create mode 100644 src/Migrations/.gitattributes create mode 100644 src/Migrations/.gitignore rename src/{Database => Migrations}/Cli/Migrate.php (95%) rename src/{Database/Migration => Migrations}/Contracts/DescribesMigration.php (79%) rename src/{Database/Migration => Migrations}/Contracts/MigratesData.php (60%) rename src/{Database/Migration => Migrations}/Contracts/Migration.php (72%) create mode 100644 src/Migrations/DataMigrationContext.php rename src/{Database/Migration => Migrations}/Exceptions/IncompatibleSchema.php (78%) create mode 100644 src/Migrations/Exceptions/InvalidMigrationId.php rename src/{Database/Migration => Migrations}/Exceptions/IrreversibleMigration.php (57%) create mode 100644 src/Migrations/Exceptions/LedgerFailure.php create mode 100644 src/Migrations/Exceptions/MigrationAlreadyRunning.php create mode 100644 src/Migrations/Exceptions/MigrationException.php create mode 100644 src/Migrations/Exceptions/MigrationInterrupted.php rename src/{Database/Migration => Migrations}/History.php (95%) rename src/{Database/Migration => Migrations}/Migration.php (74%) rename src/{Database/Migration => Migrations}/MigrationCollection.php (91%) rename src/{Database/Migration => Migrations}/MigrationDiscovery.php (86%) create mode 100644 src/Migrations/MigrationStubPath.php create mode 100644 src/Migrations/MigrationsProvider.php rename src/{Database/Migration => Migrations}/Migrator.php (84%) create mode 100644 src/Migrations/README.md rename src/{Database/Migration => Migrations}/Schema/Blueprint.php (98%) rename src/{Database/Migration => Migrations}/Schema/ColumnDefinition.php (99%) rename src/{Database/Migration => Migrations}/Schema/SchemaPlanner.php (97%) rename src/{Database/Migration => Migrations}/Schema/SchemaState.php (92%) rename src/{Database/Migration => Migrations}/Schema/TableBlueprint.php (99%) rename src/{Database/Migration => Migrations}/Schema/ValueObjects/SchemaPlan.php (76%) rename src/{Database/Table => Migrations}/Tables/MigrationTable.php (93%) rename src/{Database/Migration => Migrations}/ValueObjects/Id.php (86%) rename src/{Database/Migration => Migrations}/ValueObjects/MigrationDirectory.php (91%) rename src/{Database/Migration => Migrations}/ValueObjects/MigrationRegistration.php (72%) rename src/{Database/Migration => Migrations}/ValueObjects/MigrationStatus.php (89%) rename src/{Database/Migration => Migrations}/ValueObjects/Step.php (87%) create mode 100644 src/Migrations/composer.json rename src/{Database => Migrations}/stubs/alter-table-migration.stub (100%) rename src/{Database => Migrations}/stubs/create-table-migration.stub (100%) rename src/{Database => Migrations}/stubs/migration.stub (100%) create mode 100644 tests/Feature/Migrations/MigrationsProviderTest.php rename tests/Unit/{Database/Migration => Migrations}/ColumnDefinitionTest.php (95%) rename tests/Unit/{Database/Migration => Migrations}/MigrationCollectionTest.php (80%) rename tests/Unit/{Database/Migration => Migrations}/MigrationDiscoveryTest.php (92%) rename tests/Unit/{Database/Migration => Migrations}/TableBlueprintTest.php (88%) rename tests/integration/{Database => Migrations}/AdvisoryMigrationTest.php (90%) rename tests/integration/{Database => Migrations}/DeclarativeMigratorEdgeCaseTest.php (94%) rename tests/integration/{Database => Migrations}/DeclarativeMigratorTest.php (94%) rename tests/integration/{Database => Migrations}/GeneratedMigrationTest.php (85%) rename tests/integration/{Database => Migrations}/MigrationDiscoveryTest.php (82%) diff --git a/AGENTS.md b/AGENTS.md index c22435b..e6b32c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ Split packages: - `stellarwp/foundation-lock-database` - `stellarwp/foundation-lock-redis` - `stellarwp/foundation-database` +- `stellarwp/foundation-migrations` - `stellarwp/foundation-identifier` - `stellarwp/foundation-pipeline` - `stellarwp/foundation-shutdown` @@ -52,7 +53,7 @@ When an API invokes application callbacks, account for callbacks catching except Design contracts as the smallest coherent capabilities with meaningful substitution boundaries, not the fewest possible methods. Keep backend-specific behavior off general contracts. Use separate contracts for optional capabilities; where a provider supplies an implementation, bind its supported contracts coherently so application replacements work. Keep conveniences on concrete classes unless they are reusable capabilities, and keep internal helpers off the supported API. -Within a supported release line, do not add required methods to consumer-implemented interfaces or abstract classes, or infrastructure constructor requirements that consuming subclasses must mirror. Add optional behavior through separate capabilities or internally owned construction/lifecycle mechanisms. Generated application classes should declare application concerns while Foundation owns its infrastructure. +Within a supported release line, do not add required methods to consumer-implemented interfaces or abstract classes, or infrastructure constructor requirements that consuming subclasses must mirror. Add optional behavior through separate capabilities or internally owned construction/lifecycle mechanisms. Generated application classes should declare application concerns while Foundation owns its infrastructure. Additions to a supported base class can collide with consumer methods even when they are concrete; review that compatibility risk. Keep Table's current seven final methods unchanged throughout 2.x and offer additional conveniences through separate collaborators. ## Code Organization and Construction @@ -94,7 +95,7 @@ Migration generators must not offer a force-overwrite option. Existing migration Database migration generators distinguish table ownership from alteration through explicit options. Only `make:database:migration --create=` generates the complete initial schema and a rollback that drops the table. `--table=` selects an alteration migration: declare only the additions, changes, and removals owned by that migration, and default `down()` to `IrreversibleMigration` until the developer supplies a safe inverse. These options are mutually exclusive and take stable database names, not PHP classes. Never infer destructive behavior from a migration name. `make:database:table --migration` reuses the create-migration factory and writes the same unprefixed name into both generated files. -Generated migration files return anonymous subclasses of `Database\Migration\Migration`. Use `_.php` filenames, with 14-digit timestamps and lowercase letters, numbers, or underscores in the description. The filename basename without `.php` is the permanent ledger identity. Configure discovery and generation together through `database.migrations.path`, defaulting to `db/migrations` relative to the application's `foundation.root`. Discover recursively when migration services are resolved, using a typed file-return boundary. Do not require Composer mappings, container resolution, or per-migration provider entries. Explicit `DatabaseProvider::MIGRATIONS` contributions use `Migration\ValueObjects\MigrationRegistration` to associate an ID with a declaration; `MigrationCollection` owns identity validation and global ordering. Keep historical table names literal and independent of application table classes. Foundation supplies the connection and table-name resolver to optional data callbacks. +Generated migration files return anonymous subclasses of `Migrations\Migration`. Use `_.php` filenames, with 14-digit timestamps and lowercase letters, numbers, or underscores in the description. The filename basename without `.php` is the permanent ledger identity. Configure discovery and generation together through `migrations.path`, defaulting to `db/migrations` relative to the application's `foundation.root`. Discover recursively when migration services are resolved, using a typed file-return boundary. Do not require Composer mappings, container resolution, or per-migration provider entries. Explicit `MigrationsProvider::MIGRATIONS` contributions use `Migrations\ValueObjects\MigrationRegistration` to associate an ID with a declaration; `MigrationCollection` owns identity validation and global ordering. Keep historical table names literal and independent of application table classes. Foundation supplies `DataMigrationContext` to optional data callbacks, exposing the shared connection and table-name resolver plus quoted historical table names. ## CLI Tooling Boundary @@ -160,7 +161,7 @@ Register infrastructure providers and top-level feature providers from the appli ## Database -`stellarwp/foundation-database` provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress migration and transaction behavior. Developers use Doctrine directly for database expressions and advanced operations. Doctrine owns query construction, parameter binding, execution, result handling, schema inspection, and platform SQL generation. Foundation owns provider defaults, WordPress connection integration, resource naming, and operational guarantees. Foundation 2.0 targets Doctrine DBAL `^4.4` on PHP 8.3. DBAL 3 support is deferred until the Rector downgrade feature is implemented; it is not a Foundation 2.0 release requirement. Keep dependency-major differences confined to internal connection and schema boundaries, and validate dependency behavior separately from Rector syntax conversion before widening constraints. Future DBAL major lines require a deliberate compatibility decision. +`stellarwp/foundation-database` provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress transaction behavior. `stellarwp/foundation-migrations` owns migration discovery, schema declarations and planning, history, recovery, and migration commands. Developers use Doctrine directly for database expressions and advanced operations. Doctrine owns query construction, parameter binding, execution, result handling, schema inspection, and platform SQL generation. Foundation owns provider defaults, WordPress connection integration, resource naming, and operational guarantees. Foundation 2.0 targets Doctrine DBAL `^4.4` on PHP 8.3. DBAL 3 support is deferred until the Rector downgrade feature is implemented; it is not a Foundation 2.0 release requirement. Keep dependency-major differences confined to internal connection and schema boundaries, and validate dependency behavior separately from Rector syntax conversion before widening constraints. Future DBAL major lines require a deliberate compatibility decision. Use Doctrine's native `Connection`, query builder, results, exceptions, and schema objects where they supply the required behavior. Retire Foundation query builders, execution facades, SQL renderers, and gateway/capability contracts that merely duplicate those APIs as their callers are converted. Keep contracts for meaningful Foundation-owned policies and supported variations. Exposed Doctrine types and behavior are intentional compatibility commitments; do not imply that replacing Doctrine will be transparent to consumers. Preserve Foundation-specific exceptions for scope, migration, and managed-transaction failures. @@ -176,6 +177,8 @@ Migration IDs determine forward execution order and must remain stable across PH The same container and database services may be reused after `switch_to_blog()` between complete operations; already-built queries retain the physical names resolved when built. Managed transactions and migration operations capture their starting scope and reject site or connection changes at their operation boundaries. Migrations hold a MySQL/MariaDB named lock on their captured session, check scope and ownership before execution and ledger writes, and release only on the original session. A failed release discards that session. Migration execution never creates the optional application database-lock table. Applications must not issue DDL, manual transaction control, direct WordPress queries, or `switch_to_blog()` inside a managed transaction. Migrations must never switch sites, even temporarily. Keep application migration declarations independent of site scope so another supported scope does not require migration rewrites. +Migrations depend on Database through its shared DBAL connection, naming contracts, and `Database\Contracts\AdvisorySession`. Database must not depend on the Migrations package or its exceptions. Keep advisory ownership checks and terminal failure tracking in the shared Database session; Migrations translates advisory contention and interruption into its own exceptions. Database owns the DBAL version constraint; validate DBAL changes against both packages. Raise internal dependency floors when using capabilities added in a later Foundation release, and verify supported split-package combinations rather than relying solely on the aggregate autoloader. + ## Split Packages Split packages live in `src//` and are split to read-only repositories named `stellarwp/foundation-`. diff --git a/README.md b/README.md index 6e8de8b..bb0deb4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Foundation is a StellarWP Composer monorepo of shared PHP infrastructure for Nexcess libraries and WordPress plugins. Its packages are publicly available, while Nexcess application needs primarily drive changes and the roadmap. > [!NOTE] -> This monorepo splits each package out into their own sub-repository, if you only need a specific component you can install only that specific one. +> Projects should generally install the split packages they need. The monorepo publishes an aggregate package, `stellarwp/foundation`, that also includes developer tooling and is unsuitable for production plugin ZIPs that must exclude Foundation CLI. See the [Foundation documentation](https://foundation.nexcess.dev/) for installation, application architecture, component configuration, and developer tooling. @@ -18,7 +18,8 @@ See the [Foundation documentation](https://foundation.nexcess.dev/) for installa | [stellarwp/foundation-lock](https://github.com/stellarwp/foundation-lock) | Code needs the portable lock contract or process-local test implementation | Runtime | | [stellarwp/foundation-lock-database](https://github.com/stellarwp/foundation-lock-database) | Requests and workers coordinate through the primary WordPress database | Runtime | | [stellarwp/foundation-lock-redis](https://github.com/stellarwp/foundation-lock-redis) | Multiple processes or servers coordinate through dedicated Redis | Runtime | -| [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) | A WordPress application needs queries, managed transactions, or migrations | Runtime | +| [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) | A WordPress application needs queries or managed transactions | Runtime | +| [stellarwp/foundation-migrations](https://github.com/stellarwp/foundation-migrations) | A WordPress application needs schema history, upgrades, and rollback | Runtime | | [stellarwp/foundation-identifier](https://github.com/stellarwp/foundation-identifier) | Services need injectable ULID generation and validation | Runtime | | [stellarwp/foundation-view](https://github.com/stellarwp/foundation-view) | Services need scoped PHP template rendering without global state | Runtime | | [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) | A shipped WordPress plugin exposes WP-CLI commands | Runtime | @@ -27,13 +28,16 @@ See the [Foundation documentation](https://foundation.nexcess.dev/) for installa ## Installation -Install the aggregate package when you want the convenient all-in-one Foundation package: +Install the split runtime packages your project uses, and add the CLI separately for development: ```shell -composer require stellarwp/foundation +composer require stellarwp/foundation-container +composer require --dev stellarwp/foundation-cli ``` -The aggregate package includes all Foundation components, including the CLI package and `vendor/bin/foundation`. For lean production WordPress plugin archives, require only the split packages your plugin needs and install `stellarwp/foundation-cli` as a development dependency. +Keep every runtime package your application uses in `require`, even if installing the CLI has already made it available locally. Packages installed only through the development CLI are excluded by `composer install --no-dev`, which can leave generated code with missing classes in production. See the [installation guide](https://foundation.nexcess.dev/start/install-foundation/) for examples and production packaging. + +The aggregate `stellarwp/foundation` package physically includes CLI code and its binary. Installing it as a production dependency keeps that tooling even with `--no-dev`. ## ‍💻 Developer / Contributing Documentation diff --git a/composer.json b/composer.json index b455e07..c6fb5b6 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,8 @@ "stellarwp/foundation-pipeline": "self.version", "stellarwp/foundation-shutdown": "self.version", "stellarwp/foundation-view": "self.version", - "stellarwp/foundation-wpcli": "self.version" + "stellarwp/foundation-wpcli": "self.version", + "stellarwp/foundation-migrations": "self.version" }, "minimum-stability": "dev", "prefer-stable": true, @@ -65,6 +66,7 @@ "StellarWP\\Foundation\\LockRedis\\": "src/LockRedis/", "StellarWP\\Foundation\\Lock\\": "src/Lock/", "StellarWP\\Foundation\\Log\\": "src/Log/", + "StellarWP\\Foundation\\Migrations\\": "src/Migrations/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", "StellarWP\\Foundation\\Shutdown\\": "src/Shutdown/", "StellarWP\\Foundation\\View\\": "src/View/", diff --git a/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php b/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php index ec9133a..308a0d9 100644 --- a/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php +++ b/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php @@ -14,9 +14,9 @@ use StellarWP\Foundation\Cli\Generation\ValueObjects\ProjectDirectory; use StellarWP\Foundation\Cli\Generation\WordPressClassNameResolver; use StellarWP\Foundation\Container\Contracts\Configuration; -use StellarWP\Foundation\Database\DatabaseStubPath; -use StellarWP\Foundation\Database\Migration\ValueObjects\Id; -use StellarWP\Foundation\Database\Migration\ValueObjects\MigrationDirectory; +use StellarWP\Foundation\Migrations\MigrationStubPath; +use StellarWP\Foundation\Migrations\ValueObjects\Id; +use StellarWP\Foundation\Migrations\ValueObjects\MigrationDirectory; /** * Create anonymous migration files with permanent, timestamped filenames. @@ -43,7 +43,7 @@ public function __construct( * @throws \InvalidArgumentException When the directory or identity is invalid. */ public function generic(string $name): GeneratedFile { - return $this->render($name, 'migration', DatabaseStubPath::migration()); + return $this->render($name, 'migration', MigrationStubPath::migration()); } /** @@ -53,7 +53,7 @@ public function generic(string $name): GeneratedFile { * @throws \InvalidArgumentException When the directory or identity is invalid. */ public function createTable(string $name, string $table): GeneratedFile { - return $this->render($name, 'create-table-migration', DatabaseStubPath::createTableMigration(), $table); + return $this->render($name, 'create-table-migration', MigrationStubPath::createTableMigration(), $table); } /** @@ -63,7 +63,7 @@ public function createTable(string $name, string $table): GeneratedFile { * @throws \InvalidArgumentException When the directory or identity is invalid. */ public function alterTable(string $name, string $table): GeneratedFile { - return $this->render($name, 'alter-table-migration', DatabaseStubPath::alterTableMigration(), $table); + return $this->render($name, 'alter-table-migration', MigrationStubPath::alterTableMigration(), $table); } /** @@ -76,7 +76,7 @@ private function render(string $name, string $stubName, string $defaultStub, ?st $directory = new MigrationDirectory( $this->config->get('foundation.root', $this->projectDirectory->path), - $this->config->get('database.migrations.path', MigrationDirectory::DEFAULT_PATH), + $this->config->get('migrations.path', MigrationDirectory::DEFAULT_PATH), ); $parts = explode('/', $name); @@ -99,9 +99,9 @@ private function render(string $name, string $stubName, string $defaultStub, ?st relativePath: $this->projectDirectory->relativePath($path), contents: $this->stubRenderer->render($stub, [ 'table_php' => $this->stubRenderer->phpStringLiteral($table ?? ''), - 'foundation_database_migration' => $prefix . 'StellarWP\\Foundation\\Database\\Migration\\Migration', - 'foundation_database_schema' => $prefix . 'StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint', - 'foundation_database_irreversible_migration' => $prefix . 'StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration', + 'foundation_database_migration' => $prefix . 'StellarWP\\Foundation\\Migrations\\Migration', + 'foundation_database_schema' => $prefix . 'StellarWP\\Foundation\\Migrations\\Schema\\Blueprint', + 'foundation_database_irreversible_migration' => $prefix . 'StellarWP\\Foundation\\Migrations\\Exceptions\\IrreversibleMigration', ]), ); } diff --git a/src/Cli/Commands/Make/Database/MigrationCommand.php b/src/Cli/Commands/Make/Database/MigrationCommand.php index 089110c..38ef5d3 100644 --- a/src/Cli/Commands/Make/Database/MigrationCommand.php +++ b/src/Cli/Commands/Make/Database/MigrationCommand.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Generate an anonymous migration file for Foundation Database. + * Generate an anonymous migration file for Foundation Migrations. * * Use this from a consuming WordPress project when a feature needs a versioned * database change discovered from the configured migration directory. @@ -140,19 +140,19 @@ private function runtimeDependencyWarning(): ?string { } if ($this->hasFoundationRuntimeDependency($requireDev)) { - return 'this migration uses Foundation Database classes, but the Foundation runtime package is only in require-dev. Move stellarwp/foundation-database or stellarwp/foundation to require before shipping this migration.'; + return 'this migration uses Foundation Migrations classes, but the Foundation runtime package is only in require-dev. Move stellarwp/foundation-migrations or stellarwp/foundation to require before shipping this migration.'; } - return 'this migration uses Foundation Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this migration.'; + return 'this migration uses Foundation Migrations classes. Run composer require stellarwp/foundation-migrations, or require stellarwp/foundation, before shipping this migration.'; } /** - * Determine whether production dependencies include the Foundation database runtime. + * Determine whether production dependencies include the Foundation migrations runtime. * * @param array $dependencies */ private function hasFoundationRuntimeDependency(array $dependencies): bool { - return array_key_exists('stellarwp/foundation-database', $dependencies) + return array_key_exists('stellarwp/foundation-migrations', $dependencies) || array_key_exists('stellarwp/foundation', $dependencies); } } diff --git a/src/Cli/Commands/Make/Database/ProviderCommand.php b/src/Cli/Commands/Make/Database/ProviderCommand.php index 3a23597..6e0122f 100644 --- a/src/Cli/Commands/Make/Database/ProviderCommand.php +++ b/src/Cli/Commands/Make/Database/ProviderCommand.php @@ -126,6 +126,8 @@ private function runtimeDependencyWarning(): ?string { */ private function hasFoundationRuntimeDependency(array $dependencies): bool { return array_key_exists('stellarwp/foundation-database', $dependencies) + || array_key_exists('stellarwp/foundation-migrations', $dependencies) + || array_key_exists('stellarwp/foundation-lock-database', $dependencies) || array_key_exists('stellarwp/foundation', $dependencies); } } diff --git a/src/Cli/Commands/Make/Database/TableCommand.php b/src/Cli/Commands/Make/Database/TableCommand.php index da7a18f..6f9ccef 100644 --- a/src/Cli/Commands/Make/Database/TableCommand.php +++ b/src/Cli/Commands/Make/Database/TableCommand.php @@ -110,7 +110,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('Updated: %s', $this->projectDirectory->relativePath($providerPath))); } - $runtimeDependencyWarning = $this->runtimeDependencyWarning(); + $runtimeDependencyWarning = $this->runtimeDependencyWarning($migration !== null); if ($runtimeDependencyWarning !== null) { $output->writeln(''); @@ -305,7 +305,8 @@ private function providerExists(InputInterface $input): bool { /** * Explain when generated runtime code lacks a production Foundation dependency. */ - private function runtimeDependencyWarning(): ?string { + private function runtimeDependencyWarning(bool $withMigration): ?string { + $package = $withMigration ? 'stellarwp/foundation-migrations' : 'stellarwp/foundation-database'; $composerPath = $this->projectDirectory->absolutePath('composer.json'); if (! is_readable($composerPath)) { @@ -321,15 +322,15 @@ private function runtimeDependencyWarning(): ?string { $require = is_array($composer['require'] ?? null) ? $composer['require'] : []; $requireDev = is_array($composer['require-dev'] ?? null) ? $composer['require-dev'] : []; - if ($this->hasFoundationRuntimeDependency($require)) { + if ($this->hasFoundationRuntimeDependency($require, $package)) { return null; } - if ($this->hasFoundationRuntimeDependency($requireDev)) { - return 'this table uses Foundation Database classes, but the Foundation runtime package is only in require-dev. Move stellarwp/foundation-database or stellarwp/foundation to require before shipping this table.'; + if ($this->hasFoundationRuntimeDependency($requireDev, $package)) { + return "the generated files require {$package}, but it is only in require-dev. Move {$package} or stellarwp/foundation to require before shipping them."; } - return 'this table uses Foundation Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this table.'; + return "the generated files require {$package}. Run composer require {$package}, or require stellarwp/foundation, before shipping them."; } /** @@ -337,8 +338,16 @@ private function runtimeDependencyWarning(): ?string { * * @param array $dependencies */ - private function hasFoundationRuntimeDependency(array $dependencies): bool { - return array_key_exists('stellarwp/foundation-database', $dependencies) - || array_key_exists('stellarwp/foundation', $dependencies); + private function hasFoundationRuntimeDependency(array $dependencies, string $package): bool { + if (array_key_exists($package, $dependencies) || array_key_exists('stellarwp/foundation', $dependencies)) { + return true; + } + + if ($package !== 'stellarwp/foundation-database') { + return false; + } + + return array_key_exists('stellarwp/foundation-migrations', $dependencies) + || array_key_exists('stellarwp/foundation-lock-database', $dependencies); } } diff --git a/src/Cli/Generation/Php/PhpSourceEditor.php b/src/Cli/Generation/Php/PhpSourceEditor.php index 21887c8..e6b5774 100644 --- a/src/Cli/Generation/Php/PhpSourceEditor.php +++ b/src/Cli/Generation/Php/PhpSourceEditor.php @@ -182,7 +182,7 @@ public function insertBeforeLineComment(string $contents, string $comment, strin * Determine whether a matching `mergeArrayVar()` registration list can be edited safely. * * For example, this locates the array returned from - * `mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c) => [...])`. + * `mergeArrayVar(MigrationsProvider::MIGRATIONS, static fn (C $c) => [...])`. * Inline arrays are rejected because inserting a formatted line would be unsafe. */ public function canInsertIntoMergeArrayVar(string $contents, string $class, string $constant, ?string $beforeComment = null): bool { @@ -231,7 +231,7 @@ public function hasContainerSingleton(string $contents, string $fullyQualifiedCl * Determine whether any matching `mergeArrayVar()` contribution resolves a class from its container. * * For example, this recognizes `$c->get(CreateReportsTable::class)` inside - * every contribution to `DatabaseProvider::MIGRATIONS`. + * every contribution to `MigrationsProvider::MIGRATIONS`. */ public function mergeArrayVarContainsClass(string $contents, string $class, string $constant, string $fullyQualifiedClass): bool { foreach ($this->mergeArrayVarTargets($contents, $class, $constant) as $target) { @@ -484,7 +484,7 @@ private function mergeArrayVarTarget(string $contents, string $class, string $co * Collect every recognized `mergeArrayVar()` contribution for a class constant. * * For example, separate feature providers may each contribute to - * `DatabaseProvider::MIGRATIONS`; all contributions must be searched for duplicates. + * `MigrationsProvider::MIGRATIONS`; all contributions must be searched for duplicates. * * @return list */ diff --git a/src/Cli/README.md b/src/Cli/README.md index 93b166e..241005f 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -13,6 +13,12 @@ Install this package as a development dependency in consuming projects: composer require --dev stellarwp/foundation-cli ``` +Require the runtime packages used by generated code separately in your project's +`require`. CLI's dependencies can make missing runtime requirements go unnoticed +locally; a production `composer install --no-dev` excludes packages needed only by +CLI. See the [installation guide](https://foundation.nexcess.dev/start/install-foundation/#install-developer-tooling-separately) +for dependency setup and keeping CLI out of plugin ZIPs. + ## Documentation See the [Foundation CLI documentation](https://foundation.nexcess.dev/tooling/foundation-cli/) diff --git a/src/Cli/composer.json b/src/Cli/composer.json index ca3c7cd..6c89e58 100644 --- a/src/Cli/composer.json +++ b/src/Cli/composer.json @@ -13,7 +13,8 @@ "stellarwp/foundation-container": "^2.0", "stellarwp/foundation-database": "^2.0", "stellarwp/foundation-wpcli": "^2.0", - "symfony/console": ">=5.4" + "symfony/console": ">=5.4", + "stellarwp/foundation-migrations": "^2.0" }, "autoload": { "psr-4": { diff --git a/src/Database/Connection/AdvisoryLock.php b/src/Database/Connection/AdvisoryLock.php index 1badd37..dd0041c 100644 --- a/src/Database/Connection/AdvisoryLock.php +++ b/src/Database/Connection/AdvisoryLock.php @@ -5,12 +5,12 @@ use Doctrine\DBAL\Driver\Mysqli\Connection; use mysqli; use StellarWP\Foundation\Database\Contracts\DatabaseScope; -use StellarWP\Foundation\Database\Migration\Exceptions\MigrationAlreadyRunning; -use StellarWP\Foundation\Database\Migration\Exceptions\MigrationInterrupted; +use StellarWP\Foundation\Database\Exceptions\AdvisoryLockContended; +use StellarWP\Foundation\Database\Exceptions\AdvisoryLockInterrupted; use Throwable; /** - * Hold one MySQL advisory lock across migration transactions and implicit DDL commits. + * Hold one MySQL advisory lock across transactions and implicit DDL commits. * * @internal Owned by one WordPressSession invocation; never reconnects. */ @@ -41,14 +41,14 @@ public function __construct( } /** - * Acquire without waiting, before even initializing migration history. + * Acquire without waiting, before invoking protected work. * - * @throws MigrationAlreadyRunning When another session owns this resource. - * @throws Throwable When ambient transaction state or the database prevents acquisition. + * @throws AdvisoryLockContended When another session owns this resource. + * @throws Throwable When ambient transaction state or the database prevents acquisition. */ public function acquire(): void { if ((int) $this->driver->query('SELECT @@session.autocommit')->fetchOne() !== 1) { - throw new MigrationInterrupted('Migrations require autocommit to be enabled.'); + throw new AdvisoryLockInterrupted('Locked database operations require autocommit to be enabled.'); } // Reject an ambient transaction without implicitly committing it. $this->driver->exec('SET TRANSACTION READ WRITE'); @@ -67,38 +67,38 @@ public function acquire(): void { } if ($result === null || $result === false) { - throw new MigrationInterrupted('The database could not acquire the migration lock.'); + throw new AdvisoryLockInterrupted('The database could not acquire the advisory lock.'); } if ((int) $result !== 1) { - throw new MigrationAlreadyRunning('Another session is running these migrations; retry after it completes.'); + throw new AdvisoryLockContended('Another session owns this advisory lock; retry after it completes.'); } } /** - * Verify ownership before SQL, including after a migration catches an earlier exception. + * Verify ownership before SQL, including after a callback catches an earlier exception. * - * @throws MigrationInterrupted When work failed or the original scope or lock was lost. + * @throws AdvisoryLockInterrupted When work failed or the original scope or lock was lost. */ public function check(mixed $current): void { if ($this->failure !== null) { - throw new MigrationInterrupted('The migration session failed; start a fresh migration run.', 0, $this->failure); + throw new AdvisoryLockInterrupted('The locked database session failed; start a fresh operation.', 0, $this->failure); } try { $this->scope->assertCurrent($this->site); if ($current !== $this->native || $this->native->thread_id !== $this->connectionId || $this->scope->resolveTableName('') !== $this->prefix) { - throw new MigrationInterrupted('The migration connection or table prefix changed.'); + throw new AdvisoryLockInterrupted('The locked connection or table prefix changed.'); } if ((int) $this->driver->query("SELECT IS_USED_LOCK('{$this->name}')")->fetchOne() !== $this->connectionId) { - throw new MigrationInterrupted('The migration session no longer owns its advisory lock.'); + throw new AdvisoryLockInterrupted('The session no longer owns its advisory lock.'); } } catch (Throwable $failure) { $this->fail($failure); - throw new MigrationInterrupted('Migration ownership could not be confirmed.', 0, $failure); + throw new AdvisoryLockInterrupted('Advisory lock ownership could not be confirmed.', 0, $failure); } } @@ -117,11 +117,11 @@ public function fail(Throwable $failure): void { public function release(): void { try { if ($this->native->thread_id !== $this->connectionId) { - throw new MigrationInterrupted('The original migration connection was lost.'); + throw new AdvisoryLockInterrupted('The original locked connection was lost.'); } if ((int) $this->driver->query("SELECT RELEASE_LOCK('{$this->name}')")->fetchOne() !== 1) { - throw new MigrationInterrupted('The database did not confirm migration lock release.'); + throw new AdvisoryLockInterrupted('The database did not confirm advisory lock release.'); } } catch (Throwable $failure) { try { diff --git a/src/Database/Connection/WordPressSession.php b/src/Database/Connection/WordPressSession.php index 4ea70c8..5eba56d 100644 --- a/src/Database/Connection/WordPressSession.php +++ b/src/Database/Connection/WordPressSession.php @@ -5,6 +5,7 @@ use Closure; use Doctrine\DBAL\Driver\Mysqli\Connection; use mysqli; +use StellarWP\Foundation\Database\Contracts\AdvisorySession; use StellarWP\Foundation\Database\Contracts\DatabaseScope; use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Exceptions\TransactionFailed; @@ -16,7 +17,7 @@ * * @internal */ -final class WordPressSession +final class WordPressSession implements AdvisorySession { private ?mysqli $owned = null; private ?Throwable $failure = null; @@ -58,7 +59,7 @@ public function isActive(): bool { } /** - * Own one advisory lock across the complete migration operation, including planning. + * Own one advisory lock across the complete locked operation, including planning. * * @template T * @@ -70,7 +71,7 @@ public function isActive(): bool { */ public function withAdvisoryLock(string $resource, Closure $operation): mixed { if ($this->advisoryLock !== null || $this->isActive()) { - throw new DatabaseException('Start migrations outside an existing transaction or migration run.'); + throw new DatabaseException('Start advisory-locked work outside an existing transaction or locked operation.'); } $lock = new AdvisoryLock($this->native(), $this->scope, $resource); $lock->acquire(); @@ -83,7 +84,7 @@ public function withAdvisoryLock(string $resource, Closure $operation): mixed { try { $lock->release(); } catch (Throwable) { - // Preserve the migration failure when its cleanup also fails. + // Preserve the operation failure when its cleanup also fails. } throw $failure; diff --git a/src/Database/Contracts/AdvisorySession.php b/src/Database/Contracts/AdvisorySession.php new file mode 100644 index 0000000..13faa47 --- /dev/null +++ b/src/Database/Contracts/AdvisorySession.php @@ -0,0 +1,46 @@ +registerConnection(); - $this->registerTables(); - $this->registerMigrations(); - $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [$c->get(Migrate::class)]); - } - - private function registerConnection(): void { $this->container->singleton(wpdb::class, static function (): wpdb { $source = $GLOBALS['wpdb'] ?? null; @@ -60,6 +39,7 @@ private function registerConnection(): void { $this->container->singleton(DatabaseScope::class, SiteScope::class); $this->container->singleton(TableNameResolver::class, Table\TableNameResolver::class); $this->container->singleton(WordPressSession::class); + $this->container->singleton(AdvisorySession::class, static fn (C $c): WordPressSession => $c->get(WordPressSession::class)); $this->container->singleton(Connection::class, static function (C $c): Connection { $config = new Configuration(); $config->setSchemaManagerFactory(new DefaultSchemaManagerFactory()); @@ -71,45 +51,4 @@ private function registerConnection(): void { ], $config); }); } - - private function registerTables(): void { - $prefix = str_replace('-', '_', $this->foundationPrefix()); - $this->container->when(MigrationTable::class) - ->needs('$unprefixedTableName') - ->give($this->config->get('database.migrations_table', $prefix . '_foundation_migrations')); - $this->container->singleton(MigrationTable::class); - } - - private function registerMigrations(): void { - $this->container->when(MigrationDiscovery::class) - ->needs('$root') - ->give(fn (): ?string => $this->config->get('foundation.root')); - - $this->container->when(MigrationDiscovery::class) - ->needs('$path') - ->give(fn (): ?string => $this->config->get('database.migrations.path')); - - $this->container->singleton(MigrationDiscovery::class); - $this->container->mergeArrayVar( - self::MIGRATIONS, - static fn (C $c): array => iterator_to_array($c->get(MigrationDiscovery::class)->migrations(), false) - ); - - $this->container->when(MigrationCollection::class) - ->needs('$migrations') - ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); - - $this->container->when(SchemaPlanner::class) - ->needs('$tableOptions') - ->give(static function (C $c): array { - $source = $c->get(wpdb::class); - - return array_filter(['engine' => 'InnoDB', 'charset' => $source->charset, 'collation' => $source->collate]); - }); - - $this->container->singleton(MigrationCollection::class); - $this->container->singleton(History::class); - $this->container->singleton(SchemaPlanner::class); - $this->container->singleton(Migrator::class); - } } diff --git a/src/Database/DatabaseStubPath.php b/src/Database/DatabaseStubPath.php index 1e08f8a..9a73a6f 100644 --- a/src/Database/DatabaseStubPath.php +++ b/src/Database/DatabaseStubPath.php @@ -14,27 +14,6 @@ public static function provider(): string { return __DIR__ . '/stubs/provider.stub'; } - /** - * Return the default generic migration stub path. - */ - public static function migration(): string { - return __DIR__ . '/stubs/migration.stub'; - } - - /** - * Return the default create-table migration stub path. - */ - public static function createTableMigration(): string { - return __DIR__ . '/stubs/create-table-migration.stub'; - } - - /** - * Return the default table-alteration migration stub path. - */ - public static function alterTableMigration(): string { - return __DIR__ . '/stubs/alter-table-migration.stub'; - } - /** * Return the default database table stub path. */ diff --git a/src/Database/Exceptions/AdvisoryLockContended.php b/src/Database/Exceptions/AdvisoryLockContended.php new file mode 100644 index 0000000..c37c61e --- /dev/null +++ b/src/Database/Exceptions/AdvisoryLockContended.php @@ -0,0 +1,10 @@ + **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). Foundation Database provides a shared Doctrine DBAL connection, application tables, -managed transactions, and declarative migrations for WordPress applications. +and managed transactions for WordPress applications. ## Installation @@ -12,10 +12,9 @@ managed transactions, and declarative migrations for WordPress applications. composer require stellarwp/foundation-database ``` -The package includes `stellarwp/foundation-wpcli` for its migration command. Register -`WPCliProvider` to enable it, or use `Migrator` programmatically. +For schema history and upgrade commands, install [Foundation Migrations](https://foundation.nexcess.dev/components/migrations/). ## Documentation See the [Foundation Database documentation](https://foundation.nexcess.dev/components/database/) -for configuration, migrations, query building, and testing. +for configuration, query building, transactions, and testing. diff --git a/src/Database/composer.json b/src/Database/composer.json index 2a164df..ae7e655 100644 --- a/src/Database/composer.json +++ b/src/Database/composer.json @@ -1,7 +1,7 @@ { "name": "stellarwp/foundation-database", "type": "library", - "description": "Doctrine DBAL tables, managed transactions, and declarative migrations for WordPress.", + "description": "Doctrine DBAL tables and managed transactions for WordPress.", "license": "GPL-2.0-or-later", "config": { "vendor-dir": "vendor", @@ -10,7 +10,6 @@ "require": { "php": ">=8.3", "stellarwp/foundation-container": "^2.0", - "stellarwp/foundation-wpcli": "^2.0", "doctrine/dbal": "^4.4", "ext-mysqli": "*" }, diff --git a/src/Docs/astro.config.mjs b/src/Docs/astro.config.mjs index 0328787..e316744 100644 --- a/src/Docs/astro.config.mjs +++ b/src/Docs/astro.config.mjs @@ -37,7 +37,6 @@ export default defineConfig({ collapsed: false, items: [ { slug: 'components/database', label: 'Overview' }, - { slug: 'components/database/migrations' }, { slug: 'components/database/query-builder' }, ], }, @@ -48,6 +47,7 @@ export default defineConfig({ { slug: 'components/lock/database' }, ], }, + { slug: 'components/migrations' }, { slug: 'components/log' }, { slug: 'components/identifier' }, { slug: 'components/pipeline' }, diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index 60d472a..010dd74 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -1,13 +1,13 @@ --- title: Database -description: A shared Doctrine connection, application tables, managed transactions, and WordPress migrations. +description: A shared Doctrine connection, application tables, and managed WordPress transactions. sidebar: order: 2 --- import { CardGrid, LinkCard } from '@astrojs/starlight/components'; -Foundation Database provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress migration and transaction behavior. Use Doctrine directly for expressions, joins, parameter binding, results, and advanced operations. +Foundation Database provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress transactions. Use Doctrine directly for expressions, joins, parameter binding, results, and advanced operations. ## Get started @@ -17,7 +17,7 @@ Install the runtime package: composer require stellarwp/foundation-database ``` -Foundation requires PHP 8.3, mysqli, and Doctrine DBAL 4.4. The package includes `stellarwp/foundation-wpcli` as a runtime dependency for its migration command. Register `WPCliProvider` to enable that command; applications using only programmatic migrations may omit that provider. Command loading is deferred until WP-CLI initializes. +Foundation requires PHP 8.3, mysqli, and Doctrine DBAL 4.4. Database services borrow the active WordPress connection. Use the primary MySQL or MariaDB database and InnoDB tables for transactional application data. @@ -34,17 +34,19 @@ return [ ]; ``` +:::caution[Standalone plugin resources] +Use a stable, unique `foundation.prefix` for Foundation-managed resources such as migration history and database lock storage. Choose plugin-specific application table names separately, such as `your_plugin_reports`. See [application scoping](/start/scope-foundation/). +::: + Create your container using [ContainerFactory](/start/configure-the-container/), then register providers once, in dependency order: ```php use StellarWP\Foundation\Database\DatabaseProvider; -use StellarWP\Foundation\WPCli\WPCliProvider; -$container->register( WPCliProvider::class ); $container->register( DatabaseProvider::class ); ``` -`DatabaseProvider` supplies one shared `Doctrine\DBAL\Connection`. Tables and repositories resolved from the container participate in that connection's transactions. Registration is lazy: it creates no database tables. Register an application provider afterward when you need custom service bindings. `foundation.root` supplies the project directory for automatic migration discovery. +`DatabaseProvider` supplies one shared `Doctrine\DBAL\Connection`. Tables and repositories resolved from the container participate in that connection's transactions. Registration is lazy: it creates no database tables. Register an application provider afterward when you need custom service bindings. ### Query an application table @@ -79,39 +81,28 @@ $report = $this->reports->query() ->fetchAssociative(); ``` -[Create the table through a migration](/components/database/migrations/), then use the [query and transaction guide](/components/database/query-builder/) for results, writes, and failure handling. - -## Configure resource names - -`foundation.prefix = your-plugin` gives the migration ledger the unprefixed name `your_plugin_foundation_migrations`. WordPress adds its current site prefix. The migration advisory lock is derived from the database and ledger names, so applications with different ledgers migrate independently. - -Override table names when necessary: - -```php title="config.php" -return [ - 'foundation' => [ - 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', - ], - 'database' => [ - 'migrations_table' => $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'] ?? 'your_plugin_migrations', - ], -]; -``` - -These are names without a WordPress prefix. Physical names must contain only ASCII letters, numbers, and underscores, and fit within 64 bytes including the WordPress prefix. Application tables choose their own unique names, such as `your_plugin_reports`. - -Keep the Foundation prefix and configured storage names stable across releases. Changing a ledger name makes recorded migrations appear pending. PHP namespace changes and Strauss scoping do not change migration IDs or these resource names. +[Create the table through a migration](/components/migrations/), then use the [query and transaction guide](/components/database/query-builder/) for results, writes, and failure handling. ## Multisite and customization +`Table` is a supported base class. Inherit its constructor and implement `unprefixedName()`; application query methods may live on the subclass or in a composed repository. During 2.x, Foundation keeps its final method set (`name`, `quotedName`, `query`, `insert`, `insertGetId`, `update`, and `delete`) unchanged. New conveniences must avoid colliding with application subclass methods; they can be offered through separate collaborators. + A shared container may be reused after `switch_to_blog()` between complete operations. Tables resolve the active prefix when called; a query already built retains its original table name. Build a fresh query after switching sites. Run migrations separately for each site, for example `wp --url=https://site.example your-plugin migrate --run`. Transactions and migrations capture their starting scope and reject detected site or connection changes. Never switch sites, even temporarily, during those operations. -Applications can replace `DatabaseScope` and `TableNameResolver` when they own a different naming policy. Register replacements after `DatabaseProvider`, before resolving database services. Replacing the Doctrine connection also requires preserving Foundation's documented transaction and migration guarantees, including terminal failure tracking, acknowledged commits, and the shared WordPress session. A plain DBAL connection alone does not provide those guarantees. +Applications can replace `DatabaseScope` and `TableNameResolver` when they own a different naming policy. Register replacements after `DatabaseProvider`, before resolving database services. Replacing the Doctrine connection also requires preserving Foundation's documented transaction and migration guarantees, including terminal failure tracking and acknowledged commits. When using Foundation Migrations, a replacement must also supply a coherent `Database\Contracts\AdvisorySession` using the same connection: every execution must check ownership and retain terminal failures. Registering an unrelated lock implementation cannot supply those guarantees. A plain DBAL connection alone does not provide those guarantees. - + + +### Database and migration integration + +`Database\Contracts\AdvisorySession` is the supported integration for running work under a database-session advisory lock. `DatabaseProvider` binds it to the same session used by the managed DBAL connection. Foundation Migrations uses it automatically; ordinary table and transaction consumers continue using their existing APIs. + +Implementations acquire once without waiting, retain the original site and connection across DDL and nested transactions, check ownership before execution and completion, and preserve a caught database failure until the locked operation ends. An escaping callback exception takes precedence over release failure. Connection changes must never reconnect or replay protected work. Contention raises `AdvisoryLockContended`; failed or uncertain ownership raises `AdvisoryLockInterrupted`. + +Database owns the supported DBAL constraint. When upgrading it, verify the migration schema APIs and recovery tests as well as queries and transactions. Foundation Migrations requires a compatible Database release; applications should update their Composer lock file and run their migration tests before deployment. diff --git a/src/Docs/src/content/docs/components/lock/database.mdx b/src/Docs/src/content/docs/components/lock/database.mdx index 599edcf..67938c5 100644 --- a/src/Docs/src/content/docs/components/lock/database.mdx +++ b/src/Docs/src/content/docs/components/lock/database.mdx @@ -40,6 +40,10 @@ return [ ]; ``` +:::caution[Standalone plugin resources] +Replace `your-plugin` with your plugin's stable, unique prefix so unrelated plugins cannot share its lock storage. See [application scoping](/start/scope-foundation/). +::: + For this prefix, the lock table defaults to `your_plugin_foundation_locks` before WordPress adds its site prefix. Register the providers below before resolving lock services. ### Select the database implementation diff --git a/src/Docs/src/content/docs/components/database/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx similarity index 74% rename from src/Docs/src/content/docs/components/database/migrations.mdx rename to src/Docs/src/content/docs/components/migrations.mdx index 1f2de04..3902114 100644 --- a/src/Docs/src/content/docs/components/database/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -5,15 +5,16 @@ sidebar: order: 1 --- -Migrations are the history of your application's database schema. Each migration declares one change in `up()` and its inverse in `down()`. Foundation loads migration files, runs pending changes in order under a database lock, and records successful migrations. +`stellarwp/foundation-migrations` manages the history of your application's database schema. Each migration declares one change in `up()` and its inverse in `down()`. Foundation loads migration files, runs pending changes in order under a database lock, and records successful migrations. ## Create and change a table ### Set up migrations -First [configure DatabaseProvider](/components/database/) and install the development CLI: +Install the migrations runtime and development CLI. The runtime includes Foundation Database and the WP-CLI integration: ```shell +composer require stellarwp/foundation-migrations composer require --dev stellarwp/foundation-cli ``` @@ -30,6 +31,24 @@ return [ ]; ``` +:::caution[Standalone plugin resources] +Replace `your-plugin` with your plugin's stable, unique prefix. Sharing the default with another Foundation consumer can mix migration history and lock ownership. See [application scoping](/start/scope-foundation/). +::: + +After [creating the application container](/start/configure-the-container/) with that configuration, register providers in dependency order: + +```php +use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Migrations\MigrationsProvider; +use StellarWP\Foundation\WPCli\WPCliProvider; + +$container->register( WPCliProvider::class ); +$container->register( DatabaseProvider::class ); +$container->register( MigrationsProvider::class ); +``` + +`DatabaseProvider` supplies the shared connection. `MigrationsProvider` configures discovery, history, and migration execution; `WPCliProvider` enables `wp migrate`. Applications that invoke `Migrator` programmatically can omit `WPCliProvider`. Register application providers afterward, before resolving migration services. + The default migration directory is `db/migrations`. Each PHP file returns an anonymous migration object. Migration files need no namespace or Composer autoload mapping; these examples use `Plugin\\` mapped to `src/` for the application table class. Keep migrations in the production archive even though the generator is a development dependency. ### Generate a new table @@ -60,8 +79,8 @@ The generated `up()` already declares an auto-incrementing `id`. Add the columns ```php title="20260923000001_create_reports_table.php" db` to run native Doctrine queries: ```php -use Doctrine\DBAL\Connection; -use StellarWP\Foundation\Database\Contracts\TableNameResolver; -use StellarWP\Foundation\Database\Migration\Contracts\MigratesData; -use StellarWP\Foundation\Database\Migration\Migration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\MigratesData; +use StellarWP\Foundation\Migrations\DataMigrationContext; +use StellarWP\Foundation\Migrations\Migration; +use StellarWP\Foundation\Migrations\Schema\Blueprint; return new class extends Migration implements MigratesData { @@ -260,12 +278,10 @@ return new class extends Migration implements MigratesData { /** * Fill missing statuses without changing existing values. */ - public function migrate( Connection $db, TableNameResolver $names ): void { - $table = $db->getDatabasePlatform()->quoteSingleIdentifier( - $names->tableName( 'your_plugin_reports' ), - ); + public function migrate( DataMigrationContext $context ): void { + $table = $context->quotedTable( 'your_plugin_reports' ); - $db->executeStatement( + $context->db->executeStatement( 'UPDATE ' . $table . ' SET status = ? WHERE status IS NULL', [ 'draft' ], ); @@ -273,6 +289,8 @@ return new class extends Migration implements MigratesData { }; ``` +The context uses the same connection and naming policy as application tables. For operations that need an unquoted physical name, use `$context->names->tableName( 'your_plugin_reports' )`. Foundation supplies the context for each callback; consumers do not construct or retain it. + This example inherits the default irreversible `down()`: replacing data has no automatic safe inverse. Foundation runs this callback after schema changes and before writing history. It must be safe to repeat if the process stops or recording fails. You may use the shared connection's `transactional()` for a bounded data operation. Complete that transaction before returning; an open transaction interrupts the migration and is rolled back. DDL remains outside that transaction. Preview reports that a data callback exists but does not execute it. The data contract has no automatic reverse callback: supply schema rollback only when reversing remains safe for the resulting data, or throw `IrreversibleMigration`. @@ -296,7 +314,7 @@ Programmatic installation or upgrade code injects `Migrator` and calls `$migrato Programmatic equivalents are `Migrator::status()`, `preview($target)`, `migrate($target)`, `rollback($steps)`, `rollbackTo($target)`, and `refresh()`. Migration operations return step objects containing the stable ID, direction, SQL, and whether a forward data callback was involved. To add a readable status description, implement `DescribesMigration` alongside `Migration`: ```php -use StellarWP\Foundation\Database\Migration\Contracts\DescribesMigration; +use StellarWP\Foundation\Migrations\Contracts\DescribesMigration; // Add DescribesMigration to the migration's implements list. public function describe(): string { @@ -331,12 +349,33 @@ These are deliberate manual SQL changes, not schema repairs. Do not change histo One database advisory lock covers planning, schema execution, data callbacks, and history writes. The lock survives DDL commits and lasts until release or session termination. It has no lease TTL to configure. `MigrationAlreadyRunning` means another session is migrating this application's site ledger: defer and retry after it finishes. This is distinct from a database failure. -`MigrationInterrupted` means ownership or the starting session could not be confirmed. Foundation stops and releases only the original session's lock where possible. Never change sites or sessions during a migration, even temporarily. Migration exceptions live under `StellarWP\Foundation\Database\Migration\Exceptions` and extend `StellarWP\Foundation\Database\Exceptions\DatabaseException`. Catch a specific exception when choosing whether to defer, retry, or stop. +`MigrationInterrupted` means ownership or the starting session could not be confirmed. The database layer reports `AdvisoryLockInterrupted` during a data callback; the migrator translates it to `MigrationInterrupted` when it escapes the run. Catching it inside a callback does not make the run successful. Foundation stops and releases only the original session's lock where possible. Never change sites or sessions during a migration, even temporarily. Foundation migration exceptions live under `StellarWP\Foundation\Migrations\Exceptions` and extend `MigrationException`, which extends `StellarWP\Foundation\Database\Exceptions\DatabaseException`. Catch `MigrationException` for shared reporting, and use the specific exception when choosing whether to defer, retry, or stop. Native SQL failures still use Doctrine exceptions; application data callbacks can propagate their own exceptions. All migration participants must reach the same primary database server. Advisory locks are local to that server; a proxy that moves statements between sessions or servers cannot provide this guarantee. Preview and status are observations and can become stale before a later run. ## Configure discovery +### Name the migration ledger + +With `foundation.prefix` set to `your-plugin`, the ledger defaults to `your_plugin_foundation_migrations` before WordPress adds its site prefix. The advisory lock is scoped by database and ledger name, so applications using different ledgers migrate independently. Keep that name stable across releases. + +To override the ledger name, add `migrations.table` only when configured in root `config.php`: + +```php title="config.php" +$config = [ + 'foundation' => [ + 'root' => __DIR__, + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], +]; + +if ( isset( $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'] ) ) { + $config['migrations']['table'] = $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE']; +} + +return $config; +``` + ### Use another directory Set the path once in root `config.php`; generation and runtime discovery use the same setting: @@ -346,10 +385,8 @@ return [ 'foundation' => [ 'root' => __DIR__, ], - 'database' => [ - 'migrations' => [ - 'path' => 'migrations', - ], + 'migrations' => [ + 'path' => 'migrations', ], ]; ``` @@ -358,6 +395,10 @@ Merge this with your existing configuration. Paths are relative to `foundation.r Migration directories do not need Composer autoload mappings or a classmap rebuild. Include the PHP files in production archives. Files are application code: keep executable work inside the documented methods, and use a top-level `return new class extends Migration` declaration. +### Package migrations with Strauss + +Include your migration directory in the plugin's production archive. The Foundation generator reads `extra.strauss.namespace_prefix` and writes prefixed Foundation imports when configured. Handwritten files, older migrations, or a changed namespace-prefix configuration may still contain imports that need rewriting: include their directory in Strauss's call-site scan alongside `src/`. Verify that the packaged migration imports match the packaged Foundation namespace. PHP namespace scoping leaves filename IDs and literal historical table names unchanged. + ### Group migrations by feature Use a slash in the migration description: @@ -375,17 +416,19 @@ Generated filenames use `_.php`, such as ` Packages or applications with additional migration sources can contribute objects through a provider: ```php -$this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ +$this->container->mergeArrayVar( MigrationsProvider::MIGRATIONS, static fn ( C $c ): array => [ new MigrationRegistration( '20260923000001_package_setup', $c->get( Package_Migration::class ) ), ] ); ``` -Import `StellarWP\Foundation\Database\DatabaseProvider`, `StellarWP\Foundation\Database\Migration\ValueObjects\MigrationRegistration`, and `StellarWP\Foundation\Container\Contracts\Resolver as C` in that provider. `Package_Migration` is a package-supplied implementation of the migration contract. Explicit contributions and discovered migrations share one ordered collection; contribute each migration once. Registrations keep the ID separate from the declaration object, preserving optional data and description capabilities. +Import `StellarWP\Foundation\Migrations\MigrationsProvider`, `StellarWP\Foundation\Migrations\ValueObjects\MigrationRegistration`, and `StellarWP\Foundation\Container\Contracts\Resolver as C` in that provider. `Package_Migration` should extend `StellarWP\Foundation\Migrations\Migration`, just like generated anonymous migrations. Explicit contributions and discovered migrations share one ordered collection; contribute each migration once. Registrations keep the ID separate from the declaration object, preserving optional data and description capabilities. + +Extend `Migration` and implement `up()`; override `down()` when a safe inverse exists. Direct implementation of `Contracts\Migration` remains supported for declarations that need a different base class, but requires both methods. Optional data and description behavior use separate interfaces. Foundation preserves these extension contracts within 2.x, including inherited method signatures and constructor expectations. Adding a base-class method can collide with consumer methods, so the base class is not an unrestricted extension surface for new Foundation features. -A declaration implements `StellarWP\Foundation\Database\Migration\Contracts\Migration` through `up()` and `down()`. Its registration supplies the ID. IDs are compared in ascending byte order. They must be unique, nonblank, unpadded, and no more than 191 bytes; `0` and `latest` are reserved targets. Choose IDs whose lexical order respects dependencies. Applications using only explicit contributions can omit discovery configuration. +The registration supplies the ID. IDs are compared in ascending byte order. They must be unique, nonblank, unpadded, and no more than 191 bytes; `0` and `latest` are reserved targets. Choose IDs whose lexical order respects dependencies. Applications using only explicit contributions can omit discovery configuration. ### Customize stubs and test migrations -Copy the package stubs into `foundation/stubs/database/` to customize generated code. Start with the [CLI stub guide](/tooling/foundation-cli/#override-package-stubs). Table namespaces remain configurable through generator settings; migration placement is controlled by `database.migrations.path`. +Copy the package stubs into `foundation/stubs/database/` to customize generated code. Start with the [CLI stub guide](/tooling/foundation-cli/#override-package-stubs). Table namespaces remain configurable through generator settings; migration placement is controlled by `migrations.path`. Test create, alteration, rollback, and retry against real database tables. Include a failure after successful DDL but before history recording, then verify that retry preserves existing rows and records the migration once. Register a fresh container per test and use application-specific test table names. diff --git a/src/Docs/src/content/docs/index.mdx b/src/Docs/src/content/docs/index.mdx index b12448d..b148ea9 100644 --- a/src/Docs/src/content/docs/index.mdx +++ b/src/Docs/src/content/docs/index.mdx @@ -22,7 +22,7 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; - Use the aggregate package for convenience or select individual components for a lean production archive. + Install split runtime packages for the capabilities your project needs, and keep developer tooling separate. [Choose an installation approach](/start/install-foundation/) diff --git a/src/Docs/src/content/docs/start/install-foundation.md b/src/Docs/src/content/docs/start/install-foundation.md index f4b8ae3..9e8256d 100644 --- a/src/Docs/src/content/docs/start/install-foundation.md +++ b/src/Docs/src/content/docs/start/install-foundation.md @@ -1,14 +1,14 @@ --- title: Install Foundation -description: Install the aggregate Foundation package or select individual runtime components. +description: Install split runtime packages and keep developer tooling out of production archives. sidebar: order: 2 --- -Foundation requires PHP 8.3 or newer. Composer resolves the dependencies required by the aggregate package or selected components. +Foundation requires PHP 8.3 or newer. Projects should generally install the split packages they use, with runtime components in `require` and developer tooling in `require-dev`. :::caution[Building a production WordPress plugin?] -Do not install `stellarwp/foundation` only to get the Foundation CLI. The aggregate package includes the developer CLI as a normal dependency, so `composer install --no-dev` will not remove it. +Use split packages for distributable WordPress plugins. The aggregate `stellarwp/foundation` package physically includes Foundation CLI and its binary, so `composer install --no-dev` will not remove that tooling from an aggregate installation. Require only the split runtime packages the plugin ships, then install `stellarwp/foundation-cli` separately with `--dev`. ::: @@ -28,23 +28,37 @@ Add other components as the application needs them rather than installing integr ## Install developer tooling separately -The CLI package generates project code and normally does not belong in a production WordPress plugin archive: +The CLI package generates project code. Install it for development and exclude it from production WordPress plugin ZIPs: ```shell composer require --dev stellarwp/foundation-cli ``` -Production builds can then exclude developer dependencies: +:::caution[CLI dependencies can hide missing runtime requirements] +Installing CLI also installs Database, Migrations, and WP-CLI integration for its generators. Their availability in your development environment does not make them production dependencies. If your application uses a package installed only through the development CLI, `composer install --no-dev` excludes it and the application can fail with missing classes. + +Explicitly require the runtime packages your application uses, even when Composer has already installed them through CLI. For example, a project shipping migrations needs: + +```shell +composer require stellarwp/foundation-migrations +``` + +Composer keeps one shared installation and retains that package for production. Installing CLI before or after the runtime package makes no difference. +::: + +Build production archives from a clean staging directory with development dependencies excluded: ```shell composer install --no-dev --classmap-authoritative ``` -If generated code extends a runtime Foundation class, install that runtime package normally. For example, generated WP-CLI commands require `stellarwp/foundation-wpcli` in `require`. +Check the resulting plugin ZIP contains neither the `stellarwp/foundation-cli` package nor its `vendor/bin/foundation` binary. Shared dependencies remain when required by runtime packages. Shipped WP-CLI commands are operational functionality: require `stellarwp/foundation-wpcli` normally when your plugin uses them. + +## Deliberately choose the aggregate package -## Install every component +The aggregate includes CLI in production, even with `--no-dev`. Use split packages for distributable plugin ZIPs. -Install the aggregate package when one application intentionally owns the complete Foundation installation and expects to use most components: +For an environment that intentionally keeps every component and developer tooling, install the aggregate: ```shell composer require stellarwp/foundation diff --git a/src/Docs/src/content/docs/start/scope-foundation.md b/src/Docs/src/content/docs/start/scope-foundation.md index 451ca81..5b02eb8 100644 --- a/src/Docs/src/content/docs/start/scope-foundation.md +++ b/src/Docs/src/content/docs/start/scope-foundation.md @@ -49,8 +49,8 @@ Foundation adapts the prefix to the format required by each package. Given `your - WP-CLI commands are registered under `wp your-plugin`. - The migration ledger defaults to `your_plugin_foundation_migrations`. -- Database-backed migration locks default to `your_plugin_foundation_locks`. -- The migration lock name defaults to `your-plugin-foundation-database-migrations`. +- Application database lock storage defaults to `your_plugin_foundation_locks`. +- Migration advisory locks are derived from the active database and physical ledger name. They use the database session, with no separate lock table or lease TTL. Without configuration, those resources use `nx`. That is convenient for an application that owns the installation, but unsafe for a standalone plugin because another Foundation consumer may use the same defaults. @@ -66,4 +66,4 @@ Different environments may use different databases, but a deployed environment s Package-specific configuration takes precedence over names derived from `foundation.prefix`. Use an override when an existing installation must retain a previously published table, lock, or command name. -For Foundation-managed infrastructure, configure `foundation.prefix` and use the derived defaults unless an existing resource name must be preserved. Developers choose application table names separately: when [generating a table](/components/database/migrations/#create-and-change-a-table) for a standalone plugin, supply a stable name unique to that plugin, such as `--table-name=your_plugin_reports`. +For Foundation-managed infrastructure, configure `foundation.prefix` and use the derived defaults unless an existing resource name must be preserved. Developers choose application table names separately: when [generating a table](/components/migrations/#create-and-change-a-table) for a standalone plugin, supply a stable name unique to that plugin, such as `--table-name=your_plugin_reports`. diff --git a/src/Docs/src/content/docs/start/what-is-foundation.md b/src/Docs/src/content/docs/start/what-is-foundation.md index 7f31aec..7549644 100644 --- a/src/Docs/src/content/docs/start/what-is-foundation.md +++ b/src/Docs/src/content/docs/start/what-is-foundation.md @@ -13,9 +13,9 @@ Each component is published as a focused package from this monorepo. Application ## Choose packages by deployment boundary -Libraries and distributable WordPress plugins should generally use focused component packages. This keeps their production dependency set aligned with the infrastructure they actually ship. +Projects, libraries, and distributable WordPress plugins should generally use focused component packages. This keeps their production dependency set aligned with the infrastructure they actually ship. -Complete applications that centrally own their dependency graph can use the aggregate package when having every Foundation component available is more useful than minimizing installed code. The installation guide explains the production and developer dependency implications of each approach. +The aggregate `stellarwp/foundation` package is a deliberate exception for environments that want every component, including developer tooling. Its CLI code remains installed with `--no-dev`. Follow the [installation guide](/start/install-foundation/) to keep runtime dependencies explicit and exclude CLI from production plugin ZIPs. ## Treat Foundation as application infrastructure diff --git a/src/Docs/src/content/docs/tooling/foundation-cli.mdx b/src/Docs/src/content/docs/tooling/foundation-cli.mdx index 4a6a792..9ff966d 100644 --- a/src/Docs/src/content/docs/tooling/foundation-cli.mdx +++ b/src/Docs/src/content/docs/tooling/foundation-cli.mdx @@ -26,7 +26,13 @@ vendor/bin/foundation list :::caution[Keep the CLI out of production plugin archives] `stellarwp/foundation-cli` includes Symfony Console, generators, and scaffolding assets. Install it with `--dev` and build standalone plugin archives with `composer install --no-dev`. Generated classes depend on their runtime Foundation package, not on the generator. -The aggregate `stellarwp/foundation` package includes the CLI in its normal installation. Require individual split packages when a production archive must remain lean. +Projects should generally require individual split runtime packages. The aggregate `stellarwp/foundation` package physically includes CLI, so `--no-dev` cannot remove it from an aggregate installation. +::: + +:::caution[Require generated code's runtime packages explicitly] +CLI installs Database, Migrations, and WP-CLI integration as its own dependencies. A generated class may therefore work locally even when its runtime package is missing from your project's production requirements. After `composer install --no-dev`, that package can disappear and the class fails to load. + +Add the runtime package to `require`, even if it is already installed. For example, run `composer require stellarwp/foundation-migrations` before shipping migrations. See [runtime dependencies and production packaging](/start/install-foundation/#install-developer-tooling-separately). ::: Do not register `StellarWP\Foundation\Cli\CliProvider` in the WordPress application's provider list. It boots the Symfony Console application for the `foundation` executable and is unrelated to WordPress request bootstrap. @@ -74,13 +80,13 @@ composer require stellarwp/foundation-wpcli Install the runtime package, then generate a table with its initial migration: ```shell -composer require stellarwp/foundation-database +composer require stellarwp/foundation-migrations vendor/bin/foundation make:database:table Reports --table-name=your_plugin_reports --migration ``` -The command creates `Reports_Table.php` and a timestamped `db/migrations/_create_reports_table.php`. Add the columns to the migration, preview its SQL, and apply it through WP-CLI. Follow the [complete create-and-alter workflow](/components/database/migrations/) to configure discovery and run the generated code. +The command creates `Reports_Table.php` and a timestamped `db/migrations/_create_reports_table.php`. Add the columns to the migration, preview its SQL, and apply it through WP-CLI. Follow the [complete create-and-alter workflow](/components/migrations/) to configure discovery and run the generated code. -`--table-name` supplies the stable name before WordPress adds its site prefix. `--migration` creates an initial migration whose rollback drops the complete table. Table `--namespace` and `--path` options affect only the table class. Migrations use `database.migrations.path` from root `config.php`, defaulting to `db/migrations`. +`--table-name` supplies the stable name before WordPress adds its site prefix. `--migration` creates an initial migration whose rollback drops the complete table. Table `--namespace` and `--path` options affect only the table class. Migrations use `migrations.path` from root `config.php`, defaulting to `db/migrations`. If you want a provider for application table bindings, generate one with `make:database:provider` before generating tables. Table generation can update it; migration discovery does not require per-migration provider entries. @@ -99,7 +105,7 @@ The `--create` and `--table` options are mutually exclusive. Each accepts a stab ### Inspect command options @@ -153,7 +159,7 @@ An explicit `--namespace` takes precedence over the configured namespace. Withou The table generator looks for `Provider.php` in the configured database-provider namespace. Generate that provider first to enable table registration. Use table `--provider` to select another provider file. Migration `--create` and `--table` options take stable, unprefixed database names. -Migration placement is shared with runtime discovery through `database.migrations.path`. Configure the [migration directory](/components/database/migrations/#use-another-directory) once. Migration commands have no separate `--path`, `--namespace`, or `--provider` option. Use a name such as `reports/add_status` to generate inside a feature subfolder. +Migration placement is shared with runtime discovery through `migrations.path`. Configure the [migration directory](/components/migrations/#use-another-directory) once. Migration commands have no separate `--path`, `--namespace`, or `--provider` option. Use a name such as `reports/add_status` to generate inside a feature subfolder. Invalid settings for the selected generator or missing Composer mappings stop generation before files are written. Correct the setting or mapping before retrying. If the provider namespace has no mapping, correct `generators.database-provider.namespace` or its Composer mapping, or select an existing provider file with `--provider`. diff --git a/src/Migrations/.gitattributes b/src/Migrations/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Migrations/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/Migrations/.gitignore b/src/Migrations/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Migrations/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Database/Cli/Migrate.php b/src/Migrations/Cli/Migrate.php similarity index 95% rename from src/Database/Cli/Migrate.php rename to src/Migrations/Cli/Migrate.php index f31b4da..fa72d01 100644 --- a/src/Database/Cli/Migrate.php +++ b/src/Migrations/Cli/Migrate.php @@ -1,10 +1,10 @@ db->getDatabasePlatform()->quoteSingleIdentifier($this->names->tableName($name)); + } +} diff --git a/src/Database/Migration/Exceptions/IncompatibleSchema.php b/src/Migrations/Exceptions/IncompatibleSchema.php similarity index 78% rename from src/Database/Migration/Exceptions/IncompatibleSchema.php rename to src/Migrations/Exceptions/IncompatibleSchema.php index c60bc9e..3b25e57 100644 --- a/src/Database/Migration/Exceptions/IncompatibleSchema.php +++ b/src/Migrations/Exceptions/IncompatibleSchema.php @@ -1,8 +1,6 @@ registerTables(); + $this->registerMigrations(); + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [$c->get(Migrate::class)]); + } + + private function registerTables(): void { + $prefix = str_replace('-', '_', $this->foundationPrefix()); + $this->container->when(MigrationTable::class) + ->needs('$unprefixedTableName') + ->give($this->config->get('migrations.table', $prefix . '_foundation_migrations')); + $this->container->singleton(MigrationTable::class); + } + + private function registerMigrations(): void { + $this->container->when(MigrationDiscovery::class) + ->needs('$root') + ->give(fn (): ?string => $this->config->get('foundation.root')); + + $this->container->when(MigrationDiscovery::class) + ->needs('$path') + ->give(fn (): ?string => $this->config->get('migrations.path')); + + $this->container->singleton(MigrationDiscovery::class); + $this->container->mergeArrayVar( + self::MIGRATIONS, + static fn (C $c): array => iterator_to_array($c->get(MigrationDiscovery::class)->migrations(), false) + ); + + $this->container->when(MigrationCollection::class) + ->needs('$migrations') + ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); + + $this->container->when(SchemaPlanner::class) + ->needs('$tableOptions') + ->give(static function (C $c): array { + $source = $c->get(wpdb::class); + + return array_filter(['engine' => 'InnoDB', 'charset' => $source->charset, 'collation' => $source->collate]); + }); + + $this->container->singleton(MigrationCollection::class); + $this->container->singleton(History::class); + $this->container->singleton(SchemaPlanner::class); + $this->container->singleton(Migrator::class); + } +} diff --git a/src/Database/Migration/Migrator.php b/src/Migrations/Migrator.php similarity index 84% rename from src/Database/Migration/Migrator.php rename to src/Migrations/Migrator.php index 2db47f1..7ea28fc 100644 --- a/src/Database/Migration/Migrator.php +++ b/src/Migrations/Migrator.php @@ -1,20 +1,23 @@ session->withAdvisoryLock($this->history->table(), function () use ($operation): mixed { - try { - return $operation(); - } catch (Throwable $failure) { + try { + return $this->session->withAdvisoryLock($this->history->table(), function () use ($operation): mixed { try { - while ($this->db->isTransactionActive()) { - $this->db->rollBack(); + return $operation(); + } catch (Throwable $failure) { + try { + while ($this->db->isTransactionActive()) { + $this->db->rollBack(); + } + } catch (Throwable) { + // Preserve the migration failure when transaction cleanup also fails. } - } catch (Throwable) { - // Preserve the migration failure when transaction cleanup also fails. - } - throw $failure; - } - }); + throw $failure; + } + }); + } catch (AdvisoryLockContended $failure) { + throw new MigrationAlreadyRunning('Another session is running these migrations; retry after it completes.', 0, $failure); + } catch (AdvisoryLockInterrupted $failure) { + throw new MigrationInterrupted('Migration interrupted: ' . $failure->getMessage(), 0, $failure); + } } /** @@ -202,7 +211,7 @@ private function run(string $target, bool $execute, bool $applyPending = true): $this->session->check(); if (! $reverse && $migration instanceof MigratesData) { - $migration->migrate($this->db, $this->names); + $migration->migrate(new DataMigrationContext($this->db, $this->names)); $this->session->check(); if ($this->db->isTransactionActive()) { diff --git a/src/Migrations/README.md b/src/Migrations/README.md new file mode 100644 index 0000000..063b8cd --- /dev/null +++ b/src/Migrations/README.md @@ -0,0 +1,21 @@ +# Foundation Migrations + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Declarative WordPress migrations with automatic file discovery, schema recovery, +version history, and database advisory locking. + +## Installation + +```shell +composer require stellarwp/foundation-migrations +``` + +Register `DatabaseProvider` followed by `MigrationsProvider`. Register `WPCliProvider` +first to enable the migration command, or inject `Migrator` for programmatic upgrades. + +## Documentation + +See the [Migrations guide](https://foundation.nexcess.dev/components/migrations/) +for setup, generating tables and migrations, deployment, rollback, and recovery. diff --git a/src/Database/Migration/Schema/Blueprint.php b/src/Migrations/Schema/Blueprint.php similarity index 98% rename from src/Database/Migration/Schema/Blueprint.php rename to src/Migrations/Schema/Blueprint.php index aa471bb..59fcacf 100644 --- a/src/Database/Migration/Schema/Blueprint.php +++ b/src/Migrations/Schema/Blueprint.php @@ -1,6 +1,6 @@ =8.3", + "stellarwp/foundation-container": "^2.0", + "stellarwp/foundation-database": "^2.0", + "stellarwp/foundation-wpcli": "^2.0" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Migrations\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + } +} diff --git a/src/Database/stubs/alter-table-migration.stub b/src/Migrations/stubs/alter-table-migration.stub similarity index 100% rename from src/Database/stubs/alter-table-migration.stub rename to src/Migrations/stubs/alter-table-migration.stub diff --git a/src/Database/stubs/create-table-migration.stub b/src/Migrations/stubs/create-table-migration.stub similarity index 100% rename from src/Database/stubs/create-table-migration.stub rename to src/Migrations/stubs/create-table-migration.stub diff --git a/src/Database/stubs/migration.stub b/src/Migrations/stubs/migration.stub similarity index 100% rename from src/Database/stubs/migration.stub rename to src/Migrations/stubs/migration.stub diff --git a/tests/Feature/Migrations/MigrationsProviderTest.php b/tests/Feature/Migrations/MigrationsProviderTest.php new file mode 100644 index 0000000..d0aaaa3 --- /dev/null +++ b/tests/Feature/Migrations/MigrationsProviderTest.php @@ -0,0 +1,23 @@ +assertFalse(class_exists('WP_CLI_Command', false)); + + $this->container->register(DatabaseProvider::class); + $this->container->register(MigrationsProvider::class); + + $this->assertFalse(class_exists('WP_CLI_Command', false)); + } +} diff --git a/tests/Support/Fixtures/Database/DataDiscovery/20260923000003_publish_reports.php b/tests/Support/Fixtures/Database/DataDiscovery/20260923000003_publish_reports.php index eb274d4..9a4dd07 100644 --- a/tests/Support/Fixtures/Database/DataDiscovery/20260923000003_publish_reports.php +++ b/tests/Support/Fixtures/Database/DataDiscovery/20260923000003_publish_reports.php @@ -1,19 +1,18 @@ getDatabasePlatform()->quoteSingleIdentifier($names->tableName('discovery_reports')); - $db->executeStatement('UPDATE ' . $table . ' SET title = ? WHERE title = ?', ['Published report', 'Existing report']); + public function migrate(DataMigrationContext $context): void { + $table = $context->quotedTable('discovery_reports'); + $context->db->executeStatement('UPDATE ' . $table . ' SET title = ? WHERE title = ?', ['Published report', 'Existing report']); } public function describe(): string { diff --git a/tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php b/tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php index dee0f03..d864bf6 100644 --- a/tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php +++ b/tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php @@ -2,9 +2,9 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; -use StellarWP\Foundation\Database\Migration\Contracts\Migration; -use StellarWP\Foundation\Database\Migration\Exceptions\IrreversibleMigration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\Migration; +use StellarWP\Foundation\Migrations\Exceptions\IrreversibleMigration; +use StellarWP\Foundation\Migrations\Schema\Blueprint; /** * A change whose author refuses automatic reversal to protect data. diff --git a/tests/Support/Fixtures/Database/Declarative/AddEntryNote.php b/tests/Support/Fixtures/Database/Declarative/AddEntryNote.php index 79c522c..d02a24b 100644 --- a/tests/Support/Fixtures/Database/Declarative/AddEntryNote.php +++ b/tests/Support/Fixtures/Database/Declarative/AddEntryNote.php @@ -2,9 +2,9 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; -use StellarWP\Foundation\Database\Migration\Contracts\DescribesMigration; -use StellarWP\Foundation\Database\Migration\Contracts\Migration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\DescribesMigration; +use StellarWP\Foundation\Migrations\Contracts\Migration; +use StellarWP\Foundation\Migrations\Schema\Blueprint; /** * Alteration exactly as a generated alter migration would declare it: no guards. diff --git a/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php b/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php index 299bfc7..e1f77c8 100644 --- a/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php +++ b/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php @@ -2,11 +2,10 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; -use Doctrine\DBAL\Connection; -use StellarWP\Foundation\Database\Contracts\TableNameResolver; -use StellarWP\Foundation\Database\Migration\Contracts\MigratesData; -use StellarWP\Foundation\Database\Migration\Contracts\Migration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\MigratesData; +use StellarWP\Foundation\Migrations\Contracts\Migration; +use StellarWP\Foundation\Migrations\DataMigrationContext; +use StellarWP\Foundation\Migrations\Schema\Blueprint; /** * Data-only migration; the schema declarations are intentionally empty and the data step is repeat-safe. @@ -26,7 +25,7 @@ public function up(Blueprint $schema): void { public function down(Blueprint $schema): void { } - public function migrate(Connection $db, TableNameResolver $names): void { - $db->executeStatement("UPDATE `{$this->physicalName}` SET status = 'active' WHERE status = ''"); + public function migrate(DataMigrationContext $context): void { + $context->db->executeStatement("UPDATE `{$this->physicalName}` SET status = 'active' WHERE status = ''"); } } diff --git a/tests/Support/Fixtures/Database/Declarative/CreateEntries.php b/tests/Support/Fixtures/Database/Declarative/CreateEntries.php index 9337b2c..17dbc44 100644 --- a/tests/Support/Fixtures/Database/Declarative/CreateEntries.php +++ b/tests/Support/Fixtures/Database/Declarative/CreateEntries.php @@ -2,8 +2,8 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; -use StellarWP\Foundation\Database\Migration\Contracts\Migration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\Migration; +use StellarWP\Foundation\Migrations\Schema\Blueprint; /** * Initial table definition exactly as a generated create migration would declare it: no guards. diff --git a/tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php b/tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php index 1755c9e..6baae65 100644 --- a/tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php +++ b/tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php @@ -2,8 +2,8 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; -use StellarWP\Foundation\Database\Migration\Contracts\Migration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\Migration; +use StellarWP\Foundation\Migrations\Schema\Blueprint; /** * A second table created between the entries migrations. diff --git a/tests/Support/Fixtures/Database/Declarative/DropEntryTags.php b/tests/Support/Fixtures/Database/Declarative/DropEntryTags.php index 48bdc43..e2cac16 100644 --- a/tests/Support/Fixtures/Database/Declarative/DropEntryTags.php +++ b/tests/Support/Fixtures/Database/Declarative/DropEntryTags.php @@ -2,9 +2,9 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; -use StellarWP\Foundation\Database\Migration\Contracts\Migration; -use StellarWP\Foundation\Database\Migration\Exceptions\IrreversibleMigration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\Migration; +use StellarWP\Foundation\Migrations\Exceptions\IrreversibleMigration; +use StellarWP\Foundation\Migrations\Schema\Blueprint; /** * Drop a table an earlier migration created; a later migration recreates it differently. diff --git a/tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php b/tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php index c8b7ba8..77bc851 100644 --- a/tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php +++ b/tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php @@ -2,8 +2,8 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; -use StellarWP\Foundation\Database\Migration\Contracts\Migration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\Migration; +use StellarWP\Foundation\Migrations\Schema\Blueprint; /** * Add a plain index; an existing index with the same name must be treated as a conflict. diff --git a/tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php b/tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php index f2fc30e..11ba4e6 100644 --- a/tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php +++ b/tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php @@ -2,8 +2,8 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; -use StellarWP\Foundation\Database\Migration\Contracts\Migration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\Migration; +use StellarWP\Foundation\Migrations\Schema\Blueprint; /** * Recreate the tags table with a different definition after it was dropped. diff --git a/tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php b/tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php index aad099f..f717508 100644 --- a/tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php +++ b/tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php @@ -2,8 +2,8 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; -use StellarWP\Foundation\Database\Migration\Contracts\Migration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\Migration; +use StellarWP\Foundation\Migrations\Schema\Blueprint; /** * Change precision and default together: Doctrine sees the default change, Foundation the precision. diff --git a/tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php b/tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php index b741aed..1a57275 100644 --- a/tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php +++ b/tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php @@ -2,8 +2,8 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; -use StellarWP\Foundation\Database\Migration\Contracts\Migration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\Migration; +use StellarWP\Foundation\Migrations\Schema\Blueprint; /** * Explicitly replace an index with a unique one of the same name. diff --git a/tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php b/tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php index 857b3f7..90b3841 100644 --- a/tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php +++ b/tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php @@ -2,8 +2,8 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; -use StellarWP\Foundation\Database\Migration\Contracts\Migration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\Migration; +use StellarWP\Foundation\Migrations\Schema\Blueprint; /** * Change only an attribute DBAL cannot compare: add ON UPDATE to an existing timestamp. diff --git a/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php b/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php index 4218516..c497882 100644 --- a/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php +++ b/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php @@ -2,8 +2,8 @@ namespace StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative; -use StellarWP\Foundation\Database\Migration\Contracts\Migration; -use StellarWP\Foundation\Database\Migration\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Contracts\Migration; +use StellarWP\Foundation\Migrations\Schema\Blueprint; /** * Replace an existing column's complete definition, with an explicit inverse. diff --git a/tests/Support/Fixtures/Database/Discovery/20260923000001_create_reports_table.php b/tests/Support/Fixtures/Database/Discovery/20260923000001_create_reports_table.php index 2d5aea2..907ef9d 100644 --- a/tests/Support/Fixtures/Database/Discovery/20260923000001_create_reports_table.php +++ b/tests/Support/Fixtures/Database/Discovery/20260923000001_create_reports_table.php @@ -1,7 +1,7 @@ create(new ArrayConfiguration([ - 'database' => ['migrations_table' => 'foundation_cli_migrations'], + 'migrations' => ['table' => 'foundation_cli_migrations'], ])); $container->register(DatabaseProvider::class); + $container->register(MigrationsProvider::class); $table = new class implements Table { public function unprefixedName(): string { return 'foundation_cli_example'; @@ -44,6 +46,6 @@ public function down(Blueprint $schema): void { $schema->drop($this->table); } }; - $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [new MigrationRegistration($migration->id(), $migration)]); + $container->mergeArrayVar(MigrationsProvider::MIGRATIONS, [new MigrationRegistration($migration->id(), $migration)]); $container->get(Migrate::class)->register(new CommandContext(new CommandPrefix('foundation'))); }); diff --git a/tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php b/tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php index fbca4e6..7d6ccbb 100644 --- a/tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php +++ b/tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php @@ -27,7 +27,7 @@ protected function setUp(): void { private function configure(array $configuration = [], string $namespace = 'Example\\', string $path = 'src/'): void { file_put_contents($this->root . '/composer.json', json_encode([ 'autoload' => ['psr-4' => [$namespace => $path]], - 'require' => ['stellarwp/foundation-database' => '^2.0'], + 'require' => ['stellarwp/foundation-migrations' => '^2.0'], ], JSON_THROW_ON_ERROR)); $this->services = (new ContainerFactory())->create(new ArrayConfiguration($configuration)); $this->services->register(CliProvider::class); @@ -50,6 +50,7 @@ private function files(string $directory = 'db/migrations'): array { public function test_combined_creation_then_alteration_has_ordered_file_identities_without_provider_edits(): void { $table = $this->table(); self::assertSame(0, $table->execute(['name' => 'reports', '--migration' => true]), $table->getDisplay()); + self::assertStringNotContainsString('Runtime dependency missing', $table->getDisplay()); $createFile = $this->files()[0]; $create = (string) file_get_contents($createFile); self::assertMatchesRegularExpression('/[0-9]{14}_create_reports_table.php$/', $createFile); @@ -64,6 +65,7 @@ public function test_combined_creation_then_alteration_has_ordered_file_identiti $command = $this->migration(); self::assertSame(0, $command->execute(['name' => 'Add_Published_At', '--table' => 'reports']), $command->getDisplay()); + self::assertStringNotContainsString('Runtime dependency missing', $command->getDisplay()); $files = $this->files(); self::assertCount(2, $files); self::assertSame($createFile, $files[0]); @@ -74,8 +76,19 @@ public function test_combined_creation_then_alteration_has_ordered_file_identiti self::assertSame($create, file_get_contents($createFile)); } + public function test_combined_generation_warns_when_migrations_are_only_installed_for_development(): void { + $manifest = json_decode((string) file_get_contents($this->root . '/composer.json'), true, 512, JSON_THROW_ON_ERROR); + $manifest['require'] = ['stellarwp/foundation-database' => '^2.0']; + $manifest['require-dev'] = ['stellarwp/foundation-migrations' => '^2.0']; + file_put_contents($this->root . '/composer.json', json_encode($manifest, JSON_THROW_ON_ERROR)); + $command = $this->table(); + + self::assertSame(0, $command->execute(['name' => 'reports', '--migration' => true])); + self::assertStringContainsString('Move stellarwp/foundation-migrations or stellarwp/foundation to require', $command->getDisplay()); + } + public function test_generation_creates_custom_directories_without_composer_namespace_mappings(): void { - $this->configure(['database' => ['migrations' => ['path' => 'migrations']]]); + $this->configure(['migrations' => ['path' => 'migrations']]); $command = $this->migration(); self::assertSame(0, $command->execute(['name' => 'Reports/Add_Status']), $command->getDisplay()); $file = $this->files('migrations/reports')[0]; @@ -84,7 +97,7 @@ public function test_generation_creates_custom_directories_without_composer_name } public function test_absolute_location_and_foundation_root_use_the_same_configuration(): void { - $this->configure(['foundation' => ['root' => $this->root], 'database' => ['migrations' => ['path' => $this->root . '/src/History']]]); + $this->configure(['foundation' => ['root' => $this->root], 'migrations' => ['path' => $this->root . '/src/History']]); $command = $this->migration(); self::assertSame(0, $command->execute(['name' => 'Rebuild']), $command->getDisplay()); self::assertCount(1, $this->files('src/History')); @@ -111,7 +124,7 @@ public function test_repeated_descriptions_create_new_files_and_never_overwrite_ } public function test_standalone_create_and_alter_use_table_names_without_application_classes(): void { - file_put_contents($this->root . '/composer.json', '{"require":{"stellarwp/foundation-database":"^2.0"}}'); + file_put_contents($this->root . '/composer.json', '{"require":{"stellarwp/foundation-migrations":"^2.0"}}'); $command = $this->migration(); self::assertSame(0, $command->execute(['name' => 'create_reports', '--create' => 'your_plugin_reports']), $command->getDisplay()); self::assertSame(0, $command->execute(['name' => 'add_status', '--table' => 'your_plugin_reports']), $command->getDisplay()); @@ -153,7 +166,7 @@ public function test_invalid_options_and_locations_fail_without_writing(): void self::assertSame([], $this->files()); } - $this->configure(['database' => ['migrations' => ['path' => '']]]); + $this->configure(['migrations' => ['path' => '']]); $command = $this->migration(); self::assertSame(1, $command->execute(['name' => 'Change'])); self::assertStringContainsString('cannot be blank', $command->getDisplay()); @@ -161,7 +174,7 @@ public function test_invalid_options_and_locations_fail_without_writing(): void } public function test_combined_generation_writes_neither_file_if_migration_location_is_invalid(): void { - $this->configure(['database' => ['migrations' => ['path' => '']]]); + $this->configure(['migrations' => ['path' => '']]); self::assertSame(1, $this->table()->execute(['name' => 'reports', '--migration' => true])); self::assertFileDoesNotExist($this->root . '/src/Database/Tables/Reports_Table.php'); } @@ -176,8 +189,8 @@ public function test_strauss_imports_apply_to_every_generated_migration_kind(): foreach ($this->files() as $file) { $contents = (string) file_get_contents($file); - self::assertStringContainsString('use Scoped\\StellarWP\\Foundation\\Database\\Migration\\Migration;', $contents); - self::assertStringContainsString('use Scoped\\StellarWP\\Foundation\\Database\\Migration\\Schema\\Blueprint;', $contents); + self::assertStringContainsString('use Scoped\\StellarWP\\Foundation\\Migrations\\Migration;', $contents); + self::assertStringContainsString('use Scoped\\StellarWP\\Foundation\\Migrations\\Schema\\Blueprint;', $contents); } } diff --git a/tests/Unit/Cli/Generation/PhpSourceEditorTest.php b/tests/Unit/Cli/Generation/PhpSourceEditorTest.php index 6e6c312..0e24a0f 100644 --- a/tests/Unit/Cli/Generation/PhpSourceEditorTest.php +++ b/tests/Unit/Cli/Generation/PhpSourceEditorTest.php @@ -16,12 +16,12 @@ public function test_it_reports_parse_failures_for_invalid_php(): void { $this->assertNull($editor->addImport('assertFalse($editor->canInsertIntoMergeArrayVar( 'assertNull($editor->insertIntoMergeArrayVar( 'get(Generated::class),' )); @@ -85,7 +85,7 @@ public function test_it_ignores_function_imports_when_resolving_class_imports(): public function test_it_rejects_merge_array_var_calls_that_are_not_foundation_database_migration_lists(): void { $editor = $this->editor(); - $class = 'StellarWP\\Foundation\\Database\\DatabaseProvider'; + $class = 'StellarWP\\Foundation\\Migrations\\MigrationsProvider'; $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('not-container-merge-array-var'), $class, 'MIGRATIONS')); $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('wrong-first-argument-merge-array-var'), $class, 'MIGRATIONS')); @@ -95,7 +95,7 @@ public function test_it_rejects_merge_array_var_calls_that_are_not_foundation_da public function test_it_matches_fully_qualified_strauss_prefixed_database_provider_references(): void { $updated = $this->editor()->insertIntoMergeArrayVar( $this->fixture('strauss-prefixed-database-provider'), - 'StellarWP\\Foundation\\Database\\DatabaseProvider', + 'StellarWP\\Foundation\\Migrations\\MigrationsProvider', 'MIGRATIONS', '$this->container->get(Generated::class),' ); @@ -106,14 +106,14 @@ public function test_it_matches_fully_qualified_strauss_prefixed_database_provid public function test_it_rejects_merge_array_var_callbacks_that_do_not_expose_a_registration_list(): void { $this->assertFalse($this->editor()->canInsertIntoMergeArrayVar( $this->fixture('arrow-callback-without-registration-list'), - 'StellarWP\\Foundation\\Database\\DatabaseProvider', + 'StellarWP\\Foundation\\Migrations\\MigrationsProvider', 'MIGRATIONS' )); } public function test_it_rejects_merge_array_var_closures_without_container_parameters_or_array_returns(): void { $editor = $this->editor(); - $class = 'StellarWP\\Foundation\\Database\\DatabaseProvider'; + $class = 'StellarWP\\Foundation\\Migrations\\MigrationsProvider'; $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('closure-without-container-parameter'), $class, 'MIGRATIONS')); $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('closure-without-array-return'), $class, 'MIGRATIONS')); @@ -122,7 +122,7 @@ public function test_it_rejects_merge_array_var_closures_without_container_param public function test_it_uses_space_indentation_when_inserting_into_space_indented_arrays(): void { $updated = $this->editor()->insertIntoMergeArrayVar( $this->fixture('space-indented-registration-list'), - 'StellarWP\\Foundation\\Database\\DatabaseProvider', + 'StellarWP\\Foundation\\Migrations\\MigrationsProvider', 'MIGRATIONS', '$this->container->get(Generated::class),' ); @@ -135,7 +135,7 @@ public function test_it_uses_space_indentation_when_inserting_into_space_indente public function test_it_does_not_treat_an_unresolved_class_name_as_a_merge_array_registration(): void { $editor = $this->editor(); - $class = 'StellarWP\\Foundation\\Database\\DatabaseProvider'; + $class = 'StellarWP\\Foundation\\Migrations\\MigrationsProvider'; $target = 'Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table'; $this->assertFalse($editor->mergeArrayVarContainsClass( @@ -161,7 +161,7 @@ public function test_it_resolves_namespace_relative_provider_registrations(): vo )); $this->assertTrue($this->editor()->mergeArrayVarContainsClass( $contents, - 'StellarWP\\Foundation\\Database\\DatabaseProvider', + 'StellarWP\\Foundation\\Migrations\\MigrationsProvider', 'MIGRATIONS', 'Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table' )); @@ -176,7 +176,7 @@ public function test_it_resolves_imported_namespace_prefixes(): void { )); $this->assertTrue($this->editor()->mergeArrayVarContainsClass( $contents, - 'StellarWP\Foundation\Database\DatabaseProvider', + 'StellarWP\Foundation\Migrations\MigrationsProvider', 'MIGRATIONS', 'Acme\Plugin\Database\Migrations\Create_Reports_Table' )); @@ -191,7 +191,7 @@ public function test_it_does_not_treat_imported_namespaces_as_local_references() )); $this->assertFalse($this->editor()->mergeArrayVarContainsClass( $contents, - 'StellarWP\Foundation\Database\DatabaseProvider', + 'StellarWP\Foundation\Migrations\MigrationsProvider', 'MIGRATIONS', 'Acme\Plugin\Database\Migrations\Create_Reports_Table' )); @@ -203,12 +203,12 @@ public function test_it_uses_valid_later_merge_array_contributions(): void { $this->assertTrue($editor->canInsertIntoMergeArrayVar( $contents, - 'StellarWP\Foundation\Database\DatabaseProvider', + 'StellarWP\Foundation\Migrations\MigrationsProvider', 'MIGRATIONS' )); $this->assertTrue($editor->mergeArrayVarContainsClass( $contents, - 'StellarWP\Foundation\Database\DatabaseProvider', + 'StellarWP\Foundation\Migrations\MigrationsProvider', 'MIGRATIONS', 'Acme\Plugin\Database\Migrations\Create_Reports_Table' )); @@ -216,7 +216,7 @@ public function test_it_uses_valid_later_merge_array_contributions(): void { '$c->get(Generated::class),', (string) $editor->insertIntoMergeArrayVar( $contents, - 'StellarWP\Foundation\Database\DatabaseProvider', + 'StellarWP\Foundation\Migrations\MigrationsProvider', 'MIGRATIONS', '$c->get(Generated::class),' ) diff --git a/tests/Unit/Database/Migration/ColumnDefinitionTest.php b/tests/Unit/Migrations/ColumnDefinitionTest.php similarity index 95% rename from tests/Unit/Database/Migration/ColumnDefinitionTest.php rename to tests/Unit/Migrations/ColumnDefinitionTest.php index 5700add..5215531 100644 --- a/tests/Unit/Database/Migration/ColumnDefinitionTest.php +++ b/tests/Unit/Migrations/ColumnDefinitionTest.php @@ -1,6 +1,6 @@ fixtures = dirname(__DIR__, 3) . '/Support/Fixtures/Database/Discovery'; + $this->fixtures = dirname(__DIR__, 2) . '/Support/Fixtures/Database/Discovery'; } public function test_nested_files_are_loaded_and_ordered_without_composer_or_a_container(): void { diff --git a/tests/Unit/Database/Migration/TableBlueprintTest.php b/tests/Unit/Migrations/TableBlueprintTest.php similarity index 88% rename from tests/Unit/Database/Migration/TableBlueprintTest.php rename to tests/Unit/Migrations/TableBlueprintTest.php index 0d8a179..c50b501 100644 --- a/tests/Unit/Database/Migration/TableBlueprintTest.php +++ b/tests/Unit/Migrations/TableBlueprintTest.php @@ -1,11 +1,11 @@ container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): object => new \stdClass() ); + $this->container->mergeArrayVar( MigrationsProvider::MIGRATIONS, static fn ( C $c ): object => new \stdClass() ); } } diff --git a/tests/_data/cli/generation/php-source-editor/closure-without-array-return.stub b/tests/_data/cli/generation/php-source-editor/closure-without-array-return.stub index 688f495..8e6557a 100644 --- a/tests/_data/cli/generation/php-source-editor/closure-without-array-return.stub +++ b/tests/_data/cli/generation/php-source-editor/closure-without-array-return.stub @@ -3,12 +3,12 @@ namespace Acme; use StellarWP\Foundation\Container\Contracts\Resolver as C; -use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Migrations\MigrationsProvider; final class Provider { public function register(): void { - $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static function ( C $c ): array { + $this->container->mergeArrayVar( MigrationsProvider::MIGRATIONS, static function ( C $c ): array { return $c->get(Generated::class); } ); } diff --git a/tests/_data/cli/generation/php-source-editor/closure-without-container-parameter.stub b/tests/_data/cli/generation/php-source-editor/closure-without-container-parameter.stub index eeebc5a..0ea6e90 100644 --- a/tests/_data/cli/generation/php-source-editor/closure-without-container-parameter.stub +++ b/tests/_data/cli/generation/php-source-editor/closure-without-container-parameter.stub @@ -2,12 +2,12 @@ namespace Acme; -use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Migrations\MigrationsProvider; final class Provider { public function register(): void { - $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static function (): array { + $this->container->mergeArrayVar( MigrationsProvider::MIGRATIONS, static function (): array { return [ ]; } ); diff --git a/tests/_data/cli/generation/php-source-editor/formatted-database-provider.stub b/tests/_data/cli/generation/php-source-editor/formatted-database-provider.stub index 015cfd5..d649fde 100644 --- a/tests/_data/cli/generation/php-source-editor/formatted-database-provider.stub +++ b/tests/_data/cli/generation/php-source-editor/formatted-database-provider.stub @@ -5,7 +5,7 @@ namespace Acme\Plugin\Database; use Acme\Plugin\Database\Migrations\Create_Reports_Table; use Acme\Plugin\Database\Tables\Reports_Table; use StellarWP\Foundation\Container\Contracts\Resolver as C; -use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Migrations\MigrationsProvider; final class Provider { @@ -23,7 +23,7 @@ final class Provider private function registerMigrations(): void { - $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + $this->container->mergeArrayVar( MigrationsProvider::MIGRATIONS, static fn ( C $c ): array => [ $c->get( Create_Reports_Table::class ), ] ); } diff --git a/tests/_data/cli/generation/php-source-editor/import-shadowed-registrations.stub b/tests/_data/cli/generation/php-source-editor/import-shadowed-registrations.stub index abcef84..8dbe7fb 100644 --- a/tests/_data/cli/generation/php-source-editor/import-shadowed-registrations.stub +++ b/tests/_data/cli/generation/php-source-editor/import-shadowed-registrations.stub @@ -4,14 +4,14 @@ namespace Acme\Plugin\Database; use Other\Migrations; use Other\Tables; -use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Migrations\MigrationsProvider; final class Provider { public function register(): void { $this->container->singleton(Tables\Reports_Table::class); - $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn ($c): array => [ + $this->container->mergeArrayVar(MigrationsProvider::MIGRATIONS, static fn ($c): array => [ $c->get(Migrations\Create_Reports_Table::class), ]); } diff --git a/tests/_data/cli/generation/php-source-editor/imported-prefix-registrations.stub b/tests/_data/cli/generation/php-source-editor/imported-prefix-registrations.stub index df89920..43e2a5d 100644 --- a/tests/_data/cli/generation/php-source-editor/imported-prefix-registrations.stub +++ b/tests/_data/cli/generation/php-source-editor/imported-prefix-registrations.stub @@ -3,14 +3,14 @@ namespace Acme\Plugin\Database; use Acme\Plugin\Database as DB; -use Acme\Product\StellarWP\Foundation\Database as FoundationDatabase; +use Acme\Product\StellarWP\Foundation\Migrations as FoundationDatabase; final class Provider { public function register(): void { $this->container->singleton(DB\Tables\Reports_Table::class); - $this->container->mergeArrayVar(FoundationDatabase\DatabaseProvider::MIGRATIONS, static fn ($c): array => [ + $this->container->mergeArrayVar(FoundationDatabase\MigrationsProvider::MIGRATIONS, static fn ($c): array => [ $c->get(DB\Migrations\Create_Reports_Table::class), ]); } diff --git a/tests/_data/cli/generation/php-source-editor/migration-class-reference-without-registration.stub b/tests/_data/cli/generation/php-source-editor/migration-class-reference-without-registration.stub index 8a4d1e3..2c82ff6 100644 --- a/tests/_data/cli/generation/php-source-editor/migration-class-reference-without-registration.stub +++ b/tests/_data/cli/generation/php-source-editor/migration-class-reference-without-registration.stub @@ -4,13 +4,13 @@ namespace Acme\Plugin\Database; use Acme\Plugin\Database\Migrations\Create_Reports_Table; use StellarWP\Foundation\Container\Contracts\Resolver as C; -use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Migrations\MigrationsProvider; final class Provider { public function register(): void { - $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + $this->container->mergeArrayVar( MigrationsProvider::MIGRATIONS, static fn ( C $c ): array => [ Create_Reports_Table::class, ] ); } diff --git a/tests/_data/cli/generation/php-source-editor/multiple-migration-contributions.stub b/tests/_data/cli/generation/php-source-editor/multiple-migration-contributions.stub index 64e7a85..08ce282 100644 --- a/tests/_data/cli/generation/php-source-editor/multiple-migration-contributions.stub +++ b/tests/_data/cli/generation/php-source-editor/multiple-migration-contributions.stub @@ -3,14 +3,14 @@ namespace Acme\Plugin\Database; use Acme\Plugin\Database\Migrations\Create_Reports_Table; -use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Migrations\MigrationsProvider; final class Provider { public function register(): void { - $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn ($c): array => [$c->get(Existing::class)]); + $this->container->mergeArrayVar(MigrationsProvider::MIGRATIONS, static fn ($c): array => [$c->get(Existing::class)]); - $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn ($c): array => [ + $this->container->mergeArrayVar(MigrationsProvider::MIGRATIONS, static fn ($c): array => [ $c->get(Create_Reports_Table::class), ]); } diff --git a/tests/_data/cli/generation/php-source-editor/namespace-relative-registrations.stub b/tests/_data/cli/generation/php-source-editor/namespace-relative-registrations.stub index 347bf7b..1a0f3d2 100644 --- a/tests/_data/cli/generation/php-source-editor/namespace-relative-registrations.stub +++ b/tests/_data/cli/generation/php-source-editor/namespace-relative-registrations.stub @@ -2,7 +2,7 @@ namespace Acme\Plugin\Database; -use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Migrations\MigrationsProvider; final class Provider { @@ -10,7 +10,7 @@ final class Provider $this->container->singleton(Tables\Reports_Table::class); // foundation:database-tables - $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn ($c): array => [ + $this->container->mergeArrayVar(MigrationsProvider::MIGRATIONS, static fn ($c): array => [ $c->get(Migrations\Create_Reports_Table::class), // foundation:database-migrations ]); diff --git a/tests/_data/cli/generation/php-source-editor/nested-migration-registration.stub b/tests/_data/cli/generation/php-source-editor/nested-migration-registration.stub index 903fbe4..4f31f7a 100644 --- a/tests/_data/cli/generation/php-source-editor/nested-migration-registration.stub +++ b/tests/_data/cli/generation/php-source-editor/nested-migration-registration.stub @@ -5,13 +5,13 @@ namespace Acme\Plugin\Database; use Acme\Plugin\Database\Migrations\Create_Reports_Table; use Acme\Plugin\Database\Migrations\Wrapper; use StellarWP\Foundation\Container\Contracts\Resolver as C; -use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Migrations\MigrationsProvider; final class Provider { public function register(): void { - $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + $this->container->mergeArrayVar( MigrationsProvider::MIGRATIONS, static fn ( C $c ): array => [ new Wrapper( $c->get( Create_Reports_Table::class ) ), ] ); } diff --git a/tests/_data/cli/generation/php-source-editor/not-container-merge-array-var.stub b/tests/_data/cli/generation/php-source-editor/not-container-merge-array-var.stub index ad3f235..f322083 100644 --- a/tests/_data/cli/generation/php-source-editor/not-container-merge-array-var.stub +++ b/tests/_data/cli/generation/php-source-editor/not-container-merge-array-var.stub @@ -2,11 +2,11 @@ namespace Acme; -use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Migrations\MigrationsProvider; final class Provider { public function register(): void { - $container->mergeArrayVar( DatabaseProvider::MIGRATIONS, [ ] ); + $container->mergeArrayVar( MigrationsProvider::MIGRATIONS, [ ] ); } } diff --git a/tests/_data/cli/generation/php-source-editor/space-indented-registration-list.stub b/tests/_data/cli/generation/php-source-editor/space-indented-registration-list.stub index 2cc95e8..96e5319 100644 --- a/tests/_data/cli/generation/php-source-editor/space-indented-registration-list.stub +++ b/tests/_data/cli/generation/php-source-editor/space-indented-registration-list.stub @@ -2,12 +2,12 @@ namespace Acme; -use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Migrations\MigrationsProvider; final class Provider { public function register(): void { - $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, [ + $this->container->mergeArrayVar( MigrationsProvider::MIGRATIONS, [ $this->container->get(Existing::class), ] ); } diff --git a/tests/_data/cli/generation/php-source-editor/strauss-prefixed-database-provider.stub b/tests/_data/cli/generation/php-source-editor/strauss-prefixed-database-provider.stub index f0b7482..a67dadf 100644 --- a/tests/_data/cli/generation/php-source-editor/strauss-prefixed-database-provider.stub +++ b/tests/_data/cli/generation/php-source-editor/strauss-prefixed-database-provider.stub @@ -5,7 +5,7 @@ namespace Acme; final class Provider { public function register(): void { - $this->container->mergeArrayVar( \Acme\Product\StellarWP\Foundation\Database\DatabaseProvider::MIGRATIONS, [ + $this->container->mergeArrayVar( \Acme\Product\StellarWP\Foundation\Migrations\MigrationsProvider::MIGRATIONS, [ ] ); } } diff --git a/tests/_data/cli/generation/php-source-editor/wrong-constant-merge-array-var.stub b/tests/_data/cli/generation/php-source-editor/wrong-constant-merge-array-var.stub index 8870933..5a8374d 100644 --- a/tests/_data/cli/generation/php-source-editor/wrong-constant-merge-array-var.stub +++ b/tests/_data/cli/generation/php-source-editor/wrong-constant-merge-array-var.stub @@ -2,11 +2,11 @@ namespace Acme; -use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Migrations\MigrationsProvider; final class Provider { public function register(): void { - $this->container->mergeArrayVar( DatabaseProvider::TABLES, [ ] ); + $this->container->mergeArrayVar( MigrationsProvider::TABLES, [ ] ); } } diff --git a/tests/integration/LockDatabase/DatabaseLockTest.php b/tests/integration/LockDatabase/DatabaseLockTest.php index 3f82906..cd3af29 100644 --- a/tests/integration/LockDatabase/DatabaseLockTest.php +++ b/tests/integration/LockDatabase/DatabaseLockTest.php @@ -3,7 +3,6 @@ namespace StellarWP\Foundation\Tests\Integration\LockDatabase; use StellarWP\Foundation\Container\Contracts\Resolver as C; -use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Lock\Contracts\Lock; use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; use StellarWP\Foundation\Lock\LockLease; @@ -12,14 +11,16 @@ use StellarWP\Foundation\LockDatabase\DatabaseLock; use StellarWP\Foundation\LockDatabase\LockDatabaseProvider; use StellarWP\Foundation\LockDatabase\Tables\LockTable; +use StellarWP\Foundation\Migrations\MigrationsProvider; +use StellarWP\Foundation\Migrations\Migrator; use StellarWP\Foundation\Tests\Support\Fixtures\Database\DatabaseTestCase; final class DatabaseLockTest extends DatabaseTestCase { protected function configuration(): array { return [ - 'database' => ['migrations_table' => $this->suffix . '_history'], - 'lock' => ['database' => ['table' => $this->suffix . '_locks']], + 'migrations' => ['table' => $this->suffix . '_history'], + 'lock' => ['database' => ['table' => $this->suffix . '_locks']], ]; } @@ -31,6 +32,7 @@ protected function setUp(): void { } public function test_migrations_do_not_create_application_lock_storage(): void { + $this->container->register(MigrationsProvider::class); $this->container->get(Migrator::class)->migrate(); self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->container->get(LockTable::class)->name()])); $this->expectException(LockUnavailableException::class); diff --git a/tests/integration/Database/AdvisoryMigrationTest.php b/tests/integration/Migrations/AdvisoryMigrationTest.php similarity index 90% rename from tests/integration/Database/AdvisoryMigrationTest.php rename to tests/integration/Migrations/AdvisoryMigrationTest.php index 0d38dd0..c8c249c 100644 --- a/tests/integration/Database/AdvisoryMigrationTest.php +++ b/tests/integration/Migrations/AdvisoryMigrationTest.php @@ -1,6 +1,6 @@ container->register(MigrationsProvider::class); $this->observerSource->set_prefix($this->source->prefix); $this->observerSource->set_blog_id(get_current_blog_id()); $this->contender = (new ContainerFactory())->create(new ArrayConfiguration([])); $this->contender->register(DatabaseProvider::class); + $this->contender->register(MigrationsProvider::class); $this->contender->singleton(wpdb::class, $this->observerSource); $this->resource = $this->source->prefix . $this->suffix . '_ledger'; $this->ledger = $this->privateTable($this->suffix . '_ledger'); @@ -65,13 +69,13 @@ public function up(Blueprint $schema): void { } public function down(Blueprint $schema): void { } - public function migrate(Connection $db, TableNameResolver $names): void { - ($this->operation)($db); + public function migrate(DataMigrationContext $context): void { + ($this->operation)($context->db); } }; $migrations = new MigrationCollection([new MigrationRegistration($migration->id(), $migration)]); - return new Migrator($db, $container->get(WordPressSession::class), $scope, + return new Migrator($db, $container->get(AdvisorySession::class), $scope, new History($db, $table), new SchemaPlanner($db, $names, $migrations), $migrations, $names); } @@ -256,7 +260,7 @@ public function test_replacing_the_wordpress_connection_is_terminal_even_if_rest try { $db->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); } catch (Throwable $failure) { - self::assertInstanceOf(MigrationInterrupted::class, $failure); + self::assertInstanceOf(\StellarWP\Foundation\Database\Exceptions\AdvisoryLockInterrupted::class, $failure); $this->source->__set('dbh', $native); } })->migrate(); @@ -330,7 +334,8 @@ public function test_disabled_autocommit_is_rejected(): void { })->migrate(); self::fail('Expected disabled autocommit rejection.'); } catch (RuntimeException $failure) { - self::assertSame('Migrations require autocommit to be enabled.', $failure->getMessage()); + self::assertInstanceOf(MigrationInterrupted::class, $failure); + self::assertStringContainsString('autocommit', $failure->getPrevious()?->getMessage() ?? ''); $this->assertLockAvailable(); } finally { $native->autocommit(true); @@ -344,7 +349,7 @@ public function test_nested_migration_run_is_rejected(): void { })->migrate(); self::fail('Expected nested migration rejection.'); } catch (RuntimeException $failure) { - self::assertSame('Start migrations outside an existing transaction or migration run.', $failure->getMessage()); + self::assertSame('Start advisory-locked work outside an existing transaction or locked operation.', $failure->getMessage()); } })->migrate(); $this->assertLockAvailable(); diff --git a/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php b/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php similarity index 94% rename from tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php rename to tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php index 6f2b2fa..915f35f 100644 --- a/tests/integration/Database/DeclarativeMigratorEdgeCaseTest.php +++ b/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php @@ -1,15 +1,15 @@ ['migrations_table' => $this->suffix . '_history']]; + return ['migrations' => ['table' => $this->suffix . '_history']]; } protected function setUp(): void { parent::setUp(); + $this->container->register(MigrationsProvider::class); $this->quotedEntries = $this->privateTable($this->suffix . '_entries'); $this->tagsName = $this->source->prefix . $this->suffix . '_tags'; $this->privateTable($this->suffix . '_tags'); @@ -48,7 +49,7 @@ protected function setUp(): void { $this->container->singleton(EntriesTable::class, new EntriesTable($this->suffix . '_entries')); $this->container->singleton(TagsTable::class, new TagsTable($this->suffix . '_tags')); - $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ + $this->container->mergeArrayVar(MigrationsProvider::MIGRATIONS, static fn (C $c): array => [ new MigrationRegistration(ReduceCreatedAtPrecision::ID, $c->get(ReduceCreatedAtPrecision::class)), new MigrationRegistration(TrackEntryUpdates::ID, $c->get(TrackEntryUpdates::class)), new MigrationRegistration(WidenEntryName::ID, $c->get(WidenEntryName::class)), diff --git a/tests/integration/Database/DeclarativeMigratorTest.php b/tests/integration/Migrations/DeclarativeMigratorTest.php similarity index 94% rename from tests/integration/Database/DeclarativeMigratorTest.php rename to tests/integration/Migrations/DeclarativeMigratorTest.php index 126b70e..a50ca15 100644 --- a/tests/integration/Database/DeclarativeMigratorTest.php +++ b/tests/integration/Migrations/DeclarativeMigratorTest.php @@ -1,16 +1,16 @@ ['migrations_table' => $this->suffix . '_history']]; + return ['migrations' => ['table' => $this->suffix . '_history']]; } protected function setUp(): void { parent::setUp(); + $this->container->register(MigrationsProvider::class); $this->entries = new EntriesTable($this->suffix . '_entries'); $this->entriesName = $this->source->prefix . $this->suffix . '_entries'; $this->quotedEntries = $this->privateTable($this->suffix . '_entries'); @@ -47,7 +48,7 @@ protected function setUp(): void { // What an application's Database_Provider does: contribute migrations lazily. $this->container->singleton(EntriesTable::class, $this->entries); $this->container->when(BackfillEntryStatus::class)->needs('$physicalName')->give($this->entriesName); - $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ + $this->container->mergeArrayVar(MigrationsProvider::MIGRATIONS, static fn (C $c): array => [ new MigrationRegistration(BackfillEntryStatus::ID, $c->get(BackfillEntryStatus::class)), new MigrationRegistration(AddEntryNote::ID, $c->get(AddEntryNote::class)), new MigrationRegistration(CreateEntries::ID, $c->get(CreateEntries::class)), @@ -93,7 +94,7 @@ public function test_missing_history_is_visible_and_prevents_execution(): void { $this->observer->insert($this->quotedHistory, ['version' => '20270101000100']); $statuses = $this->migrator()->status(); $last = end($statuses); - self::assertInstanceOf(\StellarWP\Foundation\Database\Migration\ValueObjects\MigrationStatus::class, $last); + self::assertInstanceOf(\StellarWP\Foundation\Migrations\ValueObjects\MigrationStatus::class, $last); self::assertSame('missing', $last->state()); $this->expectException(MigrationInterrupted::class); $this->migrator()->migrate(); @@ -237,7 +238,7 @@ public function test_retry_after_ledger_failure_records_without_repeating_ddl(): try { $this->migrator()->migrate(self::ALTER); self::fail('Expected the ledger insert to fail.'); - } catch (\StellarWP\Foundation\Database\Migration\Exceptions\LedgerFailure $failure) { + } catch (\StellarWP\Foundation\Migrations\Exceptions\LedgerFailure $failure) { self::assertStringContainsString('History write failure', $failure->getPrevious()?->getMessage() ?? ''); } finally { $this->observer->executeStatement('DROP TRIGGER ' . $trigger); @@ -260,7 +261,7 @@ public function test_create_retry_after_ledger_failure_preserves_rows(): void { try { $this->migrator()->migrate(self::CREATE); self::fail('Expected the ledger insert to fail.'); - } catch (\StellarWP\Foundation\Database\Migration\Exceptions\LedgerFailure) { + } catch (\StellarWP\Foundation\Migrations\Exceptions\LedgerFailure) { // The table exists; history is empty. } finally { $this->observer->executeStatement('DROP TRIGGER ' . $trigger); diff --git a/tests/integration/Database/GeneratedMigrationTest.php b/tests/integration/Migrations/GeneratedMigrationTest.php similarity index 85% rename from tests/integration/Database/GeneratedMigrationTest.php rename to tests/integration/Migrations/GeneratedMigrationTest.php index 09691de..625b2c7 100644 --- a/tests/integration/Database/GeneratedMigrationTest.php +++ b/tests/integration/Migrations/GeneratedMigrationTest.php @@ -1,6 +1,6 @@ ['root' => $root]]; if ($path !== null) { - $configuration['database']['migrations']['path'] = $path; + $configuration['migrations']['path'] = $path; } - file_put_contents($root . '/composer.json', json_encode(['autoload' => ['psr-4' => $mappings], 'require' => ['stellarwp/foundation-database' => '^2.0']], JSON_THROW_ON_ERROR)); + file_put_contents($root . '/composer.json', json_encode(['autoload' => ['psr-4' => $mappings], 'require' => ['stellarwp/foundation-migrations' => '^2.0']], JSON_THROW_ON_ERROR)); $config = new ArrayConfiguration($configuration); $tooling = (new ContainerFactory())->create($config); $tooling->register(CliProvider::class); @@ -51,11 +52,12 @@ public function test_generated_migrations_load_without_application_classes_or_mi // The generated application table is deliberately not autoloadable at runtime. $runtime = (new ContainerFactory())->create($config); $runtime->register(DatabaseProvider::class); + $runtime->register(MigrationsProvider::class); $collection = $runtime->get(MigrationCollection::class); $ids = $collection->ids(); self::assertCount(2, $ids); self::assertStringEndsWith('_create_reports_table', $ids[0]); self::assertStringEndsWith('_add_published_at', $ids[1]); - self::assertInstanceOf(\StellarWP\Foundation\Database\Migration\Migration::class, $collection->get($ids[1])); + self::assertInstanceOf(\StellarWP\Foundation\Migrations\Migration::class, $collection->get($ids[1])); } } diff --git a/tests/integration/Database/MigrationDiscoveryTest.php b/tests/integration/Migrations/MigrationDiscoveryTest.php similarity index 82% rename from tests/integration/Database/MigrationDiscoveryTest.php rename to tests/integration/Migrations/MigrationDiscoveryTest.php index 0bb2b0b..91ab555 100644 --- a/tests/integration/Database/MigrationDiscoveryTest.php +++ b/tests/integration/Migrations/MigrationDiscoveryTest.php @@ -1,9 +1,10 @@ container->register(MigrationsProvider::class); + } + protected function data_dir(string $appendPath = ''): string { return codecept_data_dir($appendPath); } @@ -26,9 +32,9 @@ protected function tearDown(): void { } protected function configuration(): array { - return ['database' => [ - 'migrations_table' => $this->suffix . '_history', - 'migrations' => ['path' => $this->migrationDirectory()], + return ['migrations' => [ + 'table' => $this->suffix . '_history', + 'path' => $this->migrationDirectory(), ]]; } @@ -78,9 +84,10 @@ public function test_anonymous_data_callbacks_use_scoped_names_and_keep_their_op try { $migrator->rollback(); self::fail('The inherited down() must stop rollback.'); - } catch (\StellarWP\Foundation\Database\Migration\Exceptions\IrreversibleMigration $failure) { + } catch (\StellarWP\Foundation\Migrations\Exceptions\MigrationException $failure) { + self::assertInstanceOf(\StellarWP\Foundation\Migrations\Exceptions\IrreversibleMigration::class, $failure); self::assertStringContainsString('20260923000003_publish_reports', $failure->getMessage()); - self::assertInstanceOf(\StellarWP\Foundation\Database\Migration\Exceptions\IrreversibleMigration::class, $failure->getPrevious()); + self::assertInstanceOf(\StellarWP\Foundation\Migrations\Exceptions\IrreversibleMigration::class, $failure->getPrevious()); } self::assertSame('applied', $migrator->status()[2]->state()); } From d1e196a56d6fd257d1625cc9d650abc60b6c7ec9 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 23 Sep 2026 20:57:35 -0600 Subject: [PATCH 15/53] Improve doc examples --- AGENTS.md | 2 +- src/Docs/src/content/docs/components/database.mdx | 10 +++++++--- .../src/content/docs/components/migrations.mdx | 14 +++++++++----- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e6b32c9..44c1ac9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -278,7 +278,7 @@ Documentation examples for consuming WordPress projects should use Snake_Case cl WP-CLI command class examples should include one or more `@example` annotations showing the complete `wp ` invocation, including a representative invocation with options or flags when applicable. -When examples include WordPress application bootstrap, use the canonical architecture: `App` owns the request singleton, providers register in explicit dependency order, and feature providers group definitions and hooks by capability. Use `$this->container->callback(ClassName::class, 'method')` for lazy WordPress callbacks. +When examples include WordPress application bootstrap, use the canonical architecture: `App` owns the request singleton, providers register in explicit dependency order, and feature providers group definitions and hooks by capability. Show provider setup with imports and the ordered `App::PROVIDERS` list in `src/App.php`. Use `$this->container->callback(ClassName::class, 'method')` for lazy WordPress callbacks. Keep full source paths in the prose immediately before code examples and use only the filename in a code-block `title`. Nova's Shiki metadata parser interprets path segments such as `/Lock/` as word-highlighting instructions and otherwise adds unintended borders around matching code tokens. diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index 010dd74..8802f29 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -38,12 +38,16 @@ return [ Use a stable, unique `foundation.prefix` for Foundation-managed resources such as migration history and database lock storage. Choose plugin-specific application table names separately, such as `your_plugin_reports`. See [application scoping](/start/scope-foundation/). ::: -Create your container using [ContainerFactory](/start/configure-the-container/), then register providers once, in dependency order: +After [creating the application container](/start/configure-the-container/), register the database provider before application providers in `src/App.php`: -```php +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Provider; use StellarWP\Foundation\Database\DatabaseProvider; -$container->register( DatabaseProvider::class ); +/** @var list> */ +private const array PROVIDERS = [ + DatabaseProvider::class, +]; ``` `DatabaseProvider` supplies one shared `Doctrine\DBAL\Connection`. Tables and repositories resolved from the container participate in that connection's transactions. Registration is lazy: it creates no database tables. Register an application provider afterward when you need custom service bindings. diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index 3902114..3735135 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -35,16 +35,20 @@ return [ Replace `your-plugin` with your plugin's stable, unique prefix. Sharing the default with another Foundation consumer can mix migration history and lock ownership. See [application scoping](/start/scope-foundation/). ::: -After [creating the application container](/start/configure-the-container/) with that configuration, register providers in dependency order: +After [creating the application container](/start/configure-the-container/) with that configuration, register providers in dependency order in `src/App.php`: -```php +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Provider; use StellarWP\Foundation\Database\DatabaseProvider; use StellarWP\Foundation\Migrations\MigrationsProvider; use StellarWP\Foundation\WPCli\WPCliProvider; -$container->register( WPCliProvider::class ); -$container->register( DatabaseProvider::class ); -$container->register( MigrationsProvider::class ); +/** @var list> */ +private const array PROVIDERS = [ + WPCliProvider::class, + DatabaseProvider::class, + MigrationsProvider::class, +]; ``` `DatabaseProvider` supplies the shared connection. `MigrationsProvider` configures discovery, history, and migration execution; `WPCliProvider` enables `wp migrate`. Applications that invoke `Migrator` programmatically can omit `WPCliProvider`. Register application providers afterward, before resolving migration services. From 71e77c57435dae96d1d4d11fb4e8a60a15d9d86a Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 23 Sep 2026 21:03:28 -0600 Subject: [PATCH 16/53] Standardize test helper calls and fix clean-checkout analysis --- AGENTS.md | 2 + tests/CodeceptionSupport/WPCLITester.php | 2 + .../Fixtures/Database/DatabaseTestCase.php | 4 +- .../Commands/Make/MigrationGenerationTest.php | 128 ++++++------ .../Tooling/ToolingProviderResolverTest.php | 9 +- .../Database/Table/TableNameResolverTest.php | 6 +- .../LockDatabase/LockDatabaseProviderTest.php | 14 +- .../Unit/Migrations/ColumnDefinitionTest.php | 14 +- .../Migrations/MigrationCollectionTest.php | 6 +- .../Migrations/MigrationDiscoveryTest.php | 22 +-- tests/integration/Database/ImporterTest.php | 8 +- .../Database/NativeBehaviorTest.php | 8 +- tests/integration/Database/TableTest.php | 34 ++-- .../integration/Database/TransactionTest.php | 86 ++++---- .../LockDatabase/DatabaseLockTest.php | 36 ++-- .../Migrations/AdvisoryMigrationTest.php | 100 +++++----- .../DeclarativeMigratorEdgeCaseTest.php | 100 +++++----- .../Migrations/DeclarativeMigratorTest.php | 184 +++++++++--------- .../Migrations/GeneratedMigrationTest.php | 12 +- .../Migrations/MigrationDiscoveryTest.php | 28 +-- .../integration/Shutdown/BufferedTaskTest.php | 6 +- .../LockRedis/RedisLockIntegrationTest.php | 6 +- 22 files changed, 415 insertions(+), 400 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 44c1ac9..9a1d77a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,6 +284,8 @@ Keep full source paths in the prose immediately before code examples and use onl ## Verification +Use `$this->` for inherited PHPUnit and WordPress test helpers wherever the test instance is available, including assertions, `fail()`, mock matchers such as `never()` and `exactly()`, and `factory()`. Reserve `self::` helper calls for static methods and callbacks. Preserve deliberate `parent::` calls to inherited implementations. + Test observable behavior, operational guarantees, and supported substitutions. Keep representative consumer fixtures, including generated class shapes, stable after establishing the release baseline; changing them during an internal refactor requires a compatibility explanation. Avoid tests that merely mirror trivial construction or private wiring. Extend existing coverage rather than creating a test for every class mechanically. When `composer lint` reports style-only issues, run `composer format` to let the project formatter fix them before making manual formatting edits. diff --git a/tests/CodeceptionSupport/WPCLITester.php b/tests/CodeceptionSupport/WPCLITester.php index 8e5d15e..0ddea22 100644 --- a/tests/CodeceptionSupport/WPCLITester.php +++ b/tests/CodeceptionSupport/WPCLITester.php @@ -16,6 +16,8 @@ * @method void comment($description) * @method void pause($vars = []) * + * @mixin \lucatume\WPBrowser\Module\WPCLI + * * @SuppressWarnings(PHPMD) */ class WPCLITester extends \Codeception\Actor diff --git a/tests/Support/Fixtures/Database/DatabaseTestCase.php b/tests/Support/Fixtures/Database/DatabaseTestCase.php index 91878e4..0287c9c 100644 --- a/tests/Support/Fixtures/Database/DatabaseTestCase.php +++ b/tests/Support/Fixtures/Database/DatabaseTestCase.php @@ -93,7 +93,7 @@ protected function privateTable(string $name): string { protected function native(wpdb $source): mysqli { $native = $source->__get('dbh'); - self::assertInstanceOf(mysqli::class, $native); + $this->assertInstanceOf(mysqli::class, $native); return $native; } @@ -103,6 +103,6 @@ protected function killConnection(): void { } protected function assertOriginal(): void { - self::assertSame(['Original'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table)); + $this->assertSame(['Original'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table)); } } diff --git a/tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php b/tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php index 7d6ccbb..5fb9aec 100644 --- a/tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php +++ b/tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php @@ -49,31 +49,31 @@ private function files(string $directory = 'db/migrations'): array { public function test_combined_creation_then_alteration_has_ordered_file_identities_without_provider_edits(): void { $table = $this->table(); - self::assertSame(0, $table->execute(['name' => 'reports', '--migration' => true]), $table->getDisplay()); - self::assertStringNotContainsString('Runtime dependency missing', $table->getDisplay()); + $this->assertSame(0, $table->execute(['name' => 'reports', '--migration' => true]), $table->getDisplay()); + $this->assertStringNotContainsString('Runtime dependency missing', $table->getDisplay()); $createFile = $this->files()[0]; $create = (string) file_get_contents($createFile); - self::assertMatchesRegularExpression('/[0-9]{14}_create_reports_table.php$/', $createFile); - self::assertStringContainsString('extends Migration', $create); - self::assertStringContainsString('return new class extends Migration', $create); - self::assertStringNotContainsString('Reports_Table', $create); - self::assertStringContainsString("\$schema->create( 'reports' )", $create); - self::assertStringContainsString("\$schema->drop( 'reports' )", $create); - self::assertStringNotContainsString('const string ID', $create); - self::assertStringNotContainsString('function id()', $create); - self::assertFileDoesNotExist($this->root . '/src/Database/Provider.php'); + $this->assertMatchesRegularExpression('/[0-9]{14}_create_reports_table.php$/', $createFile); + $this->assertStringContainsString('extends Migration', $create); + $this->assertStringContainsString('return new class extends Migration', $create); + $this->assertStringNotContainsString('Reports_Table', $create); + $this->assertStringContainsString("\$schema->create( 'reports' )", $create); + $this->assertStringContainsString("\$schema->drop( 'reports' )", $create); + $this->assertStringNotContainsString('const string ID', $create); + $this->assertStringNotContainsString('function id()', $create); + $this->assertFileDoesNotExist($this->root . '/src/Database/Provider.php'); $command = $this->migration(); - self::assertSame(0, $command->execute(['name' => 'Add_Published_At', '--table' => 'reports']), $command->getDisplay()); - self::assertStringNotContainsString('Runtime dependency missing', $command->getDisplay()); + $this->assertSame(0, $command->execute(['name' => 'Add_Published_At', '--table' => 'reports']), $command->getDisplay()); + $this->assertStringNotContainsString('Runtime dependency missing', $command->getDisplay()); $files = $this->files(); - self::assertCount(2, $files); - self::assertSame($createFile, $files[0]); + $this->assertCount(2, $files); + $this->assertSame($createFile, $files[0]); $alter = (string) file_get_contents($files[1]); - self::assertStringContainsString("\$schema->table( 'reports' )", $alter); - self::assertStringContainsString('throw new IrreversibleMigration', $alter); - self::assertStringNotContainsString('$schema->drop', $alter); - self::assertSame($create, file_get_contents($createFile)); + $this->assertStringContainsString("\$schema->table( 'reports' )", $alter); + $this->assertStringContainsString('throw new IrreversibleMigration', $alter); + $this->assertStringNotContainsString('$schema->drop', $alter); + $this->assertSame($create, file_get_contents($createFile)); } public function test_combined_generation_warns_when_migrations_are_only_installed_for_development(): void { @@ -83,24 +83,24 @@ public function test_combined_generation_warns_when_migrations_are_only_installe file_put_contents($this->root . '/composer.json', json_encode($manifest, JSON_THROW_ON_ERROR)); $command = $this->table(); - self::assertSame(0, $command->execute(['name' => 'reports', '--migration' => true])); - self::assertStringContainsString('Move stellarwp/foundation-migrations or stellarwp/foundation to require', $command->getDisplay()); + $this->assertSame(0, $command->execute(['name' => 'reports', '--migration' => true])); + $this->assertStringContainsString('Move stellarwp/foundation-migrations or stellarwp/foundation to require', $command->getDisplay()); } public function test_generation_creates_custom_directories_without_composer_namespace_mappings(): void { $this->configure(['migrations' => ['path' => 'migrations']]); $command = $this->migration(); - self::assertSame(0, $command->execute(['name' => 'Reports/Add_Status']), $command->getDisplay()); + $this->assertSame(0, $command->execute(['name' => 'Reports/Add_Status']), $command->getDisplay()); $file = $this->files('migrations/reports')[0]; - self::assertStringNotContainsString('namespace ', (string) file_get_contents($file)); - self::assertDirectoryDoesNotExist($this->root . '/src'); + $this->assertStringNotContainsString('namespace ', (string) file_get_contents($file)); + $this->assertDirectoryDoesNotExist($this->root . '/src'); } public function test_absolute_location_and_foundation_root_use_the_same_configuration(): void { $this->configure(['foundation' => ['root' => $this->root], 'migrations' => ['path' => $this->root . '/src/History']]); $command = $this->migration(); - self::assertSame(0, $command->execute(['name' => 'Rebuild']), $command->getDisplay()); - self::assertCount(1, $this->files('src/History')); + $this->assertSame(0, $command->execute(['name' => 'Rebuild']), $command->getDisplay()); + $this->assertCount(1, $this->files('src/History')); } public function test_new_timestamps_follow_migrations_in_other_feature_folders(): void { @@ -108,45 +108,47 @@ public function test_new_timestamps_follow_migrations_in_other_feature_folders() file_put_contents($this->root . '/db/migrations/Reports/20990101000000_create.php', 'root . '/db/migrations/Reports/README.md', 'History'); $command = $this->migration(); - self::assertSame(0, $command->execute(['name' => 'Add_Status'])); - self::assertStringEndsWith('20990101000001_add_status.php', $this->files()[0]); + $this->assertSame(0, $command->execute(['name' => 'Add_Status'])); + $this->assertStringEndsWith('20990101000001_add_status.php', $this->files()[0]); } public function test_repeated_descriptions_create_new_files_and_never_overwrite_history(): void { - $command = $this->migration(); - self::assertSame(0, $command->execute(['name' => 'Change_Status'])); + $command = $this->migration(); + $exitCode = $command->execute(['name' => 'Change_Status']); + $this->assertSame(0, $exitCode); $file = $this->files()[0]; $contents = file_get_contents($file); - self::assertSame(0, $command->execute(['name' => 'Change_Status'])); - self::assertCount(2, $this->files()); - self::assertSame($contents, file_get_contents($file)); - self::assertFalse($this->services->get(MigrationCommand::class)->getDefinition()->hasOption('force')); + $exitCode = $command->execute(['name' => 'Change_Status']); + $this->assertSame(0, $exitCode); + $this->assertCount(2, $this->files()); + $this->assertSame($contents, file_get_contents($file)); + $this->assertFalse($this->services->get(MigrationCommand::class)->getDefinition()->hasOption('force')); } public function test_standalone_create_and_alter_use_table_names_without_application_classes(): void { file_put_contents($this->root . '/composer.json', '{"require":{"stellarwp/foundation-migrations":"^2.0"}}'); $command = $this->migration(); - self::assertSame(0, $command->execute(['name' => 'create_reports', '--create' => 'your_plugin_reports']), $command->getDisplay()); - self::assertSame(0, $command->execute(['name' => 'add_status', '--table' => 'your_plugin_reports']), $command->getDisplay()); - self::assertCount(2, $this->files()); - self::assertDirectoryDoesNotExist($this->root . '/src'); + $this->assertSame(0, $command->execute(['name' => 'create_reports', '--create' => 'your_plugin_reports']), $command->getDisplay()); + $this->assertSame(0, $command->execute(['name' => 'add_status', '--table' => 'your_plugin_reports']), $command->getDisplay()); + $this->assertCount(2, $this->files()); + $this->assertDirectoryDoesNotExist($this->root . '/src'); } public function test_combined_generation_preserves_an_explicit_physical_table_suffix(): void { - self::assertSame(0, $this->table()->execute(['name' => 'reports', '--table-name' => 'your_plugin_reports', '--migration' => true])); + $this->assertSame(0, $this->table()->execute(['name' => 'reports', '--table-name' => 'your_plugin_reports', '--migration' => true])); $table = (string) file_get_contents($this->root . '/src/Database/Tables/Reports_Table.php'); $migration = (string) file_get_contents($this->files()[0]); - self::assertStringContainsString("UNPREFIXED_TABLE_NAME = 'your_plugin_reports';", $table); - self::assertStringContainsString("\$schema->create( 'your_plugin_reports' )", $migration); + $this->assertStringContainsString("UNPREFIXED_TABLE_NAME = 'your_plugin_reports';", $table); + $this->assertStringContainsString("\$schema->create( 'your_plugin_reports' )", $migration); } public function test_generic_migration_does_not_infer_table_ownership_from_its_name(): void { $command = $this->migration(); - self::assertSame(0, $command->execute(['name' => 'Create_Reports_Table'])); + $this->assertSame(0, $command->execute(['name' => 'Create_Reports_Table'])); $contents = (string) file_get_contents($this->files()[0]); - self::assertStringNotContainsString('$schema->create', $contents); - self::assertStringNotContainsString('$schema->drop', $contents); - self::assertStringContainsString('IrreversibleMigration', $contents); + $this->assertStringNotContainsString('$schema->create', $contents); + $this->assertStringNotContainsString('$schema->drop', $contents); + $this->assertStringContainsString('IrreversibleMigration', $contents); } public function test_invalid_options_and_locations_fail_without_writing(): void { @@ -162,35 +164,35 @@ public function test_invalid_options_and_locations_fail_without_writing(): void ['name' => '/Change'], ['name' => str_repeat('a', 180)], ] as $input) { - self::assertSame(1, $this->migration()->execute($input)); - self::assertSame([], $this->files()); + $this->assertSame(1, $this->migration()->execute($input)); + $this->assertSame([], $this->files()); } $this->configure(['migrations' => ['path' => '']]); $command = $this->migration(); - self::assertSame(1, $command->execute(['name' => 'Change'])); - self::assertStringContainsString('cannot be blank', $command->getDisplay()); - self::assertDirectoryDoesNotExist($this->root . '/unmapped'); + $this->assertSame(1, $command->execute(['name' => 'Change'])); + $this->assertStringContainsString('cannot be blank', $command->getDisplay()); + $this->assertDirectoryDoesNotExist($this->root . '/unmapped'); } public function test_combined_generation_writes_neither_file_if_migration_location_is_invalid(): void { $this->configure(['migrations' => ['path' => '']]); - self::assertSame(1, $this->table()->execute(['name' => 'reports', '--migration' => true])); - self::assertFileDoesNotExist($this->root . '/src/Database/Tables/Reports_Table.php'); + $this->assertSame(1, $this->table()->execute(['name' => 'reports', '--migration' => true])); + $this->assertFileDoesNotExist($this->root . '/src/Database/Tables/Reports_Table.php'); } public function test_strauss_imports_apply_to_every_generated_migration_kind(): void { $manifest = json_decode((string) file_get_contents($this->root . '/composer.json'), true, 512, JSON_THROW_ON_ERROR); $manifest['extra']['strauss']['namespace_prefix'] = 'Scoped\\'; file_put_contents($this->root . '/composer.json', json_encode($manifest, JSON_THROW_ON_ERROR)); - self::assertSame(0, $this->table()->execute(['name' => 'reports', '--migration' => true])); - self::assertSame(0, $this->migration()->execute(['name' => 'Add_Status', '--table' => 'reports'])); - self::assertSame(0, $this->migration()->execute(['name' => 'Backfill'])); - self::assertCount(3, $this->files()); + $this->assertSame(0, $this->table()->execute(['name' => 'reports', '--migration' => true])); + $this->assertSame(0, $this->migration()->execute(['name' => 'Add_Status', '--table' => 'reports'])); + $this->assertSame(0, $this->migration()->execute(['name' => 'Backfill'])); + $this->assertCount(3, $this->files()); foreach ($this->files() as $file) { $contents = (string) file_get_contents($file); - self::assertStringContainsString('use Scoped\\StellarWP\\Foundation\\Migrations\\Migration;', $contents); - self::assertStringContainsString('use Scoped\\StellarWP\\Foundation\\Migrations\\Schema\\Blueprint;', $contents); + $this->assertStringContainsString('use Scoped\\StellarWP\\Foundation\\Migrations\\Migration;', $contents); + $this->assertStringContainsString('use Scoped\\StellarWP\\Foundation\\Migrations\\Schema\\Blueprint;', $contents); } } @@ -201,14 +203,14 @@ public function test_each_migration_mode_uses_the_project_stub_override(): void file_put_contents($this->root . '/foundation/stubs/database/' . $stub . '.stub', 'table()->execute(['name' => 'reports', '--migration' => true])); - self::assertSame(0, $this->migration()->execute(['name' => 'Add_Status', '--table' => 'reports'])); - self::assertSame(0, $this->migration()->execute(['name' => 'Backfill'])); + $this->assertSame(0, $this->table()->execute(['name' => 'reports', '--migration' => true])); + $this->assertSame(0, $this->migration()->execute(['name' => 'Add_Status', '--table' => 'reports'])); + $this->assertSame(0, $this->migration()->execute(['name' => 'Backfill'])); $files = $this->files(); - self::assertCount(3, $files); + $this->assertCount(3, $files); foreach (['create-table-migration', 'alter-table-migration', 'migration'] as $i => $stub) { - self::assertStringContainsString('// custom ' . $stub, (string) file_get_contents($files[$i])); + $this->assertStringContainsString('// custom ' . $stub, (string) file_get_contents($files[$i])); } } } diff --git a/tests/Unit/Cli/Tooling/ToolingProviderResolverTest.php b/tests/Unit/Cli/Tooling/ToolingProviderResolverTest.php index d760898..ad4d662 100644 --- a/tests/Unit/Cli/Tooling/ToolingProviderResolverTest.php +++ b/tests/Unit/Cli/Tooling/ToolingProviderResolverTest.php @@ -47,11 +47,14 @@ public function test_it_resolves_an_explicit_file_through_development_mappings() public function test_no_manifest_or_namespace_or_project_provider_is_optional(): void { unlink($this->root . '/composer.json'); - $this->assertSame([], $this->resolver()->providers()); + $providers = $this->resolver()->providers(); + $this->assertSame([], $providers); file_put_contents($this->root . '/composer.json', '{}'); - $this->assertSame([], $this->resolver()->providers()); + $providers = $this->resolver()->providers(); + $this->assertSame([], $providers); file_put_contents($this->root . '/composer.json', '{"autoload":{"psr-4":{"Absent\\\\":"src"}}}'); - $this->assertSame([], $this->resolver()->providers()); + $providers = $this->resolver()->providers(); + $this->assertSame([], $providers); } public function test_it_reports_invalid_composer_metadata(): void { diff --git a/tests/Unit/Database/Table/TableNameResolverTest.php b/tests/Unit/Database/Table/TableNameResolverTest.php index 8d27b57..982f5f0 100644 --- a/tests/Unit/Database/Table/TableNameResolverTest.php +++ b/tests/Unit/Database/Table/TableNameResolverTest.php @@ -51,9 +51,9 @@ public function test_historical_string_names_have_the_same_validation(string $na public function test_a_historical_name_is_resolved_again_for_each_site(): void { $scope = $this->createMock(DatabaseScope::class); - $scope->expects(self::exactly(2))->method('resolveTableName')->with('reports')->willReturnOnConsecutiveCalls('wp_reports', 'wp_2_reports'); + $scope->expects($this->exactly(2))->method('resolveTableName')->with('reports')->willReturnOnConsecutiveCalls('wp_reports', 'wp_2_reports'); $names = new TableNameResolver($scope); - self::assertSame('wp_reports', $names->tableName('reports')); - self::assertSame('wp_2_reports', $names->tableName('reports')); + $this->assertSame('wp_reports', $names->tableName('reports')); + $this->assertSame('wp_2_reports', $names->tableName('reports')); } } diff --git a/tests/Unit/LockDatabase/LockDatabaseProviderTest.php b/tests/Unit/LockDatabase/LockDatabaseProviderTest.php index 10c5544..cd6a2d5 100644 --- a/tests/Unit/LockDatabase/LockDatabaseProviderTest.php +++ b/tests/Unit/LockDatabase/LockDatabaseProviderTest.php @@ -41,7 +41,7 @@ public static function tableNames(): iterable { #[DataProvider('tableNames')] public function test_it_uses_the_shared_database_services_and_configured_storage(array $configuration, string $name): void { $db = $this->createMock(Connection::class); - $db->expects(self::never())->method('executeStatement'); + $db->expects($this->never())->method('executeStatement'); $scope = $this->createMock(DatabaseScope::class); $scope->method('resolveTableName')->willReturnCallback(static fn (string $suffix): string => 'wp_' . $suffix); $this->container->singleton(Configuration::class, new ArrayConfiguration($configuration)); @@ -49,11 +49,11 @@ public function test_it_uses_the_shared_database_services_and_configured_storage $this->container->singleton(TableNameResolver::class, new DatabaseTableNameResolver($scope)); $this->container->register(LockDatabaseProvider::class); - self::assertSame($name, $this->container->get(LockTable::class)->name()); + $this->assertSame($name, $this->container->get(LockTable::class)->name()); $lock = $this->container->get(DatabaseLock::class); - self::assertSame($lock, $this->container->get(DatabaseLock::class)); - self::assertSame($db, $this->container->get(Connection::class)); - self::assertFalse($this->container->has(Lock::class)); + $this->assertSame($lock, $this->container->get(DatabaseLock::class)); + $this->assertSame($db, $this->container->get(Connection::class)); + $this->assertFalse($this->container->has(Lock::class)); } public function test_registration_preserves_an_existing_application_lock_without_resolving_database_services(): void { @@ -61,7 +61,7 @@ public function test_registration_preserves_an_existing_application_lock_without $this->container->singleton(Lock::class, $existing); $this->container->register(LockDatabaseProvider::class); - self::assertSame($existing, $this->container->get(Lock::class)); - self::assertNotNull($existing->acquire('resource', 60)); + $this->assertSame($existing, $this->container->get(Lock::class)); + $this->assertNotNull($existing->acquire('resource', 60)); } } diff --git a/tests/Unit/Migrations/ColumnDefinitionTest.php b/tests/Unit/Migrations/ColumnDefinitionTest.php index 5215531..131ef50 100644 --- a/tests/Unit/Migrations/ColumnDefinitionTest.php +++ b/tests/Unit/Migrations/ColumnDefinitionTest.php @@ -40,7 +40,7 @@ public function test_decimal_defaults_are_normalized_as_strings(string $declared $table = Table::editor()->setUnquotedName('t'); (new ColumnDefinition('amount', Types::DECIMAL, ['precision' => 20, 'scale' => $scale]))->default($declared)->applyTo($table); - self::assertSame($expected, $table->create()->getColumn('amount')->getDefault()); + $this->assertSame($expected, $table->create()->getColumn('amount')->getDefault()); } public function test_change_replaces_type_default_and_comment(): void { @@ -50,10 +50,10 @@ public function test_change_replaces_type_default_and_comment(): void { (new ColumnDefinition('value', Types::INTEGER))->change()->applyTo($table); $column = $table->create()->getColumn('value'); - self::assertSame(Types::INTEGER, Type::lookupName($column->getType())); - self::assertNull($column->getDefault()); - self::assertSame('', $column->getComment()); - self::assertTrue($column->getNotnull()); + $this->assertSame(Types::INTEGER, Type::lookupName($column->getType())); + $this->assertNull($column->getDefault()); + $this->assertSame('', $column->getComment()); + $this->assertTrue($column->getNotnull()); } public function test_change_on_a_missing_column_fails_instead_of_adding_one(): void { @@ -70,7 +70,7 @@ public function test_explicit_zero_precision_matches_omitted_precision(): void { $omitted = Table::editor()->setUnquotedName('t'); (new ColumnDefinition('created_at', Types::DATETIME_MUTABLE))->useCurrent()->applyTo($omitted); - self::assertSame('CURRENT_TIMESTAMP', $explicit->create()->getColumn('created_at')->getDefault()); - self::assertSame($omitted->create()->getColumn('created_at')->toArray(), $explicit->create()->getColumn('created_at')->toArray()); + $this->assertSame('CURRENT_TIMESTAMP', $explicit->create()->getColumn('created_at')->getDefault()); + $this->assertSame($omitted->create()->getColumn('created_at')->toArray(), $explicit->create()->getColumn('created_at')->toArray()); } } diff --git a/tests/Unit/Migrations/MigrationCollectionTest.php b/tests/Unit/Migrations/MigrationCollectionTest.php index 60951ff..fe8a6fc 100644 --- a/tests/Unit/Migrations/MigrationCollectionTest.php +++ b/tests/Unit/Migrations/MigrationCollectionTest.php @@ -25,11 +25,11 @@ public function test_order_and_identity_are_byte_exact_even_for_numeric_ids(): v $ids = ['a', '10', '2', '01', 'A', '20260922000100']; $registrations = array_map($this->migration(...), $ids); $collection = new MigrationCollection($registrations); - self::assertSame(['01', '10', '2', '20260922000100', 'A', 'a'], $collection->ids()); + $this->assertSame(['01', '10', '2', '20260922000100', 'A', 'a'], $collection->ids()); foreach ($registrations as $registration) { - self::assertSame($registration->migration, $collection->get($registration->id)); + $this->assertSame($registration->migration, $collection->get($registration->id)); } - self::assertFalse($collection->has('missing')); + $this->assertFalse($collection->has('missing')); } public function test_duplicate_ids_fail_before_execution(): void { diff --git a/tests/Unit/Migrations/MigrationDiscoveryTest.php b/tests/Unit/Migrations/MigrationDiscoveryTest.php index 185a3de..429ed63 100644 --- a/tests/Unit/Migrations/MigrationDiscoveryTest.php +++ b/tests/Unit/Migrations/MigrationDiscoveryTest.php @@ -21,10 +21,10 @@ protected function setUp(): void { public function test_nested_files_are_loaded_and_ordered_without_composer_or_a_container(): void { $discovery = new MigrationDiscovery(null, $this->fixtures); $collection = new MigrationCollection($discovery->migrations()); - self::assertSame(['20260923000001_create_reports_table', '20260923000002_add_published_at'], $collection->ids()); + $this->assertSame(['20260923000001_create_reports_table', '20260923000002_add_published_at'], $collection->ids()); $again = new MigrationCollection($discovery->migrations()); - self::assertSame($collection->ids(), $again->ids()); - self::assertNotSame($collection->get($collection->ids()[0]), $again->get($again->ids()[0])); + $this->assertSame($collection->ids(), $again->ids()); + $this->assertNotSame($collection->get($collection->ids()[0]), $again->get($again->ids()[0])); } public function test_moving_a_file_preserves_identity_and_duplicate_filenames_are_rejected(): void { @@ -33,17 +33,17 @@ public function test_moving_a_file_preserves_identity_and_duplicate_filenames_ar copy($this->fixtures . '/' . $file, $root . '/' . $file); $original = iterator_to_array((new MigrationDiscovery(null, $this->fixtures))->migrations()); $moved = iterator_to_array((new MigrationDiscovery(null, $root))->migrations()); - self::assertSame(pathinfo($file, PATHINFO_FILENAME), $moved[0]->id); + $this->assertSame(pathinfo($file, PATHINFO_FILENAME), $moved[0]->id); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Duplicate migration identity'); new MigrationCollection([...$original, ...$moved]); } public function test_no_configuration_or_missing_default_directory_is_empty(): void { - self::assertSame([], iterator_to_array((new MigrationDiscovery(null, null))->migrations())); + $this->assertSame([], iterator_to_array((new MigrationDiscovery(null, null))->migrations())); $root = $this->prepare_temp_dir('missing-migrations'); - self::assertSame([], iterator_to_array((new MigrationDiscovery($root, null))->migrations())); - self::assertDirectoryDoesNotExist($root . '/db/migrations'); + $this->assertSame([], iterator_to_array((new MigrationDiscovery($root, null))->migrations())); + $this->assertDirectoryDoesNotExist($root . '/db/migrations'); } public function test_an_explicit_missing_directory_is_an_error(): void { @@ -72,13 +72,13 @@ public function test_helpers_and_non_migration_files_are_not_executed(): void { file_put_contents($root . '/' . $file, 'migrations())); + $this->assertSame([], iterator_to_array((new MigrationDiscovery(null, $root))->migrations())); } public function test_default_and_custom_paths_are_normalized_without_namespace_mappings(): void { - self::assertSame('/project/db/migrations', (new MigrationDirectory('/project'))->path); - self::assertSame('/project/history', (new MigrationDirectory('/project', 'db/../history'))->path); - self::assertSame('/shared/history', (new MigrationDirectory(null, '/shared/history'))->path); + $this->assertSame('/project/db/migrations', (new MigrationDirectory('/project'))->path); + $this->assertSame('/project/history', (new MigrationDirectory('/project', 'db/../history'))->path); + $this->assertSame('/shared/history', (new MigrationDirectory(null, '/shared/history'))->path); } public function test_blank_migration_path_is_an_error_instead_of_scanning_the_project_root(): void { diff --git a/tests/integration/Database/ImporterTest.php b/tests/integration/Database/ImporterTest.php index e13035e..d62c234 100644 --- a/tests/integration/Database/ImporterTest.php +++ b/tests/integration/Database/ImporterTest.php @@ -12,9 +12,9 @@ final class ImporterTest extends DatabaseTestCase public function test_application_service_replaces_multiple_batches_and_repository_uses_native_query_builder(): void { $this->container->when(EntryRepository::class)->needs('$tableName')->give($this->suffix); $records = array_map(static fn (int $id): array => ['id' => $id, 'name' => 'Imported'], range(1, 501)); - self::assertSame(501, $this->container->get(DatasetImporter::class)->replace($records)); - self::assertSame(501, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->table)); - self::assertCount(50, $this->container->get(EntryRepository::class)->findByName('Imported')); + $this->assertSame(501, $this->container->get(DatasetImporter::class)->replace($records)); + $this->assertSame(501, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->table)); + $this->assertCount(50, $this->container->get(EntryRepository::class)->findByName('Imported')); } public function test_a_failure_in_the_second_batch_restores_the_original_dataset(): void { @@ -24,7 +24,7 @@ public function test_a_failure_in_the_second_batch_restores_the_original_dataset try { $this->container->get(DatasetImporter::class)->replace($records); - self::fail('Expected a second-batch constraint violation.'); + $this->fail('Expected a second-batch constraint violation.'); } catch (UniqueConstraintViolationException) { $this->assertOriginal(); } diff --git a/tests/integration/Database/NativeBehaviorTest.php b/tests/integration/Database/NativeBehaviorTest.php index fc6d0b4..ed40f37 100644 --- a/tests/integration/Database/NativeBehaviorTest.php +++ b/tests/integration/Database/NativeBehaviorTest.php @@ -21,7 +21,7 @@ public function test_native_dbal_commits_after_a_caught_statement_failure(): voi } catch (UniqueConstraintViolationException) { } }); - self::assertSame('Committed despite failure', $this->observer->fetchOne('SELECT name FROM ' . $this->table)); + $this->assertSame('Committed despite failure', $this->observer->fetchOne('SELECT name FROM ' . $this->table)); } public function test_native_dbal_rejects_unacknowledged_commit(): void { @@ -34,7 +34,7 @@ public function test_native_dbal_rejects_unacknowledged_commit(): void { return 'success'; }); - self::fail('Expected DBAL to reject the unacknowledged commit.'); + $this->fail('Expected DBAL to reject the unacknowledged commit.'); } catch (ConnectionLost) { $this->assertOriginal(); } @@ -51,8 +51,8 @@ public function test_native_cleanup_can_replace_the_business_exception(): void { throw $business; }); } catch (Throwable $caught) { - self::assertNotSame($business, $caught); - self::assertStringContainsString('closed', $caught->getMessage()); + $this->assertNotSame($business, $caught); + $this->assertStringContainsString('closed', $caught->getMessage()); } } } diff --git a/tests/integration/Database/TableTest.php b/tests/integration/Database/TableTest.php index d00e3d2..1da6767 100644 --- a/tests/integration/Database/TableTest.php +++ b/tests/integration/Database/TableTest.php @@ -21,9 +21,9 @@ protected function setUp(): void { public function test_ordinary_table_calls_share_the_transaction_and_bind_values(): void { $id = $this->entries->insertGetId(['name' => "O'Reilly", 'active' => true], ['active' => Types::BOOLEAN]); - self::assertSame("O'Reilly", $this->entries->query()->select('name')->where('id = :id')->setParameter('id', $id)->fetchOne()); - self::assertSame(1, (int) $this->entries->update(['name' => 'Changed'], ['id' => $id])); - self::assertSame(1, (int) $this->entries->query()->select('active')->fetchOne()); + $this->assertSame("O'Reilly", $this->entries->query()->select('name')->where('id = :id')->setParameter('id', $id)->fetchOne()); + $this->assertSame(1, (int) $this->entries->update(['name' => 'Changed'], ['id' => $id])); + $this->assertSame(1, (int) $this->entries->query()->select('active')->fetchOne()); try { $this->db->transactional(function (): void { @@ -32,30 +32,30 @@ public function test_ordinary_table_calls_share_the_transaction_and_bind_values( throw new \RuntimeException('Cancel'); }); } catch (\RuntimeException $failure) { - self::assertSame('Cancel', $failure->getMessage()); + $this->assertSame('Cancel', $failure->getMessage()); } - self::assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->entries->quotedName())); - self::assertSame(1, (int) $this->entries->delete(['id' => $id])); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->entries->quotedName())); + $this->assertSame(1, (int) $this->entries->delete(['id' => $id])); } public function test_queries_are_fresh_and_capture_the_site_when_built(): void { $old = $this->entries->query('entry')->select('entry.id'); $fresh = $this->entries->query(); - self::assertNotSame($old, $fresh); + $this->assertNotSame($old, $fresh); $oldName = $this->entries->name(); $this->source->set_prefix($this->source->prefix . 'other_'); - self::assertStringContainsString($oldName, $old->getSQL()); - self::assertNotSame($oldName, $this->entries->name()); - self::assertStringContainsString($this->entries->name(), $this->entries->query()->select('id')->getSQL()); + $this->assertStringContainsString($oldName, $old->getSQL()); + $this->assertNotSame($oldName, $this->entries->name()); + $this->assertStringContainsString($this->entries->name(), $this->entries->query()->select('id')->getSQL()); } public function test_empty_mutation_criteria_are_rejected(): void { foreach (['update', 'delete'] as $method) { try { $method === 'update' ? $this->entries->update(['name' => 'All'], []) : $this->entries->delete([]); - self::fail('Expected empty criteria rejection.'); + $this->fail('Expected empty criteria rejection.'); } catch (InvalidArgumentException) { - self::assertSame(0, (int) $this->entries->query()->select('COUNT(*)')->fetchOne()); + $this->assertSame(0, (int) $this->entries->query()->select('COUNT(*)')->fetchOne()); } } } @@ -66,19 +66,19 @@ public function test_idle_connection_replacement_supports_queries_and_transactio $this->source->__set('dbh', $this->native($this->observerSource)); try { - self::assertSame($this->native($this->observerSource)->thread_id, (int) $this->db->fetchOne('SELECT CONNECTION_ID()')); - self::assertSame('updated', $this->db->transactional(function (Connection $db): string { + $this->assertSame($this->native($this->observerSource)->thread_id, (int) $this->db->fetchOne('SELECT CONNECTION_ID()')); + $this->assertSame('updated', $this->db->transactional(function (Connection $db): string { $this->entries->insert(['name' => 'Reconnected']); return 'updated'; })); - self::assertSame('Reconnected', $this->entries->query()->select('name')->fetchOne()); + $this->assertSame('Reconnected', $this->entries->query()->select('name')->fetchOne()); try { $oldStatement->executeQuery(); - self::fail('An old statement must not run on a replaced session.'); + $this->fail('An old statement must not run on a replaced session.'); } catch (\RuntimeException $failure) { - self::assertStringContainsString('connection changed', $failure->getMessage()); + $this->assertStringContainsString('connection changed', $failure->getMessage()); } } finally { $this->source->__set('dbh', $old); diff --git a/tests/integration/Database/TransactionTest.php b/tests/integration/Database/TransactionTest.php index dfcc7ae..1c00f38 100644 --- a/tests/integration/Database/TransactionTest.php +++ b/tests/integration/Database/TransactionTest.php @@ -27,7 +27,7 @@ public function test_a_prefix_change_cannot_commit_even_after_the_error_is_caugh $this->source->set_prefix($prefix); } }); - self::fail('A caught scope failure must remain terminal.'); + $this->fail('A caught scope failure must remain terminal.'); } catch (\StellarWP\Foundation\Database\Exceptions\TransactionFailed) { $this->assertOriginal(); } finally { @@ -36,8 +36,8 @@ public function test_a_prefix_change_cannot_commit_even_after_the_error_is_caugh } public function test_provider_shares_one_connection_and_borrows_wordpress_session(): void { - self::assertSame($this->db, $this->container->get(Connection::class)); - self::assertSame($this->native($this->source), $this->db->getNativeConnection()); + $this->assertSame($this->db, $this->container->get(Connection::class)); + $this->assertSame($this->native($this->source), $this->db->getNativeConnection()); } public function test_commit_publishes_all_work_at_once_and_returns_the_callback_result(): void { @@ -50,13 +50,13 @@ public function test_commit_publishes_all_work_at_once_and_returns_the_callback_ return 2; }); - self::assertSame(2, $result); - self::assertSame(['First', 'Second'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table . ' ORDER BY id')); + $this->assertSame(2, $result); + $this->assertSame(['First', 'Second'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table . ' ORDER BY id')); } public function test_manual_transactions_use_void_and_publish_only_at_outer_commit(): void { foreach (['beginTransaction', 'commit', 'rollBack'] as $method) { - self::assertSame('void', (string) (new \ReflectionMethod($this->db, $method))->getReturnType()); + $this->assertSame('void', (string) (new \ReflectionMethod($this->db, $method))->getReturnType()); } $this->db->beginTransaction(); @@ -71,8 +71,8 @@ public function test_manual_transactions_use_void_and_publish_only_at_outer_comm $this->db->beginTransaction(); $this->db->update($this->table, ['name' => 'Committed'], ['id' => 1]); $this->db->commit(); - self::assertSame('Committed', $this->observer->fetchOne('SELECT name FROM ' . $this->table)); - self::assertFalse($this->db->isTransactionActive()); + $this->assertSame('Committed', $this->observer->fetchOne('SELECT name FROM ' . $this->table)); + $this->assertFalse($this->db->isTransactionActive()); } public function test_false_and_null_callback_results_are_successful_committed_work(): void { @@ -84,9 +84,9 @@ public function test_false_and_null_callback_results_are_successful_committed_wo return $expected; }); - self::assertSame($expected, $result); - self::assertSame($name, $this->observer->fetchOne('SELECT name FROM ' . $this->table)); - self::assertFalse($this->db->isTransactionActive()); + $this->assertSame($expected, $result); + $this->assertSame($name, $this->observer->fetchOne('SELECT name FROM ' . $this->table)); + $this->assertFalse($this->db->isTransactionActive()); } } @@ -101,10 +101,10 @@ public function test_late_failure_restores_existing_data(): void { throw $failure; }); } catch (Throwable $caught) { - self::assertSame($failure, $caught); + $this->assertSame($failure, $caught); } $this->assertOriginal(); - self::assertSame(42, $this->db->transactional(static fn (): int => 42)); + $this->assertSame(42, $this->db->transactional(static fn (): int => 42)); } public function test_caught_prepared_statement_failure_is_terminal(): void { @@ -121,7 +121,7 @@ public function test_caught_prepared_statement_failure_is_terminal(): void { } catch (UniqueConstraintViolationException) { } }); - self::fail('A caught database failure must prevent commit.'); + $this->fail('A caught database failure must prevent commit.'); } catch (TransactionFailed) { $this->assertOriginal(); } @@ -137,12 +137,12 @@ public function test_caught_query_failure_blocks_further_queries(): void { try { $this->db->insert($this->table, ['id' => 2, 'name' => 'Must not execute']); - self::fail('A failed transaction must reject further SQL.'); + $this->fail('A failed transaction must reject further SQL.'); } catch (Throwable $failure) { - self::assertInstanceOf(TransactionFailed::class, $failure); + $this->assertInstanceOf(TransactionFailed::class, $failure); } }); - self::fail('Expected terminal failure.'); + $this->fail('Expected terminal failure.'); } catch (TransactionFailed) { $this->assertOriginal(); } @@ -164,7 +164,7 @@ public function test_nested_application_failure_uses_savepoint_and_outer_can_con $this->db->insert($this->table, ['id' => 4, 'name' => 'Nested success']); }); }); - self::assertSame([1, 2, 4], array_map('intval', $this->observer->fetchFirstColumn('SELECT id FROM ' . $this->table . ' ORDER BY id'))); + $this->assertSame([1, 2, 4], array_map('intval', $this->observer->fetchFirstColumn('SELECT id FROM ' . $this->table . ' ORDER BY id'))); } public function test_outer_rollback_includes_successful_nested_work(): void { @@ -191,12 +191,12 @@ public function test_ambient_transaction_is_rejected_without_committing_or_rolli self::fail('Callback must not run.'); }); } catch (Throwable $failure) { - self::assertStringContainsString('Transaction characteristics', $failure->getMessage()); + $this->assertStringContainsString('Transaction characteristics', $failure->getMessage()); } $this->assertOriginal(); $result = $native->query('SELECT name FROM ' . $this->table); - self::assertInstanceOf(\mysqli_result::class, $result); - self::assertSame(['Ambient'], $result->fetch_row()); + $this->assertInstanceOf(\mysqli_result::class, $result); + $this->assertSame(['Ambient'], $result->fetch_row()); $native->rollback(); } @@ -209,7 +209,7 @@ public function test_autocommit_disabled_is_rejected(): void { self::fail('Callback must not run.'); }); } catch (RuntimeException $failure) { - self::assertStringContainsString('autocommit', $failure->getMessage()); + $this->assertStringContainsString('autocommit', $failure->getMessage()); } finally { $native->autocommit(true); } @@ -232,12 +232,12 @@ public function test_lost_connection_cannot_replay_a_write_even_if_wordpress_rec } catch (Throwable) { } }); - self::fail('Expected terminal connection loss.'); + $this->fail('Expected terminal connection loss.'); } catch (TransactionFailed) { $this->assertOriginal(); } - self::assertSame(7, $this->db->transactional(static fn (): int => 7)); - self::assertSame($this->native($this->source), $this->db->getNativeConnection()); + $this->assertSame(7, $this->db->transactional(static fn (): int => 7)); + $this->assertSame($this->native($this->source), $this->db->getNativeConnection()); } public function test_lost_commit_acknowledgement_reports_uncertain_outcome(): void { @@ -246,9 +246,9 @@ public function test_lost_commit_acknowledgement_reports_uncertain_outcome(): vo $this->db->update($this->table, ['name' => 'Lost'], ['id' => 1]); $this->killConnection(); }); - self::fail('Expected uncertain commit.'); + $this->fail('Expected uncertain commit.'); } catch (DatabaseException $failure) { - self::assertInstanceOf(CommitOutcomeUnknown::class, $failure); + $this->assertInstanceOf(CommitOutcomeUnknown::class, $failure); $this->assertOriginal(); } } @@ -263,13 +263,13 @@ public function test_cleanup_failure_preserves_the_original_business_exception() throw $failure; }); } catch (Throwable $caught) { - self::assertSame($failure, $caught); + $this->assertSame($failure, $caught); } } public function test_site_change_prevents_commit_and_restoration_does_not_erase_failure(): void { - $otherSite = self::factory()->blog->create(); - self::assertIsInt($otherSite); + $otherSite = $this->factory()->blog->create(); + $this->assertIsInt($otherSite); try { $this->db->transactional(function () use ($otherSite): void { @@ -283,7 +283,7 @@ public function test_site_change_prevents_commit_and_restoration_does_not_erase_ restore_current_blog(); } }); - self::fail('Expected terminal scope failure.'); + $this->fail('Expected terminal scope failure.'); } catch (TransactionFailed) { $this->assertOriginal(); } @@ -301,7 +301,7 @@ public function test_caught_nested_database_failure_still_aborts_outer_work(): v } catch (UniqueConstraintViolationException) { } }); - self::fail('Caught nested database failure must abort the outer transaction.'); + $this->fail('Caught nested database failure must abort the outer transaction.'); } catch (TransactionFailed) { $this->assertOriginal(); } @@ -320,7 +320,7 @@ public function test_a_business_exception_takes_precedence_over_a_caught_databas throw $business; }); } catch (Throwable $failure) { - self::assertSame($business, $failure); + $this->assertSame($business, $failure); } $this->assertOriginal(); } @@ -331,12 +331,12 @@ public function test_replacing_a_live_wordpress_session_prevents_commit(): void $this->db->executeStatement('DELETE FROM ' . $this->table); $this->source->db_connect(false); }); - self::fail('Expected connection replacement to abort the operation.'); + $this->fail('Expected connection replacement to abort the operation.'); } catch (RuntimeException $failure) { - self::assertStringContainsString('connection changed', $failure->getMessage()); + $this->assertStringContainsString('connection changed', $failure->getMessage()); $this->assertOriginal(); } - self::assertSame(9, $this->db->transactional(static fn (): int => 9)); + $this->assertSame(9, $this->db->transactional(static fn (): int => 9)); } public function test_native_logging_middleware_can_wrap_the_wordpress_driver(): void { @@ -344,8 +344,8 @@ public function test_native_logging_middleware_can_wrap_the_wordpress_driver(): $handler = new \Monolog\Handler\TestHandler(); $logger->pushHandler($handler); $db = $this->withLogging($logger); - self::assertSame(1, (int) $db->transactional(static fn (Connection $db): mixed => $db->fetchOne('SELECT 1'))); - self::assertTrue($handler->hasDebugThatContains('Committing transaction')); + $this->assertSame(1, (int) $db->transactional(static fn (Connection $db): mixed => $db->fetchOne('SELECT 1'))); + $this->assertTrue($handler->hasDebugThatContains('Committing transaction')); } public function test_connection_loss_before_begin_rejects_the_callback(): void { @@ -365,11 +365,11 @@ public function test_connection_loss_before_begin_rejects_the_callback(): void { @$db->transactional(static function () use (&$called): void { $called = true; }); - self::fail('Expected begin failure.'); + $this->fail('Expected begin failure.'); } catch (RuntimeException $failure) { - self::assertStringContainsString('Unable to start', $failure->getMessage()); + $this->assertStringContainsString('Unable to start', $failure->getMessage()); } - self::assertFalse($called); + $this->assertFalse($called); $this->assertOriginal(); } @@ -394,7 +394,7 @@ public function test_caught_savepoint_release_failure_remains_terminal(): void { } catch (Throwable) { } }); - self::fail('Expected terminal savepoint failure.'); + $this->fail('Expected terminal savepoint failure.'); } catch (TransactionFailed) { $this->assertOriginal(); } @@ -412,7 +412,7 @@ public function test_stale_prepared_statement_cannot_write_to_an_old_session(): } catch (Throwable) { } }); - self::fail('Expected stale statement failure.'); + $this->fail('Expected stale statement failure.'); } catch (TransactionFailed) { $this->assertOriginal(); } diff --git a/tests/integration/LockDatabase/DatabaseLockTest.php b/tests/integration/LockDatabase/DatabaseLockTest.php index cd3af29..ab57ee2 100644 --- a/tests/integration/LockDatabase/DatabaseLockTest.php +++ b/tests/integration/LockDatabase/DatabaseLockTest.php @@ -34,7 +34,7 @@ protected function setUp(): void { public function test_migrations_do_not_create_application_lock_storage(): void { $this->container->register(MigrationsProvider::class); $this->container->get(Migrator::class)->migrate(); - self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->container->get(LockTable::class)->name()])); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->container->get(LockTable::class)->name()])); $this->expectException(LockUnavailableException::class); $this->container->get(DatabaseLock::class)->acquire('test', 60); } @@ -52,8 +52,8 @@ public function test_application_selection_supports_managed_lock_operations(): v return 'imported'; }); - self::assertSame('imported', $result); - self::assertFalse($lock->isAcquired('import')); + $this->assertSame('imported', $result); + $this->assertFalse($lock->isAcquired('import')); } public function test_a_missing_wordpress_connection_is_reported_through_the_lock_contract(): void { @@ -75,30 +75,30 @@ public function test_explicit_setup_contention_refresh_and_owner_safe_release(): $lock->initialize(); $lock->initialize(); $token = $lock->acquire("A ' quoted resource", 60); - self::assertInstanceOf(LockToken::class, $token); - self::assertTrue($lock->isAcquired($token->name)); - self::assertNull($lock->acquire($token->name, 60)); + $this->assertInstanceOf(LockToken::class, $token); + $this->assertTrue($lock->isAcquired($token->name)); + $this->assertNull($lock->acquire($token->name, 60)); $otherOwner = new LockToken($token->name, 'another-owner', $token->expiresAt); - self::assertFalse($lock->release($otherOwner)); - self::assertNull($lock->refresh($otherOwner, 60)); + $this->assertFalse($lock->release($otherOwner)); + $this->assertNull($lock->refresh($otherOwner, 60)); $renewed = $lock->refresh($token, 120); - self::assertInstanceOf(LockToken::class, $renewed); - self::assertGreaterThan($token->expiresAt, $renewed->expiresAt); - self::assertTrue($lock->release($renewed)); - self::assertFalse($lock->isAcquired($token->name)); + $this->assertInstanceOf(LockToken::class, $renewed); + $this->assertGreaterThan($token->expiresAt, $renewed->expiresAt); + $this->assertTrue($lock->release($renewed)); + $this->assertFalse($lock->isAcquired($token->name)); } public function test_expired_owner_cannot_release_a_replacement_and_names_are_case_sensitive(): void { $lock = $this->container->get(DatabaseLock::class); $lock->initialize(); $token = $lock->acquire('resource', 60); - self::assertInstanceOf(LockToken::class, $token); + $this->assertInstanceOf(LockToken::class, $token); $this->db->executeStatement('UPDATE ' . $this->container->get(LockTable::class)->quotedName() . ' SET expires_at = UTC_TIMESTAMP(6) - INTERVAL 1 SECOND'); $replacement = $lock->acquire('resource', 60); - self::assertInstanceOf(LockToken::class, $replacement); - self::assertNotSame($token->owner, $replacement->owner); - self::assertFalse($lock->release($token)); - self::assertInstanceOf(LockToken::class, $lock->acquire('Resource', 60)); - self::assertTrue($lock->release($replacement)); + $this->assertInstanceOf(LockToken::class, $replacement); + $this->assertNotSame($token->owner, $replacement->owner); + $this->assertFalse($lock->release($token)); + $this->assertInstanceOf(LockToken::class, $lock->acquire('Resource', 60)); + $this->assertTrue($lock->release($replacement)); } } diff --git a/tests/integration/Migrations/AdvisoryMigrationTest.php b/tests/integration/Migrations/AdvisoryMigrationTest.php index c8c249c..90313b4 100644 --- a/tests/integration/Migrations/AdvisoryMigrationTest.php +++ b/tests/integration/Migrations/AdvisoryMigrationTest.php @@ -84,7 +84,7 @@ private function lockName(?string $resource = null): string { } private function assertLockAvailable(?string $resource = null): void { - self::assertSame(1, (int) $this->observer->fetchOne('SELECT IS_FREE_LOCK(?)', [$this->lockName($resource)])); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT IS_FREE_LOCK(?)', [$this->lockName($resource)])); } public function test_an_unclosed_data_transaction_is_rolled_back_without_recording_success(): void { @@ -93,12 +93,12 @@ public function test_an_unclosed_data_transaction_is_rolled_back_without_recordi $db->beginTransaction(); $db->executeStatement("UPDATE {$this->table} SET name = 'Uncommitted'"); })->migrate(); - self::fail('A callback cannot return success with an open transaction.'); + $this->fail('A callback cannot return success with an open transaction.'); } catch (MigrationInterrupted $failure) { - self::assertStringContainsString('left a transaction open', $failure->getMessage()); + $this->assertStringContainsString('left a transaction open', $failure->getMessage()); $this->assertOriginal(); - self::assertFalse($this->db->isTransactionActive()); - self::assertSame(0, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->ledger)); + $this->assertFalse($this->db->isTransactionActive()); + $this->assertSame(0, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->ledger)); $this->assertLockAvailable(); } } @@ -113,28 +113,28 @@ public function test_competing_runner_cannot_plan_until_ddl_and_history_finish() try { $second->migrate(); - self::fail('The second session must not run before history is recorded.'); + $this->fail('The second session must not run before history is recorded.'); } catch (MigrationAlreadyRunning) { - self::assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); } }); $first->migrate(); - self::assertSame([], $second->migrate()); - self::assertSame('Original|once', $this->observer->fetchOne("SELECT name FROM {$this->table}")); - self::assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertSame([], $second->migrate()); + $this->assertSame('Original|once', $this->observer->fetchOne("SELECT name FROM {$this->table}")); + $this->assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); $this->assertLockAvailable(); } public function test_contention_does_not_create_the_history_table(): void { $name = $this->lockName(); - self::assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [$name])); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [$name])); try { $this->runner($this->container, static function (): void { })->migrate(); - self::fail('Expected contention.'); + $this->fail('Expected contention.'); } catch (MigrationAlreadyRunning) { - self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->resource])); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->resource])); } finally { $this->observer->fetchOne('SELECT RELEASE_LOCK(?)', [$name]); } @@ -170,14 +170,16 @@ public function test_lock_survives_commits_and_rollbacks_in_a_migration(): void $db->transactional(function (Connection $db): void { $db->executeStatement("UPDATE {$this->table} SET name = 'Committed'"); }); - self::assertSame(0, (int) $this->observer->fetchOne('SELECT IS_FREE_LOCK(?)', [$this->lockName()])); + $isFree = (int) $this->observer->fetchOne('SELECT IS_FREE_LOCK(?)', [$this->lockName()]); + $this->assertSame(0, $isFree); try { $db->transactional(static function (): void { throw new RuntimeException('Discard this inner transaction'); }); } catch (RuntimeException) { - self::assertSame(0, (int) $this->observer->fetchOne('SELECT IS_FREE_LOCK(?)', [$this->lockName()])); + $isFree = (int) $this->observer->fetchOne('SELECT IS_FREE_LOCK(?)', [$this->lockName()]); + $this->assertSame(0, $isFree); } })->migrate(); $this->assertLockAvailable(); @@ -194,15 +196,15 @@ public function test_killed_session_cannot_record_success_after_a_caught_error() // Simulate migration code that catches and ignores a lost connection. } })->migrate(); - self::fail('A caught connection loss must remain terminal.'); + $this->fail('A caught connection loss must remain terminal.'); } catch (MigrationInterrupted) { $this->assertOriginal(); - self::assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); $this->assertLockAvailable(); } $this->runner($this->contender, static function (): void { })->migrate(); - self::assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); } public function test_business_exception_survives_lost_connection_during_cleanup(): void { @@ -214,24 +216,24 @@ public function test_business_exception_survives_lost_connection_during_cleanup( throw $expected; })->migrate(); - self::fail('Expected the original exception.'); + $this->fail('Expected the original exception.'); } catch (Throwable $failure) { - self::assertSame($expected, $failure); + $this->assertSame($expected, $failure); $this->assertLockAvailable(); } } public function test_site_change_blocks_the_ledger_and_releases_only_the_original_lock(): void { - $site = self::factory()->blog->create(); - self::assertIsInt($site); + $site = $this->factory()->blog->create(); + $this->assertIsInt($site); try { $this->runner($this->container, static function () use ($site): void { switch_to_blog($site); })->migrate(); - self::fail('A migration cannot change WordPress sites.'); + $this->fail('A migration cannot change WordPress sites.'); } catch (MigrationInterrupted) { - self::assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); $this->assertLockAvailable(); } finally { restore_current_blog(); @@ -243,9 +245,9 @@ public function test_manual_lock_release_is_detected_before_more_sql(): void { $this->runner($this->container, function (): void { $this->native($this->source)->query('SELECT RELEASE_ALL_LOCKS()'); })->migrate(); - self::fail('A normal return must not hide lost ownership.'); + $this->fail('A normal return must not hide lost ownership.'); } catch (MigrationInterrupted) { - self::assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); $this->assertLockAvailable(); } } @@ -260,14 +262,14 @@ public function test_replacing_the_wordpress_connection_is_terminal_even_if_rest try { $db->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); } catch (Throwable $failure) { - self::assertInstanceOf(\StellarWP\Foundation\Database\Exceptions\AdvisoryLockInterrupted::class, $failure); + $this->assertInstanceOf(\StellarWP\Foundation\Database\Exceptions\AdvisoryLockInterrupted::class, $failure); $this->source->__set('dbh', $native); } })->migrate(); - self::fail('Restoring the connection must not reset terminal failure.'); + $this->fail('Restoring the connection must not reset terminal failure.'); } catch (MigrationInterrupted) { $this->assertOriginal(); - self::assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); $this->assertLockAvailable(); } finally { $this->source->__set('dbh', $native); @@ -285,9 +287,9 @@ public function test_caught_sql_failure_remains_terminal_after_inner_transaction // The transaction ends, but this migration must remain failed. } })->migrate(); - self::fail('A caught SQL failure must prevent the history write.'); + $this->fail('A caught SQL failure must prevent the history write.'); } catch (MigrationInterrupted) { - self::assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); $this->assertOriginal(); $this->assertLockAvailable(); } @@ -298,12 +300,12 @@ public function test_migration_failure_releases_lock_for_a_fresh_retry(): void { $this->runner($this->container, static function (): void { throw new RuntimeException('Migration failed'); })->migrate(); - self::fail('Expected failure.'); + $this->fail('Expected failure.'); } catch (RuntimeException $failure) { - self::assertSame('Migration failed', $failure->getMessage()); + $this->assertSame('Migration failed', $failure->getMessage()); $this->assertLockAvailable(); } - self::assertCount(1, $this->runner($this->container, static function (): void { + $this->assertCount(1, $this->runner($this->container, static function (): void { })->migrate()); } @@ -315,10 +317,10 @@ public function test_ambient_transaction_is_rejected_without_committing_its_writ try { $this->runner($this->container, static function (): void { })->migrate(); - self::fail('Expected an ambient transaction rejection.'); + $this->fail('Expected an ambient transaction rejection.'); } catch (Throwable) { $this->assertOriginal(); - self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->resource])); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->resource])); $this->assertLockAvailable(); } finally { $native->rollback(); @@ -332,10 +334,10 @@ public function test_disabled_autocommit_is_rejected(): void { try { $this->runner($this->container, static function (): void { })->migrate(); - self::fail('Expected disabled autocommit rejection.'); + $this->fail('Expected disabled autocommit rejection.'); } catch (RuntimeException $failure) { - self::assertInstanceOf(MigrationInterrupted::class, $failure); - self::assertStringContainsString('autocommit', $failure->getPrevious()?->getMessage() ?? ''); + $this->assertInstanceOf(MigrationInterrupted::class, $failure); + $this->assertStringContainsString('autocommit', $failure->getPrevious()?->getMessage() ?? ''); $this->assertLockAvailable(); } finally { $native->autocommit(true); @@ -347,9 +349,9 @@ public function test_nested_migration_run_is_rejected(): void { try { $this->runner($this->container, static function (): void { })->migrate(); - self::fail('Expected nested migration rejection.'); + $this->fail('Expected nested migration rejection.'); } catch (RuntimeException $failure) { - self::assertSame('Start advisory-locked work outside an existing transaction or locked operation.', $failure->getMessage()); + $this->assertSame('Start advisory-locked work outside an existing transaction or locked operation.', $failure->getMessage()); } })->migrate(); $this->assertLockAvailable(); @@ -360,9 +362,9 @@ public function test_composed_runner_resolves_the_current_prefix_between_operati }); $this->source->set_prefix($this->source->prefix . 'changed_'); $ledger = $this->privateTable($this->suffix . '_ledger'); - self::assertCount(1, $runner->migrate()); - self::assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $ledger)); - self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->resource])); + $this->assertCount(1, $runner->migrate()); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $ledger)); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->resource])); } public function test_missing_acquisition_acknowledgement_discards_the_session_and_its_lock(): void { @@ -372,10 +374,10 @@ public function test_missing_acquisition_acknowledgement_discards_the_session_an try { $this->runner($this->container, static function (): void { })->migrate(); - self::fail('A lost acquisition reply cannot permit execution.'); + $this->fail('A lost acquisition reply cannot permit execution.'); } catch (RuntimeException $failure) { - self::assertSame('Lost advisory lock acquisition reply', $failure->getMessage()); - self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->resource])); + $this->assertSame('Lost advisory lock acquisition reply', $failure->getMessage()); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->resource])); $this->assertLockAvailable(); } } @@ -389,9 +391,9 @@ public function test_a_closed_handle_during_failure_preserves_the_business_excep throw $expected; })->migrate(); - self::fail('Expected the business exception.'); + $this->fail('Expected the business exception.'); } catch (Throwable $failure) { - self::assertSame($expected, $failure); + $this->assertSame($expected, $failure); $this->assertLockAvailable(); } } diff --git a/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php b/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php index 915f35f..f221142 100644 --- a/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php +++ b/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php @@ -77,23 +77,23 @@ private function history(): array { private function createTableSql(): string { $row = $this->observer->fetchAssociative('SHOW CREATE TABLE ' . $this->quotedEntries); - self::assertIsArray($row); + $this->assertIsArray($row); return strtolower((string) $row['Create Table']); } public function test_rollback_honors_an_irreversible_down(): void { $this->migrator()->migrate(AddEntryArchivedFlag::ID); - self::assertStringContainsString('`archived`', $this->createTableSql()); + $this->assertStringContainsString('`archived`', $this->createTableSql()); try { $this->migrator()->rollback(); - self::fail('Expected the author\'s refusal to reverse.'); + $this->fail('Expected the author\'s refusal to reverse.'); } catch (IrreversibleMigration $failure) { - self::assertStringContainsString(AddEntryArchivedFlag::ID, $failure->getMessage()); + $this->assertStringContainsString(AddEntryArchivedFlag::ID, $failure->getMessage()); } - self::assertContains(AddEntryArchivedFlag::ID, $this->history(), 'The refused migration stays recorded'); - self::assertStringContainsString('`archived`', $this->createTableSql(), 'No destructive DDL was reconstructed from up()'); + $this->assertContains(AddEntryArchivedFlag::ID, $this->history(), 'The refused migration stays recorded'); + $this->assertStringContainsString('`archived`', $this->createTableSql(), 'No destructive DDL was reconstructed from up()'); } public function test_unrelated_foreign_key_survives_an_alteration(): void { @@ -103,44 +103,44 @@ public function test_unrelated_foreign_key_survives_an_alteration(): void { $steps = $this->migrator()->migrate(AddEntryNote::ID); foreach ($steps as $step) { - self::assertStringNotContainsStringIgnoringCase('foreign key', implode("\n", $step->sql)); + $this->assertStringNotContainsStringIgnoringCase('foreign key', implode("\n", $step->sql)); } - self::assertStringContainsString('constraint `fk_parent` foreign key', $this->createTableSql()); - self::assertStringContainsString('`note`', $this->createTableSql()); + $this->assertStringContainsString('constraint `fk_parent` foreign key', $this->createTableSql()); + $this->assertStringContainsString('`note`', $this->createTableSql()); } public function test_change_replaces_the_complete_column_definition_and_down_restores_it(): void { $this->migrator()->migrate(WidenEntryName::ID); $column = $this->observer->createSchemaManager()->introspectTable(trim($this->quotedEntries, '`'))->getColumn('name'); - self::assertSame(120, $column->getLength()); - self::assertFalse($column->getNotnull()); - self::assertStringContainsString("comment='imported entries'", $this->createTableSql()); + $this->assertSame(120, $column->getLength()); + $this->assertFalse($column->getNotnull()); + $this->assertStringContainsString("comment='imported entries'", $this->createTableSql()); $steps = $this->migrator()->rollback(); - self::assertSame([WidenEntryName::ID], array_map(static fn (Step $step): string => $step->id, $steps)); + $this->assertSame([WidenEntryName::ID], array_map(static fn (Step $step): string => $step->id, $steps)); $column = $this->observer->createSchemaManager()->introspectTable(trim($this->quotedEntries, '`'))->getColumn('name'); - self::assertSame(50, $column->getLength()); - self::assertTrue($column->getNotnull()); - self::assertStringNotContainsString('imported entries', $this->createTableSql()); + $this->assertSame(50, $column->getLength()); + $this->assertTrue($column->getNotnull()); + $this->assertStringNotContainsString('imported entries', $this->createTableSql()); } public function test_timestamp_attribute_changes_emit_sql_and_rerun_cleanly(): void { $this->migrator()->migrate(WidenEntryName::ID); - self::assertStringNotContainsString('`created_at` datetime(6) not null default current_timestamp(6) on update', $this->createTableSql()); + $this->assertStringNotContainsString('`created_at` datetime(6) not null default current_timestamp(6) on update', $this->createTableSql()); $steps = $this->migrator()->migrate(TrackEntryUpdates::ID); $last = end($steps); - self::assertInstanceOf(Step::class, $last); - self::assertSame(TrackEntryUpdates::ID, $last->id); - self::assertCount(1, $last->sql); - self::assertStringContainsStringIgnoringCase('MODIFY', $last->sql[0]); - self::assertStringContainsString('`created_at` datetime(6) not null default current_timestamp(6) on update current_timestamp(6)', $this->createTableSql()); - self::assertSame([], $this->migrator()->preview(TrackEntryUpdates::ID), 'Attributes DBAL cannot compare still produce no DDL on rerun'); + $this->assertInstanceOf(Step::class, $last); + $this->assertSame(TrackEntryUpdates::ID, $last->id); + $this->assertCount(1, $last->sql); + $this->assertStringContainsStringIgnoringCase('MODIFY', $last->sql[0]); + $this->assertStringContainsString('`created_at` datetime(6) not null default current_timestamp(6) on update current_timestamp(6)', $this->createTableSql()); + $this->assertSame([], $this->migrator()->preview(TrackEntryUpdates::ID), 'Attributes DBAL cannot compare still produce no DDL on rerun'); $this->migrator()->rollback(); - self::assertStringContainsString('`created_at` datetime(6) not null default current_timestamp(6),', $this->createTableSql()); + $this->assertStringContainsString('`created_at` datetime(6) not null default current_timestamp(6),', $this->createTableSql()); } public function test_undeclared_timestamp_drift_is_rejected(): void { @@ -149,12 +149,12 @@ public function test_undeclared_timestamp_drift_is_rejected(): void { try { $this->migrator()->migrate(WidenEntryName::ID); - self::fail('Expected the undeclared ON UPDATE to be rejected.'); + $this->fail('Expected the undeclared ON UPDATE to be rejected.'); } catch (IncompatibleSchema $failure) { - self::assertStringContainsString('created_at', $failure->getMessage()); - self::assertStringContainsString('ON UPDATE', $failure->getMessage()); + $this->assertStringContainsString('created_at', $failure->getMessage()); + $this->assertStringContainsString('ON UPDATE', $failure->getMessage()); } - self::assertNotContains(WidenEntryName::ID, $this->history()); + $this->assertNotContains(WidenEntryName::ID, $this->history()); } public function test_preview_sees_existing_tables_from_later_steps_and_creates_no_ledger(): void { @@ -163,18 +163,18 @@ public function test_preview_sees_existing_tables_from_later_steps_and_creates_n $preview = $this->migrator()->preview(); - self::assertSame( + $this->assertSame( [CreateEntries::ID, CreateEntryTags::ID, DropEntryTags::ID, RecreateEntryTags::ID, AddEntryNote::ID, IndexEntryNote::ID, ReplaceNoteLookup::ID, AddEntryArchivedFlag::ID, WidenEntryName::ID, TrackEntryUpdates::ID, ReduceCreatedAtPrecision::ID], array_map(static fn (Step $step): string => $step->id, $preview), ); - self::assertCount(1, $preview[0]->sql, 'Entries is created'); - self::assertSame([], $preview[1]->sql, 'Tags already matches its declaration'); - self::assertStringContainsStringIgnoringCase('DROP TABLE', $preview[2]->sql[0]); - self::assertStringContainsStringIgnoringCase('CREATE TABLE', $preview[3]->sql[0], 'Preview remembers the simulated drop and does not reload the old table'); - self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName]), 'Preview executes nothing, not even ledger creation'); + $this->assertCount(1, $preview[0]->sql, 'Entries is created'); + $this->assertSame([], $preview[1]->sql, 'Tags already matches its declaration'); + $this->assertStringContainsStringIgnoringCase('DROP TABLE', $preview[2]->sql[0]); + $this->assertStringContainsStringIgnoringCase('CREATE TABLE', $preview[3]->sql[0], 'Preview remembers the simulated drop and does not reload the old table'); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName]), 'Preview executes nothing, not even ledger creation'); $executed = $this->migrator()->migrate(); - self::assertSame( + $this->assertSame( array_map(static fn (Step $step): array => $step->sql, $preview), array_map(static fn (Step $step): array => $step->sql, $executed), ); @@ -188,10 +188,10 @@ public function test_interrupted_index_replacement_recovers_on_retry(): void { $steps = $this->migrator()->migrate(ReplaceNoteLookup::ID); $last = end($steps); - self::assertInstanceOf(Step::class, $last); - self::assertCount(1, $last->sql, 'Only the creation half remains'); - self::assertStringContainsString('unique key `note_lookup` (`note`)', $this->createTableSql()); - self::assertContains(ReplaceNoteLookup::ID, $this->history()); + $this->assertInstanceOf(Step::class, $last); + $this->assertCount(1, $last->sql, 'Only the creation half remains'); + $this->assertStringContainsString('unique key `note_lookup` (`note`)', $this->createTableSql()); + $this->assertContains(ReplaceNoteLookup::ID, $this->history()); } public function test_conflicting_existing_index_stops_an_addition(): void { @@ -200,12 +200,12 @@ public function test_conflicting_existing_index_stops_an_addition(): void { try { $this->migrator()->migrate(IndexEntryNote::ID); - self::fail('Expected the conflicting index to be rejected.'); + $this->fail('Expected the conflicting index to be rejected.'); } catch (IncompatibleSchema $failure) { - self::assertStringContainsString('existing index note_lookup conflicts', $failure->getMessage()); + $this->assertStringContainsString('existing index note_lookup conflicts', $failure->getMessage()); } - self::assertStringContainsString('unique key `note_lookup` (`name`)', $this->createTableSql(), 'The uniqueness guarantee was not replaced'); - self::assertNotContains(IndexEntryNote::ID, $this->history()); + $this->assertStringContainsString('unique key `note_lookup` (`name`)', $this->createTableSql(), 'The uniqueness guarantee was not replaced'); + $this->assertNotContains(IndexEntryNote::ID, $this->history()); } public function test_precision_and_default_change_together_emit_one_statement(): void { @@ -214,22 +214,22 @@ public function test_precision_and_default_change_together_emit_one_statement(): $steps = $this->migrator()->migrate(ReduceCreatedAtPrecision::ID); $last = end($steps); - self::assertInstanceOf(Step::class, $last); - self::assertCount(1, $last->sql, 'Doctrine\'s CHANGE already carries the complete definition; no supplemental MODIFY'); - self::assertStringContainsString('`created_at` datetime(3) not null default current_timestamp(3) on update current_timestamp(3)', $this->createTableSql()); - self::assertSame([], $this->migrator()->preview()); + $this->assertInstanceOf(Step::class, $last); + $this->assertCount(1, $last->sql, 'Doctrine\'s CHANGE already carries the complete definition; no supplemental MODIFY'); + $this->assertStringContainsString('`created_at` datetime(3) not null default current_timestamp(3) on update current_timestamp(3)', $this->createTableSql()); + $this->assertSame([], $this->migrator()->preview()); } public function test_rollback_selects_its_target_under_the_lock(): void { $this->migrator()->migrate(AddEntryNote::ID); $name = hash('sha256', constant('DB_NAME') . "\0" . $this->historyName); - self::assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [$name])); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [$name])); try { $this->migrator()->rollback(); - self::fail('Expected contention before any history read.'); + $this->fail('Expected contention before any history read.'); } catch (MigrationAlreadyRunning) { - self::assertContains(AddEntryNote::ID, $this->history()); + $this->assertContains(AddEntryNote::ID, $this->history()); } finally { $this->observer->fetchOne('SELECT RELEASE_LOCK(?)', [$name]); } diff --git a/tests/integration/Migrations/DeclarativeMigratorTest.php b/tests/integration/Migrations/DeclarativeMigratorTest.php index a50ca15..bf3a137 100644 --- a/tests/integration/Migrations/DeclarativeMigratorTest.php +++ b/tests/integration/Migrations/DeclarativeMigratorTest.php @@ -77,7 +77,7 @@ private static function ids(array $steps): array { private function createTableSql(): string { $row = $this->observer->fetchAssociative('SHOW CREATE TABLE ' . $this->quotedEntries); - self::assertIsArray($row); + $this->assertIsArray($row); return strtolower((string) $row['Create Table']); } @@ -94,32 +94,32 @@ public function test_missing_history_is_visible_and_prevents_execution(): void { $this->observer->insert($this->quotedHistory, ['version' => '20270101000100']); $statuses = $this->migrator()->status(); $last = end($statuses); - self::assertInstanceOf(\StellarWP\Foundation\Migrations\ValueObjects\MigrationStatus::class, $last); - self::assertSame('missing', $last->state()); + $this->assertInstanceOf(\StellarWP\Foundation\Migrations\ValueObjects\MigrationStatus::class, $last); + $this->assertSame('missing', $last->state()); $this->expectException(MigrationInterrupted::class); $this->migrator()->migrate(); } public function test_rollback_on_an_empty_history_has_no_work(): void { - self::assertSame([], $this->migrator()->rollback()); - self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); + $this->assertSame([], $this->migrator()->rollback()); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); } public function test_nonpositive_rollback_steps_are_rejected_without_initializing_history(): void { try { $this->migrator()->rollback(0); - self::fail('Invalid rollback count must fail.'); + $this->fail('Invalid rollback count must fail.'); } catch (\InvalidArgumentException) { - self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); } } public function test_unknown_target_is_rejected_before_creating_history(): void { try { $this->migrator()->migrate('unknown'); - self::fail('Unknown target must fail.'); + $this->fail('Unknown target must fail.'); } catch (\InvalidArgumentException) { - self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); } } @@ -127,19 +127,19 @@ public function test_step_rollback_does_not_apply_an_older_pending_migration(): $this->migrator()->migrate(); $this->observer->delete($this->quotedHistory, ['version' => self::ALTER]); $steps = $this->migrator()->rollback(); - self::assertSame([self::BACKFILL], self::ids($steps)); - self::assertTrue($steps[0]->reverse); - self::assertSame([self::CREATE], $this->history()); + $this->assertSame([self::BACKFILL], self::ids($steps)); + $this->assertTrue($steps[0]->reverse); + $this->assertSame([self::CREATE], $this->history()); } public function test_target_reconciles_reversals_and_older_pending_migrations(): void { $this->migrator()->migrate(); $this->observer->delete($this->quotedHistory, ['version' => self::ALTER]); $steps = $this->migrator()->migrate(self::ALTER); - self::assertSame([self::BACKFILL, self::ALTER], self::ids($steps)); - self::assertTrue($steps[0]->reverse); - self::assertFalse($steps[1]->reverse); - self::assertSame([self::CREATE, self::ALTER], $this->history()); + $this->assertSame([self::BACKFILL, self::ALTER], self::ids($steps)); + $this->assertTrue($steps[0]->reverse); + $this->assertFalse($steps[1]->reverse); + $this->assertSame([self::CREATE, self::ALTER], $this->history()); } public function test_target_rollback_leaves_older_pending_migrations_pending(): void { @@ -148,18 +148,18 @@ public function test_target_rollback_leaves_older_pending_migrations_pending(): $steps = $this->migrator()->rollbackTo(self::ALTER); - self::assertSame([self::BACKFILL], self::ids($steps)); - self::assertTrue($steps[0]->reverse); - self::assertSame([self::CREATE], $this->history()); - self::assertSame([], $this->migrator()->rollbackTo(self::ALTER)); + $this->assertSame([self::BACKFILL], self::ids($steps)); + $this->assertTrue($steps[0]->reverse); + $this->assertSame([self::CREATE], $this->history()); + $this->assertSame([], $this->migrator()->rollbackTo(self::ALTER)); } public function test_unknown_rollback_target_is_rejected_without_creating_history(): void { try { $this->migrator()->rollbackTo('unknown'); - self::fail('Unknown rollback target must fail.'); + $this->fail('Unknown rollback target must fail.'); } catch (\InvalidArgumentException) { - self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); } } @@ -173,7 +173,7 @@ public function test_public_migration_workflow_uses_no_deprecated_dbal_api(): vo $this->migrator()->preview(Migrator::NONE); $this->migrator()->refresh(); $this->migrator()->rollback(3); - self::assertSame([], \Doctrine\Deprecations\Deprecation::getTriggeredDeprecations()); + $this->assertSame([], \Doctrine\Deprecations\Deprecation::getTriggeredDeprecations()); } finally { \Doctrine\Deprecations\Deprecation::disable(); } @@ -182,30 +182,30 @@ public function test_public_migration_workflow_uses_no_deprecated_dbal_api(): vo public function test_guard_free_create_alter_and_data_step_apply_once(): void { $steps = $this->migrator()->migrate(); - self::assertSame([self::CREATE, self::ALTER, self::BACKFILL], self::ids($steps)); - self::assertCount(1, $steps[0]->sql, 'One CREATE TABLE statement'); - self::assertCount(1, $steps[1]->sql, 'One ALTER TABLE statement'); - self::assertSame([], $steps[2]->sql); - self::assertTrue($steps[2]->hasDataStep); - self::assertSame([self::CREATE, self::ALTER, self::BACKFILL], $this->history()); + $this->assertSame([self::CREATE, self::ALTER, self::BACKFILL], self::ids($steps)); + $this->assertCount(1, $steps[0]->sql, 'One CREATE TABLE statement'); + $this->assertCount(1, $steps[1]->sql, 'One ALTER TABLE statement'); + $this->assertSame([], $steps[2]->sql); + $this->assertTrue($steps[2]->hasDataStep); + $this->assertSame([self::CREATE, self::ALTER, self::BACKFILL], $this->history()); $sql = $this->createTableSql(); - self::assertStringContainsString('`id` bigint unsigned not null auto_increment', str_replace('bigint(20)', 'bigint', $sql)); - self::assertStringContainsString('`amount` decimal(12,4) not null default', $sql); - self::assertStringContainsString('`token` varbinary(16) default null', $sql); - self::assertStringContainsString('`created_at` datetime(6) not null default current_timestamp(6)', $sql); - self::assertStringContainsString('`updated_at` datetime(6) not null default current_timestamp(6) on update current_timestamp(6)', $sql); - self::assertStringContainsString('`note` varchar(100)', $sql); - self::assertStringContainsString('key `status` (`status`)', $sql); - self::assertStringContainsString('charset=utf8mb4', $sql); - - self::assertSame([], $this->migrator()->migrate(), 'Nothing pending'); - self::assertSame([], $this->migrator()->preview(), 'A second run declares no DDL for timestamps, decimals, or binary columns'); + $this->assertStringContainsString('`id` bigint unsigned not null auto_increment', str_replace('bigint(20)', 'bigint', $sql)); + $this->assertStringContainsString('`amount` decimal(12,4) not null default', $sql); + $this->assertStringContainsString('`token` varbinary(16) default null', $sql); + $this->assertStringContainsString('`created_at` datetime(6) not null default current_timestamp(6)', $sql); + $this->assertStringContainsString('`updated_at` datetime(6) not null default current_timestamp(6) on update current_timestamp(6)', $sql); + $this->assertStringContainsString('`note` varchar(100)', $sql); + $this->assertStringContainsString('key `status` (`status`)', $sql); + $this->assertStringContainsString('charset=utf8mb4', $sql); + + $this->assertSame([], $this->migrator()->migrate(), 'Nothing pending'); + $this->assertSame([], $this->migrator()->preview(), 'A second run declares no DDL for timestamps, decimals, or binary columns'); $status = $this->migrator()->status(); - self::assertSame(['applied', 'applied', 'applied'], array_map(static fn ($row): string => $row->state(), $status)); - self::assertSame('Add an optional note to entries', $status[1]->description); - self::assertNotNull($status[0]->appliedAt); + $this->assertSame(['applied', 'applied', 'applied'], array_map(static fn ($row): string => $row->state(), $status)); + $this->assertSame('Add an optional note to entries', $status[1]->description); + $this->assertNotNull($status[0]->appliedAt); } public function test_created_tables_use_the_wordpress_collation(): void { @@ -214,11 +214,11 @@ public function test_created_tables_use_the_wordpress_collation(): void { $this->source->collate = 'utf8mb4_bin'; $this->migrator()->migrate(self::CREATE); - self::assertSame('utf8mb4_bin', $this->observer->fetchOne( + $this->assertSame('utf8mb4_bin', $this->observer->fetchOne( 'SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', [$this->entriesName], )); - self::assertSame([], $this->migrator()->preview(self::CREATE)); + $this->assertSame([], $this->migrator()->preview(self::CREATE)); } public function test_data_step_runs_after_ddl_and_is_repeat_safe(): void { @@ -228,7 +228,7 @@ public function test_data_step_runs_after_ddl_and_is_repeat_safe(): void { $this->migrator()->migrate(); - self::assertSame(['active', 'archived'], $this->observer->fetchFirstColumn('SELECT status FROM ' . $this->quotedEntries . ' ORDER BY id')); + $this->assertSame(['active', 'archived'], $this->observer->fetchFirstColumn('SELECT status FROM ' . $this->quotedEntries . ' ORDER BY id')); } public function test_retry_after_ledger_failure_records_without_repeating_ddl(): void { @@ -237,21 +237,21 @@ public function test_retry_after_ledger_failure_records_without_repeating_ddl(): try { $this->migrator()->migrate(self::ALTER); - self::fail('Expected the ledger insert to fail.'); + $this->fail('Expected the ledger insert to fail.'); } catch (\StellarWP\Foundation\Migrations\Exceptions\LedgerFailure $failure) { - self::assertStringContainsString('History write failure', $failure->getPrevious()?->getMessage() ?? ''); + $this->assertStringContainsString('History write failure', $failure->getPrevious()?->getMessage() ?? ''); } finally { $this->observer->executeStatement('DROP TRIGGER ' . $trigger); } - self::assertSame([self::CREATE], $this->history(), 'DDL committed but the ledger did not'); + $this->assertSame([self::CREATE], $this->history(), 'DDL committed but the ledger did not'); $this->observer->insert($this->quotedEntries, ['name' => 'Survivor', 'status' => 'active', 'note' => 'Keep this']); $steps = $this->migrator()->migrate(self::ALTER); - self::assertSame([self::ALTER], self::ids($steps)); - self::assertSame([], $steps[0]->sql, 'The retry detects the completed column and issues no DDL'); - self::assertSame([self::CREATE, self::ALTER], $this->history()); - self::assertSame('Keep this', $this->observer->fetchOne('SELECT note FROM ' . $this->quotedEntries)); + $this->assertSame([self::ALTER], self::ids($steps)); + $this->assertSame([], $steps[0]->sql, 'The retry detects the completed column and issues no DDL'); + $this->assertSame([self::CREATE, self::ALTER], $this->history()); + $this->assertSame('Keep this', $this->observer->fetchOne('SELECT note FROM ' . $this->quotedEntries)); } public function test_create_retry_after_ledger_failure_preserves_rows(): void { @@ -260,23 +260,23 @@ public function test_create_retry_after_ledger_failure_preserves_rows(): void { try { $this->migrator()->migrate(self::CREATE); - self::fail('Expected the ledger insert to fail.'); + $this->fail('Expected the ledger insert to fail.'); } catch (\StellarWP\Foundation\Migrations\Exceptions\LedgerFailure) { // The table exists; history is empty. } finally { $this->observer->executeStatement('DROP TRIGGER ' . $trigger); } - self::assertSame([], $this->history()); + $this->assertSame([], $this->history()); $this->observer->insert($this->quotedEntries, ['name' => 'Survivor', 'status' => 'active']); $steps = $this->migrator()->migrate(self::CREATE); - self::assertSame([], $steps[0]->sql); - self::assertSame([self::CREATE], $this->history()); - self::assertSame(['Survivor'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->quotedEntries)); + $this->assertSame([], $steps[0]->sql); + $this->assertSame([self::CREATE], $this->history()); + $this->assertSame(['Survivor'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->quotedEntries)); $table = $this->observer->createSchemaManager()->introspectTable($this->entriesName); - self::assertSame('Application entries', $table->getComment()); - self::assertSame('Current processing state', $table->getColumn('status')->getComment()); + $this->assertSame('Application entries', $table->getComment()); + $this->assertSame('Current processing state', $table->getColumn('status')->getComment()); } public function test_undeclared_column_change_stops_the_run_and_names_the_column(): void { @@ -285,13 +285,13 @@ public function test_undeclared_column_change_stops_the_run_and_names_the_column try { $this->migrator()->migrate(self::ALTER); - self::fail('Expected drift to be rejected.'); + $this->fail('Expected drift to be rejected.'); } catch (IncompatibleSchema $failure) { - self::assertStringContainsString(self::ALTER, $failure->getMessage()); - self::assertStringContainsString('column note differs from its declaration', $failure->getMessage()); + $this->assertStringContainsString(self::ALTER, $failure->getMessage()); + $this->assertStringContainsString('column note differs from its declaration', $failure->getMessage()); } - self::assertSame([self::CREATE], $this->history()); - self::assertStringContainsString('`note` int', $this->createTableSql(), 'The runner never repaired the column silently'); + $this->assertSame([self::CREATE], $this->history()); + $this->assertStringContainsString('`note` int', $this->createTableSql(), 'The runner never repaired the column silently'); } public function test_incompatible_existing_table_stops_a_create_migration(): void { @@ -299,11 +299,11 @@ public function test_incompatible_existing_table_stops_a_create_migration(): voi try { $this->migrator()->migrate(self::CREATE); - self::fail('Expected the existing table to be rejected.'); + $this->fail('Expected the existing table to be rejected.'); } catch (IncompatibleSchema $failure) { - self::assertStringContainsString('differs from the declared initial definition', $failure->getMessage()); + $this->assertStringContainsString('differs from the declared initial definition', $failure->getMessage()); } - self::assertSame([], $this->history()); + $this->assertSame([], $this->history()); } public function test_unrelated_columns_and_indexes_survive(): void { @@ -313,22 +313,22 @@ public function test_unrelated_columns_and_indexes_survive(): void { $this->migrator()->migrate(); $sql = $this->createTableSql(); - self::assertStringContainsString('`external_value` int', $sql); - self::assertStringContainsString('key `external_lookup`', $sql); - self::assertStringContainsString('`note` varchar(100)', $sql); - self::assertSame([], $this->migrator()->preview()); + $this->assertStringContainsString('`external_value` int', $sql); + $this->assertStringContainsString('key `external_lookup`', $sql); + $this->assertStringContainsString('`note` varchar(100)', $sql); + $this->assertSame([], $this->migrator()->preview()); } public function test_preview_reports_exactly_the_sql_that_migrate_executes(): void { $preview = $this->migrator()->preview(); $executed = $this->migrator()->migrate(); - self::assertSame(self::ids($preview), self::ids($executed)); - self::assertSame( + $this->assertSame(self::ids($preview), self::ids($executed)); + $this->assertSame( array_map(static fn (Step $step): array => $step->sql, $preview), array_map(static fn (Step $step): array => $step->sql, $executed), ); - self::assertSame([], $this->migrator()->preview()); + $this->assertSame([], $this->migrator()->preview()); } public function test_rollback_by_step_and_by_target(): void { @@ -336,19 +336,19 @@ public function test_rollback_by_step_and_by_target(): void { $this->observer->insert($this->quotedEntries, ['name' => 'Original', 'status' => 'active', 'note' => 'n']); $steps = $this->migrator()->rollback(); - self::assertSame([self::BACKFILL], self::ids($steps)); - self::assertTrue($steps[0]->reverse); - self::assertSame([self::CREATE, self::ALTER], $this->history()); + $this->assertSame([self::BACKFILL], self::ids($steps)); + $this->assertTrue($steps[0]->reverse); + $this->assertSame([self::CREATE, self::ALTER], $this->history()); $steps = $this->migrator()->rollbackTo(self::CREATE); - self::assertSame([self::ALTER], self::ids($steps)); - self::assertStringNotContainsString('`note`', $this->createTableSql()); - self::assertSame(['Original'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->quotedEntries)); - self::assertSame([self::CREATE], $this->history()); + $this->assertSame([self::ALTER], self::ids($steps)); + $this->assertStringNotContainsString('`note`', $this->createTableSql()); + $this->assertSame(['Original'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->quotedEntries)); + $this->assertSame([self::CREATE], $this->history()); $this->migrator()->rollbackTo(Migrator::NONE); - self::assertSame([], $this->history()); - self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->entriesName])); + $this->assertSame([], $this->history()); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->entriesName])); } public function test_history_missing_a_dependency_is_reported_not_repaired(): void { @@ -357,24 +357,24 @@ public function test_history_missing_a_dependency_is_reported_not_repaired(): vo try { $this->migrator()->migrate(self::CREATE); - self::fail('Expected the inconsistent ledger to be reported.'); + $this->fail('Expected the inconsistent ledger to be reported.'); } catch (MigrationInterrupted $failure) { - self::assertStringContainsString(self::ALTER, $failure->getMessage()); - self::assertStringContainsString('repair the ledger', $failure->getMessage()); + $this->assertStringContainsString(self::ALTER, $failure->getMessage()); + $this->assertStringContainsString('repair the ledger', $failure->getMessage()); } - self::assertSame([self::ALTER, self::BACKFILL], $this->history(), 'Nothing was executed or recorded'); + $this->assertSame([self::ALTER, self::BACKFILL], $this->history(), 'Nothing was executed or recorded'); } public function test_another_session_holding_the_lock_blocks_the_run(): void { $name = hash('sha256', constant('DB_NAME') . "\0" . $this->historyName); - self::assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [$name])); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [$name])); try { $this->migrator()->migrate(); - self::fail('Expected contention.'); + $this->fail('Expected contention.'); } catch (DatabaseException $failure) { - self::assertInstanceOf(MigrationAlreadyRunning::class, $failure); - self::assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); + $this->assertInstanceOf(MigrationAlreadyRunning::class, $failure); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); } finally { $this->observer->fetchOne('SELECT RELEASE_LOCK(?)', [$name]); } diff --git a/tests/integration/Migrations/GeneratedMigrationTest.php b/tests/integration/Migrations/GeneratedMigrationTest.php index 625b2c7..f44f4e3 100644 --- a/tests/integration/Migrations/GeneratedMigrationTest.php +++ b/tests/integration/Migrations/GeneratedMigrationTest.php @@ -46,18 +46,18 @@ public function test_generated_migrations_load_without_application_classes_or_mi $tooling->register(CliProvider::class); $tooling->singleton(ProjectDirectory::class, new ProjectDirectory($root)); $table = new CommandTester($tooling->get(TableCommand::class)); - self::assertSame(0, $table->execute(['name' => 'Reports', '--migration' => true]), $table->getDisplay()); + $this->assertSame(0, $table->execute(['name' => 'Reports', '--migration' => true]), $table->getDisplay()); $migration = new CommandTester($tooling->get(MigrationCommand::class)); - self::assertSame(0, $migration->execute(['name' => 'Reports/Add_Published_At', '--table' => 'reports']), $migration->getDisplay()); + $this->assertSame(0, $migration->execute(['name' => 'Reports/Add_Published_At', '--table' => 'reports']), $migration->getDisplay()); // The generated application table is deliberately not autoloadable at runtime. $runtime = (new ContainerFactory())->create($config); $runtime->register(DatabaseProvider::class); $runtime->register(MigrationsProvider::class); $collection = $runtime->get(MigrationCollection::class); $ids = $collection->ids(); - self::assertCount(2, $ids); - self::assertStringEndsWith('_create_reports_table', $ids[0]); - self::assertStringEndsWith('_add_published_at', $ids[1]); - self::assertInstanceOf(\StellarWP\Foundation\Migrations\Migration::class, $collection->get($ids[1])); + $this->assertCount(2, $ids); + $this->assertStringEndsWith('_create_reports_table', $ids[0]); + $this->assertStringEndsWith('_add_published_at', $ids[1]); + $this->assertInstanceOf(\StellarWP\Foundation\Migrations\Migration::class, $collection->get($ids[1])); } } diff --git a/tests/integration/Migrations/MigrationDiscoveryTest.php b/tests/integration/Migrations/MigrationDiscoveryTest.php index 91ab555..5a3dd0a 100644 --- a/tests/integration/Migrations/MigrationDiscoveryTest.php +++ b/tests/integration/Migrations/MigrationDiscoveryTest.php @@ -53,16 +53,16 @@ public function test_discovered_create_and_alter_migrations_execute_and_rollback $table = $this->privateTable($this->suffix . '_reports'); $this->privateTable($this->suffix . '_history'); $ids = $this->container->get(MigrationCollection::class)->ids(); - self::assertCount(2, $ids); + $this->assertCount(2, $ids); $migrator = $this->container->get(Migrator::class); $migrator->migrate($ids[0]); $this->observer->executeStatement('INSERT INTO ' . $table . ' (title) VALUES (?)', ['Existing report']); $migrator->migrate(); - self::assertSame([['title' => 'Existing report', 'published_at' => null]], $this->observer->fetchAllAssociative('SELECT title, published_at FROM ' . $table)); + $this->assertSame([['title' => 'Existing report', 'published_at' => null]], $this->observer->fetchAllAssociative('SELECT title, published_at FROM ' . $table)); $migrator->rollback(1); - self::assertSame(['Existing report'], $this->observer->fetchFirstColumn('SELECT title FROM ' . $table)); + $this->assertSame(['Existing report'], $this->observer->fetchFirstColumn('SELECT title FROM ' . $table)); $migrator->migrate(); - self::assertSame([['title' => 'Existing report', 'published_at' => null]], $this->observer->fetchAllAssociative('SELECT title, published_at FROM ' . $table)); + $this->assertSame([['title' => 'Existing report', 'published_at' => null]], $this->observer->fetchAllAssociative('SELECT title, published_at FROM ' . $table)); } public function test_anonymous_data_callbacks_use_scoped_names_and_keep_their_optional_capabilities(): void { $fixture = dirname(__DIR__, 2) . '/Support/Fixtures/Database/DataDiscovery/20260923000003_publish_reports.php'; @@ -73,22 +73,22 @@ public function test_anonymous_data_callbacks_use_scoped_names_and_keep_their_op $migrator->migrate('20260923000002_add_published_at'); $this->observer->executeStatement('INSERT INTO ' . $table . ' (title) VALUES (?)', ['Existing report']); $preview = $migrator->preview(); - self::assertTrue($preview[0]->hasDataStep); - self::assertSame(['Existing report'], $this->observer->fetchFirstColumn('SELECT title FROM ' . $table)); + $this->assertTrue($preview[0]->hasDataStep); + $this->assertSame(['Existing report'], $this->observer->fetchFirstColumn('SELECT title FROM ' . $table)); $migrator->migrate(); - self::assertSame(['Published report'], $this->observer->fetchFirstColumn('SELECT title FROM ' . $table)); + $this->assertSame(['Published report'], $this->observer->fetchFirstColumn('SELECT title FROM ' . $table)); $status = $migrator->status()[2]; - self::assertSame('20260923000003_publish_reports', $status->migration); - self::assertSame('Publish existing reports', $status->description); + $this->assertSame('20260923000003_publish_reports', $status->migration); + $this->assertSame('Publish existing reports', $status->description); try { $migrator->rollback(); - self::fail('The inherited down() must stop rollback.'); + $this->fail('The inherited down() must stop rollback.'); } catch (\StellarWP\Foundation\Migrations\Exceptions\MigrationException $failure) { - self::assertInstanceOf(\StellarWP\Foundation\Migrations\Exceptions\IrreversibleMigration::class, $failure); - self::assertStringContainsString('20260923000003_publish_reports', $failure->getMessage()); - self::assertInstanceOf(\StellarWP\Foundation\Migrations\Exceptions\IrreversibleMigration::class, $failure->getPrevious()); + $this->assertInstanceOf(\StellarWP\Foundation\Migrations\Exceptions\IrreversibleMigration::class, $failure); + $this->assertStringContainsString('20260923000003_publish_reports', $failure->getMessage()); + $this->assertInstanceOf(\StellarWP\Foundation\Migrations\Exceptions\IrreversibleMigration::class, $failure->getPrevious()); } - self::assertSame('applied', $migrator->status()[2]->state()); + $this->assertSame('applied', $migrator->status()[2]->state()); } } diff --git a/tests/integration/Shutdown/BufferedTaskTest.php b/tests/integration/Shutdown/BufferedTaskTest.php index 1609d0f..7d9b4ba 100644 --- a/tests/integration/Shutdown/BufferedTaskTest.php +++ b/tests/integration/Shutdown/BufferedTaskTest.php @@ -29,12 +29,14 @@ public function test_it_writes_the_request_buffer_once_at_shutdown(): void { $this->assertFalse(get_transient($cacheKey)); do_action('shutdown'); - $this->assertSame($products, get_transient($cacheKey)); + $cachedProducts = get_transient($cacheKey); + $this->assertSame($products, $cachedProducts); $buffer->replace([['id' => 99, 'name' => 'Added after shutdown']]); do_action('shutdown'); - $this->assertSame($products, get_transient($cacheKey)); + $cachedProducts = get_transient($cacheKey); + $this->assertSame($products, $cachedProducts); } finally { delete_transient($cacheKey); } diff --git a/tests/redis/LockRedis/RedisLockIntegrationTest.php b/tests/redis/LockRedis/RedisLockIntegrationTest.php index bd9fdf2..e98d21f 100644 --- a/tests/redis/LockRedis/RedisLockIntegrationTest.php +++ b/tests/redis/LockRedis/RedisLockIntegrationTest.php @@ -245,11 +245,13 @@ public function test_retried_acquisition_recognizes_the_same_owner_without_exten $call = $recording->evaluateCalls[0]; $connection = new PhpRedisConnection($this->phpRedis); - $this->assertSame(1, $connection->evaluate($call['script'], $call['keys'], $call['arguments'])); + $result = $connection->evaluate($call['script'], $call['keys'], $call['arguments']); + $this->assertSame(1, $result); sleep(2); - $this->assertSame(1, $connection->evaluate($call['script'], $call['keys'], $call['arguments'])); + $result = $connection->evaluate($call['script'], $call['keys'], $call['arguments']); + $this->assertSame(1, $result); $this->assertLessThan(10, $this->phpRedis->ttl($call['keys'][0])); $this->assertTrue($this->phpRedisLock->release($token)); } From 0911f143a8ea94f35eb5928a2a99e52112c94f62 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 24 Sep 2026 09:30:16 -0600 Subject: [PATCH 17/53] Try to fix the agents from doing these super long single line arrays --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9a1d77a..fd87372 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,8 @@ Encapsulate meaningful closed state behind named factories and predicates such a ## Source Conventions +Write every non-empty PHP array literal across multiple lines, including single-element arrays, nested arrays, return values, and arguments. Put each element on its own line, include a trailing comma, and place the closing bracket on its own line. Apply this consistently in source code, tests, stubs, and documentation examples. Empty arrays may remain `[]`. + Separate control statements (`if`, loops, `switch`, and `try`) from surrounding statements with a blank line before and after the complete construct. This includes a blank line between a closing control-flow brace and a following assignment or method call. Do not add blank lines between connected clauses such as `} elseif`, `} else`, `} catch`, or `} finally`, or next to an enclosing block's opening or closing brace solely for this rule. Do not declare empty constructors when PHP's implicit public constructor suffices. Private empty constructors may prevent instantiation of static utilities or constants holders. From 825c07db8d373a0f93db433eb3ff46abada20255 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 24 Sep 2026 10:28:04 -0600 Subject: [PATCH 18/53] Add foreign-key declarations and recovery to migrations --- .../content/docs/components/migrations.mdx | 95 ++++++ src/LockDatabase/DatabaseLock.php | 39 ++- src/Migrations/History.php | 7 +- src/Migrations/MigrationsProvider.php | 29 +- src/Migrations/Schema/Blueprint.php | 16 +- .../{ => Definitions}/ColumnDefinition.php | 2 +- .../Definitions/ForeignKeyDefinition.php | 132 +++++++++ .../Factories/MigrationComparatorFactory.php | 40 +++ .../Schema/ForeignKeyComparator.php | 82 ++++++ src/Migrations/Schema/SchemaPlanner.php | 109 ++++--- src/Migrations/Schema/TableBlueprint.php | 86 +++++- .../ForeignKeys/AddCompositeReference.php | 25 ++ .../ForeignKeys/AddOrderReference.php | 25 ++ .../ForeignKeys/CascadeOrderReference.php | 29 ++ .../Migrations/ForeignKeys/CreateItems.php | 30 ++ .../Migrations/ForeignKeys/CreateOrders.php | 28 ++ .../ForeignKeys/CreateReferencedItems.php | 28 ++ .../ForeignKeys/RemoveOrderReference.php | 25 ++ .../ForeignKeys/RenameOrderReference.php | 29 ++ .../Unit/Migrations/ColumnDefinitionTest.php | 2 +- .../Migrations/ForeignKeyDefinitionTest.php | 121 ++++++++ .../Migrations/AdvisoryMigrationTest.php | 23 +- .../Migrations/ForeignKeyMigrationTest.php | 273 ++++++++++++++++++ 23 files changed, 1209 insertions(+), 66 deletions(-) rename src/Migrations/Schema/{ => Definitions}/ColumnDefinition.php (99%) create mode 100644 src/Migrations/Schema/Definitions/ForeignKeyDefinition.php create mode 100644 src/Migrations/Schema/Factories/MigrationComparatorFactory.php create mode 100644 src/Migrations/Schema/ForeignKeyComparator.php create mode 100644 tests/Support/Fixtures/Migrations/ForeignKeys/AddCompositeReference.php create mode 100644 tests/Support/Fixtures/Migrations/ForeignKeys/AddOrderReference.php create mode 100644 tests/Support/Fixtures/Migrations/ForeignKeys/CascadeOrderReference.php create mode 100644 tests/Support/Fixtures/Migrations/ForeignKeys/CreateItems.php create mode 100644 tests/Support/Fixtures/Migrations/ForeignKeys/CreateOrders.php create mode 100644 tests/Support/Fixtures/Migrations/ForeignKeys/CreateReferencedItems.php create mode 100644 tests/Support/Fixtures/Migrations/ForeignKeys/RemoveOrderReference.php create mode 100644 tests/Support/Fixtures/Migrations/ForeignKeys/RenameOrderReference.php create mode 100644 tests/Unit/Migrations/ForeignKeyDefinitionTest.php create mode 100644 tests/integration/Migrations/ForeignKeyMigrationTest.php diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index 3735135..c72052e 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -7,6 +7,8 @@ sidebar: `stellarwp/foundation-migrations` manages the history of your application's database schema. Each migration declares one change in `up()` and its inverse in `down()`. Foundation loads migration files, runs pending changes in order under a database lock, and records successful migrations. +Unlike WordPress’s [`dbDelta()`](https://developer.wordpress.org/reference/functions/dbdelta/), which does not manage foreign-key constraints, Foundation supports creating, changing, and removing relationships between tables. See [foreign keys](#relate-tables-with-foreign-keys) for the complete workflow. + ## Create and change a table ### Set up migrations @@ -237,6 +239,99 @@ $table->dropIndex( 'status_lookup' ); $table->index( 'status_lookup', 'status', 'published_at' ); ``` +### Relate tables with foreign keys + +Foreign keys let the database enforce relationships between your application's tables. + +For example, an order item must belong to an existing order. Create the parent table before its dependent table: + +```shell +vendor/bin/foundation make:database:migration create_orders --create=your_plugin_orders +vendor/bin/foundation make:database:migration create_order_items --create=your_plugin_order_items +``` + +The first migration's generated `bigIncrements( 'id' )` supplies the order's primary key. In the second migration, complete `up()` with the item columns, supporting index, and relationship: + +```php +public function up( Blueprint $schema ): void { + $table = $schema->create( 'your_plugin_order_items' ); + + $table->bigIncrements( 'id' ); + $table->unsignedBigInteger( 'order_id' ); + $table->string( 'description' ); + $table->index( 'order_lookup', 'order_id' ); + $table->foreignKey( 'order', 'order_id' ) + ->references( 'your_plugin_orders', 'id' ) + ->cascadeOnDelete(); +} +``` + +Keep the generated `down()` that drops `your_plugin_order_items`. The later migration ID ensures rollback drops items before orders. When adding a relationship to existing tables, use `--table=your_plugin_order_items` and `$schema->table()` instead. + +`order` is the relationship's stable logical name within the items table. Foundation scopes the physical constraint name to that table, so another table can also use `order`. Pass the same logical name to `dropForeignKey()` later. `references()` takes the historical, unprefixed application table name; Foundation resolves both tables for the current WordPress site. + +Declare columns and indexes explicitly. The local and referenced column types must match, including integer size and unsignedness. Reference a primary or unique key. Foundation-created tables use InnoDB, which enforces these constraints. + +Preview and apply as usual: + +```shell +wp your-plugin migrate --run --dry-run +wp your-plugin migrate --run +``` + +The database now rejects an item whose `order_id` has no matching order. Deleting an order also deletes its items because this example chooses `cascadeOnDelete()`. + +:::caution[Check existing data before adding a relationship] +Adding a foreign key fails if existing child rows reference missing parents. Repair those rows in an earlier [data migration](#transform-existing-data), then add the constraint in a later migration. A data callback on the constraint migration runs after DDL, so it cannot repair rows before that constraint is added. +::: + +#### Choose deletion and update behavior + +Without an action method, both deletion and referenced-key updates are restrictive: the database rejects the parent change while child rows reference it. + +| Delete action | Referenced-key update action | Effect on child rows | +| --- | --- | --- | +| `restrictOnDelete()` | `restrictOnUpdate()` | Reject the parent change while references exist | +| `cascadeOnDelete()` | `cascadeOnUpdate()` | Delete dependent rows, or propagate the new key | +| `nullOnDelete()` | `nullOnUpdate()` | Set the referencing columns to `NULL` | + +For either null action, declare every local column as `nullable()`: + +```php +$table->unsignedBigInteger( 'order_id' )->nullable(); +$table->index( 'order_lookup', 'order_id' ); +$table->foreignKey( 'order', 'order_id' ) + ->references( 'your_plugin_orders', 'id' ) + ->nullOnDelete(); +``` + +Changing an existing relationship requires a new migration. Drop and redeclare the same logical name with its complete replacement definition: + +```php +$table = $schema->table( 'your_plugin_order_items' ); + +$table->dropForeignKey( 'order' ); +$table->foreignKey( 'order', 'order_id' ) + ->references( 'your_plugin_orders', 'id' ) + ->restrictOnDelete(); +``` + +Its `down()` can drop and redeclare the relationship with the original action. To remove a relationship entirely, use only `dropForeignKey( 'order' )`. This retains its columns and indexes; remove those separately when that migration owns their removal. Remove dependent relationships or child tables before dropping a referenced parent table. + +#### Reference a composite key + +Supply columns in corresponding order on both sides. For example, given an orders table with a unique key over `account_id` and `order_number`, declare matching item columns and their index: + +```php +$table->unsignedBigInteger( 'account_id' ); +$table->unsignedBigInteger( 'order_number' ); +$table->index( 'order_lookup', 'account_id', 'order_number' ); +$table->foreignKey( 'order', 'account_id', 'order_number' ) + ->references( 'your_plugin_orders', 'account_id', 'order_number' ); +``` + +The earlier parent migration must declare those column types and `$table->unique( 'account_order', 'account_id', 'order_number' )`. Each local value pairs with the referenced column in the same position. + ### Choose a scaffold Use `--create=your_plugin_reports` to generate an initial migration for that unprefixed table name. Its `up()` declares the complete initial table, and its `down()` drops that table. Use `--table=your_plugin_reports` for a later alteration. The options are mutually exclusive. diff --git a/src/LockDatabase/DatabaseLock.php b/src/LockDatabase/DatabaseLock.php index a743638..e08556e 100644 --- a/src/LockDatabase/DatabaseLock.php +++ b/src/LockDatabase/DatabaseLock.php @@ -97,9 +97,12 @@ public function acquire(string $name, int $ttl): ?LockToken { ); $row = $this->db->fetchAssociative( - "SELECT expires_at FROM {$table} - WHERE name = ? AND owner = ? AND expires_at > UTC_TIMESTAMP(6) - LIMIT 1", + "SELECT expires_at + FROM {$table} + WHERE name = ? + AND owner = ? + AND expires_at > UTC_TIMESTAMP(6) + LIMIT 1", [$name, $owner] ); } catch (Throwable $exception) { @@ -124,8 +127,13 @@ public function acquire(string $name, int $ttl): ?LockToken { */ public function release(LockToken $token): bool { try { + $table = $this->table->quotedName(); + return $this->db->executeStatement( - 'DELETE FROM ' . $this->table->quotedName() . ' WHERE name = ? AND owner = ? AND expires_at > UTC_TIMESTAMP(6)', + "DELETE FROM {$table} + WHERE name = ? + AND owner = ? + AND expires_at > UTC_TIMESTAMP(6)", [$token->name, $token->owner] ) > 0; } catch (Throwable $exception) { @@ -146,13 +154,22 @@ public function refresh(LockToken $token, int $ttl): ?LockToken { $table = $this->table->quotedName(); $this->db->executeStatement( - "UPDATE {$table} SET expires_at = TIMESTAMPADD(SECOND, ?, UTC_TIMESTAMP(6)), updated_at = UTC_TIMESTAMP(6) - WHERE name = ? AND owner = ? AND expires_at > UTC_TIMESTAMP(6)", + "UPDATE {$table} + SET expires_at = TIMESTAMPADD(SECOND, ?, UTC_TIMESTAMP(6)), + updated_at = UTC_TIMESTAMP(6) + WHERE name = ? + AND owner = ? + AND expires_at > UTC_TIMESTAMP(6)", [$ttl, $token->name, $token->owner] ); $row = $this->db->fetchAssociative( - "SELECT expires_at FROM {$table} WHERE name = ? AND owner = ? AND expires_at > UTC_TIMESTAMP(6) LIMIT 1", + "SELECT expires_at + FROM {$table} + WHERE name = ? + AND owner = ? + AND expires_at > UTC_TIMESTAMP(6) + LIMIT 1", [$token->name, $token->owner] ); } catch (Throwable $exception) { @@ -176,8 +193,14 @@ public function isAcquired(string $name): bool { $this->assertValidName($name); try { + $table = $this->table->quotedName(); + return $this->db->fetchAssociative( - 'SELECT name FROM ' . $this->table->quotedName() . ' WHERE name = ? AND expires_at > UTC_TIMESTAMP(6) LIMIT 1', + "SELECT name + FROM {$table} + WHERE name = ? + AND expires_at > UTC_TIMESTAMP(6) + LIMIT 1", [$name] ) !== false; } catch (Throwable $exception) { diff --git a/src/Migrations/History.php b/src/Migrations/History.php index 32e4e4e..c8d2728 100644 --- a/src/Migrations/History.php +++ b/src/Migrations/History.php @@ -70,7 +70,12 @@ public function applied(): array { if (! $this->db->createSchemaManager()->tablesExist([$this->table()])) { return []; } - $rows = $this->db->fetchAllKeyValue('SELECT version, applied_at FROM ' . $this->quoted() . ' ORDER BY version'); + $table = $this->quoted(); + $rows = $this->db->fetchAllKeyValue( + "SELECT version, applied_at + FROM {$table} + ORDER BY version", + ); return array_map('strval', $rows); } diff --git a/src/Migrations/MigrationsProvider.php b/src/Migrations/MigrationsProvider.php index 8a93279..e5e0d4f 100644 --- a/src/Migrations/MigrationsProvider.php +++ b/src/Migrations/MigrationsProvider.php @@ -2,10 +2,12 @@ namespace StellarWP\Foundation\Migrations; +use Doctrine\DBAL\Schema\ComparatorConfig; use StellarWP\Foundation\Container\Contracts\Provider; use StellarWP\Foundation\Container\Contracts\Resolver as C; use StellarWP\Foundation\Container\Traits\ResolvesFoundationPrefix; use StellarWP\Foundation\Migrations\Cli\Migrate; +use StellarWP\Foundation\Migrations\Schema\Factories\MigrationComparatorFactory; use StellarWP\Foundation\Migrations\Schema\SchemaPlanner; use StellarWP\Foundation\Migrations\Tables\MigrationTable; use StellarWP\Foundation\WPCli\WPCliProvider; @@ -18,7 +20,8 @@ final class MigrationsProvider extends Provider { use ResolvesFoundationPrefix; - public const string MIGRATIONS = self::class . '.migrations'; + public const string MIGRATIONS = self::class . '.migrations'; + private const string MIGRATION_COMPARATOR_CONFIG = self::class . '.migration_comparator_config'; /** * Register migrations after DatabaseProvider, without querying or creating storage. @@ -28,14 +31,19 @@ final class MigrationsProvider extends Provider public function register(): void { $this->registerTables(); $this->registerMigrations(); - $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [$c->get(Migrate::class)]); + + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $c->get(Migrate::class), + ]); } private function registerTables(): void { $prefix = str_replace('-', '_', $this->foundationPrefix()); + $this->container->when(MigrationTable::class) ->needs('$unprefixedTableName') ->give($this->config->get('migrations.table', $prefix . '_foundation_migrations')); + $this->container->singleton(MigrationTable::class); } @@ -58,16 +66,31 @@ private function registerMigrations(): void { ->needs('$migrations') ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); + $this->container->singleton( + self::MIGRATION_COMPARATOR_CONFIG, + static fn (): ComparatorConfig => (new ComparatorConfig()) + ->withReportModifiedIndexes(false), + ); + + $this->container->when(MigrationComparatorFactory::class) + ->needs(ComparatorConfig::class) + ->give(static fn (C $c): ComparatorConfig => $c->get(self::MIGRATION_COMPARATOR_CONFIG)); + $this->container->when(SchemaPlanner::class) ->needs('$tableOptions') ->give(static function (C $c): array { $source = $c->get(wpdb::class); - return array_filter(['engine' => 'InnoDB', 'charset' => $source->charset, 'collation' => $source->collate]); + return array_filter([ + 'engine' => 'InnoDB', + 'charset' => $source->charset, + 'collation' => $source->collate, + ]); }); $this->container->singleton(MigrationCollection::class); $this->container->singleton(History::class); + $this->container->singleton(MigrationComparatorFactory::class); $this->container->singleton(SchemaPlanner::class); $this->container->singleton(Migrator::class); } diff --git a/src/Migrations/Schema/Blueprint.php b/src/Migrations/Schema/Blueprint.php index 59fcacf..286939a 100644 --- a/src/Migrations/Schema/Blueprint.php +++ b/src/Migrations/Schema/Blueprint.php @@ -3,6 +3,8 @@ namespace StellarWP\Foundation\Migrations\Schema; use Closure; +use Doctrine\DBAL\Schema\Exception\TableAlreadyExists; +use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\Table as DoctrineTable; use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Contracts\TableNameResolver; @@ -39,10 +41,10 @@ public function create(Table|string $table): TableBlueprint { $name = $blueprint->name(); if ($this->state->schema->hasTable($name)) { - throw \Doctrine\DBAL\Schema\Exception\TableAlreadyExists::new($name); + throw TableAlreadyExists::new($name); } $table = DoctrineTable::editor()->setUnquotedName($name)->setOptions($this->tableOptions); - $this->replaceTable($blueprint->applyTo($table, $this->state)); + $this->replaceTable($blueprint->applyTo($table, $this->state, $this->names)); }; return $blueprint; @@ -54,7 +56,12 @@ public function create(Table|string $table): TableBlueprint { public function table(Table|string $table): TableBlueprint { $blueprint = new TableBlueprint($this->resolve($table)); $this->operations[] = function () use ($blueprint): void { - $this->replaceTable($blueprint->applyTo($this->state->schema->getTable($blueprint->name())->edit(), $this->state)); + $table = $this->state->schema->getTable($blueprint->name()); + // Keep supporting indexes when a foreign key is removed. The editor already carries the primary key separately. + $secondaryIndexes = array_diff_key($table->getIndexes(), ['primary' => true]); + $editor = $table->edit()->setIndexes(...array_values($secondaryIndexes)); + + $this->replaceTable($blueprint->applyTo($editor, $this->state, $this->names)); }; return $blueprint; @@ -93,6 +100,7 @@ public function apply(): void { foreach ($this->operations as $operation) { $operation(); } + $this->operations = []; } @@ -100,6 +108,6 @@ private function replaceTable(DoctrineTable $table): void { $name = $table->getObjectName()->toString(); $tables = array_filter($this->state->schema->getTables(), static fn (DoctrineTable $existing): bool => $existing->getObjectName()->toString() !== $name); $tables[] = $table; - $this->state->schema = new \Doctrine\DBAL\Schema\Schema(array_values($tables)); + $this->state->schema = new Schema(array_values($tables)); } } diff --git a/src/Migrations/Schema/ColumnDefinition.php b/src/Migrations/Schema/Definitions/ColumnDefinition.php similarity index 99% rename from src/Migrations/Schema/ColumnDefinition.php rename to src/Migrations/Schema/Definitions/ColumnDefinition.php index b6be6ae..ac1f4c4 100644 --- a/src/Migrations/Schema/ColumnDefinition.php +++ b/src/Migrations/Schema/Definitions/ColumnDefinition.php @@ -1,6 +1,6 @@ |null + */ + private ?array $referencedColumns = null; + private ReferentialAction $onDelete = ReferentialAction::RESTRICT; + private ReferentialAction $onUpdate = ReferentialAction::RESTRICT; + + /** + * Receive the physical constraint identity and local columns. + * + * @param non-empty-string $name + * @param non-empty-list $localColumns + * + * @internal Constructed by TableBlueprint::foreignKey(). + */ + public function __construct( + private readonly string $name, + private readonly array $localColumns, + ) { + } + + /** + * Reference historical, unprefixed application table and column names. + * + * @param non-empty-string $table + * @param non-empty-string $columns + * + * @throws InvalidArgumentException When local and referenced column counts differ. + */ + public function references(string $table, string ...$columns): self { + if ($columns === [] || count($columns) !== count($this->localColumns)) { + throw new InvalidArgumentException('A foreign key must reference one column for each local column.'); + } + + $this->referencedTable = $table; + $this->referencedColumns = array_values($columns); + + return $this; + } + + /** + * Propagate referenced row deletion. + */ + public function cascadeOnDelete(): self { + $this->onDelete = ReferentialAction::CASCADE; + + return $this; + } + + /** + * Reject referenced row deletion. + */ + public function restrictOnDelete(): self { + $this->onDelete = ReferentialAction::RESTRICT; + + return $this; + } + + /** + * Clear the local columns on referenced row deletion. + */ + public function nullOnDelete(): self { + $this->onDelete = ReferentialAction::SET_NULL; + + return $this; + } + + /** + * Propagate referenced row key updates. + */ + public function cascadeOnUpdate(): self { + $this->onUpdate = ReferentialAction::CASCADE; + + return $this; + } + + /** + * Reject referenced row key updates. + */ + public function restrictOnUpdate(): self { + $this->onUpdate = ReferentialAction::RESTRICT; + + return $this; + } + + /** + * Clear the local columns on referenced row key updates. + */ + public function nullOnUpdate(): self { + $this->onUpdate = ReferentialAction::SET_NULL; + + return $this; + } + + /** + * Add the completed relationship to the Doctrine declaration. + * + * @internal Called by TableBlueprint::applyTo(). + * + * @throws InvalidArgumentException When references() has not completed the declaration. + */ + public function applyTo(TableEditor $editor, TableNameResolver $names): void { + if ($this->referencedTable === null || $this->referencedColumns === null) { + throw new InvalidArgumentException('Complete the foreign key declaration with references().'); + } + + $editor->addForeignKeyConstraint(ForeignKeyConstraint::editor() + ->setUnquotedName($this->name) + ->setUnquotedReferencingColumnNames(...$this->localColumns) + ->setUnquotedReferencedTableName($names->tableName($this->referencedTable)) + ->setUnquotedReferencedColumnNames(...$this->referencedColumns) + ->setOnDeleteAction($this->onDelete) + ->setOnUpdateAction($this->onUpdate) + ->create()); + } +} diff --git a/src/Migrations/Schema/Factories/MigrationComparatorFactory.php b/src/Migrations/Schema/Factories/MigrationComparatorFactory.php new file mode 100644 index 0000000..1d7bc8c --- /dev/null +++ b/src/Migrations/Schema/Factories/MigrationComparatorFactory.php @@ -0,0 +1,40 @@ +db->createSchemaManager()->createComparator($this->config); + + return new ForeignKeyComparator( + $this->db->getDatabasePlatform(), + $native, + $this->config, + ); + } +} diff --git a/src/Migrations/Schema/ForeignKeyComparator.php b/src/Migrations/Schema/ForeignKeyComparator.php new file mode 100644 index 0000000..c0cff55 --- /dev/null +++ b/src/Migrations/Schema/ForeignKeyComparator.php @@ -0,0 +1,82 @@ +comparator->compareTables($oldTable, $newTable); + $oldForeignKeys = $oldTable->getForeignKeys(); + $newForeignKeys = $newTable->getForeignKeys(); + + if ($oldForeignKeys === [] && $newForeignKeys === []) { + return $diff; + } + + $added = []; + $dropped = []; + + foreach ($oldForeignKeys as $name => $foreignKey) { + if (! isset($newForeignKeys[$name])) { + $dropped[] = $foreignKey; + + continue; + } + + if ($this->diffForeignKey($foreignKey, $newForeignKeys[$name])) { + $dropped[] = $foreignKey; + $added[] = $newForeignKeys[$name]; + } + } + + foreach ($newForeignKeys as $name => $foreignKey) { + if (! isset($oldForeignKeys[$name])) { + $added[] = $foreignKey; + } + } + + // DBAL has no diff editor. Keep this version-specific reconstruction at the comparison boundary. + return new TableDiff( + oldTable: $diff->getOldTable(), + addedColumns: $diff->getAddedColumns(), + changedColumns: $diff->getChangedColumns(), + droppedColumns: $diff->getDroppedColumns(), + addedIndexes: $diff->getAddedIndexes(), + droppedIndexes: $diff->getDroppedIndexes(), + renamedIndexes: $diff->getRenamedIndexes(), + addedForeignKeys: $added, + droppedForeignKeys: $dropped, + ); + } +} diff --git a/src/Migrations/Schema/SchemaPlanner.php b/src/Migrations/Schema/SchemaPlanner.php index 7dbdc55..6629754 100644 --- a/src/Migrations/Schema/SchemaPlanner.php +++ b/src/Migrations/Schema/SchemaPlanner.php @@ -4,7 +4,6 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Schema\Column; -use Doctrine\DBAL\Schema\ComparatorConfig; use Doctrine\DBAL\Schema\Index; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaDiff; @@ -16,14 +15,12 @@ use StellarWP\Foundation\Migrations\Exceptions\IrreversibleMigration; use StellarWP\Foundation\Migrations\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Migrations\MigrationCollection; +use StellarWP\Foundation\Migrations\Schema\Factories\MigrationComparatorFactory; use StellarWP\Foundation\Migrations\Schema\ValueObjects\SchemaPlan; /** * Compute desired schema states from migration declarations and compare them with the live schema. * - * This is the only class that speaks to Doctrine's schema, comparator, and platform APIs for - * planning against the DBAL 4 backend. - * * @internal */ final readonly class SchemaPlanner @@ -37,6 +34,7 @@ public function __construct( private Connection $db, private TableNameResolver $names, private MigrationCollection $migrations, + private MigrationComparatorFactory $comparators, private array $tableOptions = [], ) { } @@ -51,13 +49,17 @@ public function __construct( * @throws \Throwable When a declaration or schema inspection fails. */ public function plan(array $applied, string $id, bool $reverse, ?SchemaState $simulated = null, array $simulatedNames = []): SchemaPlan { - $before = $this->replay($applied); - $after = $reverse ? $this->reverse($before, $id) : $this->replay([...$applied, $id]); - $expected = $this->compare($before, $after); - $names = $this->tableNames($before, $after); - $actual = $this->introspect($names, $simulated, $simulatedNames); - $desired = $this->preserveUndeclared($actual, $before, $after); - $remaining = $this->compare($actual, $desired); + $before = $this->replay($applied); + $after = $reverse ? $this->reverse($before, $id) : $this->replay([ + ...$applied, + $id, + ]); + $comparator = $this->comparators->create(); + $expected = $comparator->compareSchemas($before->schema, $after->schema); + $names = $this->tableNames($before, $after); + $actual = $this->introspect($names, $simulated, $simulatedNames); + $desired = $this->preserveUndeclared($actual, $before, $after); + $remaining = $comparator->compareSchemas($actual->schema, $desired->schema); $this->assertContained($remaining, $expected, $id); $sql = array_merge( $this->sql($remaining), @@ -143,6 +145,7 @@ private function introspect(array $names, ?SchemaState $overlay = null, array $s } $facts = []; + foreach ($tables as $table) { $name = strtolower($table->getObjectName()->toString()); @@ -173,15 +176,27 @@ private function timestampAttributes(array $names): array { if ($names === []) { return []; } - $rows = $this->db->fetchAllAssociative( - 'SELECT TABLE_NAME, COLUMN_NAME, DATETIME_PRECISION, EXTRA FROM information_schema.COLUMNS' - . ' WHERE TABLE_SCHEMA = DATABASE() AND DATA_TYPE IN (\'datetime\', \'timestamp\') AND TABLE_NAME IN (' - . implode(', ', array_fill(0, count($names), '?')) . ')', + + $placeholders = implode(', ', array_fill(0, count($names), '?')); + $rows = $this->db->fetchAllAssociative( + "SELECT TABLE_NAME, COLUMN_NAME, DATETIME_PRECISION, EXTRA + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND DATA_TYPE IN ('datetime', 'timestamp') + AND TABLE_NAME IN ({$placeholders})", $names, ); + $attributes = []; + foreach ($rows as $row) { - $attributes[strtolower((string) $row['TABLE_NAME'])][strtolower((string) $row['COLUMN_NAME'])] = ['precision' => (int) $row['DATETIME_PRECISION'], 'on_update' => stripos((string) $row['EXTRA'], 'on update') !== false]; + $table = strtolower((string) $row['TABLE_NAME']); + $column = strtolower((string) $row['COLUMN_NAME']); + + $attributes[$table][$column] = [ + 'precision' => (int) $row['DATETIME_PRECISION'], + 'on_update' => stripos((string) $row['EXTRA'], 'on update') !== false, + ]; } return $attributes; @@ -216,6 +231,7 @@ private function normalize(Table $table): Table { private function preserveUndeclared(SchemaState $actual, SchemaState $before, SchemaState $after): SchemaState { $preserved = clone $after; $tables = []; + foreach ($preserved->schema->getTables() as $target) { $name = $target->getObjectName()->toString(); @@ -223,9 +239,11 @@ private function preserveUndeclared(SchemaState $actual, SchemaState $before, Sc $tables[] = $target; continue; } + $actualTable = $actual->schema->getTable($name); $beforeTable = $before->schema->hasTable($name) ? $before->schema->getTable($name) : null; $editor = $target->edit(); + foreach ($actualTable->getColumns() as $column) { $columnName = $column->getObjectName()->toString(); @@ -239,7 +257,9 @@ private function preserveUndeclared(SchemaState $actual, SchemaState $before, Sc $preserved->timestamps[$key] = $actual->timestamps[$key]; } } + $primary = $actualTable->getPrimaryKeyConstraint()?->getObjectName()?->toString() ?? 'primary'; + foreach ($actualTable->getIndexes() as $index) { $indexName = $index->getObjectName()->toString(); @@ -248,16 +268,19 @@ private function preserveUndeclared(SchemaState $actual, SchemaState $before, Sc } $editor->addIndex($index); } + foreach ($actualTable->getForeignKeys() as $foreignKey) { - $key = $foreignKey->getObjectName()?->toString(); + $key = $foreignKey->getObjectName()?->getIdentifier()->getValue(); if ($key !== null && ($target->hasForeignKey($key) || ($beforeTable?->hasForeignKey($key) ?? false))) { continue; } $editor->addForeignKeyConstraint($foreignKey); } + $tables[] = $editor->create(); } + $preserved->schema = new Schema($tables); return $preserved; @@ -279,6 +302,7 @@ private function timestampSql(SchemaState $actual, SchemaState $desired, SchemaD if (! $actual->schema->hasTable($table->getObjectName()->toString())) { continue; // CREATE TABLE already carries the full column definitions. } + $actualTable = $actual->schema->getTable($table->getObjectName()->toString()); foreach ($table->getColumns() as $column) { @@ -291,8 +315,12 @@ private function timestampSql(SchemaState $actual, SchemaState $desired, SchemaD if (! $actualTable->hasColumn($column->getObjectName()->toString()) || ! isset($desired->timestamps[$key]) || $desired->timestamps[$key] === ($actual->timestamps[$key] ?? ['precision' => 0, 'on_update' => false])) { continue; } + $this->require(in_array($key, $declaredChanges, true), $id, $table->getObjectName()->toString(), 'column ' . $column->getObjectName()->toString() . ' timestamp precision or ON UPDATE differs from its declaration'); - $sql[] = 'ALTER TABLE ' . $platform->quoteSingleIdentifier($table->getObjectName()->toString()) . ' MODIFY ' . $platform->getColumnDeclarationSQL($platform->quoteSingleIdentifier($column->getObjectName()->toString()), $column->toArray()); + $quotedTable = $platform->quoteSingleIdentifier($table->getObjectName()->toString()); + $declaration = $platform->getColumnDeclarationSQL($platform->quoteSingleIdentifier($column->getObjectName()->toString()), $column->toArray()); + $sql[] = "ALTER TABLE {$quotedTable} + MODIFY {$declaration}"; } } @@ -307,6 +335,7 @@ private function timestampSql(SchemaState $actual, SchemaState $desired, SchemaD private function commentSql(SchemaState $actual, SchemaState $before, SchemaState $after, string $id): array { $sql = []; $platform = $this->db->getDatabasePlatform(); + foreach ($after->schema->getTables() as $table) { $name = $table->getObjectName()->toString(); @@ -320,7 +349,10 @@ private function commentSql(SchemaState $actual, SchemaState $before, SchemaStat } $previous = $before->schema->hasTable($name) ? ($before->schema->getTable($name)->getComment() ?? '') : $comment; $this->require($previous !== $comment, $id, $name, 'table comment differs from its declaration'); - $sql[] = 'ALTER TABLE ' . $platform->quoteSingleIdentifier($name) . ' COMMENT = ' . $platform->quoteStringLiteral($comment); + $quotedTable = $platform->quoteSingleIdentifier($name); + $quotedComment = $platform->quoteStringLiteral($comment); + $sql[] = "ALTER TABLE {$quotedTable} + COMMENT = {$quotedComment}"; } return $sql; @@ -333,6 +365,7 @@ private function commentSql(SchemaState $actual, SchemaState $before, SchemaStat */ private function columnsRewrittenBy(SchemaDiff $diff): array { $keys = []; + foreach ($diff->getAlteredTables() as $tableDiff) { $table = $this->tableName($tableDiff); foreach ($tableDiff->getAddedColumns() as $column) { @@ -353,6 +386,7 @@ private function columnsRewrittenBy(SchemaDiff $diff): array { */ private function timestampChanges(SchemaState $before, SchemaState $after): array { $changes = []; + foreach ($after->timestamps as $key => $attributes) { if (($before->timestamps[$key] ?? null) !== $attributes) { $changes[] = $key; @@ -362,13 +396,6 @@ private function timestampChanges(SchemaState $before, SchemaState $after): arra return $changes; } - /** - * Compare two snapshots using the configured Doctrine platform. - */ - private function compare(SchemaState $from, SchemaState $to): SchemaDiff { - return $this->db->createSchemaManager()->createComparator((new ComparatorConfig())->withReportModifiedIndexes(false))->compareSchemas($from->schema, $to->schema); - } - /** * Generate platform DDL for the supplied differences. * @@ -385,6 +412,7 @@ private function sql(SchemaDiff $diff): array { */ private function tableNames(SchemaState ...$schemas): array { $names = []; + foreach ($schemas as $schema) { foreach ($schema->schema->getTables() as $table) { $names[] = $table->getObjectName()->toString(); @@ -407,6 +435,7 @@ private function assertContained(SchemaDiff $remaining, SchemaDiff $expected, st $expectedCreated = $this->names($expected->getCreatedTables()); $expectedDropped = $this->names($expected->getDroppedTables()); $expectedAltered = []; + foreach ($expected->getAlteredTables() as $diff) { $expectedAltered[strtolower($this->tableName($diff))] = $diff; } @@ -430,9 +459,10 @@ private function assertContained(SchemaDiff $remaining, SchemaDiff $expected, st } if (in_array($key, $expectedCreated, true)) { - // The table already exists; only missing declared columns/indexes may be added. - $this->require($diff->getChangedColumns() === [] && $diff->getDroppedColumns() === [] - && $diff->getDroppedIndexes() === [] && $diff->getRenamedIndexes() === [], $id, $name, 'existing table differs from the declared initial definition'); + // The table already exists; only missing declared columns, indexes, and constraints may be added. + $this->require($diff->getChangedColumns() === [] && $diff->getDroppedColumns() === [] + && $diff->getDroppedIndexes() === [] && $diff->getRenamedIndexes() === [] + && $diff->getDroppedForeignKeyConstraintNames() === [], $id, $name, 'existing table differs from the declared initial definition'); continue; } @@ -449,6 +479,8 @@ private function assertAlterationContained(TableDiff $remaining, TableDiff $expe 'added' => $this->names($expected->getAddedIndexes()), 'dropped' => $this->names($expected->getDroppedIndexes()), ]; + $addedForeignKeys = array_map(static fn ($key): string => strtolower($key->getObjectName()?->getIdentifier()->getValue() ?? ''), $expected->getAddedForeignKeys()); + $droppedForeignKeys = array_map(static fn ($name): string => strtolower($name->getIdentifier()->getValue()), $expected->getDroppedForeignKeyConstraintNames()); foreach ($remaining->getAddedColumns() as $column) { $this->require(in_array(strtolower($column->getObjectName()->toString()), $added, true), $id, $table, 'column ' . $column->getObjectName()->toString() . ' would be added'); @@ -464,7 +496,16 @@ private function assertAlterationContained(TableDiff $remaining, TableDiff $expe } $this->require($remaining->getRenamedIndexes() === [], $id, $table, 'a rename would be required'); - $this->requireNoForeignKeyChanges($remaining, $id, $table); + + foreach ($remaining->getAddedForeignKeys() as $foreignKey) { + $name = $foreignKey->getObjectName()?->getIdentifier()->getValue() ?? ''; + $this->require(in_array(strtolower($name), $addedForeignKeys, true), $id, $table, 'foreign key ' . $name . ' would be added'); + } + + foreach ($remaining->getDroppedForeignKeyConstraintNames() as $foreignKey) { + $name = $foreignKey->getIdentifier()->getValue(); + $this->require(in_array(strtolower($name), $droppedForeignKeys, true), $id, $table, 'existing foreign key ' . $name . ' conflicts with its declaration'); + } // A replacement authorizes both a drop and an addition. An interrupted drop leaves only // the authorized addition; a conflicting addition would require an unauthorized drop. @@ -478,15 +519,9 @@ private function assertAlterationContained(TableDiff $remaining, TableDiff $expe } } - /** - * The blueprint has no foreign-key vocabulary, so any remaining constraint change is undeclared. - */ - private function requireNoForeignKeyChanges(TableDiff $diff, string $id, string $table): void { - $this->require($diff->getAddedForeignKeys() === [] && $diff->getDroppedForeignKeyConstraintNames() === [], $id, $table, 'an undeclared foreign key would change'); - } - private function describe(TableDiff $diff): string { $parts = []; + foreach ($diff->getAddedColumns() as $column) { $parts[] = 'column ' . $column->getObjectName()->toString() . ' would be added'; } diff --git a/src/Migrations/Schema/TableBlueprint.php b/src/Migrations/Schema/TableBlueprint.php index 014ee63..b778fd2 100644 --- a/src/Migrations/Schema/TableBlueprint.php +++ b/src/Migrations/Schema/TableBlueprint.php @@ -8,6 +8,10 @@ use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableEditor; use Doctrine\DBAL\Types\Types; +use InvalidArgumentException; +use StellarWP\Foundation\Database\Contracts\TableNameResolver; +use StellarWP\Foundation\Migrations\Schema\Definitions\ColumnDefinition; +use StellarWP\Foundation\Migrations\Schema\Definitions\ForeignKeyDefinition; /** * Fluent table declaration collected during up()/down() and applied to a Doctrine table afterwards. @@ -37,7 +41,15 @@ final class TableBlueprint * @var list */ private array $droppedIndexes = []; - private ?string $comment = null; + /** + * @var list + */ + private array $foreignKeys = []; + /** + * @var list + */ + private array $droppedForeignKeys = []; + private ?string $comment = null; /** * Receive the resolved table name. @@ -190,14 +202,18 @@ public function primary(string ...$columns): self { * @param non-empty-string $name * @param non-empty-string $columns * - * @throws \InvalidArgumentException When no columns are supplied. + * @throws InvalidArgumentException When no columns are supplied. */ public function unique(string $name, string ...$columns): self { if ($columns === []) { - throw new \InvalidArgumentException('An index must contain at least one column.'); + throw new InvalidArgumentException('An index must contain at least one column.'); } - $this->indexes[] = ['name' => $name, 'columns' => array_values($columns), 'unique' => true]; + $this->indexes[] = [ + 'name' => $name, + 'columns' => array_values($columns), + 'unique' => true, + ]; return $this; } @@ -208,11 +224,11 @@ public function unique(string $name, string ...$columns): self { * @param non-empty-string $name * @param non-empty-string $columns * - * @throws \InvalidArgumentException When no columns are supplied. + * @throws InvalidArgumentException When no columns are supplied. */ public function index(string $name, string ...$columns): self { if ($columns === []) { - throw new \InvalidArgumentException('An index must contain at least one column.'); + throw new InvalidArgumentException('An index must contain at least one column.'); } $this->indexes[] = ['name' => $name, 'columns' => array_values($columns), 'unique' => false]; @@ -220,6 +236,38 @@ public function index(string $name, string ...$columns): self { return $this; } + /** + * Declare a named relationship to another application table. + * + * @param non-empty-string $name A stable logical name within this table. + * @param non-empty-string $columns Local columns, in reference order. + * + * @throws InvalidArgumentException When no columns or no name are supplied. + */ + public function foreignKey(string $name, string ...$columns): ForeignKeyDefinition { + if ($columns === []) { + throw new InvalidArgumentException('A foreign key must contain at least one column.'); + } + + $definition = new ForeignKeyDefinition($this->foreignKeyName($name), array_values($columns)); + $this->foreignKeys[] = $definition; + + return $definition; + } + + /** + * Remove a named relationship while retaining its columns and indexes. + * + * @param non-empty-string $name The logical name passed to foreignKey(). + * + * @throws InvalidArgumentException When no name is supplied. + */ + public function dropForeignKey(string $name): self { + $this->droppedForeignKeys[] = $this->foreignKeyName($name); + + return $this; + } + /** * Remove a column owned by this migration. * @@ -256,14 +304,20 @@ public function comment(string $comment): self { * * @internal Called by Blueprint::apply(). */ - public function applyTo(TableEditor $editor, SchemaState $state): Table { + public function applyTo(TableEditor $editor, SchemaState $state, TableNameResolver $names): Table { + foreach ($this->droppedForeignKeys as $name) { + $editor->dropForeignKeyConstraintByUnquotedName($name); + } + foreach ($this->droppedIndexes as $index) { $editor->dropIndexByUnquotedName($index); } + foreach ($this->droppedColumns as $column) { $editor->dropColumnByUnquotedName($column); unset($state->timestamps[strtolower($this->name . '.' . $column)]); } + foreach ($this->columns as $column) { $column->applyTo($editor); $key = strtolower($this->name . '.' . $column->name()); @@ -278,12 +332,17 @@ public function applyTo(TableEditor $editor, SchemaState $state): Table { if ($this->primary !== []) { $editor->setPrimaryKeyConstraint(PrimaryKeyConstraint::editor()->setUnquotedColumnNames(...$this->primary)->create()); } + foreach ($this->indexes as $index) { $editor->addIndex(Index::editor()->setUnquotedName($index['name']) ->setUnquotedColumnNames(...$index['columns']) ->setType($index['unique'] ? IndexType::UNIQUE : IndexType::REGULAR)->create()); } + foreach ($this->foreignKeys as $foreignKey) { + $foreignKey->applyTo($editor, $names); + } + if ($this->comment !== null) { $editor->setComment($this->comment); } @@ -291,6 +350,19 @@ public function applyTo(TableEditor $editor, SchemaState $state): Table { return $editor->create(); } + /** + * Scope constraint identity to its physical table without exceeding MySQL's identifier limit. + * + * @return non-empty-string + */ + private function foreignKeyName(string $name): string { + if (trim($name) === '') { + throw new InvalidArgumentException('A foreign key must have a name.'); + } + + return 'fk_' . substr(hash('sha256', $this->name . "\0" . $name), 0, 40); + } + /** * @param non-empty-string $name * @param array{length?: int, precision?: int, scale?: int} $options diff --git a/tests/Support/Fixtures/Migrations/ForeignKeys/AddCompositeReference.php b/tests/Support/Fixtures/Migrations/ForeignKeys/AddCompositeReference.php new file mode 100644 index 0000000..1aa7bf5 --- /dev/null +++ b/tests/Support/Fixtures/Migrations/ForeignKeys/AddCompositeReference.php @@ -0,0 +1,25 @@ +table($this->prefix . '_items')->foreignKey('account_order', 'account_id', 'order_id')->references($this->prefix . '_orders', 'account_id', 'id')->nullOnDelete()->nullOnUpdate(); + } + + public function down(Blueprint $schema): void { + $schema->table($this->prefix . '_items')->dropForeignKey('account_order'); + } +} diff --git a/tests/Support/Fixtures/Migrations/ForeignKeys/AddOrderReference.php b/tests/Support/Fixtures/Migrations/ForeignKeys/AddOrderReference.php new file mode 100644 index 0000000..bc07d1b --- /dev/null +++ b/tests/Support/Fixtures/Migrations/ForeignKeys/AddOrderReference.php @@ -0,0 +1,25 @@ +table($this->prefix . '_items')->foreignKey('order', 'order_id')->references($this->prefix . '_orders', 'id'); + } + + public function down(Blueprint $schema): void { + $schema->table($this->prefix . '_items')->dropForeignKey('order'); + } +} diff --git a/tests/Support/Fixtures/Migrations/ForeignKeys/CascadeOrderReference.php b/tests/Support/Fixtures/Migrations/ForeignKeys/CascadeOrderReference.php new file mode 100644 index 0000000..d69c1df --- /dev/null +++ b/tests/Support/Fixtures/Migrations/ForeignKeys/CascadeOrderReference.php @@ -0,0 +1,29 @@ +table($this->prefix . '_items'); + $table->dropForeignKey('order'); + $table->foreignKey('order', 'order_id')->references($this->prefix . '_orders', 'id')->cascadeOnDelete()->cascadeOnUpdate(); + } + + public function down(Blueprint $schema): void { + $table = $schema->table($this->prefix . '_items'); + $table->dropForeignKey('order'); + $table->foreignKey('order', 'order_id')->references($this->prefix . '_orders', 'id'); + } +} diff --git a/tests/Support/Fixtures/Migrations/ForeignKeys/CreateItems.php b/tests/Support/Fixtures/Migrations/ForeignKeys/CreateItems.php new file mode 100644 index 0000000..1388370 --- /dev/null +++ b/tests/Support/Fixtures/Migrations/ForeignKeys/CreateItems.php @@ -0,0 +1,30 @@ +create($this->prefix . '_items'); + $table->bigIncrements(); + $table->unsignedBigInteger('order_id')->nullable(); + $table->unsignedBigInteger('account_id')->nullable(); + $table->index('order_lookup', 'order_id'); + $table->index('account_order_lookup', 'account_id', 'order_id'); + } + + public function down(Blueprint $schema): void { + $schema->drop($this->prefix . '_items'); + } +} diff --git a/tests/Support/Fixtures/Migrations/ForeignKeys/CreateOrders.php b/tests/Support/Fixtures/Migrations/ForeignKeys/CreateOrders.php new file mode 100644 index 0000000..5f18a0f --- /dev/null +++ b/tests/Support/Fixtures/Migrations/ForeignKeys/CreateOrders.php @@ -0,0 +1,28 @@ +create($this->prefix . '_orders'); + $table->bigIncrements(); + $table->unsignedBigInteger('account_id'); + $table->unique('account_order', 'account_id', 'id'); + } + + public function down(Blueprint $schema): void { + $schema->drop($this->prefix . '_orders'); + } +} diff --git a/tests/Support/Fixtures/Migrations/ForeignKeys/CreateReferencedItems.php b/tests/Support/Fixtures/Migrations/ForeignKeys/CreateReferencedItems.php new file mode 100644 index 0000000..a0164c9 --- /dev/null +++ b/tests/Support/Fixtures/Migrations/ForeignKeys/CreateReferencedItems.php @@ -0,0 +1,28 @@ +create($this->prefix . '_items'); + $table->bigIncrements(); + $table->unsignedBigInteger('order_id'); + $table->foreignKey('order', 'order_id')->references($this->prefix . '_orders', 'id')->cascadeOnDelete(); + } + + public function down(Blueprint $schema): void { + $schema->drop($this->prefix . '_items'); + } +} diff --git a/tests/Support/Fixtures/Migrations/ForeignKeys/RemoveOrderReference.php b/tests/Support/Fixtures/Migrations/ForeignKeys/RemoveOrderReference.php new file mode 100644 index 0000000..ea5b2f7 --- /dev/null +++ b/tests/Support/Fixtures/Migrations/ForeignKeys/RemoveOrderReference.php @@ -0,0 +1,25 @@ +table($this->prefix . '_items')->dropForeignKey('order'); + } + + public function down(Blueprint $schema): void { + $schema->table($this->prefix . '_items')->foreignKey('order', 'order_id')->references($this->prefix . '_orders', 'id')->cascadeOnDelete(); + } +} diff --git a/tests/Support/Fixtures/Migrations/ForeignKeys/RenameOrderReference.php b/tests/Support/Fixtures/Migrations/ForeignKeys/RenameOrderReference.php new file mode 100644 index 0000000..55802d4 --- /dev/null +++ b/tests/Support/Fixtures/Migrations/ForeignKeys/RenameOrderReference.php @@ -0,0 +1,29 @@ +table($this->prefix . '_items'); + $table->dropForeignKey('order'); + $table->foreignKey('purchase', 'order_id')->references($this->prefix . '_orders', 'id'); + } + + public function down(Blueprint $schema): void { + $table = $schema->table($this->prefix . '_items'); + $table->dropForeignKey('purchase'); + $table->foreignKey('order', 'order_id')->references($this->prefix . '_orders', 'id'); + } +} diff --git a/tests/Unit/Migrations/ColumnDefinitionTest.php b/tests/Unit/Migrations/ColumnDefinitionTest.php index 131ef50..0d131ed 100644 --- a/tests/Unit/Migrations/ColumnDefinitionTest.php +++ b/tests/Unit/Migrations/ColumnDefinitionTest.php @@ -8,7 +8,7 @@ use Doctrine\DBAL\Types\Types; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use StellarWP\Foundation\Migrations\Schema\ColumnDefinition; +use StellarWP\Foundation\Migrations\Schema\Definitions\ColumnDefinition; /** * Decimal defaults must be normalized to the column scale without passing through floating point. diff --git a/tests/Unit/Migrations/ForeignKeyDefinitionTest.php b/tests/Unit/Migrations/ForeignKeyDefinitionTest.php new file mode 100644 index 0000000..ecd49aa --- /dev/null +++ b/tests/Unit/Migrations/ForeignKeyDefinitionTest.php @@ -0,0 +1,121 @@ +expectException(InvalidArgumentException::class); + (new TableBlueprint('items'))->foreignKey('order'); + } + + public function test_a_logical_name_cannot_be_empty(): void { + $this->expectException(InvalidArgumentException::class); + (new TableBlueprint('items'))->foreignKey(' ', 'order_id'); + } + + public function test_referenced_column_count_must_match(): void { + $this->expectException(InvalidArgumentException::class); + (new TableBlueprint('items'))->foreignKey('order', 'account_id', 'order_id')->references('orders', 'id'); + } + + public function test_missing_reference_is_reported_when_the_declaration_is_completed(): void { + $names = $this->createStub(TableNameResolver::class); + $names->method('tableName')->willReturn('wp_items'); + $blueprint = new Blueprint(new SchemaState(new Schema()), $names); + $table = $blueprint->create('items'); + $table->unsignedBigInteger('order_id'); + $table->foreignKey('order', 'order_id'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('references()'); + $blueprint->apply(); + } + + public function test_constraint_identity_is_scoped_to_the_physical_child_table(): void { + $constraints = []; + foreach (['wp_', 'wp_2_'] as $prefix) { + $names = $this->createStub(TableNameResolver::class); + $names->method('tableName')->willReturnMap([['items', $prefix . 'items'], ['orders', $prefix . 'orders']]); + $state = new SchemaState(new Schema()); + $blueprint = new Blueprint($state, $names); + $table = $blueprint->create('items'); + $table->unsignedBigInteger('order_id'); + $table->foreignKey('order', 'order_id')->references('orders', 'id'); + $blueprint->apply(); + $foreignKey = array_values($state->schema->getTable($prefix . 'items')->getForeignKeys())[0]; + $constraints[] = $foreignKey->getObjectName()?->getIdentifier()->getValue(); + $this->assertSame($prefix . 'orders', $foreignKey->getReferencedTableName()->toString()); + } + + $this->assertNotSame($constraints[0], $constraints[1]); + } + + /** + * Removing a relationship preserves both its backing index and the separate primary key. + */ + public function test_dropping_a_relationship_preserves_its_implicit_index_and_primary_key(): void { + $names = $this->createStub(TableNameResolver::class); + $names->method('tableName')->willReturnMap([['items', 'wp_items'], ['orders', 'wp_orders']]); + $state = new SchemaState(new Schema()); + $create = new Blueprint($state, $names); + $table = $create->create('items'); + $table->bigIncrements('id'); + $table->unsignedBigInteger('order_id'); + $table->foreignKey('order', 'order_id')->references('orders', 'id'); + $create->apply(); + $indexes = array_keys($state->schema->getTable('wp_items')->getIndexes()); + $this->assertCount(2, $indexes); + + $drop = new Blueprint($state, $names); + $drop->table('items')->dropForeignKey('order'); + $drop->apply(); + $remaining = $state->schema->getTable('wp_items'); + + $this->assertEqualsCanonicalizing($indexes, array_keys($remaining->getIndexes())); + $this->assertNotNull($remaining->getPrimaryKeyConstraint()); + $this->assertSame([], $remaining->getForeignKeys()); + $this->assertTrue($remaining->hasColumn('order_id')); + } + + /** @return iterable */ + public static function actions(): iterable { + yield 'cascade delete' => ['cascadeOnDelete', 'delete', 'CASCADE']; + + yield 'restrict delete' => ['restrictOnDelete', 'delete', 'RESTRICT']; + + yield 'null delete' => ['nullOnDelete', 'delete', 'SET NULL']; + + yield 'cascade update' => ['cascadeOnUpdate', 'update', 'CASCADE']; + + yield 'restrict update' => ['restrictOnUpdate', 'update', 'RESTRICT']; + + yield 'null update' => ['nullOnUpdate', 'update', 'SET NULL']; + } + + /** @dataProvider actions */ + #[DataProvider('actions')] + public function test_action_methods_translate_to_the_declared_database_behavior(string $method, string $event, string $action): void { + $names = $this->createStub(TableNameResolver::class); + $names->method('tableName')->willReturnMap([['items', 'wp_items'], ['orders', 'wp_orders']]); + $state = new SchemaState(new Schema()); + $blueprint = new Blueprint($state, $names); + $table = $blueprint->create('items'); + $table->unsignedBigInteger('order_id')->nullable(); + $table->foreignKey('order', 'order_id')->references('orders', 'id')->$method(); + $blueprint->apply(); + $foreignKey = array_values($state->schema->getTable('wp_items')->getForeignKeys())[0]; + $this->assertSame($action, ($event === 'delete' ? $foreignKey->getOnDeleteAction() : $foreignKey->getOnUpdateAction())->value); + } +} diff --git a/tests/integration/Migrations/AdvisoryMigrationTest.php b/tests/integration/Migrations/AdvisoryMigrationTest.php index 90313b4..69652e8 100644 --- a/tests/integration/Migrations/AdvisoryMigrationTest.php +++ b/tests/integration/Migrations/AdvisoryMigrationTest.php @@ -22,6 +22,7 @@ use StellarWP\Foundation\Migrations\MigrationsProvider; use StellarWP\Foundation\Migrations\Migrator; use StellarWP\Foundation\Migrations\Schema\Blueprint; +use StellarWP\Foundation\Migrations\Schema\Factories\MigrationComparatorFactory; use StellarWP\Foundation\Migrations\Schema\SchemaPlanner; use StellarWP\Foundation\Migrations\Tables\MigrationTable; use StellarWP\Foundation\Migrations\ValueObjects\MigrationRegistration; @@ -73,10 +74,24 @@ public function migrate(DataMigrationContext $context): void { ($this->operation)($context->db); } }; - $migrations = new MigrationCollection([new MigrationRegistration($migration->id(), $migration)]); - - return new Migrator($db, $container->get(AdvisorySession::class), $scope, - new History($db, $table), new SchemaPlanner($db, $names, $migrations), $migrations, $names); + $migrations = new MigrationCollection([ + new MigrationRegistration($migration->id(), $migration), + ]); + + return new Migrator( + $db, + $container->get(AdvisorySession::class), + $scope, + new History($db, $table), + new SchemaPlanner( + $db, + $names, + $migrations, + $container->get(MigrationComparatorFactory::class), + ), + $migrations, + $names, + ); } private function lockName(?string $resource = null): string { diff --git a/tests/integration/Migrations/ForeignKeyMigrationTest.php b/tests/integration/Migrations/ForeignKeyMigrationTest.php new file mode 100644 index 0000000..b8f8d49 --- /dev/null +++ b/tests/integration/Migrations/ForeignKeyMigrationTest.php @@ -0,0 +1,273 @@ + ['table' => $this->suffix . '_history']]; + } + + protected function setUp(): void { + parent::setUp(); + $this->container->register(MigrationsProvider::class); + $this->orders = $this->privateTable($this->suffix . '_orders'); + $this->items = $this->privateTable($this->suffix . '_items'); + $this->history = $this->privateTable($this->suffix . '_history'); + } + + public function test_resolving_migration_services_does_not_initialize_the_connection(): void { + $db = $this->createMock(Connection::class); + $db->expects($this->never())->method('getDatabasePlatform'); + $db->expects($this->never())->method('createSchemaManager'); + $this->container->singleton(Connection::class, $db); + + $this->assertInstanceOf(Migrator::class, $this->container->get(Migrator::class)); + } + + private function migrations(Migration ...$migrations): Migrator { + foreach (array_values($migrations) as $position => $migration) { + $this->container->mergeArrayVar(MigrationsProvider::MIGRATIONS, [new MigrationRegistration((string) ($position + 1), $migration)]); + } + + return $this->container->get(Migrator::class); + } + + /** @return list */ + private function constraints(): array { + return array_values($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_items')->getForeignKeys()); + } + + private function rows(): void { + $this->observer->insert($this->orders, ['id' => 1, 'account_id' => 10]); + $this->observer->insert($this->items, ['id' => 2, 'order_id' => 1]); + } + + public function test_create_enforces_cascade_and_rolls_back_child_before_parent(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateReferencedItems($this->suffix)); + $migrator->migrate(); + $this->rows(); + $this->observer->delete($this->orders, ['id' => 1]); + $this->assertSame([], $this->observer->fetchAllAssociative('SELECT * FROM ' . $this->items)); + $this->assertCount(1, $this->constraints()); + $this->assertSame([], $migrator->preview()); + $migrator->rollbackTo(Migrator::NONE); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->source->prefix . $this->suffix . '_orders'])); + } + + public function test_alter_defaults_to_restrict_and_drop_keeps_columns_indexes_and_rows(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddOrderReference($this->suffix)); + $migrator->migrate(); + $this->rows(); + + try { + $this->observer->delete($this->orders, ['id' => 1]); + $this->fail('Referenced rows must be protected by default.'); + } catch (ForeignKeyConstraintViolationException) { + $this->assertCount(1, $this->constraints()); + } + + $migrator->rollback(); + $constraints = $this->constraints(); + $this->assertSame([], $constraints); + $this->observer->delete($this->orders, ['id' => 1]); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT order_id FROM ' . $this->items)); + $this->assertTrue($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_items')->hasIndex('order_lookup')); + } + + public function test_replacement_changes_actions_and_retries_without_ddl_after_a_failed_ledger_write(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddOrderReference($this->suffix), new CascadeOrderReference($this->suffix)); + $migrator->migrate('3'); + $this->rows(); + $trigger = $this->observer->quoteSingleIdentifier($this->suffix . '_reject_history'); + $this->observer->executeStatement("CREATE TRIGGER $trigger BEFORE INSERT ON {$this->history} FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ledger rejected'"); + + try { + $migrator->migrate(); + $this->fail('The history write must fail after DDL.'); + } catch (LedgerFailure) { + $this->assertCount(1, $this->constraints()); + } finally { + $this->observer->executeStatement('DROP TRIGGER ' . $trigger); + } + + $steps = $migrator->migrate(); + $this->assertSame([], $steps[0]->sql); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->history . " WHERE version = '4'")); + $this->observer->update($this->orders, ['id' => 3], ['id' => 1]); + $this->assertSame(3, (int) $this->observer->fetchOne('SELECT order_id FROM ' . $this->items)); + $this->observer->delete($this->orders, ['id' => 3]); + $this->assertSame([], $this->observer->fetchAllAssociative('SELECT * FROM ' . $this->items)); + $migrator->rollback(); + $this->rows(); + $this->expectException(ForeignKeyConstraintViolationException::class); + $this->observer->delete($this->orders, ['id' => 1]); + } + + public function test_composite_reference_clears_all_columns_on_update_and_delete(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddCompositeReference($this->suffix)); + $migrator->migrate(); + $this->observer->insert($this->orders, ['id' => 1, 'account_id' => 10]); + $this->observer->insert($this->items, ['id' => 2, 'order_id' => 1, 'account_id' => 10]); + $this->observer->update($this->orders, ['account_id' => 20], ['id' => 1]); + $this->assertSame(['order_id' => null, 'account_id' => null], $this->observer->fetchAssociative('SELECT order_id, account_id FROM ' . $this->items)); + $this->observer->update($this->items, ['account_id' => 20, 'order_id' => 1], ['id' => 2]); + $this->observer->delete($this->orders, ['id' => 1]); + $row = $this->observer->fetchAssociative('SELECT order_id, account_id FROM ' . $this->items); + $this->assertSame(['order_id' => null, 'account_id' => null], $row); + } + + public function test_constraint_name_changes_are_not_lost_as_equivalent_definitions(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddOrderReference($this->suffix), new RenameOrderReference($this->suffix)); + $migrator->migrate('3'); + $old = $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue(); + $steps = $migrator->migrate(); + $this->assertNotEmpty($steps[0]->sql); + $this->assertNotSame($old, $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue()); + $migrator->rollback(); + $this->assertSame($old, $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue()); + } + + public function test_replacement_resumes_after_only_the_drop_completed(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddOrderReference($this->suffix), new CascadeOrderReference($this->suffix)); + $migrator->migrate('3'); + $name = $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue(); + $this->assertNotNull($name); + $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $this->observer->quoteSingleIdentifier($name)); + $migrator->migrate(); + $this->rows(); + $this->observer->delete($this->orders, ['id' => 1]); + $this->assertSame([], $this->observer->fetchAllAssociative('SELECT * FROM ' . $this->items)); + } + + public function test_conflicting_existing_constraint_is_not_silently_replaced(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddOrderReference($this->suffix)); + $migrator->migrate(); + $name = $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue(); + $this->assertNotNull($name); + $quoted = $this->observer->quoteSingleIdentifier($name); + $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $quoted); + $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' ADD CONSTRAINT ' . $quoted . ' FOREIGN KEY (order_id) REFERENCES ' . $this->orders . ' (id) ON DELETE CASCADE'); + $this->observer->delete($this->history, ['version' => '3']); + $this->expectException(IncompatibleSchema::class); + $migrator->migrate(); + } + + public function test_an_equivalent_unrelated_foreign_key_is_preserved(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddOrderReference($this->suffix)); + $migrator->migrate('2'); + $external = $this->suffix . '_external'; + $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' ADD CONSTRAINT ' . $this->observer->quoteSingleIdentifier($external) . ' FOREIGN KEY (order_id) REFERENCES ' . $this->orders . ' (id)'); + $migrator->migrate(); + $this->assertCount(2, $this->constraints()); + $migrator->rollback(); + $constraints = $this->constraints(); + $this->assertCount(1, $constraints); + $this->assertSame($external, $constraints[0]->getObjectName()?->getIdentifier()->getValue()); + } + + public function test_create_retry_rejects_a_conflicting_foreign_key(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateReferencedItems($this->suffix)); + $migrator->migrate(); + $name = $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue(); + $this->assertNotNull($name); + $quoted = $this->observer->quoteSingleIdentifier($name); + $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $quoted); + $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' ADD CONSTRAINT ' . $quoted . ' FOREIGN KEY (order_id) REFERENCES ' . $this->orders . ' (id)'); + $this->observer->delete($this->history, ['version' => '2']); + $this->expectException(IncompatibleSchema::class); + $migrator->migrate(); + } + + public function test_create_retry_adds_a_missing_constraint_without_recreating_the_table(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateReferencedItems($this->suffix)); + $migrator->migrate(); + $this->rows(); + $name = $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue(); + $this->assertNotNull($name); + $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $this->observer->quoteSingleIdentifier($name)); + $this->observer->delete($this->history, ['version' => '2']); + $steps = $migrator->migrate(); + $this->assertStringNotContainsString('CREATE TABLE', implode('; ', $steps[0]->sql)); + $this->assertCount(1, $this->constraints()); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->items)); + } + + public function test_the_same_migrations_use_distinct_foreign_keys_on_another_site(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateReferencedItems($this->suffix)); + $migrator->migrate(); + $original = $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue(); + $site = $this->factory()->blog->create(); + $this->assertIsInt($site); + switch_to_blog($site); + $this->source->set_blog_id($site); + + try { + $this->privateTable($this->suffix . '_orders'); + $this->privateTable($this->suffix . '_items'); + $this->privateTable($this->suffix . '_history'); + $migrator->migrate(); + $constraint = $this->constraints()[0]; + $this->assertNotSame($original, $constraint->getObjectName()?->getIdentifier()->getValue()); + $this->assertSame($this->source->prefix . $this->suffix . '_orders', $constraint->getReferencedTableName()->getUnqualifiedName()->getValue()); + } finally { + restore_current_blog(); + $this->source->set_blog_id(get_current_blog_id()); + } + } + + public function test_removal_keeps_the_automatically_supplied_index_and_data(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateReferencedItems($this->suffix), new RemoveOrderReference($this->suffix)); + $migrator->migrate('2'); + $this->rows(); + $tableName = $this->source->prefix . $this->suffix . '_items'; + $before = array_keys($this->observer->createSchemaManager()->introspectTable($tableName)->getIndexes()); + $migrator->migrate(); + $after = array_keys($this->observer->createSchemaManager()->introspectTable($tableName)->getIndexes()); + $this->assertSame($before, $after); + $this->assertCount(2, $after); + $this->assertSame([], $this->constraints()); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->items)); + $migrator->rollback(); + $this->assertCount(1, $this->constraints()); + } + + public function test_existing_orphans_fail_without_recording_the_relationship(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddOrderReference($this->suffix)); + $migrator->migrate('2'); + $this->observer->insert($this->items, ['id' => 1, 'order_id' => 99]); + + try { + $migrator->migrate(); + $this->fail('Existing orphan rows must prevent adding the foreign key.'); + } catch (ForeignKeyConstraintViolationException) { + $this->assertSame(['1', '2'], $this->observer->fetchFirstColumn('SELECT version FROM ' . $this->history . ' ORDER BY version')); + } + } +} From 7cf366d34203ce5a997b81b3104a67582370d84d Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 24 Sep 2026 15:49:39 -0600 Subject: [PATCH 19/53] Add recoverable migration renames and common column types Support native and legacy column renames, preserve foreign-key identity across prefix changes, and report logical constraint names in drift errors. Add older-server CI coverage, migration regressions, and usage documentation. --- .github/workflows/tests.yml | 6 + AGENTS.md | 4 +- src/Database/README.md | 3 + .../src/content/docs/components/database.mdx | 5 + .../content/docs/components/migrations.mdx | 128 +++ src/Migrations/MigrationsProvider.php | 8 +- src/Migrations/README.md | 3 + src/Migrations/Schema/Blueprint.php | 39 +- .../Schema/Definitions/ColumnDefinition.php | 5 +- .../Definitions/ForeignKeyDefinition.php | 24 +- .../Schema/ForeignKeyComparator.php | 161 +++ src/Migrations/Schema/RenamePlanner.php | 152 +++ .../Schema/Renames/ChangeColumnRename.php | 152 +++ .../Schema/Renames/Contracts/ColumnRename.php | 33 + .../Schema/Renames/NativeColumnRename.php | 45 + .../Schema/Renames/PlatformColumnRename.php | 49 + src/Migrations/Schema/SchemaPlanner.php | 96 +- src/Migrations/Schema/SchemaState.php | 127 ++- src/Migrations/Schema/TableBlueprint.php | 119 ++- src/Migrations/Schema/ValueObjects/Rename.php | 39 + .../Columns/ChangeCommonColumns.php | 46 + .../Columns/CreateCommonColumns.php | 41 + .../Columns/RenameCommonColumns.php | 51 + .../Migrations/ForeignKeys/AddItemNote.php | 25 + .../Migrations/ForeignKeys/AddOrderNote.php | 25 + tests/Unit/Migrations/ColumnRenameTest.php | 109 ++ .../Migrations/ForeignKeyComparatorTest.php | 83 ++ .../Migrations/ForeignKeyDefinitionTest.php | 42 + tests/Unit/Migrations/TableBlueprintTest.php | 42 +- .../Migrations/AdvisoryMigrationTest.php | 7 +- .../Migrations/ChangeColumnRenameTest.php | 129 +++ .../Migrations/CommonColumnTest.php | 212 ++++ .../Migrations/DeclarativeMigratorTest.php | 3 + .../Migrations/ForeignKeyIdentityTest.php | 304 ++++++ .../Migrations/ForeignKeyMigrationTest.php | 76 ++ .../Migrations/RenameMigrationTest.php | 962 ++++++++++++++++++ 36 files changed, 3310 insertions(+), 45 deletions(-) create mode 100644 src/Migrations/Schema/RenamePlanner.php create mode 100644 src/Migrations/Schema/Renames/ChangeColumnRename.php create mode 100644 src/Migrations/Schema/Renames/Contracts/ColumnRename.php create mode 100644 src/Migrations/Schema/Renames/NativeColumnRename.php create mode 100644 src/Migrations/Schema/Renames/PlatformColumnRename.php create mode 100644 src/Migrations/Schema/ValueObjects/Rename.php create mode 100644 tests/Support/Fixtures/Migrations/Columns/ChangeCommonColumns.php create mode 100644 tests/Support/Fixtures/Migrations/Columns/CreateCommonColumns.php create mode 100644 tests/Support/Fixtures/Migrations/Columns/RenameCommonColumns.php create mode 100644 tests/Support/Fixtures/Migrations/ForeignKeys/AddItemNote.php create mode 100644 tests/Support/Fixtures/Migrations/ForeignKeys/AddOrderNote.php create mode 100644 tests/Unit/Migrations/ColumnRenameTest.php create mode 100644 tests/Unit/Migrations/ForeignKeyComparatorTest.php create mode 100644 tests/integration/Migrations/ChangeColumnRenameTest.php create mode 100644 tests/integration/Migrations/CommonColumnTest.php create mode 100644 tests/integration/Migrations/ForeignKeyIdentityTest.php create mode 100644 tests/integration/Migrations/RenameMigrationTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 94b88f9..a3a9d2f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,6 +47,12 @@ jobs: - name: MariaDB image: mariadb:11.8.3 healthcheck: healthcheck.sh --connect --innodb_initialized + - name: MariaDB-10.4 + image: mariadb:10.4 + healthcheck: mysqladmin ping --host=127.0.0.1 --silent + - name: MySQL-5.7 + image: mysql:5.7 + healthcheck: mysqladmin ping --host=127.0.0.1 --silent - name: MySQL image: mysql:8.0 healthcheck: mysqladmin ping --host=127.0.0.1 --silent diff --git a/AGENTS.md b/AGENTS.md index fd87372..a3b616b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,8 @@ Encapsulate meaningful closed state behind named factories and predicates such a ## Source Conventions +Format SQL for readability inside multiline strings. Put each selected column on its own line when selecting multiple columns, each SQL clause on its own line, and additional `AND`/`OR` conditions on separate indented lines. Do not pack column lists or multiple predicates onto long lines. + Write every non-empty PHP array literal across multiple lines, including single-element arrays, nested arrays, return values, and arguments. Put each element on its own line, include a trailing comma, and place the closing bracket on its own line. Apply this consistently in source code, tests, stubs, and documentation examples. Empty arrays may remain `[]`. Separate control statements (`if`, loops, `switch`, and `try`) from surrounding statements with a blank line before and after the complete construct. This includes a blank line between a closing control-flow brace and a following assignment or method call. Do not add blank lines between connected clauses such as `} elseif`, `} else`, `} catch`, or `} finally`, or next to an enclosing block's opening or closing brace solely for this rule. @@ -163,7 +165,7 @@ Register infrastructure providers and top-level feature providers from the appli ## Database -`stellarwp/foundation-database` provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress transaction behavior. `stellarwp/foundation-migrations` owns migration discovery, schema declarations and planning, history, recovery, and migration commands. Developers use Doctrine directly for database expressions and advanced operations. Doctrine owns query construction, parameter binding, execution, result handling, schema inspection, and platform SQL generation. Foundation owns provider defaults, WordPress connection integration, resource naming, and operational guarantees. Foundation 2.0 targets Doctrine DBAL `^4.4` on PHP 8.3. DBAL 3 support is deferred until the Rector downgrade feature is implemented; it is not a Foundation 2.0 release requirement. Keep dependency-major differences confined to internal connection and schema boundaries, and validate dependency behavior separately from Rector syntax conversion before widening constraints. Future DBAL major lines require a deliberate compatibility decision. +`stellarwp/foundation-database` provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress transaction behavior. `stellarwp/foundation-migrations` owns migration discovery, schema declarations and planning, history, recovery, and migration commands. Developers use Doctrine directly for database expressions and advanced operations. Doctrine owns query construction, parameter binding, execution, result handling, schema inspection, and platform SQL generation. Foundation owns provider defaults, WordPress connection integration, resource naming, and operational guarantees. Foundation 2.0 targets Doctrine DBAL `^4.4` on PHP 8.3, with its MySQL 5.7.9+ and MariaDB 10.4.3+ server minimums. Keep these requirements visible in the Database and Migrations guides. DBAL 3 support is deferred until the Rector downgrade feature is implemented; it is not a Foundation 2.0 release requirement. Keep dependency-major differences confined to internal connection and schema boundaries, and validate dependency behavior separately from Rector syntax conversion before widening constraints. Future DBAL major lines require a deliberate compatibility decision. Use Doctrine's native `Connection`, query builder, results, exceptions, and schema objects where they supply the required behavior. Retire Foundation query builders, execution facades, SQL renderers, and gateway/capability contracts that merely duplicate those APIs as their callers are converted. Keep contracts for meaningful Foundation-owned policies and supported variations. Exposed Doctrine types and behavior are intentional compatibility commitments; do not imply that replacing Doctrine will be transparent to consumers. Preserve Foundation-specific exceptions for scope, migration, and managed-transaction failures. diff --git a/src/Database/README.md b/src/Database/README.md index fbc5a73..7b1b84d 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -8,6 +8,9 @@ and managed transactions for WordPress applications. ## Installation +Requires MySQL **5.7.9+** or MariaDB **10.4.3+**, matching +[Doctrine DBAL 4.4 platform support](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.4/reference/platforms.html). + ```shell composer require stellarwp/foundation-database ``` diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index 8802f29..d5e0b48 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -19,6 +19,11 @@ composer require stellarwp/foundation-database Foundation requires PHP 8.3, mysqli, and Doctrine DBAL 4.4. +:::caution[Database requirements] +Use MySQL **5.7.9+** or MariaDB **10.4.3+**, matching [Doctrine DBAL 4.4’s supported platforms](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.4/reference/platforms.html). Check the database version on every deployment target before adopting Foundation. +::: + + Database services borrow the active WordPress connection. Use the primary MySQL or MariaDB database and InnoDB tables for transactional application data. Set a stable, unique application prefix in your root configuration: diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index c72052e..ec04461 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -13,6 +13,10 @@ Unlike WordPress’s [`dbDelta()`](https://developer.wordpress.org/reference/fun ### Set up migrations +:::caution[Database requirements] +Use MySQL **5.7.9+** or MariaDB **10.4.3+**, matching [Doctrine DBAL 4.4’s supported platforms](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.4/reference/platforms.html). Check the database version on every deployment target before adopting Foundation. +::: + Install the migrations runtime and development CLI. The runtime includes Foundation Database and the WP-CLI integration: ```shell @@ -199,6 +203,43 @@ Once a migration has been applied anywhere, retain its filename and declarations `up()` and `down()` declare schema changes. Foundation may replay them during planning, including previews. Keep queries and application work in the separate [data callback](#transform-existing-data). +### Choose column types + +Use the same column methods when creating a table or adding columns to an existing one: + +```php +$table = $schema->table( 'your_plugin_reports' ); + +$table->json( 'metadata' )->nullable(); +$table->date( 'starts_on' )->nullable(); +$table->time( 'opens_at' )->nullable(); +$table->smallInteger( 'attempts' )->unsigned()->default( 0 ); +$table->char( 'currency', 3 )->default( 'USD' ); +$table->mediumText( 'content' )->nullable(); +``` + +| Method | Storage and typical use | +| --- | --- | +| `bigIncrements()` | Unsigned auto-incrementing BIGINT primary key, named `id` by default | +| `string( 'name', 191 )` | Variable-length text, with a default limit of 191 characters | +| `char( 'currency', 3 )` | Fixed-length character storage; specify the length explicitly | +| `text( 'summary' )` | Text up to 65,535 bytes | +| `mediumText( 'content' )` | Text up to 16,777,215 bytes | +| `longText( 'document' )` | LONGTEXT storage for larger documents | +| `smallInteger( 'attempts' )` | SMALLINT; use `unsigned()` for nonnegative values | +| `integer( 'quantity' )`, `bigInteger( 'external_id' )` | INT and BIGINT; unsigned convenience methods are also available | +| `boolean( 'enabled' )` | Boolean storage | +| `decimal( 'amount', 12, 4 )` | Exact numeric storage with explicit precision and scale | +| `json( 'metadata' )` | JSON document storage with database validation | +| `date( 'starts_on' )` | Calendar date, such as `2026-09-24` | +| `time( 'opens_at' )` | Time with whole-second precision, such as `09:30:00` | +| `dateTime( 'created_at', 6 )` | DATETIME with optional fractional-second precision from 0 to 6 | +| `binary( 'token', 16 )` | Variable-length binary storage with an explicit byte limit | + +Columns are non-nullable unless you call `nullable()`. Existing modifiers such as `default()`, `comment()`, and `change()` apply to the new types too. A `char()` length is a storage choice, not application validation that a supplied code has exactly that many characters. `useCurrent()` and `useCurrentOnUpdate()` are intended for `dateTime()` columns. + +[MySQL](https://dev.mysql.com/doc/refman/8.0/en/json.html) stores JSON natively; [MariaDB](https://mariadb.com/docs/server/reference/data-types/string-data-types/json) uses text with JSON validation. Foundation preserves that distinction through migration retries and renames. For compatibility with MySQL 5.7, supply a JSON value on insertion or make the column nullable instead of declaring a non-null JSON default. When writing PHP arrays through a table or Doctrine connection, bind the column as `Doctrine\DBAL\Types\Types::JSON` so Doctrine encodes it. Query results are not automatically decoded; see [queries and parameter types](/components/database/query-builder/). + ### Change a column Declare the complete replacement definition and call `change()`: @@ -211,6 +252,81 @@ $table->string( 'title', 255 )->change(); Restate any default, nullability, unsigned flag, comment, or other attribute to retain. For this example, a safe inverse can restore the original length only after accounting for values longer than that length. +### Rename a column + +Generate an alteration for the table whose column is changing: + +```shell +vendor/bin/foundation make:database:migration rename_report_title --table=your_plugin_reports +``` + +Replace the generated methods with the rename and its inverse: + +```php +/** + * Give report titles a clearer name. + */ +public function up( Blueprint $schema ): void { + $table = $schema->table( 'your_plugin_reports' ); + + $table->renameColumn( 'title', 'headline' ); +} + +/** + * Restore the original column name while retaining its values. + */ +public function down( Blueprint $schema ): void { + $table = $schema->table( 'your_plugin_reports' ); + + $table->renameColumn( 'headline', 'title' ); +} +``` + +Remove the generated `IrreversibleMigration` import and annotation when supplying this inverse. `renameColumn()` retains the column's values and complete definition, including its default, nullability, and comment. Indexes and foreign keys continue to refer to the renamed column. Use a distinct destination name that does not already exist. Column renames run before other alterations on that table; use the new name in a `change()` declaration. When creating a new table, declare columns with their final names. + +Keep renamed objects identifiable until the migration is recorded. Put reuse of an old name or removal of the renamed object in a later migration. Foundation rejects ambiguous sequences, such as swapping two column names, because a retry cannot distinguish their completed state. + +Column renames work across the supported database versions. Foundation uses native `RENAME COLUMN` when available and otherwise uses `CHANGE` with the existing column definition. Your migration declaration stays the same. + +Preview and apply the migration: + +```shell +wp your-plugin migrate --run --dry-run +wp your-plugin migrate --run +``` + +Update application queries to use `headline` when deploying this schema change. Leave the earlier migration that created `title` unchanged. Running `--rollback` reverses the rename through `down()`, preserving the values under `title` again. + +### Rename a table + +Generate a migration without `--create` or `--table`, then fill in its methods: + +```shell +vendor/bin/foundation make:database:migration rename_reports_to_articles +``` + +```php +/** + * Move report storage to its new name. + */ +public function up( Blueprint $schema ): void { + $schema->rename( 'your_plugin_reports', 'your_plugin_articles' ); +} + +/** + * Restore the original table name while retaining its rows. + */ +public function down( Blueprint $schema ): void { + $schema->rename( 'your_plugin_articles', 'your_plugin_reports' ); +} +``` + +Remove the generated irreversible exception import and annotation. Both arguments are unprefixed names; Foundation applies the current WordPress site prefix. The destination must be unused. The rename preserves existing rows, indexes, and foreign-key relationships. + +Preview and apply with `wp your-plugin migrate --run --dry-run` and `wp your-plugin migrate --run`. Update the application table class's `unprefixedName()` to return `'your_plugin_articles'`, and use that name in new migrations. Keep all earlier migration filenames and historical table names unchanged. Coordinate these application changes with migration execution; application queries must use the name currently present in the database. + +Existing foreign keys retain their physical constraint names after a table rename. Continue using their original logical names with `dropForeignKey()` on the renamed table. If a later migration creates a different table under the old table name, give its foreign keys different logical names from those still owned by the renamed table, so their physical names do not collide. + ### Remove a column ```php @@ -270,6 +386,16 @@ Keep the generated `down()` that drops `your_plugin_order_items`. The later migr `order` is the relationship's stable logical name within the items table. Foundation scopes the physical constraint name to that table, so another table can also use `order`. Pass the same logical name to `dropForeignKey()` later. `references()` takes the historical, unprefixed application table name; Foundation resolves both tables for the current WordPress site. +Foreign-key drift errors include the logical name, such as `order`, alongside the database constraint name, such as `fk_…`. Use the logical name to locate your migration declaration and the database name to inspect the constraint. Constraints without a known declaration are identified by their database name alone. + +:::caution[Keep foreign keys between application-owned tables] +Use this workflow for tables your application owns. For relationships to WordPress posts or users, store the ID in an indexed column and coordinate cleanup through WordPress's deletion hooks. + +`references()` applies the current site's prefix. On multisite, `references( 'users', 'ID' )` would target a site table such as `wp_2_users`, while WordPress uses a shared users table. Even for site-specific core tables such as `posts`, a restrictive foreign key can block WordPress deletion after related data has already been removed. A cascading foreign key deletes your application's rows directly, bypassing its PHP cleanup. See WordPress's [table naming](https://developer.wordpress.org/reference/classes/wpdb/tables/) and [post deletion workflow](https://developer.wordpress.org/reference/functions/wp_delete_post/). +::: + +If you change WordPress's table prefix, rename the application's tables and migration ledger together before running migrations again. Keep historical migration declarations unchanged. Existing constraint names can retain the old prefix's identity; Foundation recognizes a uniquely matching historical relationship by its columns, referenced table, and update/delete actions. Later migrations can still change or remove it. If multiple constraints make that identity ambiguous, migration planning stops with `IncompatibleSchema` so you can inspect the named constraints before retrying. + Declare columns and indexes explicitly. The local and referenced column types must match, including integer size and unsignedness. Reference a primary or unique key. Foundation-created tables use InnoDB, which enforces these constraints. Preview and apply as usual: @@ -431,6 +557,8 @@ public function describe(): string { MySQL DDL can commit before the history write. A retry compares the actual schema with the migration's declared change: compatible existing additions and already-absent removals count as completed work. An interrupted index replacement resumes its missing work. Undeclared columns, indexes, and constraints are retained during alterations. +Retries also recognize completed table and column renames, then continue any remaining work without renaming them again. A conflicting destination or a missing source and destination stops execution with `IncompatibleSchema`; inspect the live names before retrying. + An incompatible existing declaration stops the run with `IncompatibleSchema`; Foundation does not silently reconcile unrelated drift. Inspect and correct the mismatch before retrying. `LedgerFailure` means a history write failed after migration work; fix the storage failure and retry with the same declarations. Any data callback must tolerate repetition. ### Repair inconsistent history diff --git a/src/Migrations/MigrationsProvider.php b/src/Migrations/MigrationsProvider.php index e5e0d4f..648614c 100644 --- a/src/Migrations/MigrationsProvider.php +++ b/src/Migrations/MigrationsProvider.php @@ -8,6 +8,9 @@ use StellarWP\Foundation\Container\Traits\ResolvesFoundationPrefix; use StellarWP\Foundation\Migrations\Cli\Migrate; use StellarWP\Foundation\Migrations\Schema\Factories\MigrationComparatorFactory; +use StellarWP\Foundation\Migrations\Schema\RenamePlanner; +use StellarWP\Foundation\Migrations\Schema\Renames\Contracts\ColumnRename; +use StellarWP\Foundation\Migrations\Schema\Renames\PlatformColumnRename; use StellarWP\Foundation\Migrations\Schema\SchemaPlanner; use StellarWP\Foundation\Migrations\Tables\MigrationTable; use StellarWP\Foundation\WPCli\WPCliProvider; @@ -69,7 +72,8 @@ private function registerMigrations(): void { $this->container->singleton( self::MIGRATION_COMPARATOR_CONFIG, static fn (): ComparatorConfig => (new ComparatorConfig()) - ->withReportModifiedIndexes(false), + ->withReportModifiedIndexes(false) + ->withDetectRenamedColumns(false), ); $this->container->when(MigrationComparatorFactory::class) @@ -91,6 +95,8 @@ private function registerMigrations(): void { $this->container->singleton(MigrationCollection::class); $this->container->singleton(History::class); $this->container->singleton(MigrationComparatorFactory::class); + $this->container->singleton(ColumnRename::class, PlatformColumnRename::class); + $this->container->singleton(RenamePlanner::class); $this->container->singleton(SchemaPlanner::class); $this->container->singleton(Migrator::class); } diff --git a/src/Migrations/README.md b/src/Migrations/README.md index 063b8cd..0046b16 100644 --- a/src/Migrations/README.md +++ b/src/Migrations/README.md @@ -8,6 +8,9 @@ version history, and database advisory locking. ## Installation +Requires PHP 8.3 and MySQL **5.7.9+** or MariaDB **10.4.3+**, matching +[Doctrine DBAL 4.4 platform support](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.4/reference/platforms.html). + ```shell composer require stellarwp/foundation-migrations ``` diff --git a/src/Migrations/Schema/Blueprint.php b/src/Migrations/Schema/Blueprint.php index 286939a..7174d9d 100644 --- a/src/Migrations/Schema/Blueprint.php +++ b/src/Migrations/Schema/Blueprint.php @@ -6,8 +6,10 @@ use Doctrine\DBAL\Schema\Exception\TableAlreadyExists; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\Table as DoctrineTable; +use InvalidArgumentException; use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Contracts\TableNameResolver; +use StellarWP\Foundation\Migrations\Schema\ValueObjects\Rename; /** * The schema handed to up() and down(): declare tables to create, alter, or drop. @@ -38,11 +40,17 @@ public function __construct( public function create(Table|string $table): TableBlueprint { $blueprint = new TableBlueprint($this->resolve($table)); $this->operations[] = function () use ($blueprint): void { + if ($blueprint->renames() !== []) { + throw new InvalidArgumentException('Declare new columns with their final names; renameColumn() is for existing tables.'); + } + $name = $blueprint->name(); if ($this->state->schema->hasTable($name)) { throw TableAlreadyExists::new($name); } + + unset($this->state->tableOrigins[strtolower($name)], $this->state->foreignKeyNames[strtolower($name)]); $table = DoctrineTable::editor()->setUnquotedName($name)->setOptions($this->tableOptions); $this->replaceTable($blueprint->applyTo($table, $this->state, $this->names)); }; @@ -54,12 +62,19 @@ public function create(Table|string $table): TableBlueprint { * Declare additions, changes, and removals on an existing table. */ public function table(Table|string $table): TableBlueprint { - $blueprint = new TableBlueprint($this->resolve($table)); + $name = $this->resolve($table); + $blueprint = new TableBlueprint($name); $this->operations[] = function () use ($blueprint): void { + foreach ($blueprint->renames() as $rename) { + $this->state->rename($rename); + } + $table = $this->state->schema->getTable($blueprint->name()); // Keep supporting indexes when a foreign key is removed. The editor already carries the primary key separately. - $secondaryIndexes = array_diff_key($table->getIndexes(), ['primary' => true]); - $editor = $table->edit()->setIndexes(...array_values($secondaryIndexes)); + $secondaryIndexes = array_diff_key($table->getIndexes(), [ + 'primary' => true, + ]); + $editor = $table->edit()->setIndexes(...array_values($secondaryIndexes)); $this->replaceTable($blueprint->applyTo($editor, $this->state, $this->names)); }; @@ -67,6 +82,22 @@ public function table(Table|string $table): TableBlueprint { return $blueprint; } + /** + * Rename an existing table, preserving its rows and relationships. + * + * @throws InvalidArgumentException When names conflict or the source is not declared. + */ + public function rename(string $from, string $to): void { + $rename = new Rename($this->resolve($from), $this->resolve($to)); + $this->operations[] = function () use ($rename): void { + if (! $this->state->schema->hasTable($rename->from) || $this->state->schema->hasTable($rename->to)) { + throw new InvalidArgumentException('A table rename requires a declared source and an unused destination.'); + } + + $this->state->rename($rename); + }; + } + /** * Declare that the table no longer exists after this migration. */ @@ -74,6 +105,8 @@ public function drop(Table|string $table): void { $name = $this->resolve($table); $this->operations[] = function () use ($name): void { $this->state->schema->dropTable($name); + unset($this->state->tableOrigins[strtolower($name)], $this->state->foreignKeyNames[strtolower($name)]); + foreach (array_keys($this->state->timestamps) as $key) { if (str_starts_with($key, strtolower($name) . '.')) { unset($this->state->timestamps[$key]); diff --git a/src/Migrations/Schema/Definitions/ColumnDefinition.php b/src/Migrations/Schema/Definitions/ColumnDefinition.php index ac1f4c4..7010e32 100644 --- a/src/Migrations/Schema/Definitions/ColumnDefinition.php +++ b/src/Migrations/Schema/Definitions/ColumnDefinition.php @@ -27,8 +27,8 @@ final class ColumnDefinition /** * Collect a complete column declaration. * - * @param non-empty-string $name Column identifier. - * @param array{length?: int, precision?: int, scale?: int} $options Type-specific Doctrine options such as length, precision, scale. + * @param non-empty-string $name Column identifier. + * @param array{length?: int, fixed?: bool, precision?: int, scale?: int} $options Type-specific Doctrine options such as length, precision, scale. * * @internal Constructed by Foundation; applications receive this object through provider wiring or migration callbacks. */ @@ -138,6 +138,7 @@ public function applyTo(TableEditor $table): void { ->setTypeName($this->type) ->setNotNull(! $this->nullable) ->setUnsigned($this->unsigned) + ->setFixed($this->options['fixed'] ?? false) ->setAutoincrement($this->autoIncrement) ->setComment($this->comment ?? ''); diff --git a/src/Migrations/Schema/Definitions/ForeignKeyDefinition.php b/src/Migrations/Schema/Definitions/ForeignKeyDefinition.php index ac148b3..839129f 100644 --- a/src/Migrations/Schema/Definitions/ForeignKeyDefinition.php +++ b/src/Migrations/Schema/Definitions/ForeignKeyDefinition.php @@ -22,17 +22,33 @@ final class ForeignKeyDefinition private ReferentialAction $onUpdate = ReferentialAction::RESTRICT; /** - * Receive the physical constraint identity and local columns. + * Receive the logical constraint identity and local columns. * * @param non-empty-string $name * @param non-empty-list $localColumns * * @internal Constructed by TableBlueprint::foreignKey(). + * + * @throws InvalidArgumentException When the logical name is empty. */ public function __construct( private readonly string $name, private readonly array $localColumns, ) { + if (trim($name) === '') { + throw new InvalidArgumentException('A foreign key must have a name.'); + } + } + + /** + * Return the logical name used to resolve this constraint's historical identity. + * + * @internal + * + * @return non-empty-string + */ + public function name(): string { + return $this->name; } /** @@ -113,15 +129,17 @@ public function nullOnUpdate(): self { * * @internal Called by TableBlueprint::applyTo(). * + * @param non-empty-string $constraintName + * * @throws InvalidArgumentException When references() has not completed the declaration. */ - public function applyTo(TableEditor $editor, TableNameResolver $names): void { + public function applyTo(TableEditor $editor, TableNameResolver $names, string $constraintName): void { if ($this->referencedTable === null || $this->referencedColumns === null) { throw new InvalidArgumentException('Complete the foreign key declaration with references().'); } $editor->addForeignKeyConstraint(ForeignKeyConstraint::editor() - ->setUnquotedName($this->name) + ->setUnquotedName($constraintName) ->setUnquotedReferencingColumnNames(...$this->localColumns) ->setUnquotedReferencedTableName($names->tableName($this->referencedTable)) ->setUnquotedReferencedColumnNames(...$this->referencedColumns) diff --git a/src/Migrations/Schema/ForeignKeyComparator.php b/src/Migrations/Schema/ForeignKeyComparator.php index c0cff55..116b6b7 100644 --- a/src/Migrations/Schema/ForeignKeyComparator.php +++ b/src/Migrations/Schema/ForeignKeyComparator.php @@ -6,8 +6,11 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Schema\Comparator; use Doctrine\DBAL\Schema\ComparatorConfig; +use Doctrine\DBAL\Schema\ForeignKeyConstraint; +use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; +use StellarWP\Foundation\Migrations\Exceptions\IncompatibleSchema; /** * Preserve foreign-key identity while retaining Doctrine's platform comparison. @@ -30,6 +33,36 @@ public function __construct( parent::__construct($platform, $config); } + /** + * Retain live constraint names when a prefix change altered historical physical identities. + * + * Only previously declared relationships with Foundation-generated names may match by structure. + * Update private planning snapshots, leaving the live database and logical names unchanged. + * + * @throws IncompatibleSchema When ambiguous matches or changed actions prevent identifying ownership. + */ + public function alignForeignKeyNames(SchemaState $before, SchemaState $actual, SchemaState $after, string $id): void { + $names = []; + + foreach ($before->schema->getTables() as $table) { + $name = $table->getObjectName()->getUnqualifiedName()->getValue(); + + if (! $actual->schema->hasTable($name)) { + continue; + } + + $names[strtolower($name)] = $this->historicalNames( + $actual->schema->getTable($name), + $before, + $after->schema->hasTable($name) ? $after->schema->getTable($name)->getForeignKeys() : [], + $id, + ); + } + + $this->applyForeignKeyNames($before, $names); + $this->applyForeignKeyNames($after, $names); + } + /** * Compare columns and indexes natively, then match foreign keys by identity. * @@ -79,4 +112,132 @@ public function compareTables(Table $oldTable, Table $newTable): TableDiff { droppedForeignKeys: $dropped, ); } + + /** + * Match missing historical names uniquely, reserving every exact identity first. + * + * @param array $after + * + * @return array Historical name to live name. + */ + private function historicalNames(Table $actual, SchemaState $before, array $after, string $id): array { + $table = $actual->getObjectName()->getUnqualifiedName()->getValue(); + $historical = $before->schema->getTable($table)->getForeignKeys(); + $live = $actual->getForeignKeys(); + $candidates = array_diff_key($live, $historical, $after); + $names = []; + + foreach (array_diff_key($historical, $live) as $name => $constraint) { + $label = $before->foreignKeyLabel($table, $name); + $matches = $this->matchingNames($constraint, $after[$name] ?? null, $candidates, $label); + + if ($matches === []) { + continue; + } + + $matched = $matches[0]; + + if (count($matches) !== 1 || in_array($matched, $names, true)) { + throw IncompatibleSchema::forChange( + $id, + $table, + 'ambiguous foreign-key identity for ' . $label . ' among ' . implode(', ', $matches), + ); + } + + $names[$name] = $matched; + } + + return $names; + } + + /** + * Find managed names matching the historical definition or its completed replacement. + * + * @param array $candidates + * + * @throws IncompatibleSchema When changed actions prevent identifying a historical relationship. + * + * @return list + */ + private function matchingNames(ForeignKeyConstraint $before, ?ForeignKeyConstraint $after, array $candidates, string $label): array { + $names = []; + $conflicts = []; + + foreach ($candidates as $candidate) { + $name = $candidate->getObjectName()?->getIdentifier()->getValue(); + + if ($name === null || preg_match('/^fk_[0-9a-f]{40}$/D', $name) !== 1) { + continue; + } + + // A replacement may have completed before its ledger write failed. + if (! $this->diffForeignKey($before, $candidate) || ($after !== null && ! $this->diffForeignKey($after, $candidate))) { + $names[] = $name; + + continue; + } + + // Ignore actions only to detect uncertain ownership, never to accept a match. + $expectedActions = $candidate->edit() + ->setOnUpdateAction($before->getOnUpdateAction()) + ->setOnDeleteAction($before->getOnDeleteAction()) + ->create(); + + if (! $this->diffForeignKey($before, $expectedActions)) { + $conflicts[] = $name; + } + } + + if ($names === [] && $conflicts !== []) { + throw new IncompatibleSchema(sprintf( + 'Cannot identify historical foreign key %s: constraints %s have matching columns and references but different update/delete actions. Repair the schema or the ledger before retrying.', + $label, + implode(', ', $conflicts), + )); + } + + return $names; + } + + /** + * Apply physical aliases without changing definitions or implicit supporting indexes. + * + * @param array> $names + */ + private function applyForeignKeyNames(SchemaState $state, array $names): void { + $tables = []; + + foreach ($state->schema->getTables() as $table) { + $tableName = strtolower($table->getObjectName()->getUnqualifiedName()->getValue()); + $aliases = $names[$tableName] ?? []; + + if ($aliases === []) { + $tables[] = $table; + + continue; + } + + $constraints = []; + + foreach ($table->getForeignKeys() as $name => $constraint) { + $constraints[] = isset($aliases[$name]) ? $constraint->edit()->setUnquotedName($aliases[$name])->create() : $constraint; + + if (isset($aliases[$name], $state->foreignKeyNames[$tableName][$name])) { + $state->foreignKeyNames[$tableName][strtolower($aliases[$name])] = $state->foreignKeyNames[$tableName][$name]; + unset($state->foreignKeyNames[$tableName][$name]); + } + } + + $indexes = array_diff_key($table->getIndexes(), [ + 'primary' => true, + ]); + $tables[] = $table->edit() + ->setIndexes(...array_values($indexes)) + ->setForeignKeyConstraints(...$constraints) + ->create(); + } + + $state->schema = new Schema($tables); + } } diff --git a/src/Migrations/Schema/RenamePlanner.php b/src/Migrations/Schema/RenamePlanner.php new file mode 100644 index 0000000..92a2fb6 --- /dev/null +++ b/src/Migrations/Schema/RenamePlanner.php @@ -0,0 +1,152 @@ + + */ + public function plan(SchemaState $before, SchemaState $actual, SchemaState $after, string $id): array { + if ($after->renames === []) { + return []; + } + + if (! $this->isRecoverable($before, $after)) { + throw new InvalidArgumentException('Migration ' . $id . ' has ambiguous renames; put name reuse or removal in a later migration.'); + } + + $completed = $this->completedPrefix($actual, $after->renames); + + if ($completed === null) { + throw new IncompatibleSchema('Migration ' . $id . ' cannot resume its renames: a source is missing, a destination conflicts, or names changed out of order.'); + } + + foreach (array_reverse(array_slice($after->renames, 0, $completed)) as $rename) { + $actual->rename($rename->inverse()); + } + + $sql = []; + + foreach ($after->renames as $position => $rename) { + if ($position >= $completed) { + $sql = array_merge($sql, $this->sql($rename, $actual)); + } + + $before->rename($rename); + $actual->rename($rename); + } + + return $sql; + } + + /** + * Require every interruption point to have a distinguishable set of names. + */ + private function isRecoverable(SchemaState $before, SchemaState $after): bool { + $probe = clone $before; + + foreach ($after->renames as $position => $rename) { + if (! $this->applySequence($probe, [ + $rename, + ]) || $this->completedPrefix($probe, $after->renames) !== $position + 1) { + return false; + } + } + + return $this->completedPrefix($after, $after->renames) === count($after->renames); + } + + /** + * Find how much of the ordered rename SQL already completed before an interruption. + * + * Rewind each possible prefix on a private snapshot, then check the complete sequence. + * This also handles a table rename followed by column renames, and chained names. + * + * @param list $renames + */ + private function completedPrefix(SchemaState $actual, array $renames): ?int { + for ($completed = 0; $completed <= count($renames); $completed++) { + $probe = clone $actual; + $rewind = array_map(static fn (Rename $rename): Rename => $rename->inverse(), array_reverse(array_slice($renames, 0, $completed))); + + if ($this->applySequence($probe, array_merge($rewind, $renames))) { + return $completed; + } + } + + return null; + } + + /** + * Apply an unambiguous sequence to a private snapshot. + * + * @param list $renames + */ + private function applySequence(SchemaState $state, array $renames): bool { + foreach ($renames as $rename) { + if (! $this->exists($state, $rename, $rename->from) || $this->exists($state, $rename, $rename->to)) { + return false; + } + + $state->rename($rename); + } + + return true; + } + + private function exists(SchemaState $state, Rename $rename, string $name): bool { + if ($rename->table === null) { + return $state->schema->hasTable($name); + } + + return $state->schema->hasTable($rename->table) + && $state->schema->getTable($rename->table)->hasColumn($name); + } + + /** + * Keep each platform statement separate and in execution order. + * + * @return list + */ + private function sql(Rename $rename, SchemaState $actual): array { + $platform = $this->db->getDatabasePlatform(); + $from = $platform->quoteSingleIdentifier($rename->from); + $to = $platform->quoteSingleIdentifier($rename->to); + + if ($rename->table === null) { + return [ + $platform->getRenameTableSQL($from, $to), + ]; + } + + return $this->columns->sql($actual->schema->getTable($rename->table), $rename->from, $rename->to); + } +} diff --git a/src/Migrations/Schema/Renames/ChangeColumnRename.php b/src/Migrations/Schema/Renames/ChangeColumnRename.php new file mode 100644 index 0000000..5db3feb --- /dev/null +++ b/src/Migrations/Schema/Renames/ChangeColumnRename.php @@ -0,0 +1,152 @@ +db->fetchAllAssociative( + 'SELECT + COLUMN_NAME, + COLUMN_TYPE, + DATA_TYPE, + IS_NULLABLE, + COLUMN_DEFAULT, + CHARACTER_SET_NAME, + COLLATION_NAME, + COLUMN_COMMENT, + EXTRA, + GENERATION_EXPRESSION + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ?', + [ + $table->getObjectName()->getUnqualifiedName()->getValue(), + ], + ); + $editor = $table->edit()->setIndexes(...array_values(array_diff_key($table->getIndexes(), [ + 'primary' => true, + ]))); + + foreach ($rows as $row) { + $column = $table->getColumn((string) $row['COLUMN_NAME']); + $definition = $this->definition($row, $column); + $editor->modifyColumn($column->getObjectName(), static function (ColumnEditor $column) use ($definition): void { + $column->setColumnDefinition($definition); + }); + } + + return $editor->create(); + } + + /** + * {@inheritDoc} + */ + public function sql(Table $table, string $from, string $to): array { + $column = $table->getColumn($from)->edit()->setQuotedName($from)->create(); + $renamed = $column->edit()->setQuotedName($to)->create(); + $diff = new TableDiff($table, changedColumns: [ + $from => new ColumnDiff($column, $renamed), + ]); + + return $this->db->getDatabasePlatform()->getAlterTableSQL($diff); + } + + /** + * Preserve catalog attributes literally where Doctrine's portable model loses information. + * + * @param array $row + * + * @return non-empty-string + */ + private function definition(array $row, Column $column): string { + $platform = $this->db->getDatabasePlatform(); + $json = $column->getType() instanceof JsonType; + // MariaDB reports JSON as LONGTEXT; restating JSON preserves its validation constraint. + $sql = $json ? $platform->getJsonTypeDeclarationSQL($column->toArray()) : (string) $row['COLUMN_TYPE']; + + if (! $json && $row['CHARACTER_SET_NAME'] !== null) { + $sql .= ' CHARACTER SET ' . $platform->quoteSingleIdentifier((string) $row['CHARACTER_SET_NAME']); + $sql .= ' COLLATE ' . $platform->quoteSingleIdentifier((string) $row['COLLATION_NAME']); + } + + $extra = (string) $row['EXTRA']; + $expression = (string) $row['GENERATION_EXPRESSION']; + + if ($expression !== '') { + $sql .= ' GENERATED ALWAYS AS (' . $expression . ')'; + $sql .= stripos($extra, 'stored') !== false ? ' STORED' : ' VIRTUAL'; + + if (stripos($extra, 'invisible') !== false) { + $sql .= ' INVISIBLE'; + } + } else { + $sql .= $column->getNotnull() ? ' NOT NULL' : ' NULL'; + $sql .= $this->defaultSql($row, $column); + $sql .= ' ' . trim(str_ireplace('DEFAULT_GENERATED', '', $extra)); + } + + if ($row['COLUMN_COMMENT'] !== '') { + $sql .= ' COMMENT ' . $platform->quoteStringLiteral((string) $row['COLUMN_COMMENT']); + } + + return $sql; + } + + /** + * MariaDB reports SQL defaults; MySQL reports literal values except generated defaults. + * + * @param array $row + */ + private function defaultSql(array $row, Column $column): string { + if ($row['COLUMN_DEFAULT'] === null) { + return $column->getNotnull() ? '' : ' DEFAULT NULL'; + } + + $platform = $this->db->getDatabasePlatform(); + $default = (string) $row['COLUMN_DEFAULT']; + + if ($platform instanceof MariaDBPlatform) { + return ' DEFAULT ' . $default; + } + + if (in_array($row['DATA_TYPE'], [ + 'datetime', + 'timestamp', + ], true) && preg_match('/^current_timestamp(?:\(\d*\))?$/i', $default) === 1) { + return ' DEFAULT ' . $default; + } + + if (stripos((string) $row['EXTRA'], 'DEFAULT_GENERATED') !== false) { + return ' DEFAULT (' . $default . ')'; + } + + return ' DEFAULT ' . $platform->quoteStringLiteral($default); + } +} diff --git a/src/Migrations/Schema/Renames/Contracts/ColumnRename.php b/src/Migrations/Schema/Renames/Contracts/ColumnRename.php new file mode 100644 index 0000000..d2a40b0 --- /dev/null +++ b/src/Migrations/Schema/Renames/Contracts/ColumnRename.php @@ -0,0 +1,33 @@ + Statements in execution order. + */ + public function sql(Table $table, string $from, string $to): array; +} diff --git a/src/Migrations/Schema/Renames/NativeColumnRename.php b/src/Migrations/Schema/Renames/NativeColumnRename.php new file mode 100644 index 0000000..553fee8 --- /dev/null +++ b/src/Migrations/Schema/Renames/NativeColumnRename.php @@ -0,0 +1,45 @@ +db->getDatabasePlatform(); + $name = $platform->quoteSingleIdentifier($table->getObjectName()->getUnqualifiedName()->getValue()); + $from = $platform->quoteSingleIdentifier($from); + $to = $platform->quoteSingleIdentifier($to); + + return [ + "ALTER TABLE {$name} + RENAME COLUMN {$from} TO {$to}", + ]; + } +} diff --git a/src/Migrations/Schema/Renames/PlatformColumnRename.php b/src/Migrations/Schema/Renames/PlatformColumnRename.php new file mode 100644 index 0000000..21b08cf --- /dev/null +++ b/src/Migrations/Schema/Renames/PlatformColumnRename.php @@ -0,0 +1,49 @@ +strategy()->inspect($table); + } + + /** + * {@inheritDoc} + */ + public function sql(Table $table, string $from, string $to): array { + return $this->strategy()->sql($table, $from, $to); + } + + private function strategy(): ColumnRename { + $platform = $this->db->getDatabasePlatform(); + + return $platform instanceof MySQL80Platform || $platform instanceof MariaDB1052Platform + ? $this->native + : $this->change; + } +} diff --git a/src/Migrations/Schema/SchemaPlanner.php b/src/Migrations/Schema/SchemaPlanner.php index 6629754..65c0b77 100644 --- a/src/Migrations/Schema/SchemaPlanner.php +++ b/src/Migrations/Schema/SchemaPlanner.php @@ -4,6 +4,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Schema\Column; +use Doctrine\DBAL\Schema\ColumnEditor; use Doctrine\DBAL\Schema\Index; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaDiff; @@ -16,6 +17,7 @@ use StellarWP\Foundation\Migrations\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Migrations\MigrationCollection; use StellarWP\Foundation\Migrations\Schema\Factories\MigrationComparatorFactory; +use StellarWP\Foundation\Migrations\Schema\Renames\Contracts\ColumnRename; use StellarWP\Foundation\Migrations\Schema\ValueObjects\SchemaPlan; /** @@ -35,6 +37,8 @@ public function __construct( private TableNameResolver $names, private MigrationCollection $migrations, private MigrationComparatorFactory $comparators, + private RenamePlanner $renames, + private ColumnRename $columnRenames, private array $tableOptions = [], ) { } @@ -53,15 +57,27 @@ public function plan(array $applied, string $id, bool $reverse, ?SchemaState $si $after = $reverse ? $this->reverse($before, $id) : $this->replay([ ...$applied, $id, - ]); + ], $id); $comparator = $this->comparators->create(); - $expected = $comparator->compareSchemas($before->schema, $after->schema); $names = $this->tableNames($before, $after); - $actual = $this->introspect($names, $simulated, $simulatedNames); - $desired = $this->preserveUndeclared($actual, $before, $after); - $remaining = $comparator->compareSchemas($actual->schema, $desired->schema); - $this->assertContained($remaining, $expected, $id); + + foreach ($after->renames as $rename) { + if ($rename->table === null) { + $names[] = $rename->from; + $names[] = $rename->to; + } + } + + $names = array_values(array_unique($names)); + $actual = $this->introspect($names, $simulated, $simulatedNames); + $renameSql = $this->renames->plan($before, $actual, $after, $id); + $comparator->alignForeignKeyNames($before, $actual, $after, $id); + $expected = $comparator->compareSchemas($before->schema, $after->schema); + $desired = $this->preserveUndeclared($actual, $before, $after); + $remaining = $comparator->compareSchemas($actual->schema, $desired->schema); + $this->assertContained($remaining, $expected, $before, $after, $id); $sql = array_merge( + $renameSql, $this->sql($remaining), $this->timestampSql($actual, $desired, $remaining, $this->timestampChanges($before, $after), $id), $this->commentSql($actual, $before, $after, $id), @@ -75,16 +91,24 @@ public function plan(array $applied, string $id, bool $reverse, ?SchemaState $si * * @param list $ids */ - private function replay(array $ids): SchemaState { - $schema = new SchemaState(new Schema()); + private function replay(array $ids, ?string $renamedBy = null): SchemaState { + $schema = new SchemaState(new Schema()); + $renames = []; sort($ids, SORT_STRING); foreach ($ids as $id) { - $blueprint = new Blueprint($schema, $this->names, $this->tableOptions); + $schema->renames = []; + $blueprint = new Blueprint($schema, $this->names, $this->tableOptions); $this->migrations->get($id)->up($blueprint); $this->apply($blueprint, $id); + + if ($id === $renamedBy) { + $renames = $schema->renames; + } } + $schema->renames = $renames; + return $schema; } @@ -140,7 +164,8 @@ private function introspect(array $names, ?SchemaState $overlay = null, array $s } elseif (in_array(strtolower($name), $simulated, true)) { continue; // Dropped by an earlier simulated step. } elseif ($manager->tablesExist([$name])) { - $tables[] = $this->normalize($manager->introspectTable($name)); + $table = $this->normalize($manager->introspectTable($name)); + $tables[] = $this->columnRenames->inspect($table); } } @@ -179,7 +204,11 @@ private function timestampAttributes(array $names): array { $placeholders = implode(', ', array_fill(0, count($names), '?')); $rows = $this->db->fetchAllAssociative( - "SELECT TABLE_NAME, COLUMN_NAME, DATETIME_PRECISION, EXTRA + "SELECT + TABLE_NAME, + COLUMN_NAME, + DATETIME_PRECISION, + EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND DATA_TYPE IN ('datetime', 'timestamp') @@ -246,8 +275,22 @@ private function preserveUndeclared(SchemaState $actual, SchemaState $before, Sc foreach ($actualTable->getColumns() as $column) { $columnName = $column->getObjectName()->toString(); + $definition = $column->getColumnDefinition(); + + if ($target->hasColumn($columnName)) { + // Retain the live SQL definition in preview snapshots when this step leaves it unchanged. + if (($beforeTable?->hasColumn($columnName) ?? false) + && $beforeTable->getColumn($columnName)->toArray() == $target->getColumn($columnName)->toArray() + && $definition !== null && $definition !== '') { + $editor->modifyColumn($column->getObjectName(), static function (ColumnEditor $targetColumn) use ($definition): void { + $targetColumn->setColumnDefinition($definition); + }); + } - if ($target->hasColumn($columnName) || ($beforeTable?->hasColumn($columnName) ?? false)) { + continue; + } + + if ($beforeTable?->hasColumn($columnName) ?? false) { continue; } $editor->addColumn($column); @@ -431,7 +474,7 @@ private function tableNames(SchemaState ...$schemas): array { * * @throws IncompatibleSchema */ - private function assertContained(SchemaDiff $remaining, SchemaDiff $expected, string $id): void { + private function assertContained(SchemaDiff $remaining, SchemaDiff $expected, SchemaState $before, SchemaState $after, string $id): void { $expectedCreated = $this->names($expected->getCreatedTables()); $expectedDropped = $this->names($expected->getDroppedTables()); $expectedAltered = []; @@ -453,7 +496,7 @@ private function assertContained(SchemaDiff $remaining, SchemaDiff $expected, st $key = strtolower($name); if (isset($expectedAltered[$key])) { - $this->assertAlterationContained($diff, $expectedAltered[$key], $id, $name); + $this->assertAlterationContained($diff, $expectedAltered[$key], $before, $after, $id); continue; } @@ -462,16 +505,17 @@ private function assertContained(SchemaDiff $remaining, SchemaDiff $expected, st // The table already exists; only missing declared columns, indexes, and constraints may be added. $this->require($diff->getChangedColumns() === [] && $diff->getDroppedColumns() === [] && $diff->getDroppedIndexes() === [] && $diff->getRenamedIndexes() === [] - && $diff->getDroppedForeignKeyConstraintNames() === [], $id, $name, 'existing table differs from the declared initial definition'); + && $diff->getDroppedForeignKeyConstraintNames() === [], $id, $name, 'existing table differs from the declared initial definition: ' . $this->describe($diff, $before, $after)); continue; } - $this->require(false, $id, $name, $this->describe($diff)); + $this->require(false, $id, $name, $this->describe($diff, $before, $after)); } } - private function assertAlterationContained(TableDiff $remaining, TableDiff $expected, string $id, string $table): void { + private function assertAlterationContained(TableDiff $remaining, TableDiff $expected, SchemaState $before, SchemaState $after, string $id): void { + $table = $this->tableName($remaining); $added = $this->names($expected->getAddedColumns()); $modified = array_map(static fn ($diff): string => strtolower($diff->getOldColumn()->getObjectName()->toString()), $expected->getChangedColumns()); $dropped = $this->names($expected->getDroppedColumns()); @@ -499,12 +543,12 @@ private function assertAlterationContained(TableDiff $remaining, TableDiff $expe foreach ($remaining->getAddedForeignKeys() as $foreignKey) { $name = $foreignKey->getObjectName()?->getIdentifier()->getValue() ?? ''; - $this->require(in_array(strtolower($name), $addedForeignKeys, true), $id, $table, 'foreign key ' . $name . ' would be added'); + $this->require(in_array(strtolower($name), $addedForeignKeys, true), $id, $table, 'foreign key ' . $after->foreignKeyLabel($table, $name) . ' would be added'); } foreach ($remaining->getDroppedForeignKeyConstraintNames() as $foreignKey) { $name = $foreignKey->getIdentifier()->getValue(); - $this->require(in_array(strtolower($name), $droppedForeignKeys, true), $id, $table, 'existing foreign key ' . $name . ' conflicts with its declaration'); + $this->require(in_array(strtolower($name), $droppedForeignKeys, true), $id, $table, 'existing foreign key ' . $before->foreignKeyLabel($table, $name, $after) . ' conflicts with its declaration'); } // A replacement authorizes both a drop and an addition. An interrupted drop leaves only @@ -519,19 +563,31 @@ private function assertAlterationContained(TableDiff $remaining, TableDiff $expe } } - private function describe(TableDiff $diff): string { + private function describe(TableDiff $diff, SchemaState $before, SchemaState $after): string { + $table = $this->tableName($diff); $parts = []; foreach ($diff->getAddedColumns() as $column) { $parts[] = 'column ' . $column->getObjectName()->toString() . ' would be added'; } + foreach ($diff->getChangedColumns() as $columnDiff) { $parts[] = 'column ' . $columnDiff->getOldColumn()->getObjectName()->toString() . ' differs from its declaration'; } + foreach ($diff->getDroppedColumns() as $column) { $parts[] = 'column ' . $column->getObjectName()->toString() . ' would be dropped'; } + foreach ($diff->getAddedForeignKeys() as $foreignKey) { + $name = $foreignKey->getObjectName()?->getIdentifier()->getValue() ?? ''; + $parts[] = 'foreign key ' . $after->foreignKeyLabel($table, $name) . ' would be added'; + } + + foreach ($diff->getDroppedForeignKeyConstraintNames() as $foreignKey) { + $parts[] = 'existing foreign key ' . $before->foreignKeyLabel($table, $foreignKey->getIdentifier()->getValue(), $after) . ' conflicts with its declaration'; + } + return $parts === [] ? 'table would be altered' : implode('; ', $parts); } diff --git a/src/Migrations/Schema/SchemaState.php b/src/Migrations/Schema/SchemaState.php index 5c41134..164e0a1 100644 --- a/src/Migrations/Schema/SchemaState.php +++ b/src/Migrations/Schema/SchemaState.php @@ -2,15 +2,34 @@ namespace StellarWP\Foundation\Migrations\Schema; +use Doctrine\DBAL\Schema\ForeignKeyConstraint; use Doctrine\DBAL\Schema\Schema; +use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Schema\TableEditor; +use StellarWP\Foundation\Migrations\Schema\ValueObjects\Rename; /** - * A replayed or inspected schema with the timestamp facts Doctrine does not model. + * A schema snapshot that keeps relationships, logical labels, and supplemental timestamp facts together. * * @internal Owned by one planning operation; copies keep both representations together. */ final class SchemaState { + /** + * @var list Name changes declared by the migration being planned. + */ + public array $renames = []; + + /** + * @var array Original table names retain foreign-key identity after a rename. + */ + public array $tableOrigins = []; + + /** + * @var array> Logical names keyed by physical table and constraint. + */ + public array $foreignKeyNames = []; + /** * Hold a schema snapshot and its supplemental timestamp facts. * @@ -22,6 +41,112 @@ public function __construct( ) { } + /** + * Describe a constraint using its declared name when this snapshot knows it. + * + * An interrupted addition may have a declaration only in the target snapshot. + */ + public function foreignKeyLabel(string $table, string $constraint, ?self $target = null): string { + $name = $this->foreignKeyNames[strtolower($table)][strtolower($constraint)] + ?? $target?->foreignKeyNames[strtolower($table)][strtolower($constraint)] + ?? null; + + return $name === null ? $constraint : sprintf('"%s" (database constraint "%s")', $name, $constraint); + } + + /** + * Rename an object and carry its references, logical labels, and timestamp facts with it. + */ + public function rename(Rename $rename): void { + $tables = []; + + foreach ($this->schema->getTables() as $table) { + $tables[] = $this->renamedTable($table, $rename); + } + + $this->schema = new Schema($tables); + $from = strtolower($rename->table === null ? $rename->from : $rename->table . '.' . $rename->from); + $to = strtolower($rename->table === null ? $rename->to : $rename->table . '.' . $rename->to); + + foreach ($this->timestamps as $key => $attributes) { + if (($rename->table === null && str_starts_with($key, $from . '.')) || $key === $from) { + unset($this->timestamps[$key]); + $this->timestamps[$to . substr($key, strlen($from))] = $attributes; + } + } + + if ($rename->table === null) { + $this->foreignKeyNames[$to] = $this->foreignKeyNames[$from] ?? []; + unset($this->foreignKeyNames[$from]); + $this->tableOrigins[strtolower($rename->to)] = $this->tableOrigins[strtolower($rename->from)] ?? $rename->from; + unset($this->tableOrigins[strtolower($rename->from)]); + } + + $this->renames[] = $rename; + } + + private function renamedTable(Table $table, Rename $rename): Table { + $name = $table->getObjectName()->getUnqualifiedName()->getValue(); + $editor = $this->edit($table); + + if ($rename->table === null && strcasecmp($name, $rename->from) === 0) { + $editor->setUnquotedName($rename->to); + } + + if ($rename->table !== null && strcasecmp($name, $rename->table) === 0) { + $editor->renameColumnByUnquotedName($rename->from, $rename->to); + } + + // The editor updates local index and key columns; update referenced columns afterwards. + $table = $editor->create(); + + return $this->edit($table)->setForeignKeyConstraints(...$this->renamedReferences($table, $rename))->create(); + } + + /** + * @return list + */ + private function renamedReferences(Table $table, Rename $rename): array { + $constraints = []; + + foreach ($table->getForeignKeys() as $key) { + $referenced = $key->getReferencedTableName()->getUnqualifiedName()->getValue(); + + if (strcasecmp($referenced, $rename->table ?? $rename->from) !== 0) { + $constraints[] = $key; + + continue; + } + + $constraint = $key->edit(); + + if ($rename->table === null) { + $constraint->setUnquotedReferencedTableName($rename->to); + } else { + $columns = array_map( + static fn ($column) => strcasecmp($column->getIdentifier()->getValue(), $rename->from) === 0 ? $rename->to : $column->getIdentifier()->getValue(), + $key->getReferencedColumnNames(), + ); + $constraint->setUnquotedReferencedColumnNames(...$columns); + } + + $constraints[] = $constraint->create(); + } + + return $constraints; + } + + /** + * Keep implicit supporting indexes, which Doctrine's editor omits by default. + */ + private function edit(Table $table): TableEditor { + $indexes = array_diff_key($table->getIndexes(), [ + 'primary' => true, + ]); + + return $table->edit()->setIndexes(...array_values($indexes)); + } + /** * Isolate mutable Doctrine objects when computing another migration state. */ diff --git a/src/Migrations/Schema/TableBlueprint.php b/src/Migrations/Schema/TableBlueprint.php index b778fd2..98140d8 100644 --- a/src/Migrations/Schema/TableBlueprint.php +++ b/src/Migrations/Schema/TableBlueprint.php @@ -12,6 +12,7 @@ use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Migrations\Schema\Definitions\ColumnDefinition; use StellarWP\Foundation\Migrations\Schema\Definitions\ForeignKeyDefinition; +use StellarWP\Foundation\Migrations\Schema\ValueObjects\Rename; /** * Fluent table declaration collected during up()/down() and applied to a Doctrine table afterwards. @@ -25,6 +26,10 @@ final class TableBlueprint * @var list */ private array $columns = []; + /** + * @var list + */ + private array $renamedColumns = []; /** * @var list, unique: bool}> */ @@ -83,6 +88,18 @@ public function bigIncrements(string $name = 'id'): ColumnDefinition { return $this->column($name, Types::BIGINT)->unsigned()->autoIncrement(); } + /** + * Declare a fixed-length character string. + * + * @param non-empty-string $name + */ + public function char(string $name, int $length): ColumnDefinition { + return $this->column($name, Types::STRING, [ + 'length' => $length, + 'fixed' => true, + ]); + } + /** * Declare a variable-length string. * @@ -101,6 +118,17 @@ public function text(string $name): ColumnDefinition { return $this->column($name, Types::TEXT, ['length' => 65535]); } + /** + * Declare MEDIUMTEXT storage (up to 16 MiB minus one byte). + * + * @param non-empty-string $name + */ + public function mediumText(string $name): ColumnDefinition { + return $this->column($name, Types::TEXT, [ + 'length' => 16777215, + ]); + } + /** * Declare a long text column. * @@ -110,6 +138,15 @@ public function longText(string $name): ColumnDefinition { return $this->column($name, Types::TEXT); } + /** + * Declare a small integer column. + * + * @param non-empty-string $name + */ + public function smallInteger(string $name): ColumnDefinition { + return $this->column($name, Types::SMALLINT); + } + /** * Declare an integer column. * @@ -164,6 +201,33 @@ public function decimal(string $name, int $precision = 10, int $scale = 2): Colu return $this->column($name, Types::DECIMAL, ['precision' => $precision, 'scale' => $scale]); } + /** + * Declare a JSON document column. + * + * @param non-empty-string $name + */ + public function json(string $name): ColumnDefinition { + return $this->column($name, Types::JSON); + } + + /** + * Declare a calendar date without a time component. + * + * @param non-empty-string $name + */ + public function date(string $name): ColumnDefinition { + return $this->column($name, Types::DATE_MUTABLE); + } + + /** + * Declare a time column with whole-second precision. + * + * @param non-empty-string $name + */ + public function time(string $name): ColumnDefinition { + return $this->column($name, Types::TIME_MUTABLE); + } + /** * DATETIME with optional fractional-second precision (0-6). * @@ -249,7 +313,7 @@ public function foreignKey(string $name, string ...$columns): ForeignKeyDefiniti throw new InvalidArgumentException('A foreign key must contain at least one column.'); } - $definition = new ForeignKeyDefinition($this->foreignKeyName($name), array_values($columns)); + $definition = new ForeignKeyDefinition($name, array_values($columns)); $this->foreignKeys[] = $definition; return $definition; @@ -263,11 +327,40 @@ public function foreignKey(string $name, string ...$columns): ForeignKeyDefiniti * @throws InvalidArgumentException When no name is supplied. */ public function dropForeignKey(string $name): self { - $this->droppedForeignKeys[] = $this->foreignKeyName($name); + if (trim($name) === '') { + throw new InvalidArgumentException('A foreign key must have a name.'); + } + + $this->droppedForeignKeys[] = $name; + + return $this; + } + + /** + * Rename an existing column while retaining its definition and values. + * + * @param non-empty-string $from + * @param non-empty-string $to + * + * @throws InvalidArgumentException When the names are empty or equal ignoring case. + */ + public function renameColumn(string $from, string $to): self { + $this->renamedColumns[] = new Rename($from, $to, $this->name); return $this; } + /** + * Return name changes to apply before this table's other alterations. + * + * @internal + * + * @return list + */ + public function renames(): array { + return $this->renamedColumns; + } + /** * Remove a column owned by this migration. * @@ -305,8 +398,12 @@ public function comment(string $comment): self { * @internal Called by Blueprint::apply(). */ public function applyTo(TableEditor $editor, SchemaState $state, TableNameResolver $names): Table { + $scope = $state->tableOrigins[strtolower($this->name)] ?? $this->name; + foreach ($this->droppedForeignKeys as $name) { - $editor->dropForeignKeyConstraintByUnquotedName($name); + $constraint = $this->foreignKeyName($scope, $name); + $editor->dropForeignKeyConstraintByUnquotedName($constraint); + unset($state->foreignKeyNames[strtolower($this->name)][$constraint]); } foreach ($this->droppedIndexes as $index) { @@ -340,7 +437,9 @@ public function applyTo(TableEditor $editor, SchemaState $state, TableNameResolv } foreach ($this->foreignKeys as $foreignKey) { - $foreignKey->applyTo($editor, $names); + $constraint = $this->foreignKeyName($scope, $foreignKey->name()); + $foreignKey->applyTo($editor, $names, $constraint); + $state->foreignKeyNames[strtolower($this->name)][$constraint] = $foreignKey->name(); } if ($this->comment !== null) { @@ -355,17 +454,13 @@ public function applyTo(TableEditor $editor, SchemaState $state, TableNameResolv * * @return non-empty-string */ - private function foreignKeyName(string $name): string { - if (trim($name) === '') { - throw new InvalidArgumentException('A foreign key must have a name.'); - } - - return 'fk_' . substr(hash('sha256', $this->name . "\0" . $name), 0, 40); + private function foreignKeyName(string $scope, string $name): string { + return 'fk_' . substr(hash('sha256', $scope . "\0" . $name), 0, 40); } /** - * @param non-empty-string $name - * @param array{length?: int, precision?: int, scale?: int} $options + * @param non-empty-string $name + * @param array{length?: int, fixed?: bool, precision?: int, scale?: int} $options */ private function column(string $name, string $type, array $options = []): ColumnDefinition { $definition = new ColumnDefinition($name, $type, $options); diff --git a/src/Migrations/Schema/ValueObjects/Rename.php b/src/Migrations/Schema/ValueObjects/Rename.php new file mode 100644 index 0000000..71edefe --- /dev/null +++ b/src/Migrations/Schema/ValueObjects/Rename.php @@ -0,0 +1,39 @@ +to, $this->from, $this->table); + } +} diff --git a/tests/Support/Fixtures/Migrations/Columns/ChangeCommonColumns.php b/tests/Support/Fixtures/Migrations/Columns/ChangeCommonColumns.php new file mode 100644 index 0000000..5f5dc9e --- /dev/null +++ b/tests/Support/Fixtures/Migrations/Columns/ChangeCommonColumns.php @@ -0,0 +1,46 @@ +table($this->table); + $table->json('metadata')->comment('Required metadata')->change(); + $table->date('starts_on')->nullable()->change(); + $table->time('opens_at')->nullable()->default('10:00:00')->change(); + $table->smallInteger('attempts')->default(3)->change(); + $table->char('currency', 4)->default('USDX')->comment('Extended currency')->change(); + $table->mediumText('content')->comment('Required content')->change(); + } + + /** + * {@inheritDoc} + */ + public function down(Blueprint $schema): void { + $table = $schema->table($this->table); + $table->json('metadata')->nullable()->comment('External metadata')->change(); + $table->date('starts_on')->default('2026-01-01')->change(); + $table->time('opens_at')->default('09:00:00')->change(); + $table->smallInteger('attempts')->unsigned()->default(0)->change(); + $table->char('currency', 3)->default('USD')->comment('Currency code')->change(); + $table->mediumText('content')->nullable()->comment('Article content')->change(); + } +} diff --git a/tests/Support/Fixtures/Migrations/Columns/CreateCommonColumns.php b/tests/Support/Fixtures/Migrations/Columns/CreateCommonColumns.php new file mode 100644 index 0000000..f4bea09 --- /dev/null +++ b/tests/Support/Fixtures/Migrations/Columns/CreateCommonColumns.php @@ -0,0 +1,41 @@ +create($this->table); + $table->bigIncrements(); + $table->json('metadata')->nullable()->comment('External metadata'); + $table->date('starts_on')->default('2026-01-01'); + $table->time('opens_at')->default('09:00:00'); + $table->smallInteger('attempts')->unsigned()->default(0); + $table->char('currency', 3)->default('USD')->comment('Currency code'); + $table->mediumText('content')->nullable()->comment('Article content'); + } + + /** + * {@inheritDoc} + */ + public function down(Blueprint $schema): void { + $schema->drop($this->table); + } +} diff --git a/tests/Support/Fixtures/Migrations/Columns/RenameCommonColumns.php b/tests/Support/Fixtures/Migrations/Columns/RenameCommonColumns.php new file mode 100644 index 0000000..ec87b1e --- /dev/null +++ b/tests/Support/Fixtures/Migrations/Columns/RenameCommonColumns.php @@ -0,0 +1,51 @@ +table($this->table); + + foreach (self::COLUMNS as $name) { + $table->renameColumn($name, $name . '_renamed'); + } + } + + /** + * {@inheritDoc} + */ + public function down(Blueprint $schema): void { + $table = $schema->table($this->table); + + foreach (array_reverse(self::COLUMNS) as $name) { + $table->renameColumn($name . '_renamed', $name); + } + } +} diff --git a/tests/Support/Fixtures/Migrations/ForeignKeys/AddItemNote.php b/tests/Support/Fixtures/Migrations/ForeignKeys/AddItemNote.php new file mode 100644 index 0000000..2f5ede4 --- /dev/null +++ b/tests/Support/Fixtures/Migrations/ForeignKeys/AddItemNote.php @@ -0,0 +1,25 @@ +table($this->prefix . '_items')->string('note')->nullable(); + } + + public function down(Blueprint $schema): void { + $schema->table($this->prefix . '_items')->dropColumn('note'); + } +} diff --git a/tests/Support/Fixtures/Migrations/ForeignKeys/AddOrderNote.php b/tests/Support/Fixtures/Migrations/ForeignKeys/AddOrderNote.php new file mode 100644 index 0000000..50a8873 --- /dev/null +++ b/tests/Support/Fixtures/Migrations/ForeignKeys/AddOrderNote.php @@ -0,0 +1,25 @@ +table($this->prefix . '_orders')->string('note')->nullable(); + } + + public function down(Blueprint $schema): void { + $schema->table($this->prefix . '_orders')->dropColumn('note'); + } +} diff --git a/tests/Unit/Migrations/ColumnRenameTest.php b/tests/Unit/Migrations/ColumnRenameTest.php new file mode 100644 index 0000000..be1a5ba --- /dev/null +++ b/tests/Unit/Migrations/ColumnRenameTest.php @@ -0,0 +1,109 @@ + + */ + public static function platforms(): iterable { + yield 'MySQL 5.7' => [ + new MySQLPlatform(), + 'CHANGE `old` `new`', + ]; + + yield 'MySQL 8.0' => [ + new MySQL80Platform(), + 'RENAME COLUMN `old` TO `new`', + ]; + + yield 'MariaDB 10.4' => [ + new MariaDBPlatform(), + 'CHANGE `old` `new`', + ]; + + yield 'MariaDB 10.5.2' => [ + new MariaDB1052Platform(), + 'RENAME COLUMN `old` TO `new`', + ]; + } + + /** + * Use the SQL supported by the server Doctrine selected. + * + * @dataProvider platforms + */ + #[DataProvider('platforms')] + public function test_platform_selects_compatible_sql(AbstractPlatform $platform, string $expected): void { + $db = $this->createMock(Connection::class); + $db->method('getDatabasePlatform')->willReturn($platform); + $strategy = new PlatformColumnRename($db, new NativeColumnRename($db), new ChangeColumnRename($db)); + $table = Table::editor()->setUnquotedName('reports')->addColumn( + Column::editor()->setUnquotedName('old')->setTypeName(Types::INTEGER)->create(), + )->create(); + + $sql = $strategy->sql($table, 'old', 'new'); + $this->assertCount(1, $sql); + $this->assertStringContainsString($expected, $sql[0]); + } + + /** + * Preserve multiple platform statements through the strategy and the complete rename plan. + */ + public function test_platform_statements_remain_separate_and_ordered(): void { + $statements = [ + 'ALTER TABLE reports CHANGE old new INT NOT NULL', + "ALTER TABLE reports COMMENT = 'Renamed'", + ]; + $platform = $this->createMock(MySQLPlatform::class); + $platform->expects($this->once())->method('getAlterTableSQL')->willReturn($statements); + $db = $this->createMock(Connection::class); + $db->method('getDatabasePlatform')->willReturn($platform); + $strategy = new PlatformColumnRename($db, new NativeColumnRename($db), new ChangeColumnRename($db)); + $planner = new RenamePlanner($db, $strategy); + $table = Table::editor()->setUnquotedName('reports')->addColumn( + Column::editor()->setUnquotedName('old')->setTypeName(Types::INTEGER)->create(), + )->create(); + $before = new SchemaState(new Schema([ + $table, + ])); + $actual = clone $before; + $after = clone $before; + $after->rename(new Rename('old', 'new', 'reports')); + + $this->assertSame($statements, $planner->plan($before, $actual, $after, '1')); + } + + /** + * Resolving strategies must not initialize the WordPress database session. + */ + public function test_construction_does_not_access_the_database(): void { + $db = $this->createMock(Connection::class); + $db->expects($this->never())->method('getDatabasePlatform'); + $strategy = new PlatformColumnRename($db, new NativeColumnRename($db), new ChangeColumnRename($db)); + $this->assertInstanceOf(PlatformColumnRename::class, $strategy); + } +} diff --git a/tests/Unit/Migrations/ForeignKeyComparatorTest.php b/tests/Unit/Migrations/ForeignKeyComparatorTest.php new file mode 100644 index 0000000..37bb71c --- /dev/null +++ b/tests/Unit/Migrations/ForeignKeyComparatorTest.php @@ -0,0 +1,83 @@ +schema($historical); + + $before->foreignKeyNames['items'][$historical] = 'order'; + + $after = clone $before; + $actual = $this->schema($live); + $comparator->alignForeignKeyNames($before, $actual, $after, '4'); + + $this->assertSame('"order" (database constraint "' . $live . '")', $before->foreignKeyLabel('items', $live)); + $this->assertSame($before->foreignKeyLabel('items', $live), $after->foreignKeyLabel('items', $live)); + $this->assertSame($historical, $after->foreignKeyLabel('items', $historical)); + $this->assertTrue($comparator->compareSchemas($actual->schema, $after->schema)->isEmpty()); + $this->assertSame($live, $actual->foreignKeyLabel('items', $live)); + } + + public function test_two_historical_relationships_cannot_claim_the_same_live_constraint(): void { + $config = new ComparatorConfig(); + $platform = new MySQL84Platform(); + $comparator = new ForeignKeyComparator($platform, new Comparator($platform, $config), $config); + $before = $this->schema('fk_' . str_repeat('a', 40), 'fk_' . str_repeat('b', 40)); + $actual = $this->schema('fk_' . str_repeat('c', 40)); + $after = clone $before; + + $this->expectException(IncompatibleSchema::class); + $this->expectExceptionMessage('ambiguous foreign-key identity'); + $comparator->alignForeignKeyNames($before, $actual, $after, '4'); + } + + /** + * Model equivalent relationships with distinct physical names. + * + * @param non-empty-string ...$names + */ + private function schema(string ...$names): SchemaState { + $table = Table::editor()->setUnquotedName('items')->addColumn( + Column::editor()->setUnquotedName('order_id')->setTypeName(Types::INTEGER)->create(), + ); + + foreach ($names as $name) { + $table->addForeignKeyConstraint(ForeignKeyConstraint::editor() + ->setUnquotedName($name) + ->setUnquotedReferencingColumnNames('order_id') + ->setUnquotedReferencedTableName('orders') + ->setUnquotedReferencedColumnNames('id') + ->create()); + } + + return new SchemaState(new Schema([ + $table->create(), + ])); + } +} diff --git a/tests/Unit/Migrations/ForeignKeyDefinitionTest.php b/tests/Unit/Migrations/ForeignKeyDefinitionTest.php index ecd49aa..1e5884e 100644 --- a/tests/Unit/Migrations/ForeignKeyDefinitionTest.php +++ b/tests/Unit/Migrations/ForeignKeyDefinitionTest.php @@ -62,6 +62,48 @@ public function test_constraint_identity_is_scoped_to_the_physical_child_table() $this->assertNotSame($constraints[0], $constraints[1]); } + /** + * Labels follow declarations through renames without leaking into reused tables or prior snapshots. + */ + public function test_logical_labels_follow_the_relationship_lifecycle(): void { + $names = $this->createStub(TableNameResolver::class); + $names->method('tableName')->willReturnCallback(static fn (string $name): string => 'wp_' . $name); + $state = new SchemaState(new Schema()); + $blueprint = new Blueprint($state, $names); + $table = $blueprint->create('items'); + $table->unsignedBigInteger('order_id'); + $table->foreignKey('order', 'order_id')->references('orders', 'id'); + $blueprint->apply(); + $constraint = array_key_first($state->schema->getTable('wp_items')->getForeignKeys()); + $this->assertNotNull($constraint); + $label = '"order" (database constraint "' . $constraint . '")'; + $this->assertSame($label, $state->foreignKeyLabel('wp_items', $constraint)); + $this->assertSame($constraint, $state->foreignKeyLabel('wp_other_items', $constraint)); + $this->assertSame('external_constraint', $state->foreignKeyLabel('wp_items', 'external_constraint')); + $before = clone $state; + + $blueprint->table('items')->renameColumn('order_id', 'purchase_id'); + $blueprint->rename('items', 'archive'); + $blueprint->apply(); + $this->assertSame($label, $state->foreignKeyLabel('wp_archive', $constraint)); + $this->assertSame($constraint, $state->foreignKeyLabel('wp_items', $constraint)); + $this->assertSame($label, $before->foreignKeyLabel('wp_items', $constraint)); + + $blueprint->create('items')->integer('id'); + $blueprint->apply(); + $this->assertSame($constraint, $state->foreignKeyLabel('wp_items', $constraint)); + $blueprint->table('archive')->dropForeignKey('order'); + $blueprint->apply(); + $this->assertSame($constraint, $state->foreignKeyLabel('wp_archive', $constraint)); + + $blueprint->table('archive')->foreignKey('order', 'purchase_id')->references('orders', 'id'); + $blueprint->apply(); + $this->assertSame($label, $state->foreignKeyLabel('wp_archive', $constraint)); + $blueprint->drop('archive'); + $blueprint->apply(); + $this->assertSame($constraint, $state->foreignKeyLabel('wp_archive', $constraint)); + } + /** * Removing a relationship preserves both its backing index and the separate primary key. */ diff --git a/tests/Unit/Migrations/TableBlueprintTest.php b/tests/Unit/Migrations/TableBlueprintTest.php index c50b501..b298fef 100644 --- a/tests/Unit/Migrations/TableBlueprintTest.php +++ b/tests/Unit/Migrations/TableBlueprintTest.php @@ -8,7 +8,7 @@ use StellarWP\Foundation\Migrations\Schema\TableBlueprint; /** - * Index declarations must identify at least one column before schema translation. + * Table declarations validate their names and required columns before schema translation. */ final class TableBlueprintTest extends TestCase { @@ -36,4 +36,44 @@ public function test_an_index_requires_columns(string $method): void { $this->expectExceptionMessage('An index must contain at least one column.'); $table->$method('lookup'); } + /** + * Reject blank and case-only rename destinations before schema translation. + * + * @param non-empty-string $from + * @param non-empty-string $to + * + * @dataProvider invalidRenameNames + */ + #[DataProvider('invalidRenameNames')] + public function test_column_rename_requires_distinct_names(string $from, string $to): void { + $this->expectException(InvalidArgumentException::class); + (new TableBlueprint('entries'))->renameColumn($from, $to); + } + + /** + * Supply invalid rename pairs, including MySQL's case-insensitive column identity. + * + * @return iterable + */ + public static function invalidRenameNames(): iterable { + yield 'blank source' => [ + ' ', + 'headline', + ]; + + yield 'blank destination' => [ + 'title', + ' ', + ]; + + yield 'same name' => [ + 'title', + 'title', + ]; + + yield 'case-only rename' => [ + 'title', + 'Title', + ]; + } } diff --git a/tests/integration/Migrations/AdvisoryMigrationTest.php b/tests/integration/Migrations/AdvisoryMigrationTest.php index 69652e8..5b879a2 100644 --- a/tests/integration/Migrations/AdvisoryMigrationTest.php +++ b/tests/integration/Migrations/AdvisoryMigrationTest.php @@ -23,6 +23,8 @@ use StellarWP\Foundation\Migrations\Migrator; use StellarWP\Foundation\Migrations\Schema\Blueprint; use StellarWP\Foundation\Migrations\Schema\Factories\MigrationComparatorFactory; +use StellarWP\Foundation\Migrations\Schema\RenamePlanner; +use StellarWP\Foundation\Migrations\Schema\Renames\Contracts\ColumnRename; use StellarWP\Foundation\Migrations\Schema\SchemaPlanner; use StellarWP\Foundation\Migrations\Tables\MigrationTable; use StellarWP\Foundation\Migrations\ValueObjects\MigrationRegistration; @@ -88,6 +90,8 @@ public function migrate(DataMigrationContext $context): void { $names, $migrations, $container->get(MigrationComparatorFactory::class), + new RenamePlanner($db, $container->get(ColumnRename::class)), + $container->get(ColumnRename::class), ), $migrations, $names, @@ -258,7 +262,8 @@ public function test_site_change_blocks_the_ledger_and_releases_only_the_origina public function test_manual_lock_release_is_detected_before_more_sql(): void { try { $this->runner($this->container, function (): void { - $this->native($this->source)->query('SELECT RELEASE_ALL_LOCKS()'); + $name = $this->lockName(); + $this->native($this->source)->query("SELECT RELEASE_LOCK('{$name}')"); })->migrate(); $this->fail('A normal return must not hide lost ownership.'); } catch (MigrationInterrupted) { diff --git a/tests/integration/Migrations/ChangeColumnRenameTest.php b/tests/integration/Migrations/ChangeColumnRenameTest.php new file mode 100644 index 0000000..40319e7 --- /dev/null +++ b/tests/integration/Migrations/ChangeColumnRenameTest.php @@ -0,0 +1,129 @@ + + */ + public static function definitions(): iterable { + yield 'precision and unsigned' => [ + 'DECIMAL(15, 4) UNSIGNED NOT NULL DEFAULT 12.3400', + ]; + + yield 'SQL null' => [ + 'VARCHAR(30) NULL DEFAULT NULL', + ]; + + yield 'literal NULL and collation' => [ + "VARCHAR(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT 'NULL' COMMENT 'Owner''s value'", + ]; + + yield 'empty default' => [ + "VARCHAR(30) NOT NULL DEFAULT ''", + ]; + + yield 'quoted default' => [ + "VARCHAR(30) NOT NULL DEFAULT 'It''s ready'", + ]; + + yield 'temporal expression and update' => [ + 'DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)', + ]; + + yield 'stored generation' => [ + 'INT GENERATED ALWAYS AS (id + 1) STORED', + ]; + + yield 'virtual generation' => [ + 'INT GENERATED ALWAYS AS (id + 1) VIRTUAL', + ]; + } + + /** + * Rename without changing the server's complete column description. + * + * @dataProvider definitions + */ + #[DataProvider('definitions')] + public function test_change_preserves_catalog_attributes(string $definition): void { + $this->observer->executeStatement("ALTER TABLE {$this->table}\n\t\t\tADD original {$definition}"); + $this->assertPreserved('original', 'renamed'); + } + + /** + * Auto-increment remains attached to the renamed primary-key column. + */ + public function test_auto_increment_survives(): void { + $this->observer->executeStatement("ALTER TABLE {$this->table}\n\t\t\tMODIFY id INT NOT NULL AUTO_INCREMENT"); + $this->assertPreserved('id', 'renamed_id'); + $this->observer->insert($this->table, [ + 'name' => 'Next', + ]); + $this->assertSame(2, (int) $this->observer->lastInsertId()); + } + + /** + * MariaDB's invisible generated columns remain invisible after CHANGE. + */ + public function test_generated_column_visibility_survives(): void { + if (! $this->db->getDatabasePlatform() instanceof MariaDBPlatform) { + $this->markTestSkipped('This declaration uses MariaDB generated-column visibility.'); + } + + $this->observer->executeStatement("ALTER TABLE {$this->table}\n\t\t\tADD original INT GENERATED ALWAYS AS (id + 1) VIRTUAL INVISIBLE"); + $this->assertPreserved('original', 'renamed'); + } + + /** + * @param non-empty-string $from + * @param non-empty-string $to + */ + private function assertPreserved(string $from, string $to): void { + $strategy = new ChangeColumnRename($this->db); + $name = $this->source->prefix . $this->suffix; + $table = $strategy->inspect($this->db->createSchemaManager()->introspectTable($name)); + $before = $this->metadata($from); + + foreach ($strategy->sql($table, $from, $to) as $sql) { + $this->db->executeStatement($sql); + } + + $this->assertSame($before, $this->metadata($to)); + $this->assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->table}")); + } + + /** + * @return list> + */ + private function metadata(string $column): array { + return $this->observer->fetchAllAssociative( + 'SELECT + COLUMN_TYPE, + IS_NULLABLE, + COLUMN_DEFAULT, + CHARACTER_SET_NAME, + COLLATION_NAME, + COLUMN_COMMENT, + EXTRA, + GENERATION_EXPRESSION + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ?', + [ + $this->source->prefix . $this->suffix, + $column, + ], + ); + } +} diff --git a/tests/integration/Migrations/CommonColumnTest.php b/tests/integration/Migrations/CommonColumnTest.php new file mode 100644 index 0000000..35875aa --- /dev/null +++ b/tests/integration/Migrations/CommonColumnTest.php @@ -0,0 +1,212 @@ + [ + 'table' => $this->suffix . '_history', + ], + ]; + } + + protected function setUp(): void { + parent::setUp(); + $this->container->register(MigrationsProvider::class); + $this->entries = $this->privateTable($this->suffix . '_entries'); + $this->history = $this->privateTable($this->suffix . '_history'); + $name = $this->suffix . '_entries'; + $this->container->mergeArrayVar(MigrationsProvider::MIGRATIONS, [ + new MigrationRegistration('1', new CreateCommonColumns($name)), + new MigrationRegistration('2', new ChangeCommonColumns($name)), + new MigrationRegistration('3', new RenameCommonColumns($name)), + ]); + } + + /** + * Retry completed creation without replacing the table or losing written values. + */ + public function test_creation_retry_preserves_types_defaults_and_data(): void { + $migrator = $this->container->get(Migrator::class); + $migrator->migrate(Migrator::NONE); + $this->failLedgerWrite($migrator, '1'); + $this->insertEntry(); + $steps = $migrator->migrate('1'); + $this->assertSame([], $steps[0]->sql); + $this->assertInitialColumns(); + $this->assertEntry(); + $this->assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->history} WHERE version = '1'")); + + $this->observer->insert($this->entries, [ + 'content' => null, + ]); + $this->assertSame('2026-01-01', $this->observer->fetchOne("SELECT starts_on FROM {$this->entries} WHERE id = 2")); + $this->assertSame('09:00:00', $this->observer->fetchOne("SELECT opens_at FROM {$this->entries} WHERE id = 2")); + $this->assertNull($this->observer->fetchOne("SELECT metadata FROM {$this->entries} WHERE id = 2")); + $migrator->rollback(); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([ + $this->source->prefix . $this->suffix . '_entries', + ])); + } + + /** + * Alter each complete definition, retry after committed DDL, then restore the original attributes. + */ + public function test_alteration_retry_and_rollback_preserve_values(): void { + $migrator = $this->container->get(Migrator::class); + $migrator->migrate('1'); + $this->insertEntry(); + $this->failLedgerWrite($migrator, '2'); + $this->assertSame([], $migrator->migrate('2')[0]->sql); + $table = $this->introspect(); + $this->assertTrue($table->getColumn('metadata')->getNotnull()); + $this->assertSame('Required metadata', $table->getColumn('metadata')->getComment()); + $this->assertFalse($table->getColumn('starts_on')->getNotnull()); + $this->assertNull($table->getColumn('starts_on')->getDefault()); + $this->assertSame('10:00:00', $table->getColumn('opens_at')->getDefault()); + $this->assertFalse($table->getColumn('attempts')->getUnsigned()); + $this->assertSame(3, (int) $table->getColumn('attempts')->getDefault()); + $this->assertTrue($table->getColumn('currency')->getFixed()); + $this->assertSame(4, $table->getColumn('currency')->getLength()); + $this->assertSame('USDX', $table->getColumn('currency')->getDefault()); + $this->assertTrue($table->getColumn('content')->getNotnull()); + $this->assertEntry(); + $migrator->rollback(); + $this->assertInitialColumns(); + $this->assertEntry(); + } + + /** + * @return iterable}> + */ + public static function renameStrategies(): iterable { + yield 'detected platform' => [ + PlatformColumnRename::class, + ]; + + yield 'legacy CHANGE fallback' => [ + ChangeColumnRename::class, + ]; + } + + /** + * Renaming retains each type, including JSON detection on MariaDB. + * + * @param class-string $strategy + * + * @dataProvider renameStrategies + */ + #[DataProvider('renameStrategies')] + public function test_column_types_survive_rename_and_retry(string $strategy): void { + $this->container->singleton(ColumnRename::class, $strategy); + $migrator = $this->container->get(Migrator::class); + $migrator->migrate('2'); + $this->insertEntry(); + $this->failLedgerWrite($migrator, '3'); + $this->assertSame([], $migrator->migrate('3')[0]->sql); + $this->assertSame(Types::JSON, Type::lookupName($this->introspect()->getColumn('metadata_renamed')->getType())); + $migrator->rollbackTo('1'); + $this->assertInitialColumns(); + $this->assertEntry(); + } + + private function insertEntry(): void { + $this->observer->insert($this->entries, [ + 'id' => 1, + 'metadata' => [ + 'source' => 'example', + ], + 'starts_on' => '2026-09-24', + 'opens_at' => '12:34:56', + 'attempts' => 2, + 'currency' => 'CAD', + 'content' => str_repeat('a', 70000), + ], [ + 'metadata' => Types::JSON, + ]); + } + + private function assertEntry(): void { + $row = $this->observer->fetchAssociative("SELECT + metadata, + starts_on, + opens_at, + attempts, + currency, + content + FROM {$this->entries} + WHERE id = 1"); + $this->assertIsArray($row); + $this->assertSame([ + 'source' => 'example', + ], json_decode((string) $row['metadata'], true, flags: JSON_THROW_ON_ERROR)); + $this->assertSame('2026-09-24', $row['starts_on']); + $this->assertSame('12:34:56', $row['opens_at']); + $this->assertSame(2, (int) $row['attempts']); + $this->assertSame('CAD', $row['currency']); + $this->assertSame(str_repeat('a', 70000), $row['content']); + } + + private function assertInitialColumns(): void { + $table = $this->introspect(); + $this->assertSame(Types::JSON, Type::lookupName($table->getColumn('metadata')->getType())); + $this->assertSame(Types::DATE_MUTABLE, Type::lookupName($table->getColumn('starts_on')->getType())); + $this->assertSame(Types::TIME_MUTABLE, Type::lookupName($table->getColumn('opens_at')->getType())); + $this->assertSame(Types::SMALLINT, Type::lookupName($table->getColumn('attempts')->getType())); + $this->assertFalse($table->getColumn('metadata')->getNotnull()); + $this->assertSame('External metadata', $table->getColumn('metadata')->getComment()); + $this->assertTrue($table->getColumn('currency')->getFixed()); + $this->assertSame(3, $table->getColumn('currency')->getLength()); + $this->assertSame('USD', $table->getColumn('currency')->getDefault()); + $this->assertTrue($table->getColumn('attempts')->getUnsigned()); + $this->assertSame(0, (int) $table->getColumn('attempts')->getDefault()); + $this->assertSame(16777215, $table->getColumn('content')->getLength()); + $this->assertFalse($table->getColumn('content')->getNotnull()); + } + + private function introspect(): Table { + return $this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_entries'); + } + + private function failLedgerWrite(Migrator $migrator, string $target): void { + $trigger = $this->observer->quoteSingleIdentifier($this->suffix . '_reject_history'); + $this->observer->executeStatement("CREATE TRIGGER $trigger + BEFORE INSERT ON {$this->history} + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ledger rejected'"); + + try { + $migrator->migrate($target); + $this->fail('DDL must complete before the ledger write fails.'); + } catch (LedgerFailure) { + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->history} WHERE version = ?", [ + $target, + ])); + } finally { + $this->observer->executeStatement('DROP TRIGGER ' . $trigger); + } + } +} diff --git a/tests/integration/Migrations/DeclarativeMigratorTest.php b/tests/integration/Migrations/DeclarativeMigratorTest.php index bf3a137..c66b8eb 100644 --- a/tests/integration/Migrations/DeclarativeMigratorTest.php +++ b/tests/integration/Migrations/DeclarativeMigratorTest.php @@ -164,6 +164,9 @@ public function test_unknown_rollback_target_is_rejected_without_creating_histor } public function test_public_migration_workflow_uses_no_deprecated_dbal_api(): void { + // Platform selection warns about older servers supported by DBAL 4 but removed in DBAL 5. + // This check concerns deprecated migration APIs, independently of the selected server. + $this->db->getDatabasePlatform(); \Doctrine\Deprecations\Deprecation::enableTrackingDeprecations(); try { diff --git a/tests/integration/Migrations/ForeignKeyIdentityTest.php b/tests/integration/Migrations/ForeignKeyIdentityTest.php new file mode 100644 index 0000000..2645cfd --- /dev/null +++ b/tests/integration/Migrations/ForeignKeyIdentityTest.php @@ -0,0 +1,304 @@ + [ + 'table' => $this->suffix . '_history', + ], + ]; + } + + protected function setUp(): void { + parent::setUp(); + $this->container->register(MigrationsProvider::class); + $this->trackTables(); + } + + public function test_unrelated_changes_and_rollback_keep_the_existing_relationship_and_data(): void { + $migrator = $this->migrations(new AddItemNote($this->suffix)); + $migrator->migrate('3'); + $this->observer->insert($this->orders, [ + 'id' => 1, + 'account_id' => 10, + ]); + $this->observer->insert($this->items, [ + 'id' => 2, + 'order_id' => 1, + ]); + $original = $this->constraintName(); + $this->changePrefix(); + + $preview = $migrator->preview(); + $this->assertStringNotContainsString('FOREIGN KEY', implode('; ', $preview[0]->sql)); + $steps = $migrator->migrate(); + $this->assertSame($preview[0]->sql, $steps[0]->sql); + $this->assertSame($original, $this->constraintName()); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT order_id FROM ' . $this->items)); + $migrator->rollback(); + $this->assertSame($original, $this->constraintName()); + $migrator->rollback(); + $this->assertSame([], $this->constraints()); + } + + public function test_replacement_retry_recognizes_the_new_actions_under_the_old_physical_name(): void { + $migrator = $this->migrations(new CascadeOrderReference($this->suffix)); + $migrator->migrate('3'); + $original = $this->constraintName(); + $this->changePrefix(); + $trigger = $this->observer->quoteSingleIdentifier($this->suffix . '_reject_history'); + $this->observer->executeStatement("CREATE TRIGGER $trigger + BEFORE INSERT ON {$this->history} + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ledger rejected'"); + + try { + $migrator->migrate(); + $this->fail('The ledger write must fail after replacing the relationship.'); + } catch (LedgerFailure) { + $this->assertSame($original, $this->constraintName()); + $this->assertSame('CASCADE', $this->constraints()[0]->onDelete()); + } finally { + $this->observer->executeStatement('DROP TRIGGER ' . $trigger); + } + + $retry = $migrator->migrate(); + $this->assertSame([], $retry[0]->sql); + $this->assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->history} WHERE version = '4'")); + $migrator->rollback(); + $this->assertSame($original, $this->constraintName()); + $this->assertNull($this->constraints()[0]->onDelete()); + } + + public function test_logical_name_changes_still_replace_the_constraint(): void { + $migrator = $this->migrations(new RenameOrderReference($this->suffix)); + $migrator->migrate('3'); + $original = $this->constraintName(); + $this->changePrefix(); + $steps = $migrator->migrate(); + $this->assertNotEmpty($steps[0]->sql); + $this->assertNotSame($original, $this->constraintName()); + $this->assertCount(1, $this->constraints()); + $this->observer->delete($this->history, [ + 'version' => '4', + ]); + $this->assertSame([], $migrator->migrate()[0]->sql); + $migrator->rollback(); + $this->assertCount(1, $this->constraints()); + $migrator->rollback(); + $this->assertSame([], $this->constraints()); + } + + public function test_replacement_resumes_when_only_the_old_constraint_was_dropped(): void { + $migrator = $this->migrations(new CascadeOrderReference($this->suffix)); + $migrator->migrate('3'); + $original = $this->constraintName(); + $this->changePrefix(); + $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $this->observer->quoteSingleIdentifier($original)); + $migrator->migrate(); + $this->assertCount(1, $this->constraints()); + $this->assertSame('CASCADE', $this->constraints()[0]->onDelete()); + $migrator->rollback(); + $migrator->rollback(); + $this->assertSame([], $this->constraints()); + } + + public function test_equivalent_external_constraints_are_preserved(): void { + $migrator = $this->migrations(new AddItemNote($this->suffix)); + $migrator->migrate('3'); + $this->changePrefix(); + $external = $this->suffix . '_external'; + $this->addConstraint($external); + $migrator->migrate(); + $migrator->rollback(); + $migrator->rollback(); + $this->assertCount(1, $this->constraints()); + $this->assertSame($external, $this->constraintName()); + } + + public function test_ambiguous_managed_names_fail_before_any_ddl(): void { + $migrator = $this->migrations(new AddItemNote($this->suffix)); + $migrator->migrate('3'); + $this->changePrefix(); + $this->addConstraint('fk_' . sha1($this->suffix)); + + try { + $migrator->migrate(); + $this->fail('Two equivalent candidates must not be assigned arbitrary ownership.'); + } catch (IncompatibleSchema $failure) { + $this->assertStringContainsString('ambiguous foreign-key identity for "order" (database constraint "', $failure->getMessage()); + $this->assertStringContainsString($this->suffix . '_items', $failure->getMessage()); + $this->assertFalse($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_items')->hasColumn('note')); + } + } + + public function test_changed_actions_are_not_accepted_as_an_unrelated_migration(): void { + $migrator = $this->migrations(new AddItemNote($this->suffix)); + $migrator->migrate('3'); + $original = $this->constraintName(); + $this->changePrefix(); + $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $this->observer->quoteSingleIdentifier($original)); + $this->addConstraint($original, ' ON DELETE CASCADE'); + $this->expectException(IncompatibleSchema::class); + $migrator->migrate(); + } + + public function test_removing_a_relationship_with_uncertain_ownership_does_not_record_success(): void { + $migrator = $this->migrations(); + $migrator->migrate(); + $original = $this->constraintName(); + $this->changePrefix(); + $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $this->observer->quoteSingleIdentifier($original)); + $this->addConstraint($original, ' ON DELETE CASCADE'); + + try { + $migrator->rollback(); + $this->fail('Uncertain ownership must not be treated as an already removed constraint.'); + } catch (IncompatibleSchema $failure) { + $this->assertStringContainsString($original, $failure->getMessage()); + $this->assertStringContainsString('historical foreign key "order" (database constraint "', $failure->getMessage()); + $this->assertStringContainsString('different update/delete actions', $failure->getMessage()); + $this->assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->history} WHERE version = '3'")); + } + } + + public function test_full_definition_identifies_a_constraint_among_different_actions(): void { + $migrator = $this->migrations(new AddItemNote($this->suffix)); + $migrator->migrate('3'); + $this->changePrefix(); + $external = 'fk_' . sha1($this->suffix); + $this->addConstraint($external, ' ON DELETE CASCADE'); + $migrator->migrate(); + $migrator->rollback(); + $migrator->rollback(); + $this->assertCount(1, $this->constraints()); + $this->assertSame($external, $this->constraintName()); + } + + public function test_a_new_relationship_does_not_adopt_an_existing_managed_name(): void { + $migrator = $this->migrations(); + $migrator->migrate('2'); + $this->changePrefix(); + $external = 'fk_' . sha1($this->suffix); + $this->addConstraint($external); + $migrator->migrate(); + $this->assertCount(2, $this->constraints()); + $migrator->rollback(); + $this->assertCount(1, $this->constraints()); + $this->assertSame($external, $this->constraintName()); + } + + public function test_exact_names_take_priority_over_equivalent_managed_names(): void { + $migrator = $this->migrations(new AddItemNote($this->suffix)); + $migrator->migrate('3'); + $external = 'fk_' . sha1($this->suffix); + $this->addConstraint($external); + $migrator->migrate(); + $migrator->rollback(); + $migrator->rollback(); + $this->assertCount(1, $this->constraints()); + $this->assertSame($external, $this->constraintName()); + } + + public function test_preview_carries_identity_through_multiple_changes(): void { + $migrator = $this->migrations(new CascadeOrderReference($this->suffix), new AddItemNote($this->suffix)); + $migrator->migrate('3'); + $this->changePrefix(); + $preview = $migrator->preview(); + $steps = $migrator->migrate(); + $this->assertCount(2, $steps); + $this->assertSame($preview[0]->sql, $steps[0]->sql); + $this->assertSame($preview[1]->sql, $steps[1]->sql); + $this->assertStringNotContainsString('FOREIGN KEY', implode('; ', $steps[1]->sql)); + $migrator->rollbackTo('2'); + $this->assertSame([], $this->constraints()); + } + + private function migrations(Migration ...$later): Migrator { + $migrations = [ + new CreateOrders($this->suffix), + new CreateItems($this->suffix), + new AddOrderReference($this->suffix), + ...$later, + ]; + + foreach (array_values($migrations) as $position => $migration) { + $this->container->mergeArrayVar(MigrationsProvider::MIGRATIONS, [ + new MigrationRegistration((string) ($position + 1), $migration), + ]); + } + + return $this->container->get(Migrator::class); + } + + /** + * Move the application's tables and ledger, then use the new WordPress prefix between operations. + */ + private function changePrefix(): void { + $original = $this->source->prefix; + $this->source->set_prefix('moved_' . $original); + + foreach ([ + 'orders', + 'items', + 'history', + ] as $name) { + $from = $this->observer->quoteSingleIdentifier($original . $this->suffix . '_' . $name); + $to = $this->observer->quoteSingleIdentifier($this->source->prefix . $this->suffix . '_' . $name); + $this->observer->executeStatement('RENAME TABLE ' . $from . ' TO ' . $to); + } + + $this->trackTables(); + } + + private function trackTables(): void { + $this->orders = $this->privateTable($this->suffix . '_orders'); + $this->items = $this->privateTable($this->suffix . '_items'); + $this->history = $this->privateTable($this->suffix . '_history'); + } + + /** + * @return list + */ + private function constraints(): array { + return array_values($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_items')->getForeignKeys()); + } + + private function constraintName(): string { + $name = $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue(); + $this->assertNotNull($name); + + return $name; + } + + private function addConstraint(string $name, string $actions = ''): void { + $quoted = $this->observer->quoteSingleIdentifier($name); + $this->observer->executeStatement("ALTER TABLE {$this->items} + ADD CONSTRAINT $quoted FOREIGN KEY (order_id) + REFERENCES {$this->orders} (id)$actions"); + } +} diff --git a/tests/integration/Migrations/ForeignKeyMigrationTest.php b/tests/integration/Migrations/ForeignKeyMigrationTest.php index b8f8d49..ab00590 100644 --- a/tests/integration/Migrations/ForeignKeyMigrationTest.php +++ b/tests/integration/Migrations/ForeignKeyMigrationTest.php @@ -5,6 +5,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException; use Doctrine\DBAL\Schema\ForeignKeyConstraint; +use PHPUnit\Framework\Attributes\DataProvider; use StellarWP\Foundation\Migrations\Contracts\Migration; use StellarWP\Foundation\Migrations\Exceptions\IncompatibleSchema; use StellarWP\Foundation\Migrations\Exceptions\LedgerFailure; @@ -13,6 +14,8 @@ use StellarWP\Foundation\Migrations\ValueObjects\MigrationRegistration; use StellarWP\Foundation\Tests\Support\Fixtures\Database\DatabaseTestCase; use StellarWP\Foundation\Tests\Support\Fixtures\Migrations\ForeignKeys\AddCompositeReference; +use StellarWP\Foundation\Tests\Support\Fixtures\Migrations\ForeignKeys\AddItemNote; +use StellarWP\Foundation\Tests\Support\Fixtures\Migrations\ForeignKeys\AddOrderNote; use StellarWP\Foundation\Tests\Support\Fixtures\Migrations\ForeignKeys\AddOrderReference; use StellarWP\Foundation\Tests\Support\Fixtures\Migrations\ForeignKeys\CascadeOrderReference; use StellarWP\Foundation\Tests\Support\Fixtures\Migrations\ForeignKeys\CreateItems; @@ -176,9 +179,68 @@ public function test_conflicting_existing_constraint_is_not_silently_replaced(): $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' ADD CONSTRAINT ' . $quoted . ' FOREIGN KEY (order_id) REFERENCES ' . $this->orders . ' (id) ON DELETE CASCADE'); $this->observer->delete($this->history, ['version' => '3']); $this->expectException(IncompatibleSchema::class); + $this->expectExceptionMessage('foreign key "order" (database constraint "' . $name . '")'); $migrator->migrate(); } + /** + * @return iterable + */ + public static function foreignKeyDrift(): iterable { + yield 'missing constraint on untouched table' => [ + false, + false, + ]; + + yield 'changed actions on untouched table' => [ + true, + false, + ]; + + yield 'missing constraint on altered table' => [ + false, + true, + ]; + + yield 'changed actions on altered table' => [ + true, + true, + ]; + } + + /** + * Name the affected declaration and database constraint wherever drift is detected. + * + * @dataProvider foreignKeyDrift + */ + #[DataProvider('foreignKeyDrift')] + public function test_drift_names_the_logical_and_physical_foreign_key(bool $replace, bool $alterItems): void { + $pending = $alterItems ? new AddItemNote($this->suffix) : new AddOrderNote($this->suffix); + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddOrderReference($this->suffix), $pending); + $migrator->migrate('3'); + $name = $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue(); + $this->assertNotNull($name); + $quoted = $this->observer->quoteSingleIdentifier($name); + $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $quoted); + + if ($replace) { + $this->observer->executeStatement("ALTER TABLE {$this->items} + ADD CONSTRAINT $quoted FOREIGN KEY (order_id) + REFERENCES {$this->orders} (id) ON DELETE CASCADE"); + } + + try { + $migrator->migrate(); + $this->fail('Undeclared relationship drift must stop the migration.'); + } catch (IncompatibleSchema $failure) { + $this->assertStringContainsString('foreign key "order" (database constraint "' . $name . '")', $failure->getMessage()); + $this->assertStringContainsString($this->suffix . '_items', $failure->getMessage()); + $this->assertStringNotContainsString('table would be altered', $failure->getMessage()); + $pendingTable = $this->source->prefix . $this->suffix . ($alterItems ? '_items' : '_orders'); + $this->assertFalse($this->observer->createSchemaManager()->introspectTable($pendingTable)->hasColumn('note')); + } + } + public function test_an_equivalent_unrelated_foreign_key_is_preserved(): void { $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddOrderReference($this->suffix)); $migrator->migrate('2'); @@ -202,6 +264,7 @@ public function test_create_retry_rejects_a_conflicting_foreign_key(): void { $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' ADD CONSTRAINT ' . $quoted . ' FOREIGN KEY (order_id) REFERENCES ' . $this->orders . ' (id)'); $this->observer->delete($this->history, ['version' => '2']); $this->expectException(IncompatibleSchema::class); + $this->expectExceptionMessage('foreign key "order" (database constraint "' . $name . '")'); $migrator->migrate(); } @@ -219,6 +282,19 @@ public function test_create_retry_adds_a_missing_constraint_without_recreating_t $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->items)); } + /** + * Resume rollback after the child table was dropped but its ledger entry remains. + */ + public function test_rollback_retry_accepts_an_already_dropped_table_with_foreign_keys(): void { + $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateReferencedItems($this->suffix)); + $migrator->migrate(); + $this->observer->executeStatement('DROP TABLE ' . $this->items); + + $steps = $migrator->rollback(); + $this->assertSame([], $steps[0]->sql); + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->history} WHERE version = '2'")); + } + public function test_the_same_migrations_use_distinct_foreign_keys_on_another_site(): void { $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateReferencedItems($this->suffix)); $migrator->migrate(); diff --git a/tests/integration/Migrations/RenameMigrationTest.php b/tests/integration/Migrations/RenameMigrationTest.php new file mode 100644 index 0000000..72ccc0c --- /dev/null +++ b/tests/integration/Migrations/RenameMigrationTest.php @@ -0,0 +1,962 @@ + [ + 'table' => $this->suffix . '_history', + ], + ]; + } + + protected function setUp(): void { + parent::setUp(); + $this->container->register(MigrationsProvider::class); + $this->reports = $this->privateTable($this->suffix . '_reports'); + $this->archive = $this->privateTable($this->suffix . '_archive'); + $this->history = $this->privateTable($this->suffix . '_history'); + } + + /** + * Column rename preserves rows and unrelated schema in both directions. + */ + public function test_column_rename_preserves_rows_and_unrelated_schema_in_both_directions(): void { + $migrator = $this->migrations($this->createReports(), $this->renameTitle()); + $migrator->migrate('1'); + $this->insertReport(); + $this->observer->executeStatement('ALTER TABLE ' . $this->reports . ' ADD external_value INT NOT NULL DEFAULT 7, ADD INDEX external_lookup (external_value)'); + $migrator->migrate(); + $this->assertSame('Original', $this->observer->fetchOne('SELECT headline FROM ' . $this->reports)); + $this->assertSame(7, (int) $this->observer->fetchOne('SELECT external_value FROM ' . $this->reports)); + $this->assertTrue($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_reports')->hasIndex('external_lookup')); + $migrator->rollback(); + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + } + + /** + * Table rename preserves rows and unrelated schema in both directions. + */ + public function test_table_rename_preserves_rows_and_unrelated_schema_in_both_directions(): void { + $migrator = $this->migrations($this->createReports(), $this->renameReports()); + $migrator->migrate('1'); + $this->insertReport(); + $this->observer->executeStatement('ALTER TABLE ' . $this->reports . ' ADD external_value INT NOT NULL DEFAULT 7'); + $migrator->migrate(); + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->archive)); + $this->assertSame(7, (int) $this->observer->fetchOne('SELECT external_value FROM ' . $this->archive)); + $migrator->rollback(); + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + } + + /** + * Column rename retries without ddl after history write failure. + * + * @param class-string $strategy + * + * @dataProvider renameStrategies + */ + #[DataProvider('renameStrategies')] + public function test_column_rename_retries_without_ddl_after_history_write_failure(string $strategy): void { + $this->container->singleton(ColumnRename::class, $strategy); + $migrator = $this->migrations($this->createReports(), $this->renameTitle()); + $migrator->migrate('1'); + $this->insertReport(); + $this->failHistoryWrite($migrator); + $steps = $migrator->migrate(); + $this->assertSame([], $steps[0]->sql); + $this->assertSame('Original', $this->observer->fetchOne('SELECT headline FROM ' . $this->reports)); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->history . " WHERE version = '2'")); + } + + /** + * Table rename retries without ddl after history write failure. + */ + public function test_table_rename_retries_without_ddl_after_history_write_failure(): void { + $migrator = $this->migrations($this->createReports(), $this->renameReports()); + $migrator->migrate('1'); + $this->insertReport(); + $this->failHistoryWrite($migrator); + $steps = $migrator->migrate(); + $this->assertSame([], $steps[0]->sql); + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->archive)); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->history . " WHERE version = '2'")); + } + + /** + * Column rename rejects existing source and destination. + */ + public function test_column_rename_rejects_existing_source_and_destination(): void { + $migrator = $this->migrations($this->createReports(), $this->renameTitle()); + $migrator->migrate('1'); + $this->observer->executeStatement('ALTER TABLE ' . $this->reports . ' ADD headline VARCHAR(80) NOT NULL'); + $this->expectException(IncompatibleSchema::class); + $migrator->migrate(); + } + + /** + * Table rename rejects existing source and destination. + */ + public function test_table_rename_rejects_existing_source_and_destination(): void { + $migrator = $this->migrations($this->createReports(), $this->renameReports()); + $migrator->migrate('1'); + $this->observer->executeStatement('CREATE TABLE ' . $this->archive . ' LIKE ' . $this->reports); + $this->expectException(IncompatibleSchema::class); + $migrator->migrate(); + } + + /** + * Column rename rejects missing source and destination. + */ + public function test_column_rename_rejects_missing_source_and_destination(): void { + $migrator = $this->migrations($this->createReports(), $this->renameTitle()); + $migrator->migrate('1'); + $this->observer->executeStatement('ALTER TABLE ' . $this->reports . ' DROP COLUMN title'); + $this->expectException(IncompatibleSchema::class); + $migrator->migrate(); + } + + /** + * Table rename rejects missing source and destination. + */ + public function test_table_rename_rejects_missing_source_and_destination(): void { + $migrator = $this->migrations($this->createReports(), $this->renameReports()); + $migrator->migrate('1'); + $this->observer->executeStatement('DROP TABLE ' . $this->reports); + $this->expectException(IncompatibleSchema::class); + $migrator->migrate(); + } + + /** + * Explicit drop and add does not implicitly rename a column. + */ + public function test_explicit_drop_and_add_does_not_implicitly_rename_a_column(): void { + $replace = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $table = $schema->table($this->suffix . '_reports'); + $table->dropColumn('title'); + $table->string('headline', 80)->default(''); + } + }; + $migrator = $this->migrations($this->createReports(), $replace); + $migrator->migrate('1'); + $this->insertReport(); + $migrator->migrate(); + $this->assertSame('', $this->observer->fetchOne('SELECT headline FROM ' . $this->reports)); + } + + /** + * Timestamp precision and update behavior survive column and table renames. + * + * @param class-string $strategy + * + * @dataProvider renameStrategies + */ + #[DataProvider('renameStrategies')] + public function test_timestamp_precision_and_update_behavior_survive_column_and_table_renames(string $strategy): void { + $this->container->singleton(ColumnRename::class, $strategy); + $rename = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $schema->table($this->suffix . '_reports')->renameColumn('updated_at', 'modified_at'); + $schema->rename($this->suffix . '_reports', $this->suffix . '_archive'); + } + + /** + * {@inheritDoc} + */ + public function down(Blueprint $schema): void { + $schema->rename($this->suffix . '_archive', $this->suffix . '_reports'); + $schema->table($this->suffix . '_reports')->renameColumn('modified_at', 'updated_at'); + } + }; + $migrator = $this->migrations($this->createReports(), $rename); + $migrator->migrate('1'); + $this->insertReport(); + $this->failHistoryWrite($migrator); + $this->assertSame([], $migrator->migrate()[0]->sql); + $this->assertSame('2026-01-02 03:04:05.123456', $this->observer->fetchOne('SELECT modified_at FROM ' . $this->archive)); + $column = $this->observer->fetchAssociative( + "SELECT DATETIME_PRECISION, EXTRA + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'modified_at'", + [ + $this->source->prefix . $this->suffix . '_archive', + ], + ); + $this->assertIsArray($column); + $this->assertSame(6, (int) $column['DATETIME_PRECISION']); + $this->assertStringContainsString('on update', strtolower($column['EXTRA'])); + $migrator->rollback(); + $this->assertSame('2026-01-02 03:04:05.123456', $this->observer->fetchOne('SELECT updated_at FROM ' . $this->reports)); + } + + /** + * Foreign keys follow renamed tables and columns and can be removed by logical name. + */ + public function test_foreign_keys_follow_renamed_tables_and_columns_and_can_be_removed_by_logical_name(): void { + $items = $this->privateTable($this->suffix . '_items'); + $archiveItems = $this->privateTable($this->suffix . '_archive_items'); + $create = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $table = $schema->create($this->suffix . '_items'); + $table->bigIncrements(); + $table->unsignedBigInteger('report_id'); + $table->index('report_lookup', 'report_id'); + $table->foreignKey('report', 'report_id')->references($this->suffix . '_reports', 'id')->cascadeOnDelete(); + } + }; + $rename = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $schema->table($this->suffix . '_reports')->renameColumn('id', 'report_id'); + $schema->table($this->suffix . '_items')->renameColumn('report_id', 'archive_id'); + $schema->rename($this->suffix . '_reports', $this->suffix . '_archive'); + $schema->rename($this->suffix . '_items', $this->suffix . '_archive_items'); + } + + /** + * {@inheritDoc} + */ + public function down(Blueprint $schema): void { + $schema->rename($this->suffix . '_archive_items', $this->suffix . '_items'); + $schema->rename($this->suffix . '_archive', $this->suffix . '_reports'); + $schema->table($this->suffix . '_items')->renameColumn('archive_id', 'report_id'); + $schema->table($this->suffix . '_reports')->renameColumn('report_id', 'id'); + } + }; + $drop = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $schema->table($this->suffix . '_archive_items')->dropForeignKey('report'); + } + }; + $migrator = $this->migrations($this->createReports(), $create, $rename, $drop); + $migrator->migrate('2'); + $this->insertReport(); + $this->observer->insert($items, [ + 'id' => 2, + 'report_id' => 1, + ]); + $this->failHistoryWrite($migrator, '3'); + $this->assertSame([], $migrator->migrate('3')[0]->sql); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT archive_id FROM ' . $archiveItems)); + $migrator->rollback(); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT report_id FROM ' . $items)); + $migrator->migrate('3'); + $this->observer->delete($this->archive, [ + 'report_id' => 1, + ]); + $this->assertSame(0, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $archiveItems)); + $migrator->migrate(); + $this->assertSame([], $this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_archive_items')->getForeignKeys()); + } + + /** + * Resume only the missing suffix of a chained rename sequence, then reverse it. + * + * @param class-string $strategy + * + * @dataProvider renameStrategies + */ + #[DataProvider('renameStrategies')] + public function test_partial_chained_renames_resume_and_roll_back_without_losing_rows(string $strategy): void { + $this->container->singleton(ColumnRename::class, $strategy); + $migrator = $this->migrations($this->createReports(), $this->chainedRename()); + $migrator->migrate('1'); + $this->insertReport(); + $sql = $migrator->preview()[0]->sql; + $this->assertCount(3, $sql); + $this->observer->executeStatement($sql[0]); + $this->observer->executeStatement($sql[1]); + $steps = $migrator->migrate(); + $this->assertCount(1, $steps[0]->sql); + $this->assertSame('Original', $this->observer->fetchOne('SELECT headline FROM ' . $this->archive)); + $migrator->rollback(); + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + } + + /** + * Preview simulates names for later migrations without changing the live tables or ledger. + * + * @param class-string $strategy + * + * @dataProvider renameStrategies + */ + #[DataProvider('renameStrategies')] + public function test_preview_uses_renamed_schema_for_later_migrations_without_executing(string $strategy): void { + $this->container->singleton(ColumnRename::class, $strategy); + $alter = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $schema->table($this->suffix . '_archive')->index('headline_lookup', 'headline'); + } + }; + $migrator = $this->migrations($this->createReports(), $this->chainedRename(), $alter); + $migrator->migrate('1'); + $this->insertReport(); + $preview = $migrator->preview(); + $this->assertCount(2, $preview); + $this->assertStringContainsString('headline_lookup', implode('; ', $preview[1]->sql)); + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([ + $this->source->prefix . $this->suffix . '_archive', + ])); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->history)); + $migrator->migrate(); + $this->assertSame('Original', $this->observer->fetchOne('SELECT headline FROM ' . $this->archive)); + $this->assertTrue($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_archive')->hasIndex('headline_lookup')); + } + + /** + * An incompatible source definition must stop the migration before any rename executes. + */ + public function test_schema_drift_is_rejected_before_the_first_rename(): void { + $migrator = $this->migrations($this->createReports(), $this->chainedRename()); + $migrator->migrate('1'); + $this->insertReport(); + $this->observer->executeStatement('ALTER TABLE ' . $this->reports . " MODIFY title VARCHAR(255) NOT NULL DEFAULT ''"); + + try { + $migrator->migrate(); + $this->fail('Schema drift must fail before renaming the live table.'); + } catch (IncompatibleSchema) { + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([ + $this->source->prefix . $this->suffix . '_archive', + ])); + } + } + + /** + * A self-reference follows both local and referenced column names and the table name. + */ + public function test_self_referencing_foreign_key_survives_table_and_column_renames(): void { + $rename = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $table = $schema->table($this->suffix . '_reports'); + $table->renameColumn('id', 'report_id'); + $table->renameColumn('parent_id', 'parent_report_id'); + $schema->rename($this->suffix . '_reports', $this->suffix . '_archive'); + } + + /** + * {@inheritDoc} + */ + public function down(Blueprint $schema): void { + $schema->rename($this->suffix . '_archive', $this->suffix . '_reports'); + $table = $schema->table($this->suffix . '_reports'); + $table->renameColumn('parent_report_id', 'parent_id'); + $table->renameColumn('report_id', 'id'); + } + }; + $migrator = $this->migrations($this->createReports(), $this->selfReference(), $rename); + $migrator->migrate('2'); + $this->insertReport(); + $this->observer->insert($this->reports, [ + 'id' => 2, + 'parent_id' => 1, + ]); + $migrator->migrate(); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT parent_report_id FROM ' . $this->archive . ' WHERE report_id = 2')); + $migrator->rollback(); + $this->observer->delete($this->reports, [ + 'id' => 1, + ]); + $this->assertSame(0, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->reports)); + } + + /** + * A renamed table retains the logical identities of existing keys within one declaration. + */ + public function test_table_rename_and_foreign_key_drop_work_in_the_same_migration(): void { + $rename = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $schema->rename($this->suffix . '_reports', $this->suffix . '_archive'); + $schema->table($this->suffix . '_archive')->dropForeignKey('parent'); + } + }; + $migrator = $this->migrations($this->createReports(), $this->selfReference(), $rename); + $migrator->migrate(); + $this->assertSame([], $this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_archive')->getForeignKeys()); + } + + /** + * Swapping column names cannot be safely distinguished from an interrupted retry. + */ + public function test_column_name_cycles_are_rejected_before_sql_executes(): void { + $swap = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $table = $schema->table($this->suffix . '_reports'); + $table->renameColumn('title', 'temporary'); + $table->renameColumn('updated_at', 'title'); + $table->renameColumn('temporary', 'updated_at'); + } + }; + $migrator = $this->migrations($this->createReports(), $swap); + $migrator->migrate('1'); + $this->insertReport(); + + try { + $migrator->migrate(); + $this->fail('A rename cycle must be rejected before modifying the live schema.'); + } catch (InvalidArgumentException) { + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + $this->assertSame('2026-01-02 03:04:05.123456', $this->observer->fetchOne('SELECT updated_at FROM ' . $this->reports)); + } + } + + /** + * Reusing a source name requires a later migration so retries remain unambiguous. + */ + public function test_renaming_and_recreating_the_source_column_in_one_migration_is_rejected(): void { + $reuse = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $table = $schema->table($this->suffix . '_reports'); + $table->renameColumn('title', 'headline'); + $table->string('title', 80)->default(''); + } + }; + $migrator = $this->migrations($this->createReports(), $reuse); + $migrator->migrate('1'); + $this->insertReport(); + + try { + $migrator->migrate(); + $this->fail('Reusing the source name must fail before modifying the live schema.'); + } catch (InvalidArgumentException) { + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + $this->assertFalse($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_reports')->hasColumn('headline')); + } + } + + /** + * A failed rollback history deletion must not repeat already completed inverse renames. + * + * @param class-string $strategy + * + * @dataProvider renameStrategies + */ + #[DataProvider('renameStrategies')] + public function test_chained_rollback_retries_without_ddl_after_history_delete_failure(string $strategy): void { + $this->container->singleton(ColumnRename::class, $strategy); + $migrator = $this->migrations($this->createReports(), $this->chainedRename()); + $migrator->migrate('1'); + $this->insertReport(); + $migrator->migrate(); + $trigger = $this->observer->quoteSingleIdentifier($this->suffix . '_reject_delete'); + $this->observer->executeStatement("CREATE TRIGGER $trigger BEFORE DELETE ON {$this->history} FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ledger rejected'"); + + try { + $migrator->rollback(); + $this->fail('The history deletion must fail after the inverse renames complete.'); + } catch (LedgerFailure) { + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + } finally { + $this->observer->executeStatement('DROP TRIGGER ' . $trigger); + } + + $steps = $migrator->rollback(); + $this->assertSame([], $steps[0]->sql); + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->history)); + } + + /** + * An unrelated final rename does not make an earlier name-swap safe to resume. + */ + public function test_cycle_followed_by_another_rename_is_rejected_before_sql(): void { + $swap = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $table = $schema->table($this->suffix . '_reports'); + $table->renameColumn('title', 'temporary'); + $table->renameColumn('subtitle', 'title'); + $table->renameColumn('temporary', 'subtitle'); + $table->renameColumn('updated_at', 'modified_at'); + } + }; + $migrator = $this->migrations($this->createReports(), $swap); + $migrator->migrate('1'); + $this->insertReport(); + + try { + $migrator->migrate(); + $this->fail('An ambiguous intermediate rename state must be rejected before SQL.'); + } catch (InvalidArgumentException) { + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + $this->assertSame('Other', $this->observer->fetchOne('SELECT subtitle FROM ' . $this->reports)); + $this->assertSame('2026-01-02 03:04:05.123456', $this->observer->fetchOne('SELECT updated_at FROM ' . $this->reports)); + } + } + + /** + * A create declaration must not silently discard a column rename. + */ + public function test_renaming_a_column_inside_a_create_declaration_is_rejected(): void { + $create = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $table = $schema->create($this->suffix . '_reports'); + $table->bigIncrements(); + $table->string('title'); + $table->renameColumn('title', 'headline'); + } + }; + $migrator = $this->migrations($create); + + try { + $migrator->migrate(); + $this->fail('Rename declarations require an existing table.'); + } catch (InvalidArgumentException) { + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([ + $this->source->prefix . $this->suffix . '_reports', + ])); + } + } + + /** + * A rename can change the destination definition while preserving the existing value. + */ + public function test_column_rename_and_destination_definition_change_resume_together(): void { + $rename = new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $table = $schema->table($this->suffix . '_reports'); + $table->renameColumn('title', 'headline'); + $table->string('headline', 255)->nullable()->default(null)->change(); + } + + /** + * {@inheritDoc} + */ + public function down(Blueprint $schema): void { + $table = $schema->table($this->suffix . '_reports'); + $table->renameColumn('headline', 'title'); + $table->string('title', 80)->default('')->change(); + } + }; + $migrator = $this->migrations($this->createReports(), $rename); + $migrator->migrate('1'); + $this->insertReport(); + $sql = $migrator->preview()[0]->sql; + $this->observer->executeStatement($sql[0]); + $migrator->migrate(); + $this->assertSame('Original', $this->observer->fetchOne('SELECT headline FROM ' . $this->reports)); + $column = $this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_reports')->getColumn('headline'); + $this->assertSame(255, $column->getLength()); + $this->assertFalse($column->getNotnull()); + $migrator->rollback(); + $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + } + + private function chainedRename(): Migration { + return new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $schema->rename($this->suffix . '_reports', $this->suffix . '_archive'); + $table = $schema->table($this->suffix . '_archive'); + $table->renameColumn('title', 'heading'); + $table->renameColumn('heading', 'headline'); + } + + /** + * {@inheritDoc} + */ + public function down(Blueprint $schema): void { + $table = $schema->table($this->suffix . '_archive'); + $table->renameColumn('headline', 'heading'); + $table->renameColumn('heading', 'title'); + $schema->rename($this->suffix . '_archive', $this->suffix . '_reports'); + } + }; + } + + private function selfReference(): Migration { + return new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $table = $schema->table($this->suffix . '_reports'); + $table->unsignedBigInteger('parent_id')->nullable(); + $table->index('parent_lookup', 'parent_id'); + $table->foreignKey('parent', 'parent_id')->references($this->suffix . '_reports', 'id')->cascadeOnDelete(); + } + }; + } + + /** + * Exercise both SQL implementations with the same recovery guarantees. + * + * @return iterable}> + */ + public static function renameStrategies(): iterable { + yield 'platform' => [ + PlatformColumnRename::class, + ]; + + yield 'change' => [ + ChangeColumnRename::class, + ]; + } + + /** + * Preview retains complete timestamp definitions from both live and newly created tables. + */ + public function test_change_preview_preserves_timestamp_metadata_across_earlier_steps(): void { + $this->container->singleton(ColumnRename::class, ChangeColumnRename::class); + $create = new class($this->suffix) extends Migration { + /** + * Receive the private table name. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $table = $schema->create($this->suffix . '_reports'); + $table->bigIncrements('id'); + $table->dateTime('updated_at', 6)->useCurrent()->useCurrentOnUpdate(); + } + }; + $alter = new class($this->suffix) extends Migration { + /** + * Receive the private table name. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $schema->table($this->suffix . '_reports')->string('title', 80); + } + }; + $rename = new class($this->suffix) extends Migration { + /** + * Receive the private table name. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $schema->table($this->suffix . '_reports')->renameColumn('updated_at', 'modified_at'); + } + }; + $migrator = $this->migrations($create, $alter, $rename); + $preview = $migrator->preview(); + $this->assertStringContainsString('DATETIME(6)', strtoupper(implode('; ', $preview[2]->sql))); + $this->assertStringContainsString('ON UPDATE CURRENT_TIMESTAMP(6)', strtoupper(implode('; ', $preview[2]->sql))); + $migrator->migrate('1'); + $preview = $migrator->preview(); + $this->assertStringContainsString('DATETIME(6)', strtoupper(implode('; ', $preview[1]->sql))); + $this->assertStringContainsString('ON UPDATE CURRENT_TIMESTAMP(6)', strtoupper(implode('; ', $preview[1]->sql))); + $migrator->migrate(); + $this->assertSame([], $migrator->migrate()); + } + + private function migrations(Migration ...$migrations): Migrator { + foreach (array_values($migrations) as $position => $migration) { + $this->container->mergeArrayVar(MigrationsProvider::MIGRATIONS, [ + new MigrationRegistration((string) ($position + 1), $migration), + ]); + } + + return $this->container->get(Migrator::class); + } + + private function createReports(): Migration { + return new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $table = $schema->create($this->suffix . '_reports'); + $table->bigIncrements(); + $table->string('title', 80)->default(''); + $table->string('subtitle', 80)->default('Other'); + $table->dateTime('updated_at', 6)->useCurrent()->useCurrentOnUpdate(); + } + + /** + * {@inheritDoc} + */ + public function down(Blueprint $schema): void { + $schema->drop($this->suffix . '_reports'); + } + }; + } + + private function renameTitle(): Migration { + return new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $schema->table($this->suffix . '_reports')->renameColumn('title', 'headline'); + } + + /** + * {@inheritDoc} + */ + public function down(Blueprint $schema): void { + $schema->table($this->suffix . '_reports')->renameColumn('headline', 'title'); + } + }; + } + + private function renameReports(): Migration { + return new class($this->suffix) extends Migration { + /** + * Identify this test's private tables. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * {@inheritDoc} + */ + public function up(Blueprint $schema): void { + $schema->rename($this->suffix . '_reports', $this->suffix . '_archive'); + } + + /** + * {@inheritDoc} + */ + public function down(Blueprint $schema): void { + $schema->rename($this->suffix . '_archive', $this->suffix . '_reports'); + } + }; + } + + private function insertReport(): void { + $this->observer->insert($this->reports, [ + 'id' => 1, + 'title' => 'Original', + 'updated_at' => '2026-01-02 03:04:05.123456', + ]); + } + + private function failHistoryWrite(Migrator $migrator, string $target = Migrator::LATEST): void { + $applied = (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->history); + $trigger = $this->observer->quoteSingleIdentifier($this->suffix . '_reject_history'); + $this->observer->executeStatement("CREATE TRIGGER $trigger BEFORE INSERT ON {$this->history} FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ledger rejected'"); + + try { + $migrator->migrate($target); + $this->fail('The history write must fail after the rename completed.'); + } catch (LedgerFailure) { + $this->assertSame($applied, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->history)); + } finally { + $this->observer->executeStatement('DROP TRIGGER ' . $trigger); + } + } +} From 924cee4aef813b7390719cac69379944df12c647 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 24 Sep 2026 15:58:24 -0600 Subject: [PATCH 20/53] Refactor SchemaPlanner to pull out some responsibilities --- src/Migrations/MigrationsProvider.php | 2 + src/Migrations/Schema/SchemaInspector.php | 140 ++++++++++++++++++ src/Migrations/Schema/SchemaPlanner.php | 113 +------------- .../Migrations/AdvisoryMigrationTest.php | 3 +- 4 files changed, 146 insertions(+), 112 deletions(-) create mode 100644 src/Migrations/Schema/SchemaInspector.php diff --git a/src/Migrations/MigrationsProvider.php b/src/Migrations/MigrationsProvider.php index 648614c..75abb0f 100644 --- a/src/Migrations/MigrationsProvider.php +++ b/src/Migrations/MigrationsProvider.php @@ -11,6 +11,7 @@ use StellarWP\Foundation\Migrations\Schema\RenamePlanner; use StellarWP\Foundation\Migrations\Schema\Renames\Contracts\ColumnRename; use StellarWP\Foundation\Migrations\Schema\Renames\PlatformColumnRename; +use StellarWP\Foundation\Migrations\Schema\SchemaInspector; use StellarWP\Foundation\Migrations\Schema\SchemaPlanner; use StellarWP\Foundation\Migrations\Tables\MigrationTable; use StellarWP\Foundation\WPCli\WPCliProvider; @@ -97,6 +98,7 @@ private function registerMigrations(): void { $this->container->singleton(MigrationComparatorFactory::class); $this->container->singleton(ColumnRename::class, PlatformColumnRename::class); $this->container->singleton(RenamePlanner::class); + $this->container->singleton(SchemaInspector::class); $this->container->singleton(SchemaPlanner::class); $this->container->singleton(Migrator::class); } diff --git a/src/Migrations/Schema/SchemaInspector.php b/src/Migrations/Schema/SchemaInspector.php new file mode 100644 index 0000000..b494db9 --- /dev/null +++ b/src/Migrations/Schema/SchemaInspector.php @@ -0,0 +1,140 @@ + $names + * @param list $simulated Lower-cased names of every table an earlier preview step decided on. + * + * @throws Exception When the database schema or supplemental metadata cannot be read. + */ + public function inspect(array $names, ?SchemaState $overlay = null, array $simulated = []): SchemaState { + $manager = $this->db->createSchemaManager(); + $tables = []; + $timestamps = $this->timestampAttributes($names); + + foreach (array_unique($names) as $name) { + if ($overlay?->schema->hasTable($name) ?? false) { + $tables[] = $overlay->schema->getTable($name); + } elseif (in_array(strtolower($name), $simulated, true)) { + continue; // Dropped by an earlier simulated step. + } elseif ($manager->tablesExist([ + $name, + ])) { + $table = $this->normalize($manager->introspectTable($name)); + $tables[] = $this->columnRenames->inspect($table); + } + } + + $facts = []; + + foreach ($tables as $table) { + $name = strtolower($table->getObjectName()->toString()); + + if ($overlay?->schema->hasTable($name)) { + foreach ($overlay->timestamps as $key => $attributes) { + if (str_starts_with($key, $name . '.')) { + $facts[$key] = $attributes; + } + } + } else { + foreach ($timestamps[$name] ?? [] as $column => $attributes) { + $facts[$name . '.' . $column] = $attributes; + } + } + } + + return new SchemaState(new Schema($tables), $facts); + } + + /** + * Read what DBAL does not: fractional precision and ON UPDATE for datetime columns. + * + * @param list $names + * + * @return array> Lower-cased table, then column. + */ + private function timestampAttributes(array $names): array { + if ($names === []) { + return []; + } + + $placeholders = implode(', ', array_fill(0, count($names), '?')); + $rows = $this->db->fetchAllAssociative( + "SELECT + TABLE_NAME, + COLUMN_NAME, + DATETIME_PRECISION, + EXTRA + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND DATA_TYPE IN ('datetime', 'timestamp') + AND TABLE_NAME IN ({$placeholders})", + $names, + ); + + $attributes = []; + + foreach ($rows as $row) { + $table = strtolower((string) $row['TABLE_NAME']); + $column = strtolower((string) $row['COLUMN_NAME']); + + $attributes[$table][$column] = [ + 'precision' => (int) $row['DATETIME_PRECISION'], + 'on_update' => stripos((string) $row['EXTRA'], 'on update') !== false, + ]; + } + + return $attributes; + } + + /** + * Align engine-reported values with Foundation's declarations where DBAL does not. + * + * MariaDB reports fractional timestamp defaults as `current_timestamp(6)`; DBAL only recognizes + * the exact `CURRENT_TIMESTAMP`. Declarations always use upper case, so upper-case the report. + */ + private function normalize(Table $table): Table { + $editor = $table->edit(); + + foreach ($table->getColumns() as $column) { + $default = $column->getDefault(); + + if (is_string($default) && preg_match('/^current_timestamp(\(\d+\))?$/i', $default) === 1) { + $editor->modifyColumn($column->getObjectName(), static function (ColumnEditor $column) use ($default): void { + $column->setDefaultValue(strtoupper($default)); + }); + } + } + + return $editor->create(); + } +} diff --git a/src/Migrations/Schema/SchemaPlanner.php b/src/Migrations/Schema/SchemaPlanner.php index 65c0b77..dc64f7b 100644 --- a/src/Migrations/Schema/SchemaPlanner.php +++ b/src/Migrations/Schema/SchemaPlanner.php @@ -17,7 +17,6 @@ use StellarWP\Foundation\Migrations\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Migrations\MigrationCollection; use StellarWP\Foundation\Migrations\Schema\Factories\MigrationComparatorFactory; -use StellarWP\Foundation\Migrations\Schema\Renames\Contracts\ColumnRename; use StellarWP\Foundation\Migrations\Schema\ValueObjects\SchemaPlan; /** @@ -38,7 +37,7 @@ public function __construct( private MigrationCollection $migrations, private MigrationComparatorFactory $comparators, private RenamePlanner $renames, - private ColumnRename $columnRenames, + private SchemaInspector $inspector, private array $tableOptions = [], ) { } @@ -69,7 +68,7 @@ public function plan(array $applied, string $id, bool $reverse, ?SchemaState $si } $names = array_values(array_unique($names)); - $actual = $this->introspect($names, $simulated, $simulatedNames); + $actual = $this->inspector->inspect($names, $simulated, $simulatedNames); $renameSql = $this->renames->plan($before, $actual, $after, $id); $comparator->alignForeignKeyNames($before, $actual, $after, $id); $expected = $comparator->compareSchemas($before->schema, $after->schema); @@ -144,114 +143,6 @@ private function reverse(SchemaState $before, string $id): SchemaState { return $after; } - /** - * Introspect the owned tables that currently exist, overlaying tables a preview has already simulated. - * - * A simulated table that is absent from the overlay was deliberately dropped by an earlier step, - * so it must not be reloaded from the database. - * - * @param list $names - * @param list $simulated Lower-cased names of every table an earlier preview step decided on. - */ - private function introspect(array $names, ?SchemaState $overlay = null, array $simulated = []): SchemaState { - $manager = $this->db->createSchemaManager(); - $tables = []; - $timestamps = $this->timestampAttributes($names); - - foreach (array_unique($names) as $name) { - if ($overlay?->schema->hasTable($name) ?? false) { - $tables[] = $overlay->schema->getTable($name); - } elseif (in_array(strtolower($name), $simulated, true)) { - continue; // Dropped by an earlier simulated step. - } elseif ($manager->tablesExist([$name])) { - $table = $this->normalize($manager->introspectTable($name)); - $tables[] = $this->columnRenames->inspect($table); - } - } - - $facts = []; - - foreach ($tables as $table) { - $name = strtolower($table->getObjectName()->toString()); - - if ($overlay?->schema->hasTable($name)) { - foreach ($overlay->timestamps as $key => $attributes) { - if (str_starts_with($key, $name . '.')) { - $facts[$key] = $attributes; - } - } - } else { - foreach ($timestamps[$name] ?? [] as $column => $attributes) { - $facts[$name . '.' . $column] = $attributes; - } - } - } - - return new SchemaState(new Schema($tables), $facts); - } - - /** - * Read what DBAL does not: fractional precision and ON UPDATE for datetime columns. - * - * @param list $names - * - * @return array> Lower-cased table, then column. - */ - private function timestampAttributes(array $names): array { - if ($names === []) { - return []; - } - - $placeholders = implode(', ', array_fill(0, count($names), '?')); - $rows = $this->db->fetchAllAssociative( - "SELECT - TABLE_NAME, - COLUMN_NAME, - DATETIME_PRECISION, - EXTRA - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND DATA_TYPE IN ('datetime', 'timestamp') - AND TABLE_NAME IN ({$placeholders})", - $names, - ); - - $attributes = []; - - foreach ($rows as $row) { - $table = strtolower((string) $row['TABLE_NAME']); - $column = strtolower((string) $row['COLUMN_NAME']); - - $attributes[$table][$column] = [ - 'precision' => (int) $row['DATETIME_PRECISION'], - 'on_update' => stripos((string) $row['EXTRA'], 'on update') !== false, - ]; - } - - return $attributes; - } - - /** - * Align engine-reported values with Foundation's declarations where DBAL does not. - * - * MariaDB reports fractional timestamp defaults as `current_timestamp(6)`; DBAL only recognizes - * the exact `CURRENT_TIMESTAMP`. Declarations always use upper case, so upper-case the report. - */ - private function normalize(Table $table): Table { - $editor = $table->edit(); - foreach ($table->getColumns() as $column) { - $default = $column->getDefault(); - - if (is_string($default) && preg_match('/^current_timestamp(\(\d+\))?$/i', $default) === 1) { - $editor->modifyColumn($column->getObjectName(), static function (\Doctrine\DBAL\Schema\ColumnEditor $column) use ($default): void { - $column->setDefaultValue(strtoupper($default)); - }); - } - } - - return $editor->create(); - } - /** * Return a copy of the desired schema that keeps columns and indexes the history never declared. * diff --git a/tests/integration/Migrations/AdvisoryMigrationTest.php b/tests/integration/Migrations/AdvisoryMigrationTest.php index 5b879a2..87b27c3 100644 --- a/tests/integration/Migrations/AdvisoryMigrationTest.php +++ b/tests/integration/Migrations/AdvisoryMigrationTest.php @@ -25,6 +25,7 @@ use StellarWP\Foundation\Migrations\Schema\Factories\MigrationComparatorFactory; use StellarWP\Foundation\Migrations\Schema\RenamePlanner; use StellarWP\Foundation\Migrations\Schema\Renames\Contracts\ColumnRename; +use StellarWP\Foundation\Migrations\Schema\SchemaInspector; use StellarWP\Foundation\Migrations\Schema\SchemaPlanner; use StellarWP\Foundation\Migrations\Tables\MigrationTable; use StellarWP\Foundation\Migrations\ValueObjects\MigrationRegistration; @@ -91,7 +92,7 @@ public function migrate(DataMigrationContext $context): void { $migrations, $container->get(MigrationComparatorFactory::class), new RenamePlanner($db, $container->get(ColumnRename::class)), - $container->get(ColumnRename::class), + $container->get(SchemaInspector::class), ), $migrations, $names, From 6a812204198c61ce977b410986ce84bc8c95f5ce Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 24 Sep 2026 16:10:24 -0600 Subject: [PATCH 21/53] Give Redis Lock a dedicated documentation guide --- src/Docs/astro.config.mjs | 1 + src/Docs/src/content/docs/components/lock.mdx | 153 +------------- .../content/docs/components/lock/redis.mdx | 196 ++++++++++++++++++ src/LockRedis/README.md | 7 +- 4 files changed, 207 insertions(+), 150 deletions(-) create mode 100644 src/Docs/src/content/docs/components/lock/redis.mdx diff --git a/src/Docs/astro.config.mjs b/src/Docs/astro.config.mjs index e316744..5775c2d 100644 --- a/src/Docs/astro.config.mjs +++ b/src/Docs/astro.config.mjs @@ -45,6 +45,7 @@ export default defineConfig({ items: [ { slug: 'components/lock', label: 'Overview' }, { slug: 'components/lock/database' }, + { slug: 'components/lock/redis' }, ], }, { slug: 'components/migrations' }, diff --git a/src/Docs/src/content/docs/components/lock.mdx b/src/Docs/src/content/docs/components/lock.mdx index 30946fe..e7dffcd 100644 --- a/src/Docs/src/content/docs/components/lock.mdx +++ b/src/Docs/src/content/docs/components/lock.mdx @@ -66,85 +66,13 @@ Install the Redis implementation and the supported Predis client: composer require stellarwp/foundation-lock-redis "predis/predis:>=3.3 <4.0" ``` -Set a stable application prefix and configure one writable Redis endpoint for locks. Foundation supports TCP, TLS, and Unix-socket connections. The required `lock.redis.parameters` setting accepts a Predis URI or parameter array; `lock.redis.options` accepts optional Predis client settings. Missing parameters raise `InvalidArgumentException` during provider registration; add the setting shown below before registering the provider. +Follow the Redis lock guide to configure the connection, register its providers, and select `RedisLock` as your application's `Lock` implementation. Then continue to [Usage](#usage) below. -In `config.php`: - -```php title="config.php" - [ 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin' ], - 'lock' => [ 'redis' => [ - 'parameters' => [ - 'host' => $_ENV['FOUNDATION_LOCK_REDIS_HOST'] ?? '127.0.0.1', - 'port' => (int) ( $_ENV['FOUNDATION_LOCK_REDIS_PORT'] ?? 6379 ), - 'database' => (int) ( $_ENV['FOUNDATION_LOCK_REDIS_DATABASE'] ?? 1 ), - ], - ] ], -]; - -if ( isset( $_ENV['FOUNDATION_LOCK_REDIS_PREFIX'] ) ) { - $config['lock']['redis']['prefix'] = $_ENV['FOUNDATION_LOCK_REDIS_PREFIX']; -} - -return $config; -``` - -Invalid Predis configuration raises `ContainerException` when the connection is resolved; correct the configuration before retrying. - -Create `src/Lock/Provider.php` to select Redis as this application's default lock implementation: - -```php title="Provider.php" -container->singleton( - Lock::class, - static fn ( C $c ): RedisLock => $c->get( RedisLock::class ) - ); - } -} -``` - -Register the connection provider, Redis lock provider, and your application provider in that order: - -In `src/App.php`: - -```php title="App.php" -use StellarWP\Foundation\Container\Contracts\Provider; -use StellarWP\Foundation\LockRedis\LockRedisProvider; -use StellarWP\Foundation\LockRedis\PredisConnectionProvider; -use Plugin\Lock; - -/** @var list> */ -private const array PROVIDERS = [ - PredisConnectionProvider::class, - LockRedisProvider::class, - Lock\Provider::class, -]; -``` - -The Redis key prefix defaults to `foundation.prefix . ':lock:'`. Without configuration, Foundation uses `nx:lock:`; this example defaults to `your-plugin:lock:`. A complete application that owns its shared composition root can use `nx`, but a distributable standalone plugin must set a stable unique `foundation.prefix`. The optional `FOUNDATION_LOCK_REDIS_PREFIX` environment variable sets `lock.redis.prefix` explicitly; Foundation preserves its nonempty value exactly. When that variable is absent, the provider derives the prefix from `foundation.prefix`. - -:::caution[Protect active lock keys] -Use a dedicated Redis database or endpoint for locks. A cache flush or eviction that removes an active lock allows another worker to acquire it while the original work is still running. Keep lock keys out of cache-clearing operations. -::: + ## Usage @@ -301,75 +229,6 @@ Keep renewal outside any per-record `catch ( Throwable )` that intentionally ski Configure the replacement backend and change the application's `Lock` binding before resolving any consumer that receives `LockOperation`; `Catalog_Synchronizer` remains unchanged. During deployment, quiesce workers using the old backend or prefix before starting workers using the replacement, because different backends and prefixes do not coordinate with each other. -### Use PhpRedis or a custom connection - -Predis is the supplied setup path. For PhpRedis, install and enable the extension, omit `PredisConnectionProvider` and the Predis dependency, and supply an application-owned connection dedicated to locks. Bind the Redis `Connection` contract before `RedisLock` resolves. An application can also replace a provider-supplied connection after provider registration and before resolution. - -In `src/Lock/PhpRedis_Connection_Provider.php`: - -```php title="PhpRedis_Connection_Provider.php" -container->singleton( Connection::class, PhpRedisConnection::class ); - } -} -``` - -The application must bind its dedicated, connected `Redis` instance before this adapter resolves. A custom adapter can implement `Connection` and use the same replacement point, provided its `evaluate()` and `exists()` methods preserve the lock contract's atomicity and failure behavior. - -### Use Redis for one feature while another lock stays default - -An application can keep `DatabaseLock` as the global `Lock` while one feature uses Redis. Register both Redis Foundation providers, keep the database `Lock` binding, then configure that feature's `LockOperation` construction with the Redis lock. - -In `src/Catalog/Provider.php`: - -```php title="Provider.php" -container->when( Catalog_Synchronizer::class ) - ->needs( LockOperation::class ) - ->give( - static fn ( C $c ): LockOperation => new LockOperation( - $c->get( RedisLock::class ) - ) - ); - } -} -``` - -Register this feature provider in `App` after the Redis providers. Its contextual binding selects Redis for `Catalog_Synchronizer`; other services continue using the application's default lock. - ## Test application behavior Use `InMemoryLock` to test a service's lock behavior without Redis or WordPress. Inject `new LockOperation( $lock )` alongside the service's usual application test doubles. The following standalone test demonstrates successful work and contention with that same lock instance. diff --git a/src/Docs/src/content/docs/components/lock/redis.mdx b/src/Docs/src/content/docs/components/lock/redis.mdx new file mode 100644 index 0000000..782e15b --- /dev/null +++ b/src/Docs/src/content/docs/components/lock/redis.mdx @@ -0,0 +1,196 @@ +--- +title: Redis Lock +description: Use Redis as the shared backend for Foundation lock ownership. +sidebar: + order: 2 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +`stellarwp/foundation-lock-redis` supplies `RedisLock`, which implements the shared Foundation `Lock` contract with atomic Redis acquisition, renewal, and release. Choose it when requests and workers share a Redis endpoint for lock coordination. + + + + + + +## Set up Redis locks + +Install the Redis implementation and the supported Predis client: + +```shell +composer require stellarwp/foundation-lock-redis "predis/predis:>=3.3 <4.0" +``` + +### Configure the connection + +Set a stable application prefix and configure one writable Redis endpoint for locks. Foundation supports TCP, TLS, and Unix-socket connections. The required `lock.redis.parameters` setting accepts a Predis URI or parameter array; `lock.redis.options` accepts optional Predis client settings. Missing parameters raise `InvalidArgumentException` during provider registration; add the setting shown below before registering the provider. + +In `config.php`: + +```php title="config.php" + [ + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], + 'lock' => [ + 'redis' => [ + 'parameters' => [ + 'host' => $_ENV['FOUNDATION_LOCK_REDIS_HOST'] ?? '127.0.0.1', + 'port' => (int) ( $_ENV['FOUNDATION_LOCK_REDIS_PORT'] ?? 6379 ), + 'database' => (int) ( $_ENV['FOUNDATION_LOCK_REDIS_DATABASE'] ?? 1 ), + ], + ], + ], +]; + +if ( isset( $_ENV['FOUNDATION_LOCK_REDIS_PREFIX'] ) ) { + $config['lock']['redis']['prefix'] = $_ENV['FOUNDATION_LOCK_REDIS_PREFIX']; +} + +return $config; +``` + +Invalid Predis configuration raises `ContainerException` when the connection is resolved; correct the configuration before retrying. + +### Select the Redis implementation + +`PredisConnectionProvider` supplies the connection. `LockRedisProvider` wires `RedisLock`. Select it as the application's `Lock` implementation in your existing application provider, or create `src/Lock/Provider.php`: + +```php title="Provider.php" +container->singleton( + Lock::class, + static fn ( C $c ): RedisLock => $c->get( RedisLock::class ) + ); + } +} +``` + +Register providers in dependency order in `src/App.php`: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Provider; +use StellarWP\Foundation\LockRedis\LockRedisProvider; +use StellarWP\Foundation\LockRedis\PredisConnectionProvider; +use Plugin\Lock; + +/** @var list> */ +private const array PROVIDERS = [ + PredisConnectionProvider::class, + LockRedisProvider::class, + Lock\Provider::class, +]; +``` + +The Redis key prefix defaults to `foundation.prefix . ':lock:'`. Without configuration, Foundation uses `nx:lock:`; this example defaults to `your-plugin:lock:`. A complete application that owns its shared composition root can use `nx`, but a distributable standalone plugin must set a stable unique `foundation.prefix`. The optional `FOUNDATION_LOCK_REDIS_PREFIX` environment variable sets `lock.redis.prefix` explicitly; Foundation preserves its nonempty value exactly. When that variable is absent, the provider derives the prefix from `foundation.prefix`. + +:::caution[Protect active lock keys] +Use a dedicated Redis database or endpoint for locks. A cache flush or eviction that removes an active lock allows another worker to acquire it while the original work is still running. Keep lock keys out of cache-clearing operations. +::: + +## Usage + +Inject `LockOperation` into application services to run bounded work using the selected backend. The [Lock guide](/components/lock/#usage) covers complete usage, contention, renewal, and failure handling. + +## Customization + +### Use PhpRedis or a custom connection + +Predis is the supplied setup path. For PhpRedis, install and enable the extension, omit `PredisConnectionProvider` and the Predis dependency, and supply an application-owned connection dedicated to locks. Bind the Redis `Connection` contract before `RedisLock` resolves. An application can also replace a provider-supplied connection after provider registration and before resolution. + +In `src/Lock/PhpRedis_Connection_Provider.php`: + +```php title="PhpRedis_Connection_Provider.php" +container->singleton( Connection::class, PhpRedisConnection::class ); + } +} +``` + +The application must bind its dedicated, connected `Redis` instance before this adapter resolves. A custom adapter can implement `Connection` and use the same replacement point, provided its `evaluate()` and `exists()` methods preserve the lock contract's atomicity and failure behavior. + +### Use Redis for one feature while another lock stays default + +An application can keep [Database Lock](/components/lock/database/) as the global `Lock` while one feature uses Redis. Register both Redis Foundation providers, keep the existing application provider that selects the database `Lock`, then configure that feature's `LockOperation` construction with the Redis lock. + +In `src/Catalog/Provider.php`: + +```php title="Provider.php" +container->when( Catalog_Synchronizer::class ) + ->needs( LockOperation::class ) + ->give( + static fn ( C $c ): LockOperation => new LockOperation( + $c->get( RedisLock::class ) + ) + ); + } +} +``` + +Register this feature provider in `App` after the Redis providers. The [shared usage example](/components/lock/#run-a-protected-operation) defines `Catalog_Synchronizer`. This contextual binding selects Redis for that service; other services continue using the application's default lock. + +## Testing + +Use `InMemoryLock` when testing application behavior against the shared contract. The [Lock testing example](/components/lock/#test-application-behavior) shows successful work and contention. Use a real Redis service for integration tests involving Redis connections, expiration, or coordination between processes. diff --git a/src/LockRedis/README.md b/src/LockRedis/README.md index a6276f2..7541ce1 100644 --- a/src/LockRedis/README.md +++ b/src/LockRedis/README.md @@ -23,6 +23,7 @@ Alternatively, install and enable the PhpRedis extension. ## Documentation -See the [Foundation Lock guide](https://foundation.nexcess.dev/components/lock/) -for backend selection, Redis configuration, container registration, lease -handling, failure behavior, and usage examples. +See the [Redis Lock guide](https://foundation.nexcess.dev/components/lock/redis/) +for connection configuration, provider registration, and client customization. +The [Lock overview](https://foundation.nexcess.dev/components/lock/) covers shared +usage, renewal, failure handling, and testing. From d994764ad60a069b5800f67da6d13781ad332b3f Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 24 Sep 2026 16:34:19 -0600 Subject: [PATCH 22/53] Remove duplicate checks that already exist outside of migrations --- src/Migrations/Migrator.php | 18 ------------------ .../Migrations/AdvisoryMigrationTest.php | 1 - 2 files changed, 19 deletions(-) diff --git a/src/Migrations/Migrator.php b/src/Migrations/Migrator.php index 7ea28fc..f322113 100644 --- a/src/Migrations/Migrator.php +++ b/src/Migrations/Migrator.php @@ -6,7 +6,6 @@ use Doctrine\DBAL\Connection; use InvalidArgumentException; use StellarWP\Foundation\Database\Contracts\AdvisorySession; -use StellarWP\Foundation\Database\Contracts\DatabaseScope; use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Database\Exceptions\AdvisoryLockContended; use StellarWP\Foundation\Database\Exceptions\AdvisoryLockInterrupted; @@ -41,7 +40,6 @@ final class Migrator public function __construct( private readonly Connection $db, private readonly AdvisorySession $session, - private readonly DatabaseScope $scope, private readonly History $history, private readonly SchemaPlanner $planner, private readonly MigrationCollection $migrations, @@ -186,10 +184,6 @@ public function status(): array { * @return list */ private function run(string $target, bool $execute, bool $applyPending = true): array { - // Capture the operation's site and prefix; a change mid-run is rejected before any ledger write. - $site = $this->scope->capture(); - $prefix = $this->scope->resolveTableName(''); - if ($execute) { $this->history->initialize(); } @@ -218,7 +212,6 @@ private function run(string $target, bool $execute, bool $applyPending = true): throw new MigrationInterrupted('A migration data callback left a transaction open; complete its transaction before returning.'); } } - $this->assertScope($site, $prefix); try { $reverse ? $this->history->remove($id) : $this->history->record($id); @@ -283,17 +276,6 @@ private function assertTarget(string $target): void { } } - /** - * @throws MigrationInterrupted When the WordPress site or table prefix changed during the run. - */ - private function assertScope(int $site, string $prefix): void { - $this->scope->assertCurrent($site); - - if ($this->scope->resolveTableName('') !== $prefix) { - throw new MigrationInterrupted('The WordPress table prefix changed during the migration run.'); - } - } - private static function displayName(Migration $migration, string $id): string { if (str_contains($migration::class, "\0")) { return $id; diff --git a/tests/integration/Migrations/AdvisoryMigrationTest.php b/tests/integration/Migrations/AdvisoryMigrationTest.php index 87b27c3..c0658fa 100644 --- a/tests/integration/Migrations/AdvisoryMigrationTest.php +++ b/tests/integration/Migrations/AdvisoryMigrationTest.php @@ -84,7 +84,6 @@ public function migrate(DataMigrationContext $context): void { return new Migrator( $db, $container->get(AdvisorySession::class), - $scope, new History($db, $table), new SchemaPlanner( $db, From 6183cfb5a9bb930edfd83da43e63cf4f7d5ff561 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 24 Sep 2026 20:28:43 -0600 Subject: [PATCH 23/53] Simplify migrations to execute current declarations without historical replay --- AGENTS.md | 6 +- .../src/content/docs/components/database.mdx | 4 +- .../content/docs/components/migrations.mdx | 38 +- src/Migrations/Contracts/Migration.php | 4 +- .../Exceptions/IncompatibleSchema.php | 24 - src/Migrations/Exceptions/LedgerFailure.php | 2 +- src/Migrations/Migrator.php | 20 +- src/Migrations/README.md | 4 +- src/Migrations/Schema/Blueprint.php | 37 +- .../Schema/ForeignKeyComparator.php | 161 ------- src/Migrations/Schema/RenamePlanner.php | 101 +---- src/Migrations/Schema/SchemaInspector.php | 4 + src/Migrations/Schema/SchemaPlanner.php | 409 ++---------------- src/Migrations/Schema/SchemaState.php | 34 +- src/Migrations/Schema/TableBlueprint.php | 34 +- src/Migrations/Schema/ValueObjects/Rename.php | 9 +- src/Migrations/composer.json | 2 +- tests/Unit/Migrations/ColumnRenameTest.php | 5 +- .../Migrations/ForeignKeyComparatorTest.php | 39 +- .../Migrations/ForeignKeyDefinitionTest.php | 60 ++- .../Migrations/CommonColumnTest.php | 43 +- .../DeclarativeMigratorEdgeCaseTest.php | 43 +- .../Migrations/DeclarativeMigratorTest.php | 60 ++- .../Migrations/ForeignKeyIdentityTest.php | 139 +----- .../Migrations/ForeignKeyMigrationTest.php | 78 +--- .../Migrations/MigrationExecutionTest.php | 241 +++++++++++ .../Migrations/RenameMigrationTest.php | 195 +++------ 27 files changed, 577 insertions(+), 1219 deletions(-) delete mode 100644 src/Migrations/Exceptions/IncompatibleSchema.php create mode 100644 tests/integration/Migrations/MigrationExecutionTest.php diff --git a/AGENTS.md b/AGENTS.md index a3b616b..049a1b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,7 +165,7 @@ Register infrastructure providers and top-level feature providers from the appli ## Database -`stellarwp/foundation-database` provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress transaction behavior. `stellarwp/foundation-migrations` owns migration discovery, schema declarations and planning, history, recovery, and migration commands. Developers use Doctrine directly for database expressions and advanced operations. Doctrine owns query construction, parameter binding, execution, result handling, schema inspection, and platform SQL generation. Foundation owns provider defaults, WordPress connection integration, resource naming, and operational guarantees. Foundation 2.0 targets Doctrine DBAL `^4.4` on PHP 8.3, with its MySQL 5.7.9+ and MariaDB 10.4.3+ server minimums. Keep these requirements visible in the Database and Migrations guides. DBAL 3 support is deferred until the Rector downgrade feature is implemented; it is not a Foundation 2.0 release requirement. Keep dependency-major differences confined to internal connection and schema boundaries, and validate dependency behavior separately from Rector syntax conversion before widening constraints. Future DBAL major lines require a deliberate compatibility decision. +`stellarwp/foundation-database` provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress transaction behavior. `stellarwp/foundation-migrations` owns migration discovery, schema declarations and planning, history, failure handling, and migration commands. Developers use Doctrine directly for database expressions and advanced operations. Doctrine owns query construction, parameter binding, execution, result handling, schema inspection, and platform SQL generation. Foundation owns provider defaults, WordPress connection integration, resource naming, and operational guarantees. Foundation 2.0 targets Doctrine DBAL `^4.4` on PHP 8.3, with its MySQL 5.7.9+ and MariaDB 10.4.3+ server minimums. Keep these requirements visible in the Database and Migrations guides. DBAL 3 support is deferred until the Rector downgrade feature is implemented; it is not a Foundation 2.0 release requirement. Keep dependency-major differences confined to internal connection and schema boundaries, and validate dependency behavior separately from Rector syntax conversion before widening constraints. Future DBAL major lines require a deliberate compatibility decision. Use Doctrine's native `Connection`, query builder, results, exceptions, and schema objects where they supply the required behavior. Retire Foundation query builders, execution facades, SQL renderers, and gateway/capability contracts that merely duplicate those APIs as their callers are converted. Keep contracts for meaningful Foundation-owned policies and supported variations. Exposed Doctrine types and behavior are intentional compatibility commitments; do not imply that replacing Doctrine will be transparent to consumers. Preserve Foundation-specific exceptions for scope, migration, and managed-transaction failures. @@ -175,9 +175,9 @@ Every table implementation returns a stable unprefixed name from `unprefixedName Providers supply one shared managed Doctrine connection per container so tables, repositories, and transaction-owning services participate in the same session. Keep WordPress's native mysqli access inside the connection integration; route Foundation database execution through Doctrine rather than `wpdb::query()` or `dbDelta()`. Applications choose the transaction boundary; Foundation owns cleanup, terminal failure tracking, and commit acknowledgement. Never replay part of a failed transaction. A caught database execution failure must prevent successful completion, an escaping business exception must survive cleanup failure, and an uncertain commit must be reported distinctly. Nested success remains provisional until the outer transaction commits. A replacement connection must satisfy these documented guarantees, not merely extend Doctrine's connection class. -Use Doctrine schema objects and platform tools for schema declarations, inspection, comparison, and DDL generation. Retain Foundation policy only where it enforces migration ownership, safe recovery, or demonstrated normalization gaps. An initial migration owns the complete table definition; later migrations own explicit additions, full column modifications, and removals. After DDL succeeds without a ledger write, compatible additions and absent removals count as completed work, while incompatible existing declarations fail verification. Do not reconcile continuously against a mutable current-schema definition or remove unrelated columns and indexes. Preserve decimal and timestamp semantics when replacing schema machinery. DBAL adoption does not by itself establish Doctrine Migrations as a suitable replacement runner. +Use Doctrine schema objects and platform tools for schema declarations, inspection, comparison, and DDL generation. Plan the current migration against affected live tables, preserving unrelated schema. Do not replay applied migration declarations or reconcile historical schema drift. Preview carries simulated schema through pending operations. Keep the public blueprint API, explicit foreign keys and renames, and supported decimal/timestamp semantics. On failure, stop and leave the migration unrecorded; DDL may have committed, so developers must inspect and repair before retrying. Do not add automatic partial-migration recovery without a demonstrated need and a bounded design. -Migration IDs determine forward execution order and must remain stable across PHP namespace changes and Strauss scoping. Collect all provider contributions, sort globally in ascending byte-exact ID order, and run pending migrations under migration locking. Contribution order must not affect execution. Use target-version rollback in descending byte-exact ID order. Rollback operations only reverse applied migrations; only migration reconciliation may also apply pending IDs through a target. Developers own migration dependencies and selecting a safe rollback target; document that behavior rather than adding execution-order tracking or batch semantics solely to handle migrations introduced out of order. Generated timestamps provide sortable IDs; custom IDs remain valid and assume responsibility for their lexical position. Foundation owns migration registration, stable identities, WordPress integration, locking, and its documented recovery guarantees so application migrations declare their changes without assembling that infrastructure. Foundation owns declarative migration planning, recovery, execution, and history. Doctrine supplies schema comparison and platform SQL. Keep DBAL-specific schema translation internal so a future DBAL 3 downgrade does not change application migration declarations. +Migration IDs determine forward execution order and must remain stable across PHP namespace changes and Strauss scoping. Collect all provider contributions, sort globally in ascending byte-exact ID order, and run pending migrations under migration locking. Contribution order must not affect execution. Missing applied declarations remain visible in status but do not block forward work. Preflight only declarations selected for reversal before executing any reversal; refresh selects all applied migrations. Keep recommending migration history for fresh installations and rollback, without requiring old files for unrelated forward work. Use target-version rollback in descending byte-exact ID order. Rollback operations only reverse applied migrations; only migration reconciliation may also apply pending IDs through a target. Developers own migration dependencies and selecting a safe rollback target; document that behavior rather than adding execution-order tracking or batch semantics solely to handle migrations introduced out of order. Generated timestamps provide sortable IDs; custom IDs remain valid and assume responsibility for their lexical position. Foundation owns migration registration, stable identities, WordPress integration, locking, and its documented failure guarantees so application migrations declare their changes without assembling that infrastructure. Foundation owns migration planning, execution, and history. Doctrine supplies schema comparison and platform SQL. Keep DBAL-specific schema translation internal so a future DBAL 3 downgrade does not change application migration declarations. The same container and database services may be reused after `switch_to_blog()` between complete operations; already-built queries retain the physical names resolved when built. Managed transactions and migration operations capture their starting scope and reject site or connection changes at their operation boundaries. Migrations hold a MySQL/MariaDB named lock on their captured session, check scope and ownership before execution and ledger writes, and release only on the original session. A failed release discards that session. Migration execution never creates the optional application database-lock table. Applications must not issue DDL, manual transaction control, direct WordPress queries, or `switch_to_blog()` inside a managed transaction. Migrations must never switch sites, even temporarily. Keep application migration declarations independent of site scope so another supported scope does not require migration rewrites. diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index d5e0b48..caaf7db 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -103,7 +103,7 @@ Transactions and migrations capture their starting scope and reject detected sit Applications can replace `DatabaseScope` and `TableNameResolver` when they own a different naming policy. Register replacements after `DatabaseProvider`, before resolving database services. Replacing the Doctrine connection also requires preserving Foundation's documented transaction and migration guarantees, including terminal failure tracking and acknowledged commits. When using Foundation Migrations, a replacement must also supply a coherent `Database\Contracts\AdvisorySession` using the same connection: every execution must check ownership and retain terminal failures. Registering an unrelated lock implementation cannot supply those guarantees. A plain DBAL connection alone does not provide those guarantees. - + @@ -114,4 +114,4 @@ Applications can replace `DatabaseScope` and `TableNameResolver` when they own a Implementations acquire once without waiting, retain the original site and connection across DDL and nested transactions, check ownership before execution and completion, and preserve a caught database failure until the locked operation ends. An escaping callback exception takes precedence over release failure. Connection changes must never reconnect or replay protected work. Contention raises `AdvisoryLockContended`; failed or uncertain ownership raises `AdvisoryLockInterrupted`. -Database owns the supported DBAL constraint. When upgrading it, verify the migration schema APIs and recovery tests as well as queries and transactions. Foundation Migrations requires a compatible Database release; applications should update their Composer lock file and run their migration tests before deployment. +Database owns the supported DBAL constraint. When upgrading it, verify the migration schema APIs and failure-handling tests as well as queries and transactions. Foundation Migrations requires a compatible Database release; applications should update their Composer lock file and run their migration tests before deployment. diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index ec04461..731b60b 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -201,7 +201,7 @@ Once a migration has been applied anywhere, retain its filename and declarations ## Write schema changes -`up()` and `down()` declare schema changes. Foundation may replay them during planning, including previews. Keep queries and application work in the separate [data callback](#transform-existing-data). +`up()` and `down()` declare schema changes. Foundation evaluates the selected declaration during planning, including previews. Keep queries and application work in the separate [data callback](#transform-existing-data). ### Choose column types @@ -238,7 +238,7 @@ $table->mediumText( 'content' )->nullable(); Columns are non-nullable unless you call `nullable()`. Existing modifiers such as `default()`, `comment()`, and `change()` apply to the new types too. A `char()` length is a storage choice, not application validation that a supplied code has exactly that many characters. `useCurrent()` and `useCurrentOnUpdate()` are intended for `dateTime()` columns. -[MySQL](https://dev.mysql.com/doc/refman/8.0/en/json.html) stores JSON natively; [MariaDB](https://mariadb.com/docs/server/reference/data-types/string-data-types/json) uses text with JSON validation. Foundation preserves that distinction through migration retries and renames. For compatibility with MySQL 5.7, supply a JSON value on insertion or make the column nullable instead of declaring a non-null JSON default. When writing PHP arrays through a table or Doctrine connection, bind the column as `Doctrine\DBAL\Types\Types::JSON` so Doctrine encodes it. Query results are not automatically decoded; see [queries and parameter types](/components/database/query-builder/). +[MySQL](https://dev.mysql.com/doc/refman/8.0/en/json.html) stores JSON natively; [MariaDB](https://mariadb.com/docs/server/reference/data-types/string-data-types/json) uses text with JSON validation. Foundation preserves that distinction through alterations and renames. For compatibility with MySQL 5.7, supply a JSON value on insertion or make the column nullable instead of declaring a non-null JSON default. When writing PHP arrays through a table or Doctrine connection, bind the column as `Doctrine\DBAL\Types\Types::JSON` so Doctrine encodes it. Query results are not automatically decoded; see [queries and parameter types](/components/database/query-builder/). ### Change a column @@ -284,7 +284,7 @@ public function down( Blueprint $schema ): void { Remove the generated `IrreversibleMigration` import and annotation when supplying this inverse. `renameColumn()` retains the column's values and complete definition, including its default, nullability, and comment. Indexes and foreign keys continue to refer to the renamed column. Use a distinct destination name that does not already exist. Column renames run before other alterations on that table; use the new name in a `change()` declaration. When creating a new table, declare columns with their final names. -Keep renamed objects identifiable until the migration is recorded. Put reuse of an old name or removal of the renamed object in a later migration. Foundation rejects ambiguous sequences, such as swapping two column names, because a retry cannot distinguish their completed state. +Renames run in declaration order. You can reuse an old column name after renaming it, including swapping names through a temporary name. If execution stops partway through, inspect which renames completed and repair the partial change before retrying. Column renames work across the supported database versions. Foundation uses native `RENAME COLUMN` when available and otherwise uses `CHANGE` with the existing column definition. Your migration declaration stays the same. @@ -325,7 +325,9 @@ Remove the generated irreversible exception import and annotation. Both argument Preview and apply with `wp your-plugin migrate --run --dry-run` and `wp your-plugin migrate --run`. Update the application table class's `unprefixedName()` to return `'your_plugin_articles'`, and use that name in new migrations. Keep all earlier migration filenames and historical table names unchanged. Coordinate these application changes with migration execution; application queries must use the name currently present in the database. -Existing foreign keys retain their physical constraint names after a table rename. Continue using their original logical names with `dropForeignKey()` on the renamed table. If a later migration creates a different table under the old table name, give its foreign keys different logical names from those still owned by the renamed table, so their physical names do not collide. +If you recreate the old table while the renamed table still exists, choose different logical foreign-key names for the new table. For example, use `current_order` on the new table when the archived table retains `order`. Constraint names must be unique across the database on supported MySQL and MariaDB versions. + +Existing foreign keys retain their physical constraint names after a table rename. Continue using their original logical names with `dropForeignKey()` on the renamed table. ### Remove a column @@ -386,7 +388,7 @@ Keep the generated `down()` that drops `your_plugin_order_items`. The later migr `order` is the relationship's stable logical name within the items table. Foundation scopes the physical constraint name to that table, so another table can also use `order`. Pass the same logical name to `dropForeignKey()` later. `references()` takes the historical, unprefixed application table name; Foundation resolves both tables for the current WordPress site. -Foreign-key drift errors include the logical name, such as `order`, alongside the database constraint name, such as `fk_…`. Use the logical name to locate your migration declaration and the database name to inspect the constraint. Constraints without a known declaration are identified by their database name alone. +Generated constraint names contain separate table and logical identity components. This lets `dropForeignKey('order')` locate the same relationship after a table rename without loading earlier migrations. An existing logical name must be dropped explicitly before adding its replacement. :::caution[Keep foreign keys between application-owned tables] Use this workflow for tables your application owns. For relationships to WordPress posts or users, store the ID in an indexed column and coordinate cleanup through WordPress's deletion hooks. @@ -394,7 +396,7 @@ Use this workflow for tables your application owns. For relationships to WordPre `references()` applies the current site's prefix. On multisite, `references( 'users', 'ID' )` would target a site table such as `wp_2_users`, while WordPress uses a shared users table. Even for site-specific core tables such as `posts`, a restrictive foreign key can block WordPress deletion after related data has already been removed. A cascading foreign key deletes your application's rows directly, bypassing its PHP cleanup. See WordPress's [table naming](https://developer.wordpress.org/reference/classes/wpdb/tables/) and [post deletion workflow](https://developer.wordpress.org/reference/functions/wp_delete_post/). ::: -If you change WordPress's table prefix, rename the application's tables and migration ledger together before running migrations again. Keep historical migration declarations unchanged. Existing constraint names can retain the old prefix's identity; Foundation recognizes a uniquely matching historical relationship by its columns, referenced table, and update/delete actions. Later migrations can still change or remove it. If multiple constraints make that identity ambiguous, migration planning stops with `IncompatibleSchema` so you can inspect the named constraints before retrying. +If you change WordPress's table prefix, rename the application's tables and migration ledger together before running migrations again. Keep historical migration declarations unchanged. Preserve existing constraint names: their logical identity component lets later declarations locate the relationship on its current table. Externally renamed constraints require explicit repair before a declaration can address them by their original logical name. Declare columns and indexes explicitly. The local and referenced column types must match, including integer size and unsignedness. Reference a primary or unique key. Foundation-created tables use InnoDB, which enforces these constraints. @@ -442,7 +444,7 @@ $table->foreignKey( 'order', 'order_id' ) ->restrictOnDelete(); ``` -Its `down()` can drop and redeclare the relationship with the original action. To remove a relationship entirely, use only `dropForeignKey( 'order' )`. This retains its columns and indexes; remove those separately when that migration owns their removal. Remove dependent relationships or child tables before dropping a referenced parent table. +Its `down()` can drop and redeclare the relationship with the original action. To remove a relationship entirely, use only `dropForeignKey( 'order' )`. This retains its columns and indexes; remove those separately when that migration owns their removal. Remove dependent relationships or child tables before dropping a referenced parent table. Table operations execute in declaration order, including within a single migration. Create referenced parent tables before their children, and reverse that order when removing them. #### Reference a composite key @@ -480,7 +482,7 @@ Omitting both produces a generic migration with an empty `up()` and an irreversi Columns support `nullable()`, `notNull()`, `unsigned()`, `default()`, `comment()`, and `change()`. Date/time declarations additionally support `useCurrent()` and `useCurrentOnUpdate()`. Use strings for exact decimal defaults. Table declarations support `primary(...$columns)` and `comment()`. -`up()` and `down()` must be pure schema declarations. Foundation may replay them many times, including during previews. Do not query the live database, perform application work, or put existence guards in them. +`up()` and `down()` must be pure schema declarations. Foundation evaluates the selected declaration during planning and again if you later execute a previewed migration. Previously applied declarations are not replayed. Do not query the live database, perform application work, or put existence guards in them. ### Transform existing data @@ -547,7 +549,7 @@ public function describe(): string { } ``` -`migrate($target)` and `--run --to=` reverse applied IDs above the target in descending order, then apply pending IDs through it in ascending order. `latest` applies all pending migrations. `rollbackTo($target)` and `--rollback --to=` only reverse applied IDs above the target; any pending IDs, including the target itself, remain pending. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied migration files must be restored before execution can continue. +`migrate($target)` and `--run --to=` reverse applied IDs above the target in descending order, then apply pending IDs through it in ascending order. `latest` applies all pending migrations. `rollbackTo($target)` and `--rollback --to=` only reverse applied IDs above the target; any pending IDs, including the target itself, remain pending. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied files appear in status but do not block new forward migrations. Before rollback, Foundation checks that every migration selected for reversal is available; unrelated missing files do not block it. Refresh requires every applied declaration because it reverses all migrations. Retain migration files for fresh installations and rollbacks; deleting history is not a supported schema-squashing workflow. :::danger[Rollback can delete data] `down()` is application code. Reversing an initial migration drops its complete table, including rows and additions made later. Review the inverse and take the application's required backup before rolling back. `--refresh`, `--run --to=0`, and `--rollback --to=0` prompt for confirmation because they reverse every applied migration. Use `--yes` only when that data loss is intended. `--run --to=0 --dry-run` previews the reversal without prompting or executing it. @@ -555,28 +557,32 @@ public function describe(): string { ### Interrupted schema changes -MySQL DDL can commit before the history write. A retry compares the actual schema with the migration's declared change: compatible existing additions and already-absent removals count as completed work. An interrupted index replacement resumes its missing work. Undeclared columns, indexes, and constraints are retained during alterations. +MySQL and MariaDB DDL can commit before a later statement, data callback, or history write fails. Foundation stops on failure and leaves that migration unrecorded. Earlier migrations that completed remain recorded, and earlier statements within the failed migration may already have changed the database. + +Foundation plans the current migration against the affected live tables. Alterations preserve unrelated columns, indexes, and relationships; the runner does not replay historical declarations or check unrelated tables against old definitions. Adding an existing object or removing a missing one fails rather than automatically completing an interrupted migration. -Retries also recognize completed table and column renames, then continue any remaining work without renaming them again. A conflicting destination or a missing source and destination stops execution with `IncompatibleSchema`; inspect the live names before retrying. +:::caution[Inspect failures before retrying] +Pause automatic upgrade attempts for this application and site. Inspect the failed migration, live schema, data effects, and history. Either undo its partial work and correct the failure before rerunning, or verify and complete all intended work before deliberately repairing the ledger. Do not blindly retry: data callbacks and completed DDL may run again. +::: -An incompatible existing declaration stops the run with `IncompatibleSchema`; Foundation does not silently reconcile unrelated drift. Inspect and correct the mismatch before retrying. `LedgerFailure` means a history write failed after migration work; fix the storage failure and retry with the same declarations. Any data callback must tolerate repetition. +`LedgerFailure` means migration work completed but its history update failed. Fixing the storage error alone does not reconcile the ledger; verify the completed schema and data effects before repairing history. A failed rollback likewise leaves its history entry in place even if some inverse statements committed. ### Repair inconsistent history -If the ledger itself is wrong, pause all application upgrade triggers and migration workers for the affected site and take a backup. Restore missing migration files first, then compare the ledger's exact IDs with the live schema and each migration's schema and data effects. +If the ledger itself is wrong, pause all application upgrade triggers and migration workers for the affected site and take a backup. Restore any missing declarations needed to understand or reverse the affected work, then compare the ledger's exact IDs with the live schema and each migration's schema and data effects. Use your database administration tool against the configured physical ledger table, including its WordPress site prefix. For a confirmed bookkeeping error: - Insert the migration's exact `version` only after verifying that its complete `up()` and any `migrate()` data callback already succeeded. The ledger supplies `applied_at` automatically. - Delete its `version` row only after verifying that its complete inverse has already been performed and that recorded dependent migrations remain valid. -These are deliberate manual SQL changes, not schema repairs. Do not change history to suppress a genuine `IncompatibleSchema` mismatch. Restore the intended schema first when history is accurate. After correcting history, inspect status and preview the next run before resuming upgrades. Keep an operational record of the repair. +These are deliberate manual SQL changes, not schema repairs. Do not record an incomplete migration merely to suppress an error. Verify or finish its intended schema and data work first. After correcting history, inspect status and preview the next run before resuming upgrades. Keep an operational record of the repair. ### Concurrent upgrades One database advisory lock covers planning, schema execution, data callbacks, and history writes. The lock survives DDL commits and lasts until release or session termination. It has no lease TTL to configure. `MigrationAlreadyRunning` means another session is migrating this application's site ledger: defer and retry after it finishes. This is distinct from a database failure. -`MigrationInterrupted` means ownership or the starting session could not be confirmed. The database layer reports `AdvisoryLockInterrupted` during a data callback; the migrator translates it to `MigrationInterrupted` when it escapes the run. Catching it inside a callback does not make the run successful. Foundation stops and releases only the original session's lock where possible. Never change sites or sessions during a migration, even temporarily. Foundation migration exceptions live under `StellarWP\Foundation\Migrations\Exceptions` and extend `MigrationException`, which extends `StellarWP\Foundation\Database\Exceptions\DatabaseException`. Catch `MigrationException` for shared reporting, and use the specific exception when choosing whether to defer, retry, or stop. Native SQL failures still use Doctrine exceptions; application data callbacks can propagate their own exceptions. +`MigrationInterrupted` reports a declaration that cannot be applied to the current schema, an unavailable migration required for rollback, or a lost operation scope/session. Inspect its message and underlying exception before deciding how to repair the failure. The database layer reports `AdvisoryLockInterrupted` during a data callback; the migrator translates it to `MigrationInterrupted` when it escapes the run. Catching it inside a callback does not make the run successful. Foundation stops and releases only the original session's lock where possible. Never change sites or sessions during a migration, even temporarily. Foundation migration exceptions live under `StellarWP\Foundation\Migrations\Exceptions` and extend `MigrationException`, which extends `StellarWP\Foundation\Database\Exceptions\DatabaseException`. Catch `MigrationException` for shared reporting, and use the specific exception when choosing whether to defer, retry, or stop. Native SQL failures still use Doctrine exceptions; application data callbacks can propagate their own exceptions. All migration participants must reach the same primary database server. Advisory locks are local to that server; a proxy that moves statements between sessions or servers cannot provide this guarantee. Preview and status are observations and can become stale before a later run. @@ -658,4 +664,4 @@ The registration supplies the ID. IDs are compared in ascending byte order. They Copy the package stubs into `foundation/stubs/database/` to customize generated code. Start with the [CLI stub guide](/tooling/foundation-cli/#override-package-stubs). Table namespaces remain configurable through generator settings; migration placement is controlled by `migrations.path`. -Test create, alteration, rollback, and retry against real database tables. Include a failure after successful DDL but before history recording, then verify that retry preserves existing rows and records the migration once. Register a fresh container per test and use application-specific test table names. +Test create, alteration, rollback, and preview against real database tables. Include failures after an earlier DDL statement and before history recording. Verify that the failed migration remains unrecorded, existing data survives, later migrations do not run, and deliberate repair allows a subsequent run. Register a fresh container per test and use application-specific test table names. diff --git a/src/Migrations/Contracts/Migration.php b/src/Migrations/Contracts/Migration.php index 0132ccc..d520fdb 100644 --- a/src/Migrations/Contracts/Migration.php +++ b/src/Migrations/Contracts/Migration.php @@ -8,8 +8,8 @@ * Declare one historical schema change through Foundation's blueprint vocabulary. * * Declarations must be pure: no queries, no conditionals on the live database. - * The runner replays them in memory to compute desired states and decides what - * remains to be executed, so migrations never need existence guards. + * The runner evaluates the current declaration against live tables, or simulated tables + * during preview. A failed migration requires inspection and repair before retry. */ interface Migration { diff --git a/src/Migrations/Exceptions/IncompatibleSchema.php b/src/Migrations/Exceptions/IncompatibleSchema.php deleted file mode 100644 index 3b25e57..0000000 --- a/src/Migrations/Exceptions/IncompatibleSchema.php +++ /dev/null @@ -1,24 +0,0 @@ -plan($target, $applied, $applyPending) as [$id, $reverse]) { $migration = $this->migrations->get($id); - $afterIds = $reverse ? array_values(array_diff($applied, [$id])) : array_merge($applied, [$id]); - $change = $this->planner->plan($applied, $id, $reverse, $simulated, $simulatedNames); + $change = $this->planner->plan($id, $reverse, $simulated, $simulatedNames); $sql = $change->sql; if ($execute) { @@ -216,14 +213,13 @@ private function run(string $target, bool $execute, bool $applyPending = true): try { $reverse ? $this->history->remove($id) : $this->history->record($id); } catch (Throwable $failure) { - throw new Exceptions\LedgerFailure('Migration ' . $id . ' changed the schema but could not update its history; retry the migration.', 0, $failure); + throw new Exceptions\LedgerFailure('Migration ' . $id . ' completed its work but could not update its history. Inspect the schema and data, then repair the history or undo the work before retrying.', 0, $failure); } } else { $simulated = $change->schema; $simulatedNames = array_values(array_unique(array_merge($simulatedNames, array_map('strtolower', $change->tableNames)))); } - $applied = $afterIds; $steps[] = new Step($id, self::displayName($migration, $id), $reverse, $sql, ! $reverse && $migration instanceof MigratesData); } @@ -243,7 +239,7 @@ private function plan(string $target, array $applied, bool $applyPending): array static fn (string $id): bool => $target === self::NONE || strcmp($id, $target) > 0, )); rsort($reverse, SORT_STRING); - $this->assertKnown($applied); + $this->assertKnown($reverse); $plan = array_map(static fn (string $id): array => [$id, true], $reverse); if ($target === self::NONE || ! $applyPending) { @@ -265,13 +261,13 @@ private function plan(string $target, array $applied, bool $applyPending): array private function assertKnown(array $ids): void { foreach ($ids as $id) { if (! $this->migrations->has($id)) { - throw new MigrationInterrupted('Restore the missing migration before running migrations: ' . $id); + throw new MigrationInterrupted('Restore the missing migration before reversing it: ' . $id); } } } private function assertTarget(string $target): void { - if (! in_array($target, [self::NONE, self::LATEST], true) && ! $this->migrations->has($target)) { + if (! in_array($target, [self::NONE, self::LATEST], true) && ! $this->migrations->has($target) && ! array_key_exists($target, $this->history->applied())) { throw new InvalidArgumentException('Unknown migration target: ' . $target); } } diff --git a/src/Migrations/README.md b/src/Migrations/README.md index 0046b16..9e381c4 100644 --- a/src/Migrations/README.md +++ b/src/Migrations/README.md @@ -3,7 +3,7 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -Declarative WordPress migrations with automatic file discovery, schema recovery, +Declarative WordPress migrations with automatic file discovery, SQL preview, version history, and database advisory locking. ## Installation @@ -21,4 +21,4 @@ first to enable the migration command, or inject `Migrator` for programmatic upg ## Documentation See the [Migrations guide](https://foundation.nexcess.dev/components/migrations/) -for setup, generating tables and migrations, deployment, rollback, and recovery. +for setup, generating tables and migrations, deployment, rollback, and handling interrupted upgrades. diff --git a/src/Migrations/Schema/Blueprint.php b/src/Migrations/Schema/Blueprint.php index 7174d9d..5af39f5 100644 --- a/src/Migrations/Schema/Blueprint.php +++ b/src/Migrations/Schema/Blueprint.php @@ -19,9 +19,16 @@ */ final class Blueprint { - /** @var list Deferred mutations of the Doctrine schema, in declaration order. */ + /** + * @var list Deferred mutations of the Doctrine schema, in declaration order. + */ private array $operations = []; + /** + * @var list Physical tables used by this declaration. + */ + private array $tableNames = []; + /** * @param array $tableOptions Default engine/charset options for created tables. * @@ -50,7 +57,6 @@ public function create(Table|string $table): TableBlueprint { throw TableAlreadyExists::new($name); } - unset($this->state->tableOrigins[strtolower($name)], $this->state->foreignKeyNames[strtolower($name)]); $table = DoctrineTable::editor()->setUnquotedName($name)->setOptions($this->tableOptions); $this->replaceTable($blueprint->applyTo($table, $this->state, $this->names)); }; @@ -105,7 +111,6 @@ public function drop(Table|string $table): void { $name = $this->resolve($table); $this->operations[] = function () use ($name): void { $this->state->schema->dropTable($name); - unset($this->state->tableOrigins[strtolower($name)], $this->state->foreignKeyNames[strtolower($name)]); foreach (array_keys($this->state->timestamps) as $key) { if (str_starts_with($key, strtolower($name) . '.')) { @@ -121,20 +126,42 @@ public function drop(Table|string $table): void { * @return non-empty-string */ public function resolve(Table|string $table): string { - return $this->names->tableName($table); + $name = $this->names->tableName($table); + $this->tableNames[] = $name; + + return $name; + } + + /** + * Return the tables to inspect before applying this declaration. + * + * @internal + * + * @return list + */ + public function tableNames(): array { + return array_values(array_unique($this->tableNames)); } /** * Mutate the underlying Doctrine schema with the collected declarations. * * @internal Called by the runner after up()/down() returns. + * + * @return list Snapshots after each operation, in declaration order. */ - public function apply(): void { + public function apply(): array { + $steps = []; + foreach ($this->operations as $operation) { + $this->state->renames = []; $operation(); + $steps[] = clone $this->state; } $this->operations = []; + + return $steps; } private function replaceTable(DoctrineTable $table): void { diff --git a/src/Migrations/Schema/ForeignKeyComparator.php b/src/Migrations/Schema/ForeignKeyComparator.php index 116b6b7..c0cff55 100644 --- a/src/Migrations/Schema/ForeignKeyComparator.php +++ b/src/Migrations/Schema/ForeignKeyComparator.php @@ -6,11 +6,8 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Schema\Comparator; use Doctrine\DBAL\Schema\ComparatorConfig; -use Doctrine\DBAL\Schema\ForeignKeyConstraint; -use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; -use StellarWP\Foundation\Migrations\Exceptions\IncompatibleSchema; /** * Preserve foreign-key identity while retaining Doctrine's platform comparison. @@ -33,36 +30,6 @@ public function __construct( parent::__construct($platform, $config); } - /** - * Retain live constraint names when a prefix change altered historical physical identities. - * - * Only previously declared relationships with Foundation-generated names may match by structure. - * Update private planning snapshots, leaving the live database and logical names unchanged. - * - * @throws IncompatibleSchema When ambiguous matches or changed actions prevent identifying ownership. - */ - public function alignForeignKeyNames(SchemaState $before, SchemaState $actual, SchemaState $after, string $id): void { - $names = []; - - foreach ($before->schema->getTables() as $table) { - $name = $table->getObjectName()->getUnqualifiedName()->getValue(); - - if (! $actual->schema->hasTable($name)) { - continue; - } - - $names[strtolower($name)] = $this->historicalNames( - $actual->schema->getTable($name), - $before, - $after->schema->hasTable($name) ? $after->schema->getTable($name)->getForeignKeys() : [], - $id, - ); - } - - $this->applyForeignKeyNames($before, $names); - $this->applyForeignKeyNames($after, $names); - } - /** * Compare columns and indexes natively, then match foreign keys by identity. * @@ -112,132 +79,4 @@ public function compareTables(Table $oldTable, Table $newTable): TableDiff { droppedForeignKeys: $dropped, ); } - - /** - * Match missing historical names uniquely, reserving every exact identity first. - * - * @param array $after - * - * @return array Historical name to live name. - */ - private function historicalNames(Table $actual, SchemaState $before, array $after, string $id): array { - $table = $actual->getObjectName()->getUnqualifiedName()->getValue(); - $historical = $before->schema->getTable($table)->getForeignKeys(); - $live = $actual->getForeignKeys(); - $candidates = array_diff_key($live, $historical, $after); - $names = []; - - foreach (array_diff_key($historical, $live) as $name => $constraint) { - $label = $before->foreignKeyLabel($table, $name); - $matches = $this->matchingNames($constraint, $after[$name] ?? null, $candidates, $label); - - if ($matches === []) { - continue; - } - - $matched = $matches[0]; - - if (count($matches) !== 1 || in_array($matched, $names, true)) { - throw IncompatibleSchema::forChange( - $id, - $table, - 'ambiguous foreign-key identity for ' . $label . ' among ' . implode(', ', $matches), - ); - } - - $names[$name] = $matched; - } - - return $names; - } - - /** - * Find managed names matching the historical definition or its completed replacement. - * - * @param array $candidates - * - * @throws IncompatibleSchema When changed actions prevent identifying a historical relationship. - * - * @return list - */ - private function matchingNames(ForeignKeyConstraint $before, ?ForeignKeyConstraint $after, array $candidates, string $label): array { - $names = []; - $conflicts = []; - - foreach ($candidates as $candidate) { - $name = $candidate->getObjectName()?->getIdentifier()->getValue(); - - if ($name === null || preg_match('/^fk_[0-9a-f]{40}$/D', $name) !== 1) { - continue; - } - - // A replacement may have completed before its ledger write failed. - if (! $this->diffForeignKey($before, $candidate) || ($after !== null && ! $this->diffForeignKey($after, $candidate))) { - $names[] = $name; - - continue; - } - - // Ignore actions only to detect uncertain ownership, never to accept a match. - $expectedActions = $candidate->edit() - ->setOnUpdateAction($before->getOnUpdateAction()) - ->setOnDeleteAction($before->getOnDeleteAction()) - ->create(); - - if (! $this->diffForeignKey($before, $expectedActions)) { - $conflicts[] = $name; - } - } - - if ($names === [] && $conflicts !== []) { - throw new IncompatibleSchema(sprintf( - 'Cannot identify historical foreign key %s: constraints %s have matching columns and references but different update/delete actions. Repair the schema or the ledger before retrying.', - $label, - implode(', ', $conflicts), - )); - } - - return $names; - } - - /** - * Apply physical aliases without changing definitions or implicit supporting indexes. - * - * @param array> $names - */ - private function applyForeignKeyNames(SchemaState $state, array $names): void { - $tables = []; - - foreach ($state->schema->getTables() as $table) { - $tableName = strtolower($table->getObjectName()->getUnqualifiedName()->getValue()); - $aliases = $names[$tableName] ?? []; - - if ($aliases === []) { - $tables[] = $table; - - continue; - } - - $constraints = []; - - foreach ($table->getForeignKeys() as $name => $constraint) { - $constraints[] = isset($aliases[$name]) ? $constraint->edit()->setUnquotedName($aliases[$name])->create() : $constraint; - - if (isset($aliases[$name], $state->foreignKeyNames[$tableName][$name])) { - $state->foreignKeyNames[$tableName][strtolower($aliases[$name])] = $state->foreignKeyNames[$tableName][$name]; - unset($state->foreignKeyNames[$tableName][$name]); - } - } - - $indexes = array_diff_key($table->getIndexes(), [ - 'primary' => true, - ]); - $tables[] = $table->edit() - ->setIndexes(...array_values($indexes)) - ->setForeignKeyConstraints(...$constraints) - ->create(); - } - - $state->schema = new Schema($tables); - } } diff --git a/src/Migrations/Schema/RenamePlanner.php b/src/Migrations/Schema/RenamePlanner.php index 92a2fb6..2bd3721 100644 --- a/src/Migrations/Schema/RenamePlanner.php +++ b/src/Migrations/Schema/RenamePlanner.php @@ -3,8 +3,6 @@ namespace StellarWP\Foundation\Migrations\Schema; use Doctrine\DBAL\Connection; -use InvalidArgumentException; -use StellarWP\Foundation\Migrations\Exceptions\IncompatibleSchema; use StellarWP\Foundation\Migrations\Schema\Renames\Contracts\ColumnRename; use StellarWP\Foundation\Migrations\Schema\ValueObjects\Rename; @@ -25,112 +23,21 @@ public function __construct( } /** - * Recognize completed renames, then align historical and live names with the target. - * - * Both supplied snapshots are private to this planning operation. No SQL is executed. - * - * @throws IncompatibleSchema When live names cannot identify a completed prefix. - * @throws InvalidArgumentException When a declaration reuses or removes renamed names ambiguously. + * Emit each declared rename and align the comparison snapshot with its new names. * * @return list */ - public function plan(SchemaState $before, SchemaState $actual, SchemaState $after, string $id): array { - if ($after->renames === []) { - return []; - } - - if (! $this->isRecoverable($before, $after)) { - throw new InvalidArgumentException('Migration ' . $id . ' has ambiguous renames; put name reuse or removal in a later migration.'); - } - - $completed = $this->completedPrefix($actual, $after->renames); - - if ($completed === null) { - throw new IncompatibleSchema('Migration ' . $id . ' cannot resume its renames: a source is missing, a destination conflicts, or names changed out of order.'); - } - - foreach (array_reverse(array_slice($after->renames, 0, $completed)) as $rename) { - $actual->rename($rename->inverse()); - } - + public function plan(SchemaState $before, SchemaState $after): array { $sql = []; - foreach ($after->renames as $position => $rename) { - if ($position >= $completed) { - $sql = array_merge($sql, $this->sql($rename, $actual)); - } - + foreach ($after->renames as $rename) { + $sql = array_merge($sql, $this->sql($rename, $before)); $before->rename($rename); - $actual->rename($rename); } return $sql; } - /** - * Require every interruption point to have a distinguishable set of names. - */ - private function isRecoverable(SchemaState $before, SchemaState $after): bool { - $probe = clone $before; - - foreach ($after->renames as $position => $rename) { - if (! $this->applySequence($probe, [ - $rename, - ]) || $this->completedPrefix($probe, $after->renames) !== $position + 1) { - return false; - } - } - - return $this->completedPrefix($after, $after->renames) === count($after->renames); - } - - /** - * Find how much of the ordered rename SQL already completed before an interruption. - * - * Rewind each possible prefix on a private snapshot, then check the complete sequence. - * This also handles a table rename followed by column renames, and chained names. - * - * @param list $renames - */ - private function completedPrefix(SchemaState $actual, array $renames): ?int { - for ($completed = 0; $completed <= count($renames); $completed++) { - $probe = clone $actual; - $rewind = array_map(static fn (Rename $rename): Rename => $rename->inverse(), array_reverse(array_slice($renames, 0, $completed))); - - if ($this->applySequence($probe, array_merge($rewind, $renames))) { - return $completed; - } - } - - return null; - } - - /** - * Apply an unambiguous sequence to a private snapshot. - * - * @param list $renames - */ - private function applySequence(SchemaState $state, array $renames): bool { - foreach ($renames as $rename) { - if (! $this->exists($state, $rename, $rename->from) || $this->exists($state, $rename, $rename->to)) { - return false; - } - - $state->rename($rename); - } - - return true; - } - - private function exists(SchemaState $state, Rename $rename, string $name): bool { - if ($rename->table === null) { - return $state->schema->hasTable($name); - } - - return $state->schema->hasTable($rename->table) - && $state->schema->getTable($rename->table)->hasColumn($name); - } - /** * Keep each platform statement separate and in execution order. * diff --git a/src/Migrations/Schema/SchemaInspector.php b/src/Migrations/Schema/SchemaInspector.php index b494db9..2fdb43a 100644 --- a/src/Migrations/Schema/SchemaInspector.php +++ b/src/Migrations/Schema/SchemaInspector.php @@ -37,6 +37,10 @@ public function __construct( * @throws Exception When the database schema or supplemental metadata cannot be read. */ public function inspect(array $names, ?SchemaState $overlay = null, array $simulated = []): SchemaState { + foreach ($overlay?->schema->getTables() ?? [] as $table) { + $names[] = $table->getObjectName()->toString(); + } + $manager = $this->db->createSchemaManager(); $tables = []; $timestamps = $this->timestampAttributes($names); diff --git a/src/Migrations/Schema/SchemaPlanner.php b/src/Migrations/Schema/SchemaPlanner.php index dc64f7b..0a2752c 100644 --- a/src/Migrations/Schema/SchemaPlanner.php +++ b/src/Migrations/Schema/SchemaPlanner.php @@ -3,16 +3,10 @@ namespace StellarWP\Foundation\Migrations\Schema; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Schema\Column; -use Doctrine\DBAL\Schema\ColumnEditor; -use Doctrine\DBAL\Schema\Index; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaDiff; use Doctrine\DBAL\Schema\SchemaException; -use Doctrine\DBAL\Schema\Table; -use Doctrine\DBAL\Schema\TableDiff; use StellarWP\Foundation\Database\Contracts\TableNameResolver; -use StellarWP\Foundation\Migrations\Exceptions\IncompatibleSchema; use StellarWP\Foundation\Migrations\Exceptions\IrreversibleMigration; use StellarWP\Foundation\Migrations\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Migrations\MigrationCollection; @@ -20,14 +14,14 @@ use StellarWP\Foundation\Migrations\Schema\ValueObjects\SchemaPlan; /** - * Compute desired schema states from migration declarations and compare them with the live schema. + * Plan declared operations against the current database schema. * * @internal */ final readonly class SchemaPlanner { /** - * Receive the shared connection, naming policy, and historical declarations. + * Receive the shared connection, naming policy, and migration declarations. * * @param array $tableOptions Defaults such as charset/collation applied to created tables. */ @@ -43,191 +37,60 @@ public function __construct( } /** - * Authorize only the remaining changes declared by one historical migration. + * Plan the current declaration against live tables or earlier preview results. * - * @param list $applied * @param list $simulatedNames Tables already decided by an earlier preview step. * - * @throws IncompatibleSchema When live drift would require undeclared changes. - * @throws \Throwable When a declaration or schema inspection fails. + * @throws \Throwable When a declaration is invalid or schema inspection fails. */ - public function plan(array $applied, string $id, bool $reverse, ?SchemaState $simulated = null, array $simulatedNames = []): SchemaPlan { - $before = $this->replay($applied); - $after = $reverse ? $this->reverse($before, $id) : $this->replay([ - ...$applied, - $id, - ], $id); - $comparator = $this->comparators->create(); - $names = $this->tableNames($before, $after); + public function plan(string $id, bool $reverse, ?SchemaState $simulated = null, array $simulatedNames = []): SchemaPlan { + $state = new SchemaState(new Schema()); + $blueprint = new Blueprint($state, $this->names, $this->tableOptions); + $migration = $this->migrations->get($id); - foreach ($after->renames as $rename) { - if ($rename->table === null) { - $names[] = $rename->from; - $names[] = $rename->to; - } - } - - $names = array_values(array_unique($names)); - $actual = $this->inspector->inspect($names, $simulated, $simulatedNames); - $renameSql = $this->renames->plan($before, $actual, $after, $id); - $comparator->alignForeignKeyNames($before, $actual, $after, $id); - $expected = $comparator->compareSchemas($before->schema, $after->schema); - $desired = $this->preserveUndeclared($actual, $before, $after); - $remaining = $comparator->compareSchemas($actual->schema, $desired->schema); - $this->assertContained($remaining, $expected, $before, $after, $id); - $sql = array_merge( - $renameSql, - $this->sql($remaining), - $this->timestampSql($actual, $desired, $remaining, $this->timestampChanges($before, $after), $id), - $this->commentSql($actual, $before, $after, $id), - ); - - return new SchemaPlan($desired, $sql, $names); - } - - /** - * Replay the given IDs' up() declarations in byte order on an empty schema. Pure PHP; no queries. - * - * @param list $ids - */ - private function replay(array $ids, ?string $renamedBy = null): SchemaState { - $schema = new SchemaState(new Schema()); - $renames = []; - sort($ids, SORT_STRING); - - foreach ($ids as $id) { - $schema->renames = []; - $blueprint = new Blueprint($schema, $this->names, $this->tableOptions); - $this->migrations->get($id)->up($blueprint); - $this->apply($blueprint, $id); - - if ($id === $renamedBy) { - $renames = $schema->renames; - } + try { + $reverse ? $migration->down($blueprint) : $migration->up($blueprint); + } catch (IrreversibleMigration $failure) { + throw new IrreversibleMigration(sprintf('Migration "%s" cannot be reversed: %s', $id, $failure->getMessage()), 0, $failure); } - $schema->renames = $renames; - - return $schema; - } + $names = $blueprint->tableNames(); + $before = $this->inspector->inspect($names, $simulated, $simulatedNames); + $state->schema = clone $before->schema; + $state->timestamps = $before->timestamps; + $comparator = $this->comparators->create(); + $sql = []; - private function apply(Blueprint $blueprint, string $id): void { try { - $blueprint->apply(); + foreach ($blueprint->apply() as $after) { + $renameSql = $this->renames->plan($before, $after); + $diff = $comparator->compareSchemas($before->schema, $after->schema); + $sql = array_merge( + $sql, + $renameSql, + $this->db->getDatabasePlatform()->getAlterSchemaSQL($diff), + $this->timestampSql($before, $after, $diff), + $this->commentSql($before, $after), + ); + $before = $after; + } } catch (SchemaException $failure) { throw new MigrationInterrupted(sprintf( - 'Migration %s cannot be applied to the schema that recorded history produces (%s). Check the declaration and migration order, or repair the ledger.', + 'Migration %s cannot apply its declaration to the current schema (%s). Inspect and repair any partially applied work before retrying.', $id, $failure->getMessage(), ), 0, $failure); } - } - /** - * Apply one migration's down() to a copy of the schema it was applied on. - * - * @throws MigrationInterrupted When the inverse cannot be applied to recorded history. - */ - private function reverse(SchemaState $before, string $id): SchemaState { - $after = clone $before; - $blueprint = new Blueprint($after, $this->names, $this->tableOptions); - - try { - $this->migrations->get($id)->down($blueprint); - } catch (IrreversibleMigration $failure) { - throw new IrreversibleMigration(sprintf('Migration "%s" cannot be reversed: %s', $id, $failure->getMessage()), 0, $failure); - } - - $this->apply($blueprint, $id); - - return $after; + return new SchemaPlan($state, $sql, $names); } /** - * Return a copy of the desired schema that keeps columns and indexes the history never declared. - * - * Unrelated additions made outside migrations are preserved rather than dropped. - */ - private function preserveUndeclared(SchemaState $actual, SchemaState $before, SchemaState $after): SchemaState { - $preserved = clone $after; - $tables = []; - - foreach ($preserved->schema->getTables() as $target) { - $name = $target->getObjectName()->toString(); - - if (! $actual->schema->hasTable($name)) { - $tables[] = $target; - continue; - } - - $actualTable = $actual->schema->getTable($name); - $beforeTable = $before->schema->hasTable($name) ? $before->schema->getTable($name) : null; - $editor = $target->edit(); - - foreach ($actualTable->getColumns() as $column) { - $columnName = $column->getObjectName()->toString(); - $definition = $column->getColumnDefinition(); - - if ($target->hasColumn($columnName)) { - // Retain the live SQL definition in preview snapshots when this step leaves it unchanged. - if (($beforeTable?->hasColumn($columnName) ?? false) - && $beforeTable->getColumn($columnName)->toArray() == $target->getColumn($columnName)->toArray() - && $definition !== null && $definition !== '') { - $editor->modifyColumn($column->getObjectName(), static function (ColumnEditor $targetColumn) use ($definition): void { - $targetColumn->setColumnDefinition($definition); - }); - } - - continue; - } - - if ($beforeTable?->hasColumn($columnName) ?? false) { - continue; - } - $editor->addColumn($column); - $key = strtolower($name . '.' . $columnName); - - if (isset($actual->timestamps[$key])) { - $preserved->timestamps[$key] = $actual->timestamps[$key]; - } - } - - $primary = $actualTable->getPrimaryKeyConstraint()?->getObjectName()?->toString() ?? 'primary'; - - foreach ($actualTable->getIndexes() as $index) { - $indexName = $index->getObjectName()->toString(); - - if (strcasecmp($indexName, $primary) === 0 || $target->hasIndex($indexName) || ($beforeTable?->hasIndex($indexName) ?? false)) { - continue; - } - $editor->addIndex($index); - } - - foreach ($actualTable->getForeignKeys() as $foreignKey) { - $key = $foreignKey->getObjectName()?->getIdentifier()->getValue(); - - if ($key !== null && ($target->hasForeignKey($key) || ($beforeTable?->hasForeignKey($key) ?? false))) { - continue; - } - $editor->addForeignKeyConstraint($foreignKey); - } - - $tables[] = $editor->create(); - } - - $preserved->schema = new Schema($tables); - - return $preserved; - } - - /** - * Authorize SQL for timestamp attributes DBAL cannot compare. - * - * @param list $declaredChanges Lower-cased "table.column" keys. + * Emit changes to timestamp attributes DBAL cannot compare. * * @return list SQL statements for attributes DBAL does not compare. */ - private function timestampSql(SchemaState $actual, SchemaState $desired, SchemaDiff $remaining, array $declaredChanges, string $id): array { + private function timestampSql(SchemaState $actual, SchemaState $desired, SchemaDiff $remaining): array { $platform = $this->db->getDatabasePlatform(); $sql = []; $covered = $this->columnsRewrittenBy($remaining); @@ -246,11 +109,13 @@ private function timestampSql(SchemaState $actual, SchemaState $desired, SchemaD continue; // Doctrine's CHANGE/ADD already applies the complete declaration. } - if (! $actualTable->hasColumn($column->getObjectName()->toString()) || ! isset($desired->timestamps[$key]) || $desired->timestamps[$key] === ($actual->timestamps[$key] ?? ['precision' => 0, 'on_update' => false])) { + if (! $actualTable->hasColumn($column->getObjectName()->toString()) || ! isset($desired->timestamps[$key]) || $desired->timestamps[$key] === ($actual->timestamps[$key] ?? [ + 'precision' => 0, + 'on_update' => false, + ])) { continue; } - $this->require(in_array($key, $declaredChanges, true), $id, $table->getObjectName()->toString(), 'column ' . $column->getObjectName()->toString() . ' timestamp precision or ON UPDATE differs from its declaration'); $quotedTable = $platform->quoteSingleIdentifier($table->getObjectName()->toString()); $declaration = $platform->getColumnDeclarationSQL($platform->quoteSingleIdentifier($column->getObjectName()->toString()), $column->toArray()); $sql[] = "ALTER TABLE {$quotedTable} @@ -266,7 +131,7 @@ private function timestampSql(SchemaState $actual, SchemaState $desired, SchemaD * * @return list */ - private function commentSql(SchemaState $actual, SchemaState $before, SchemaState $after, string $id): array { + private function commentSql(SchemaState $actual, SchemaState $after): array { $sql = []; $platform = $this->db->getDatabasePlatform(); @@ -276,13 +141,13 @@ private function commentSql(SchemaState $actual, SchemaState $before, SchemaStat if (! $actual->schema->hasTable($name)) { continue; } + $comment = $table->getComment() ?? ''; if (($actual->schema->getTable($name)->getComment() ?? '') === $comment) { continue; } - $previous = $before->schema->hasTable($name) ? ($before->schema->getTable($name)->getComment() ?? '') : $comment; - $this->require($previous !== $comment, $id, $name, 'table comment differs from its declaration'); + $quotedTable = $platform->quoteSingleIdentifier($name); $quotedComment = $platform->quoteStringLiteral($comment); $sql[] = "ALTER TABLE {$quotedTable} @@ -301,10 +166,12 @@ private function columnsRewrittenBy(SchemaDiff $diff): array { $keys = []; foreach ($diff->getAlteredTables() as $tableDiff) { - $table = $this->tableName($tableDiff); + $table = $tableDiff->getOldTable()->getObjectName()->toString(); + foreach ($tableDiff->getAddedColumns() as $column) { $keys[] = strtolower($table . '.' . $column->getObjectName()->toString()); } + foreach ($tableDiff->getChangedColumns() as $columnDiff) { $keys[] = strtolower($table . '.' . $columnDiff->getOldColumn()->getObjectName()->toString()); } @@ -312,192 +179,4 @@ private function columnsRewrittenBy(SchemaDiff $diff): array { return $keys; } - - /** - * Timestamp columns whose declared attributes change between two desired states. - * - * @return list Lower-cased "table.column" keys. - */ - private function timestampChanges(SchemaState $before, SchemaState $after): array { - $changes = []; - - foreach ($after->timestamps as $key => $attributes) { - if (($before->timestamps[$key] ?? null) !== $attributes) { - $changes[] = $key; - } - } - - return $changes; - } - - /** - * Generate platform DDL for the supplied differences. - * - * @return list - */ - private function sql(SchemaDiff $diff): array { - return $this->db->getDatabasePlatform()->getAlterSchemaSQL($diff); - } - - /** - * Names of every table present in either schema. - * - * @return list - */ - private function tableNames(SchemaState ...$schemas): array { - $names = []; - - foreach ($schemas as $schema) { - foreach ($schema->schema->getTables() as $table) { - $names[] = $table->getObjectName()->toString(); - } - } - - return array_values(array_unique($names)); - } - - /** - * Fail when the remaining work contains anything the migration did not declare. - * - * Remaining work is the difference between the live schema and the desired state; expected work - * is the difference the migration itself declares. Remaining must be a subset of expected, except - * that a table the migration creates may already exist with a subset of its declared columns. - * - * @throws IncompatibleSchema - */ - private function assertContained(SchemaDiff $remaining, SchemaDiff $expected, SchemaState $before, SchemaState $after, string $id): void { - $expectedCreated = $this->names($expected->getCreatedTables()); - $expectedDropped = $this->names($expected->getDroppedTables()); - $expectedAltered = []; - - foreach ($expected->getAlteredTables() as $diff) { - $expectedAltered[strtolower($this->tableName($diff))] = $diff; - } - - foreach ($remaining->getCreatedTables() as $table) { - $this->require(in_array(strtolower($table->getObjectName()->toString()), $expectedCreated, true), $id, $table->getObjectName()->toString(), 'table would be created'); - } - - foreach ($remaining->getDroppedTables() as $table) { - $this->require(in_array(strtolower($table->getObjectName()->toString()), $expectedDropped, true), $id, $table->getObjectName()->toString(), 'table would be dropped'); - } - - foreach ($remaining->getAlteredTables() as $diff) { - $name = $this->tableName($diff); - $key = strtolower($name); - - if (isset($expectedAltered[$key])) { - $this->assertAlterationContained($diff, $expectedAltered[$key], $before, $after, $id); - - continue; - } - - if (in_array($key, $expectedCreated, true)) { - // The table already exists; only missing declared columns, indexes, and constraints may be added. - $this->require($diff->getChangedColumns() === [] && $diff->getDroppedColumns() === [] - && $diff->getDroppedIndexes() === [] && $diff->getRenamedIndexes() === [] - && $diff->getDroppedForeignKeyConstraintNames() === [], $id, $name, 'existing table differs from the declared initial definition: ' . $this->describe($diff, $before, $after)); - - continue; - } - - $this->require(false, $id, $name, $this->describe($diff, $before, $after)); - } - } - - private function assertAlterationContained(TableDiff $remaining, TableDiff $expected, SchemaState $before, SchemaState $after, string $id): void { - $table = $this->tableName($remaining); - $added = $this->names($expected->getAddedColumns()); - $modified = array_map(static fn ($diff): string => strtolower($diff->getOldColumn()->getObjectName()->toString()), $expected->getChangedColumns()); - $dropped = $this->names($expected->getDroppedColumns()); - $indexes = [ - 'added' => $this->names($expected->getAddedIndexes()), - 'dropped' => $this->names($expected->getDroppedIndexes()), - ]; - $addedForeignKeys = array_map(static fn ($key): string => strtolower($key->getObjectName()?->getIdentifier()->getValue() ?? ''), $expected->getAddedForeignKeys()); - $droppedForeignKeys = array_map(static fn ($name): string => strtolower($name->getIdentifier()->getValue()), $expected->getDroppedForeignKeyConstraintNames()); - - foreach ($remaining->getAddedColumns() as $column) { - $this->require(in_array(strtolower($column->getObjectName()->toString()), $added, true), $id, $table, 'column ' . $column->getObjectName()->toString() . ' would be added'); - } - - foreach ($remaining->getChangedColumns() as $diff) { - $name = $diff->getOldColumn()->getObjectName()->toString(); - $this->require(! $diff->hasNameChanged() && in_array(strtolower($name), $modified, true), $id, $table, 'column ' . $name . ' differs from its declaration'); - } - - foreach ($remaining->getDroppedColumns() as $column) { - $this->require(in_array(strtolower($column->getObjectName()->toString()), $dropped, true), $id, $table, 'column ' . $column->getObjectName()->toString() . ' would be dropped'); - } - - $this->require($remaining->getRenamedIndexes() === [], $id, $table, 'a rename would be required'); - - foreach ($remaining->getAddedForeignKeys() as $foreignKey) { - $name = $foreignKey->getObjectName()?->getIdentifier()->getValue() ?? ''; - $this->require(in_array(strtolower($name), $addedForeignKeys, true), $id, $table, 'foreign key ' . $after->foreignKeyLabel($table, $name) . ' would be added'); - } - - foreach ($remaining->getDroppedForeignKeyConstraintNames() as $foreignKey) { - $name = $foreignKey->getIdentifier()->getValue(); - $this->require(in_array(strtolower($name), $droppedForeignKeys, true), $id, $table, 'existing foreign key ' . $before->foreignKeyLabel($table, $name, $after) . ' conflicts with its declaration'); - } - - // A replacement authorizes both a drop and an addition. An interrupted drop leaves only - // the authorized addition; a conflicting addition would require an unauthorized drop. - foreach ($remaining->getAddedIndexes() as $index) { - $name = strtolower($index->getObjectName()->toString()); - $this->require(in_array($name, $indexes['added'], true), $id, $table, 'index ' . $index->getObjectName()->toString() . ' would be added'); - } - - foreach ($remaining->getDroppedIndexes() as $index) { - $this->require(in_array(strtolower($index->getObjectName()->toString()), $indexes['dropped'], true), $id, $table, 'existing index ' . $index->getObjectName()->toString() . ' conflicts with its declaration'); - } - } - - private function describe(TableDiff $diff, SchemaState $before, SchemaState $after): string { - $table = $this->tableName($diff); - $parts = []; - - foreach ($diff->getAddedColumns() as $column) { - $parts[] = 'column ' . $column->getObjectName()->toString() . ' would be added'; - } - - foreach ($diff->getChangedColumns() as $columnDiff) { - $parts[] = 'column ' . $columnDiff->getOldColumn()->getObjectName()->toString() . ' differs from its declaration'; - } - - foreach ($diff->getDroppedColumns() as $column) { - $parts[] = 'column ' . $column->getObjectName()->toString() . ' would be dropped'; - } - - foreach ($diff->getAddedForeignKeys() as $foreignKey) { - $name = $foreignKey->getObjectName()?->getIdentifier()->getValue() ?? ''; - $parts[] = 'foreign key ' . $after->foreignKeyLabel($table, $name) . ' would be added'; - } - - foreach ($diff->getDroppedForeignKeyConstraintNames() as $foreignKey) { - $parts[] = 'existing foreign key ' . $before->foreignKeyLabel($table, $foreignKey->getIdentifier()->getValue(), $after) . ' conflicts with its declaration'; - } - - return $parts === [] ? 'table would be altered' : implode('; ', $parts); - } - - /** - * @param array $assets - * - * @return list - */ - private function names(array $assets): array { - return array_values(array_map(static fn (Table|Column|Index $asset): string => strtolower($asset->getObjectName()->toString()), $assets)); - } - - private function tableName(TableDiff $diff): string { - return $diff->getOldTable()->getObjectName()->toString(); - } - - private function require(bool $condition, string $id, string $table, string $change): void { - if (! $condition) { - throw IncompatibleSchema::forChange($id, $table, $change); - } - } } diff --git a/src/Migrations/Schema/SchemaState.php b/src/Migrations/Schema/SchemaState.php index 164e0a1..a9d7998 100644 --- a/src/Migrations/Schema/SchemaState.php +++ b/src/Migrations/Schema/SchemaState.php @@ -9,7 +9,7 @@ use StellarWP\Foundation\Migrations\Schema\ValueObjects\Rename; /** - * A schema snapshot that keeps relationships, logical labels, and supplemental timestamp facts together. + * A schema snapshot that keeps relationships and supplemental timestamp facts together. * * @internal Owned by one planning operation; copies keep both representations together. */ @@ -20,16 +20,6 @@ final class SchemaState */ public array $renames = []; - /** - * @var array Original table names retain foreign-key identity after a rename. - */ - public array $tableOrigins = []; - - /** - * @var array> Logical names keyed by physical table and constraint. - */ - public array $foreignKeyNames = []; - /** * Hold a schema snapshot and its supplemental timestamp facts. * @@ -42,20 +32,7 @@ public function __construct( } /** - * Describe a constraint using its declared name when this snapshot knows it. - * - * An interrupted addition may have a declaration only in the target snapshot. - */ - public function foreignKeyLabel(string $table, string $constraint, ?self $target = null): string { - $name = $this->foreignKeyNames[strtolower($table)][strtolower($constraint)] - ?? $target?->foreignKeyNames[strtolower($table)][strtolower($constraint)] - ?? null; - - return $name === null ? $constraint : sprintf('"%s" (database constraint "%s")', $name, $constraint); - } - - /** - * Rename an object and carry its references, logical labels, and timestamp facts with it. + * Rename an object and carry its references and timestamp facts with it. */ public function rename(Rename $rename): void { $tables = []; @@ -75,13 +52,6 @@ public function rename(Rename $rename): void { } } - if ($rename->table === null) { - $this->foreignKeyNames[$to] = $this->foreignKeyNames[$from] ?? []; - unset($this->foreignKeyNames[$from]); - $this->tableOrigins[strtolower($rename->to)] = $this->tableOrigins[strtolower($rename->from)] ?? $rename->from; - unset($this->tableOrigins[strtolower($rename->from)]); - } - $this->renames[] = $rename; } diff --git a/src/Migrations/Schema/TableBlueprint.php b/src/Migrations/Schema/TableBlueprint.php index 98140d8..436342c 100644 --- a/src/Migrations/Schema/TableBlueprint.php +++ b/src/Migrations/Schema/TableBlueprint.php @@ -398,12 +398,11 @@ public function comment(string $comment): self { * @internal Called by Blueprint::apply(). */ public function applyTo(TableEditor $editor, SchemaState $state, TableNameResolver $names): Table { - $scope = $state->tableOrigins[strtolower($this->name)] ?? $this->name; + $existing = $state->schema->hasTable($this->name) ? $state->schema->getTable($this->name)->getForeignKeys() : []; foreach ($this->droppedForeignKeys as $name) { - $constraint = $this->foreignKeyName($scope, $name); + $constraint = $this->foreignKeyName($existing, $name); $editor->dropForeignKeyConstraintByUnquotedName($constraint); - unset($state->foreignKeyNames[strtolower($this->name)][$constraint]); } foreach ($this->droppedIndexes as $index) { @@ -437,9 +436,9 @@ public function applyTo(TableEditor $editor, SchemaState $state, TableNameResolv } foreach ($this->foreignKeys as $foreignKey) { - $constraint = $this->foreignKeyName($scope, $foreignKey->name()); + $constraint = $this->foreignKeyName($existing, $foreignKey->name()); + $foreignKey->applyTo($editor, $names, $constraint); - $state->foreignKeyNames[strtolower($this->name)][$constraint] = $foreignKey->name(); } if ($this->comment !== null) { @@ -450,12 +449,31 @@ public function applyTo(TableEditor $editor, SchemaState $state, TableNameResolv } /** - * Scope constraint identity to its physical table without exceeding MySQL's identifier limit. + * Resolve a logical key on its live table, retaining its identity after table renames. + * + * Generated names have independent table and logical components, within MySQL's 64-byte limit. + * + * @param array $constraints * * @return non-empty-string */ - private function foreignKeyName(string $scope, string $name): string { - return 'fk_' . substr(hash('sha256', $scope . "\0" . $name), 0, 40); + private function foreignKeyName(array $constraints, string $name): string { + $identity = '_' . hash('sha1', $name); + $matches = []; + + foreach ($constraints as $key) { + $physical = $key->getObjectName()?->getIdentifier()->getValue(); + + if ($physical !== null && preg_match('/^fk_[0-9a-f]{20}_[0-9a-f]{40}$/D', $physical) === 1 && str_ends_with($physical, $identity)) { + $matches[] = $physical; + } + } + + if (count($matches) > 1) { + throw new InvalidArgumentException('More than one constraint has the logical name ' . $name . ' on table ' . $this->name . '.'); + } + + return $matches[0] ?? 'fk_' . substr(hash('sha256', $this->name), 0, 20) . $identity; } /** diff --git a/src/Migrations/Schema/ValueObjects/Rename.php b/src/Migrations/Schema/ValueObjects/Rename.php index 71edefe..1a4a0d5 100644 --- a/src/Migrations/Schema/ValueObjects/Rename.php +++ b/src/Migrations/Schema/ValueObjects/Rename.php @@ -12,7 +12,7 @@ final readonly class Rename { /** - * Keep the resolved names used by schema replay and SQL planning. + * Keep the resolved names used by schema changes and SQL planning. * * @param non-empty-string $from * @param non-empty-string $to @@ -29,11 +29,4 @@ public function __construct( throw new InvalidArgumentException('A rename requires two distinct, non-empty names.'); } } - - /** - * Return the inverse declaration for rollback inspection. - */ - public function inverse(): self { - return new self($this->to, $this->from, $this->table); - } } diff --git a/src/Migrations/composer.json b/src/Migrations/composer.json index 4f5f500..f061003 100644 --- a/src/Migrations/composer.json +++ b/src/Migrations/composer.json @@ -1,7 +1,7 @@ { "name": "stellarwp/foundation-migrations", "type": "library", - "description": "Declarative WordPress migrations with schema recovery and database session locking.", + "description": "Declarative WordPress migrations with SQL preview and database session locking.", "license": "GPL-2.0-or-later", "config": { "vendor-dir": "vendor", diff --git a/tests/Unit/Migrations/ColumnRenameTest.php b/tests/Unit/Migrations/ColumnRenameTest.php index be1a5ba..4f131e4 100644 --- a/tests/Unit/Migrations/ColumnRenameTest.php +++ b/tests/Unit/Migrations/ColumnRenameTest.php @@ -90,11 +90,10 @@ public function test_platform_statements_remain_separate_and_ordered(): void { $before = new SchemaState(new Schema([ $table, ])); - $actual = clone $before; - $after = clone $before; + $after = clone $before; $after->rename(new Rename('old', 'new', 'reports')); - $this->assertSame($statements, $planner->plan($before, $actual, $after, '1')); + $this->assertSame($statements, $planner->plan($before, $after)); } /** diff --git a/tests/Unit/Migrations/ForeignKeyComparatorTest.php b/tests/Unit/Migrations/ForeignKeyComparatorTest.php index 37bb71c..74cb8a0 100644 --- a/tests/Unit/Migrations/ForeignKeyComparatorTest.php +++ b/tests/Unit/Migrations/ForeignKeyComparatorTest.php @@ -11,50 +11,27 @@ use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Types\Types; use PHPUnit\Framework\TestCase; -use StellarWP\Foundation\Migrations\Exceptions\IncompatibleSchema; use StellarWP\Foundation\Migrations\Schema\ForeignKeyComparator; use StellarWP\Foundation\Migrations\Schema\SchemaState; /** - * Physical aliases must identify exactly one historical relationship. + * Constraint comparison preserves explicitly declared identities. */ final class ForeignKeyComparatorTest extends TestCase { /** - * Prefix aliases retain the declared label while errors identify the actual database constraint. + * Changing the declared name must replace even a structurally equivalent constraint. */ - public function test_aliases_keep_logical_labels_in_both_planning_snapshots(): void { + public function test_distinct_names_require_drop_and_add(): void { $config = new ComparatorConfig(); $platform = new MySQL84Platform(); $comparator = new ForeignKeyComparator($platform, new Comparator($platform, $config), $config); - $historical = 'fk_' . str_repeat('a', 40); - $live = 'fk_' . str_repeat('b', 40); - $before = $this->schema($historical); + $before = $this->schema('original'); + $after = $this->schema('replacement'); + $diff = $comparator->compareTables($before->schema->getTable('items'), $after->schema->getTable('items')); - $before->foreignKeyNames['items'][$historical] = 'order'; - - $after = clone $before; - $actual = $this->schema($live); - $comparator->alignForeignKeyNames($before, $actual, $after, '4'); - - $this->assertSame('"order" (database constraint "' . $live . '")', $before->foreignKeyLabel('items', $live)); - $this->assertSame($before->foreignKeyLabel('items', $live), $after->foreignKeyLabel('items', $live)); - $this->assertSame($historical, $after->foreignKeyLabel('items', $historical)); - $this->assertTrue($comparator->compareSchemas($actual->schema, $after->schema)->isEmpty()); - $this->assertSame($live, $actual->foreignKeyLabel('items', $live)); - } - - public function test_two_historical_relationships_cannot_claim_the_same_live_constraint(): void { - $config = new ComparatorConfig(); - $platform = new MySQL84Platform(); - $comparator = new ForeignKeyComparator($platform, new Comparator($platform, $config), $config); - $before = $this->schema('fk_' . str_repeat('a', 40), 'fk_' . str_repeat('b', 40)); - $actual = $this->schema('fk_' . str_repeat('c', 40)); - $after = clone $before; - - $this->expectException(IncompatibleSchema::class); - $this->expectExceptionMessage('ambiguous foreign-key identity'); - $comparator->alignForeignKeyNames($before, $actual, $after, '4'); + $this->assertCount(1, $diff->getAddedForeignKeys()); + $this->assertCount(1, $diff->getDroppedForeignKeyConstraintNames()); } /** diff --git a/tests/Unit/Migrations/ForeignKeyDefinitionTest.php b/tests/Unit/Migrations/ForeignKeyDefinitionTest.php index 1e5884e..457b115 100644 --- a/tests/Unit/Migrations/ForeignKeyDefinitionTest.php +++ b/tests/Unit/Migrations/ForeignKeyDefinitionTest.php @@ -63,9 +63,9 @@ public function test_constraint_identity_is_scoped_to_the_physical_child_table() } /** - * Labels follow declarations through renames without leaking into reused tables or prior snapshots. + * Logical identities remain discoverable from live constraint names after renames. */ - public function test_logical_labels_follow_the_relationship_lifecycle(): void { + public function test_logical_identity_survives_table_and_column_renames(): void { $names = $this->createStub(TableNameResolver::class); $names->method('tableName')->willReturnCallback(static fn (string $name): string => 'wp_' . $name); $state = new SchemaState(new Schema()); @@ -76,32 +76,50 @@ public function test_logical_labels_follow_the_relationship_lifecycle(): void { $blueprint->apply(); $constraint = array_key_first($state->schema->getTable('wp_items')->getForeignKeys()); $this->assertNotNull($constraint); - $label = '"order" (database constraint "' . $constraint . '")'; - $this->assertSame($label, $state->foreignKeyLabel('wp_items', $constraint)); - $this->assertSame($constraint, $state->foreignKeyLabel('wp_other_items', $constraint)); - $this->assertSame('external_constraint', $state->foreignKeyLabel('wp_items', 'external_constraint')); - $before = clone $state; + $this->assertSame(64, strlen($constraint)); $blueprint->table('items')->renameColumn('order_id', 'purchase_id'); $blueprint->rename('items', 'archive'); $blueprint->apply(); - $this->assertSame($label, $state->foreignKeyLabel('wp_archive', $constraint)); - $this->assertSame($constraint, $state->foreignKeyLabel('wp_items', $constraint)); - $this->assertSame($label, $before->foreignKeyLabel('wp_items', $constraint)); + $this->assertTrue($state->schema->getTable('wp_archive')->hasForeignKey($constraint)); - $blueprint->create('items')->integer('id'); - $blueprint->apply(); - $this->assertSame($constraint, $state->foreignKeyLabel('wp_items', $constraint)); - $blueprint->table('archive')->dropForeignKey('order'); - $blueprint->apply(); - $this->assertSame($constraint, $state->foreignKeyLabel('wp_archive', $constraint)); + // A fresh blueprint has no historical naming metadata. + $drop = new Blueprint($state, $names); + $drop->table('archive')->dropForeignKey('order'); + $drop->apply(); + $this->assertSame([], $state->schema->getTable('wp_archive')->getForeignKeys()); + } + + /** + * A logical removal must not guess between constraints combined from different tables. + */ + public function test_ambiguous_logical_identity_requires_an_explicit_repair(): void { + $names = $this->createStub(TableNameResolver::class); + $names->method('tableName')->willReturnCallback(static fn (string $name): string => 'wp_' . $name); + $state = new SchemaState(new Schema()); + $blueprint = new Blueprint($state, $names); + + foreach ([ + 'items', + 'archive', + ] as $name) { + $table = $blueprint->create($name); + $table->unsignedBigInteger('order_id'); + $table->foreignKey('order', 'order_id')->references('orders', 'id'); + } - $blueprint->table('archive')->foreignKey('order', 'purchase_id')->references('orders', 'id'); - $blueprint->apply(); - $this->assertSame($label, $state->foreignKeyLabel('wp_archive', $constraint)); - $blueprint->drop('archive'); $blueprint->apply(); - $this->assertSame($constraint, $state->foreignKeyLabel('wp_archive', $constraint)); + $archivedKey = array_values($state->schema->getTable('wp_archive')->getForeignKeys())[0]; + $combined = $state->schema->getTable('wp_items')->edit()->addForeignKeyConstraint($archivedKey)->create(); + $state->schema = new Schema([ + $combined, + ]); + $drop = new Blueprint($state, $names); + $drop->table('items')->dropForeignKey('order'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('More than one constraint has the logical name order on table wp_items.'); + $drop->apply(); } /** diff --git a/tests/integration/Migrations/CommonColumnTest.php b/tests/integration/Migrations/CommonColumnTest.php index 35875aa..eb228f3 100644 --- a/tests/integration/Migrations/CommonColumnTest.php +++ b/tests/integration/Migrations/CommonColumnTest.php @@ -6,7 +6,6 @@ use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; use PHPUnit\Framework\Attributes\DataProvider; -use StellarWP\Foundation\Migrations\Exceptions\LedgerFailure; use StellarWP\Foundation\Migrations\MigrationsProvider; use StellarWP\Foundation\Migrations\Migrator; use StellarWP\Foundation\Migrations\Schema\Renames\ChangeColumnRename; @@ -19,7 +18,7 @@ use StellarWP\Foundation\Tests\Support\Fixtures\Migrations\Columns\RenameCommonColumns; /** - * Common column declarations round-trip through the database and migration recovery. + * Common column declarations round-trip through the database and migration execution. */ final class CommonColumnTest extends DatabaseTestCase { @@ -48,15 +47,15 @@ protected function setUp(): void { } /** - * Retry completed creation without replacing the table or losing written values. + * Completed migrations are not repeated and retain their column definitions and values. */ - public function test_creation_retry_preserves_types_defaults_and_data(): void { + public function test_creation_preserves_types_defaults_and_data(): void { $migrator = $this->container->get(Migrator::class); $migrator->migrate(Migrator::NONE); - $this->failLedgerWrite($migrator, '1'); + $migrator->migrate('1'); $this->insertEntry(); $steps = $migrator->migrate('1'); - $this->assertSame([], $steps[0]->sql); + $this->assertSame([], $steps); $this->assertInitialColumns(); $this->assertEntry(); $this->assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->history} WHERE version = '1'")); @@ -74,14 +73,14 @@ public function test_creation_retry_preserves_types_defaults_and_data(): void { } /** - * Alter each complete definition, retry after committed DDL, then restore the original attributes. + * Alter each complete definition, then restore the original attributes. */ - public function test_alteration_retry_and_rollback_preserve_values(): void { + public function test_alteration_and_rollback_preserve_values(): void { $migrator = $this->container->get(Migrator::class); $migrator->migrate('1'); $this->insertEntry(); - $this->failLedgerWrite($migrator, '2'); - $this->assertSame([], $migrator->migrate('2')[0]->sql); + $migrator->migrate('2'); + $this->assertSame([], $migrator->migrate('2')); $table = $this->introspect(); $this->assertTrue($table->getColumn('metadata')->getNotnull()); $this->assertSame('Required metadata', $table->getColumn('metadata')->getComment()); @@ -121,13 +120,13 @@ public static function renameStrategies(): iterable { * @dataProvider renameStrategies */ #[DataProvider('renameStrategies')] - public function test_column_types_survive_rename_and_retry(string $strategy): void { + public function test_column_types_survive_rename(string $strategy): void { $this->container->singleton(ColumnRename::class, $strategy); $migrator = $this->container->get(Migrator::class); $migrator->migrate('2'); $this->insertEntry(); - $this->failLedgerWrite($migrator, '3'); - $this->assertSame([], $migrator->migrate('3')[0]->sql); + $migrator->migrate('3'); + $this->assertSame([], $migrator->migrate('3')); $this->assertSame(Types::JSON, Type::lookupName($this->introspect()->getColumn('metadata_renamed')->getType())); $migrator->rollbackTo('1'); $this->assertInitialColumns(); @@ -191,22 +190,4 @@ private function assertInitialColumns(): void { private function introspect(): Table { return $this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_entries'); } - - private function failLedgerWrite(Migrator $migrator, string $target): void { - $trigger = $this->observer->quoteSingleIdentifier($this->suffix . '_reject_history'); - $this->observer->executeStatement("CREATE TRIGGER $trigger - BEFORE INSERT ON {$this->history} - FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ledger rejected'"); - - try { - $migrator->migrate($target); - $this->fail('DDL must complete before the ledger write fails.'); - } catch (LedgerFailure) { - $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->history} WHERE version = ?", [ - $target, - ])); - } finally { - $this->observer->executeStatement('DROP TRIGGER ' . $trigger); - } - } } diff --git a/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php b/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php index f221142..84c09d4 100644 --- a/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php +++ b/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php @@ -3,9 +3,9 @@ namespace StellarWP\Foundation\Tests\Integration\Migrations; use StellarWP\Foundation\Container\Contracts\Resolver as C; -use StellarWP\Foundation\Migrations\Exceptions\IncompatibleSchema; use StellarWP\Foundation\Migrations\Exceptions\IrreversibleMigration; use StellarWP\Foundation\Migrations\Exceptions\MigrationAlreadyRunning; +use StellarWP\Foundation\Migrations\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Migrations\MigrationsProvider; use StellarWP\Foundation\Migrations\Migrator; use StellarWP\Foundation\Migrations\ValueObjects\MigrationRegistration; @@ -31,7 +31,6 @@ final class DeclarativeMigratorEdgeCaseTest extends DatabaseTestCase { private string $quotedEntries; - private string $tagsName; private string $historyName; protected function configuration(): array { @@ -42,7 +41,6 @@ protected function setUp(): void { parent::setUp(); $this->container->register(MigrationsProvider::class); $this->quotedEntries = $this->privateTable($this->suffix . '_entries'); - $this->tagsName = $this->source->prefix . $this->suffix . '_tags'; $this->privateTable($this->suffix . '_tags'); $this->historyName = $this->source->prefix . $this->suffix . '_history'; $this->privateTable($this->suffix . '_history'); @@ -143,24 +141,15 @@ public function test_timestamp_attribute_changes_emit_sql_and_rerun_cleanly(): v $this->assertStringContainsString('`created_at` datetime(6) not null default current_timestamp(6),', $this->createTableSql()); } - public function test_undeclared_timestamp_drift_is_rejected(): void { + public function test_unrelated_timestamp_changes_are_preserved(): void { $this->migrator()->migrate(AddEntryArchivedFlag::ID); $this->observer->executeStatement("ALTER TABLE {$this->quotedEntries} MODIFY created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)"); - - try { - $this->migrator()->migrate(WidenEntryName::ID); - $this->fail('Expected the undeclared ON UPDATE to be rejected.'); - } catch (IncompatibleSchema $failure) { - $this->assertStringContainsString('created_at', $failure->getMessage()); - $this->assertStringContainsString('ON UPDATE', $failure->getMessage()); - } - $this->assertNotContains(WidenEntryName::ID, $this->history()); + $this->migrator()->migrate(WidenEntryName::ID); + $this->assertContains(WidenEntryName::ID, $this->history()); + $this->assertStringContainsString('on update current_timestamp(6)', $this->createTableSql()); } public function test_preview_sees_existing_tables_from_later_steps_and_creates_no_ledger(): void { - // An interrupted earlier attempt left the tags table behind, exactly as declared. - $this->observer->executeStatement("CREATE TABLE `{$this->tagsName}` (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, label VARCHAR(50) NOT NULL, PRIMARY KEY (id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); - $preview = $this->migrator()->preview(); $this->assertSame( @@ -168,7 +157,7 @@ public function test_preview_sees_existing_tables_from_later_steps_and_creates_n array_map(static fn (Step $step): string => $step->id, $preview), ); $this->assertCount(1, $preview[0]->sql, 'Entries is created'); - $this->assertSame([], $preview[1]->sql, 'Tags already matches its declaration'); + $this->assertStringContainsStringIgnoringCase('CREATE TABLE', $preview[1]->sql[0]); $this->assertStringContainsStringIgnoringCase('DROP TABLE', $preview[2]->sql[0]); $this->assertStringContainsStringIgnoringCase('CREATE TABLE', $preview[3]->sql[0], 'Preview remembers the simulated drop and does not reload the old table'); $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName]), 'Preview executes nothing, not even ledger creation'); @@ -180,18 +169,18 @@ public function test_preview_sees_existing_tables_from_later_steps_and_creates_n ); } - public function test_interrupted_index_replacement_recovers_on_retry(): void { + public function test_interrupted_index_replacement_stops_without_recording_success(): void { $this->migrator()->migrate(IndexEntryNote::ID); - // A previous attempt dropped the old index and died before creating the new one. $this->observer->executeStatement("DROP INDEX note_lookup ON {$this->quotedEntries}"); - $steps = $this->migrator()->migrate(ReplaceNoteLookup::ID); + try { + $this->migrator()->migrate(ReplaceNoteLookup::ID); + $this->fail('A missing index requires inspection before retry.'); + } catch (MigrationInterrupted $failure) { + $this->assertStringContainsString('note_lookup does not exist', $failure->getMessage()); + } - $last = end($steps); - $this->assertInstanceOf(Step::class, $last); - $this->assertCount(1, $last->sql, 'Only the creation half remains'); - $this->assertStringContainsString('unique key `note_lookup` (`note`)', $this->createTableSql()); - $this->assertContains(ReplaceNoteLookup::ID, $this->history()); + $this->assertNotContains(ReplaceNoteLookup::ID, $this->history()); } public function test_conflicting_existing_index_stops_an_addition(): void { @@ -201,8 +190,8 @@ public function test_conflicting_existing_index_stops_an_addition(): void { try { $this->migrator()->migrate(IndexEntryNote::ID); $this->fail('Expected the conflicting index to be rejected.'); - } catch (IncompatibleSchema $failure) { - $this->assertStringContainsString('existing index note_lookup conflicts', $failure->getMessage()); + } catch (MigrationInterrupted $failure) { + $this->assertStringContainsString('Index note_lookup already exists', $failure->getMessage()); } $this->assertStringContainsString('unique key `note_lookup` (`name`)', $this->createTableSql(), 'The uniqueness guarantee was not replaced'); $this->assertNotContains(IndexEntryNote::ID, $this->history()); diff --git a/tests/integration/Migrations/DeclarativeMigratorTest.php b/tests/integration/Migrations/DeclarativeMigratorTest.php index c66b8eb..25c5b9a 100644 --- a/tests/integration/Migrations/DeclarativeMigratorTest.php +++ b/tests/integration/Migrations/DeclarativeMigratorTest.php @@ -4,7 +4,6 @@ use StellarWP\Foundation\Container\Contracts\Resolver as C; use StellarWP\Foundation\Database\Exceptions\DatabaseException; -use StellarWP\Foundation\Migrations\Exceptions\IncompatibleSchema; use StellarWP\Foundation\Migrations\Exceptions\MigrationAlreadyRunning; use StellarWP\Foundation\Migrations\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Migrations\MigrationsProvider; @@ -18,7 +17,7 @@ use StellarWP\Foundation\Tests\Support\Fixtures\Database\Declarative\EntriesTable; /** - * Prove the desired-state runner: guard-free migrations, safe retry, drift detection, preview, rollback. + * Prove current-operation planning, failure handling, preview, and rollback. */ final class DeclarativeMigratorTest extends DatabaseTestCase { @@ -89,15 +88,14 @@ private function rejectHistoryWrite(string $event): string { return $trigger; } - public function test_missing_history_is_visible_and_prevents_execution(): void { + public function test_missing_history_is_visible_without_blocking_forward_execution(): void { $this->migrator()->migrate(); $this->observer->insert($this->quotedHistory, ['version' => '20270101000100']); $statuses = $this->migrator()->status(); $last = end($statuses); $this->assertInstanceOf(\StellarWP\Foundation\Migrations\ValueObjects\MigrationStatus::class, $last); $this->assertSame('missing', $last->state()); - $this->expectException(MigrationInterrupted::class); - $this->migrator()->migrate(); + $this->assertSame([], $this->migrator()->migrate()); } public function test_rollback_on_an_empty_history_has_no_work(): void { @@ -135,6 +133,7 @@ public function test_step_rollback_does_not_apply_an_older_pending_migration(): public function test_target_reconciles_reversals_and_older_pending_migrations(): void { $this->migrator()->migrate(); $this->observer->delete($this->quotedHistory, ['version' => self::ALTER]); + $this->observer->executeStatement('ALTER TABLE ' . $this->quotedEntries . ' DROP COLUMN note'); $steps = $this->migrator()->migrate(self::ALTER); $this->assertSame([self::BACKFILL, self::ALTER], self::ids($steps)); $this->assertTrue($steps[0]->reverse); @@ -234,7 +233,7 @@ public function test_data_step_runs_after_ddl_and_is_repeat_safe(): void { $this->assertSame(['active', 'archived'], $this->observer->fetchFirstColumn('SELECT status FROM ' . $this->quotedEntries . ' ORDER BY id')); } - public function test_retry_after_ledger_failure_records_without_repeating_ddl(): void { + public function test_ledger_failure_requires_deliberate_repair_before_retry(): void { $this->migrator()->migrate(self::CREATE); $trigger = $this->rejectHistoryWrite('INSERT'); @@ -249,15 +248,22 @@ public function test_retry_after_ledger_failure_records_without_repeating_ddl(): $this->assertSame([self::CREATE], $this->history(), 'DDL committed but the ledger did not'); $this->observer->insert($this->quotedEntries, ['name' => 'Survivor', 'status' => 'active', 'note' => 'Keep this']); - $steps = $this->migrator()->migrate(self::ALTER); + try { + $this->migrator()->migrate(self::ALTER); + $this->fail('Completed DDL is not automatically reconciled.'); + } catch (MigrationInterrupted $failure) { + $this->assertStringContainsString('note already exists', $failure->getMessage()); + } - $this->assertSame([self::ALTER], self::ids($steps)); - $this->assertSame([], $steps[0]->sql, 'The retry detects the completed column and issues no DDL'); - $this->assertSame([self::CREATE, self::ALTER], $this->history()); + $this->assertSame([self::CREATE], $this->history()); $this->assertSame('Keep this', $this->observer->fetchOne('SELECT note FROM ' . $this->quotedEntries)); + // An operator verifies the completed schema and deliberately repairs its ledger entry. + $this->observer->insert($this->quotedHistory, ['version' => self::ALTER]); + $this->assertSame([], $this->migrator()->migrate(self::ALTER)); + $this->assertSame([self::CREATE, self::ALTER], $this->history()); } - public function test_create_retry_after_ledger_failure_preserves_rows(): void { + public function test_create_retry_stops_without_adopting_existing_rows(): void { $this->migrator()->migrate(Migrator::NONE); // Nothing to do, but creates the ledger so the trigger can be attached. $trigger = $this->rejectHistoryWrite('INSERT'); @@ -272,10 +278,14 @@ public function test_create_retry_after_ledger_failure_preserves_rows(): void { $this->assertSame([], $this->history()); $this->observer->insert($this->quotedEntries, ['name' => 'Survivor', 'status' => 'active']); - $steps = $this->migrator()->migrate(self::CREATE); + try { + $this->migrator()->migrate(self::CREATE); + $this->fail('An existing table must not be adopted after a failed history write.'); + } catch (MigrationInterrupted $failure) { + $this->assertStringContainsString('already exists', $failure->getMessage()); + } - $this->assertSame([], $steps[0]->sql); - $this->assertSame([self::CREATE], $this->history()); + $this->assertSame([], $this->history()); $this->assertSame(['Survivor'], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->quotedEntries)); $table = $this->observer->createSchemaManager()->introspectTable($this->entriesName); $this->assertSame('Application entries', $table->getComment()); @@ -289,9 +299,9 @@ public function test_undeclared_column_change_stops_the_run_and_names_the_column try { $this->migrator()->migrate(self::ALTER); $this->fail('Expected drift to be rejected.'); - } catch (IncompatibleSchema $failure) { + } catch (MigrationInterrupted $failure) { $this->assertStringContainsString(self::ALTER, $failure->getMessage()); - $this->assertStringContainsString('column note differs from its declaration', $failure->getMessage()); + $this->assertStringContainsString('Column note already exists', $failure->getMessage()); } $this->assertSame([self::CREATE], $this->history()); $this->assertStringContainsString('`note` int', $this->createTableSql(), 'The runner never repaired the column silently'); @@ -303,8 +313,8 @@ public function test_incompatible_existing_table_stops_a_create_migration(): voi try { $this->migrator()->migrate(self::CREATE); $this->fail('Expected the existing table to be rejected.'); - } catch (IncompatibleSchema $failure) { - $this->assertStringContainsString('differs from the declared initial definition', $failure->getMessage()); + } catch (MigrationInterrupted $failure) { + $this->assertStringContainsString('already exists', $failure->getMessage()); } $this->assertSame([], $this->history()); } @@ -354,20 +364,6 @@ public function test_rollback_by_step_and_by_target(): void { $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->entriesName])); } - public function test_history_missing_a_dependency_is_reported_not_repaired(): void { - $this->migrator()->migrate(); - $this->observer->delete($this->quotedHistory, ['version' => self::CREATE]); - - try { - $this->migrator()->migrate(self::CREATE); - $this->fail('Expected the inconsistent ledger to be reported.'); - } catch (MigrationInterrupted $failure) { - $this->assertStringContainsString(self::ALTER, $failure->getMessage()); - $this->assertStringContainsString('repair the ledger', $failure->getMessage()); - } - $this->assertSame([self::ALTER, self::BACKFILL], $this->history(), 'Nothing was executed or recorded'); - } - public function test_another_session_holding_the_lock_blocks_the_run(): void { $name = hash('sha256', constant('DB_NAME') . "\0" . $this->historyName); $this->assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [$name])); diff --git a/tests/integration/Migrations/ForeignKeyIdentityTest.php b/tests/integration/Migrations/ForeignKeyIdentityTest.php index 2645cfd..7088398 100644 --- a/tests/integration/Migrations/ForeignKeyIdentityTest.php +++ b/tests/integration/Migrations/ForeignKeyIdentityTest.php @@ -4,8 +4,6 @@ use Doctrine\DBAL\Schema\ForeignKeyConstraint; use StellarWP\Foundation\Migrations\Contracts\Migration; -use StellarWP\Foundation\Migrations\Exceptions\IncompatibleSchema; -use StellarWP\Foundation\Migrations\Exceptions\LedgerFailure; use StellarWP\Foundation\Migrations\MigrationsProvider; use StellarWP\Foundation\Migrations\Migrator; use StellarWP\Foundation\Migrations\ValueObjects\MigrationRegistration; @@ -24,7 +22,6 @@ final class ForeignKeyIdentityTest extends DatabaseTestCase { private string $orders; private string $items; - private string $history; protected function configuration(): array { return [ @@ -66,34 +63,6 @@ public function test_unrelated_changes_and_rollback_keep_the_existing_relationsh $this->assertSame([], $this->constraints()); } - public function test_replacement_retry_recognizes_the_new_actions_under_the_old_physical_name(): void { - $migrator = $this->migrations(new CascadeOrderReference($this->suffix)); - $migrator->migrate('3'); - $original = $this->constraintName(); - $this->changePrefix(); - $trigger = $this->observer->quoteSingleIdentifier($this->suffix . '_reject_history'); - $this->observer->executeStatement("CREATE TRIGGER $trigger - BEFORE INSERT ON {$this->history} - FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ledger rejected'"); - - try { - $migrator->migrate(); - $this->fail('The ledger write must fail after replacing the relationship.'); - } catch (LedgerFailure) { - $this->assertSame($original, $this->constraintName()); - $this->assertSame('CASCADE', $this->constraints()[0]->onDelete()); - } finally { - $this->observer->executeStatement('DROP TRIGGER ' . $trigger); - } - - $retry = $migrator->migrate(); - $this->assertSame([], $retry[0]->sql); - $this->assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->history} WHERE version = '4'")); - $migrator->rollback(); - $this->assertSame($original, $this->constraintName()); - $this->assertNull($this->constraints()[0]->onDelete()); - } - public function test_logical_name_changes_still_replace_the_constraint(): void { $migrator = $this->migrations(new RenameOrderReference($this->suffix)); $migrator->migrate('3'); @@ -103,30 +72,12 @@ public function test_logical_name_changes_still_replace_the_constraint(): void { $this->assertNotEmpty($steps[0]->sql); $this->assertNotSame($original, $this->constraintName()); $this->assertCount(1, $this->constraints()); - $this->observer->delete($this->history, [ - 'version' => '4', - ]); - $this->assertSame([], $migrator->migrate()[0]->sql); $migrator->rollback(); $this->assertCount(1, $this->constraints()); $migrator->rollback(); $this->assertSame([], $this->constraints()); } - public function test_replacement_resumes_when_only_the_old_constraint_was_dropped(): void { - $migrator = $this->migrations(new CascadeOrderReference($this->suffix)); - $migrator->migrate('3'); - $original = $this->constraintName(); - $this->changePrefix(); - $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $this->observer->quoteSingleIdentifier($original)); - $migrator->migrate(); - $this->assertCount(1, $this->constraints()); - $this->assertSame('CASCADE', $this->constraints()[0]->onDelete()); - $migrator->rollback(); - $migrator->rollback(); - $this->assertSame([], $this->constraints()); - } - public function test_equivalent_external_constraints_are_preserved(): void { $migrator = $this->migrations(new AddItemNote($this->suffix)); $migrator->migrate('3'); @@ -140,90 +91,6 @@ public function test_equivalent_external_constraints_are_preserved(): void { $this->assertSame($external, $this->constraintName()); } - public function test_ambiguous_managed_names_fail_before_any_ddl(): void { - $migrator = $this->migrations(new AddItemNote($this->suffix)); - $migrator->migrate('3'); - $this->changePrefix(); - $this->addConstraint('fk_' . sha1($this->suffix)); - - try { - $migrator->migrate(); - $this->fail('Two equivalent candidates must not be assigned arbitrary ownership.'); - } catch (IncompatibleSchema $failure) { - $this->assertStringContainsString('ambiguous foreign-key identity for "order" (database constraint "', $failure->getMessage()); - $this->assertStringContainsString($this->suffix . '_items', $failure->getMessage()); - $this->assertFalse($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_items')->hasColumn('note')); - } - } - - public function test_changed_actions_are_not_accepted_as_an_unrelated_migration(): void { - $migrator = $this->migrations(new AddItemNote($this->suffix)); - $migrator->migrate('3'); - $original = $this->constraintName(); - $this->changePrefix(); - $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $this->observer->quoteSingleIdentifier($original)); - $this->addConstraint($original, ' ON DELETE CASCADE'); - $this->expectException(IncompatibleSchema::class); - $migrator->migrate(); - } - - public function test_removing_a_relationship_with_uncertain_ownership_does_not_record_success(): void { - $migrator = $this->migrations(); - $migrator->migrate(); - $original = $this->constraintName(); - $this->changePrefix(); - $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $this->observer->quoteSingleIdentifier($original)); - $this->addConstraint($original, ' ON DELETE CASCADE'); - - try { - $migrator->rollback(); - $this->fail('Uncertain ownership must not be treated as an already removed constraint.'); - } catch (IncompatibleSchema $failure) { - $this->assertStringContainsString($original, $failure->getMessage()); - $this->assertStringContainsString('historical foreign key "order" (database constraint "', $failure->getMessage()); - $this->assertStringContainsString('different update/delete actions', $failure->getMessage()); - $this->assertSame(1, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->history} WHERE version = '3'")); - } - } - - public function test_full_definition_identifies_a_constraint_among_different_actions(): void { - $migrator = $this->migrations(new AddItemNote($this->suffix)); - $migrator->migrate('3'); - $this->changePrefix(); - $external = 'fk_' . sha1($this->suffix); - $this->addConstraint($external, ' ON DELETE CASCADE'); - $migrator->migrate(); - $migrator->rollback(); - $migrator->rollback(); - $this->assertCount(1, $this->constraints()); - $this->assertSame($external, $this->constraintName()); - } - - public function test_a_new_relationship_does_not_adopt_an_existing_managed_name(): void { - $migrator = $this->migrations(); - $migrator->migrate('2'); - $this->changePrefix(); - $external = 'fk_' . sha1($this->suffix); - $this->addConstraint($external); - $migrator->migrate(); - $this->assertCount(2, $this->constraints()); - $migrator->rollback(); - $this->assertCount(1, $this->constraints()); - $this->assertSame($external, $this->constraintName()); - } - - public function test_exact_names_take_priority_over_equivalent_managed_names(): void { - $migrator = $this->migrations(new AddItemNote($this->suffix)); - $migrator->migrate('3'); - $external = 'fk_' . sha1($this->suffix); - $this->addConstraint($external); - $migrator->migrate(); - $migrator->rollback(); - $migrator->rollback(); - $this->assertCount(1, $this->constraints()); - $this->assertSame($external, $this->constraintName()); - } - public function test_preview_carries_identity_through_multiple_changes(): void { $migrator = $this->migrations(new CascadeOrderReference($this->suffix), new AddItemNote($this->suffix)); $migrator->migrate('3'); @@ -276,9 +143,9 @@ private function changePrefix(): void { } private function trackTables(): void { - $this->orders = $this->privateTable($this->suffix . '_orders'); - $this->items = $this->privateTable($this->suffix . '_items'); - $this->history = $this->privateTable($this->suffix . '_history'); + $this->orders = $this->privateTable($this->suffix . '_orders'); + $this->items = $this->privateTable($this->suffix . '_items'); + $this->privateTable($this->suffix . '_history'); } /** diff --git a/tests/integration/Migrations/ForeignKeyMigrationTest.php b/tests/integration/Migrations/ForeignKeyMigrationTest.php index ab00590..bd5d1cf 100644 --- a/tests/integration/Migrations/ForeignKeyMigrationTest.php +++ b/tests/integration/Migrations/ForeignKeyMigrationTest.php @@ -7,8 +7,7 @@ use Doctrine\DBAL\Schema\ForeignKeyConstraint; use PHPUnit\Framework\Attributes\DataProvider; use StellarWP\Foundation\Migrations\Contracts\Migration; -use StellarWP\Foundation\Migrations\Exceptions\IncompatibleSchema; -use StellarWP\Foundation\Migrations\Exceptions\LedgerFailure; +use StellarWP\Foundation\Migrations\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Migrations\MigrationsProvider; use StellarWP\Foundation\Migrations\Migrator; use StellarWP\Foundation\Migrations\ValueObjects\MigrationRegistration; @@ -25,7 +24,7 @@ use StellarWP\Foundation\Tests\Support\Fixtures\Migrations\ForeignKeys\RenameOrderReference; /** - * Relationships enforce data integrity and retain migration retry and ownership guarantees. + * Relationships enforce data integrity through creation, alteration, and reversal. */ final class ForeignKeyMigrationTest extends DatabaseTestCase { @@ -104,25 +103,11 @@ public function test_alter_defaults_to_restrict_and_drop_keeps_columns_indexes_a $this->assertTrue($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_items')->hasIndex('order_lookup')); } - public function test_replacement_changes_actions_and_retries_without_ddl_after_a_failed_ledger_write(): void { + public function test_replacement_changes_actions_and_rollback_restores_them(): void { $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddOrderReference($this->suffix), new CascadeOrderReference($this->suffix)); $migrator->migrate('3'); $this->rows(); - $trigger = $this->observer->quoteSingleIdentifier($this->suffix . '_reject_history'); - $this->observer->executeStatement("CREATE TRIGGER $trigger BEFORE INSERT ON {$this->history} FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ledger rejected'"); - - try { - $migrator->migrate(); - $this->fail('The history write must fail after DDL.'); - } catch (LedgerFailure) { - $this->assertCount(1, $this->constraints()); - } finally { - $this->observer->executeStatement('DROP TRIGGER ' . $trigger); - } - - $steps = $migrator->migrate(); - $this->assertSame([], $steps[0]->sql); - $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->history . " WHERE version = '4'")); + $migrator->migrate(); $this->observer->update($this->orders, ['id' => 3], ['id' => 1]); $this->assertSame(3, (int) $this->observer->fetchOne('SELECT order_id FROM ' . $this->items)); $this->observer->delete($this->orders, ['id' => 3]); @@ -157,16 +142,14 @@ public function test_constraint_name_changes_are_not_lost_as_equivalent_definiti $this->assertSame($old, $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue()); } - public function test_replacement_resumes_after_only_the_drop_completed(): void { + public function test_replacement_stops_after_only_the_drop_completed(): void { $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddOrderReference($this->suffix), new CascadeOrderReference($this->suffix)); $migrator->migrate('3'); $name = $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue(); $this->assertNotNull($name); $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $this->observer->quoteSingleIdentifier($name)); + $this->expectException(MigrationInterrupted::class); $migrator->migrate(); - $this->rows(); - $this->observer->delete($this->orders, ['id' => 1]); - $this->assertSame([], $this->observer->fetchAllAssociative('SELECT * FROM ' . $this->items)); } public function test_conflicting_existing_constraint_is_not_silently_replaced(): void { @@ -178,8 +161,8 @@ public function test_conflicting_existing_constraint_is_not_silently_replaced(): $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $quoted); $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' ADD CONSTRAINT ' . $quoted . ' FOREIGN KEY (order_id) REFERENCES ' . $this->orders . ' (id) ON DELETE CASCADE'); $this->observer->delete($this->history, ['version' => '3']); - $this->expectException(IncompatibleSchema::class); - $this->expectExceptionMessage('foreign key "order" (database constraint "' . $name . '")'); + $this->expectException(MigrationInterrupted::class); + $this->expectExceptionMessage('already exists'); $migrator->migrate(); } @@ -209,12 +192,12 @@ public static function foreignKeyDrift(): iterable { } /** - * Name the affected declaration and database constraint wherever drift is detected. + * Pending operations do not repair or reject unrelated relationship changes. * * @dataProvider foreignKeyDrift */ #[DataProvider('foreignKeyDrift')] - public function test_drift_names_the_logical_and_physical_foreign_key(bool $replace, bool $alterItems): void { + public function test_unrelated_relationship_drift_is_preserved(bool $replace, bool $alterItems): void { $pending = $alterItems ? new AddItemNote($this->suffix) : new AddOrderNote($this->suffix); $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateItems($this->suffix), new AddOrderReference($this->suffix), $pending); $migrator->migrate('3'); @@ -229,15 +212,13 @@ public function test_drift_names_the_logical_and_physical_foreign_key(bool $repl REFERENCES {$this->orders} (id) ON DELETE CASCADE"); } - try { - $migrator->migrate(); - $this->fail('Undeclared relationship drift must stop the migration.'); - } catch (IncompatibleSchema $failure) { - $this->assertStringContainsString('foreign key "order" (database constraint "' . $name . '")', $failure->getMessage()); - $this->assertStringContainsString($this->suffix . '_items', $failure->getMessage()); - $this->assertStringNotContainsString('table would be altered', $failure->getMessage()); - $pendingTable = $this->source->prefix . $this->suffix . ($alterItems ? '_items' : '_orders'); - $this->assertFalse($this->observer->createSchemaManager()->introspectTable($pendingTable)->hasColumn('note')); + $migrator->migrate(); + $pendingTable = $this->source->prefix . $this->suffix . ($alterItems ? '_items' : '_orders'); + $this->assertTrue($this->observer->createSchemaManager()->introspectTable($pendingTable)->hasColumn('note')); + $this->assertCount($replace ? 1 : 0, $this->constraints()); + + if ($replace) { + $this->assertSame('CASCADE', $this->constraints()[0]->onDelete()); } } @@ -263,36 +244,21 @@ public function test_create_retry_rejects_a_conflicting_foreign_key(): void { $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $quoted); $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' ADD CONSTRAINT ' . $quoted . ' FOREIGN KEY (order_id) REFERENCES ' . $this->orders . ' (id)'); $this->observer->delete($this->history, ['version' => '2']); - $this->expectException(IncompatibleSchema::class); - $this->expectExceptionMessage('foreign key "order" (database constraint "' . $name . '")'); + $this->expectException(MigrationInterrupted::class); + $this->expectExceptionMessage('already exists'); $migrator->migrate(); } - public function test_create_retry_adds_a_missing_constraint_without_recreating_the_table(): void { - $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateReferencedItems($this->suffix)); - $migrator->migrate(); - $this->rows(); - $name = $this->constraints()[0]->getObjectName()?->getIdentifier()->getValue(); - $this->assertNotNull($name); - $this->observer->executeStatement('ALTER TABLE ' . $this->items . ' DROP FOREIGN KEY ' . $this->observer->quoteSingleIdentifier($name)); - $this->observer->delete($this->history, ['version' => '2']); - $steps = $migrator->migrate(); - $this->assertStringNotContainsString('CREATE TABLE', implode('; ', $steps[0]->sql)); - $this->assertCount(1, $this->constraints()); - $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->items)); - } - /** * Resume rollback after the child table was dropped but its ledger entry remains. */ - public function test_rollback_retry_accepts_an_already_dropped_table_with_foreign_keys(): void { + public function test_rollback_retry_requires_repair_when_the_table_is_already_gone(): void { $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateReferencedItems($this->suffix)); $migrator->migrate(); $this->observer->executeStatement('DROP TABLE ' . $this->items); - $steps = $migrator->rollback(); - $this->assertSame([], $steps[0]->sql); - $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->history} WHERE version = '2'")); + $this->expectException(MigrationInterrupted::class); + $migrator->rollback(); } public function test_the_same_migrations_use_distinct_foreign_keys_on_another_site(): void { diff --git a/tests/integration/Migrations/MigrationExecutionTest.php b/tests/integration/Migrations/MigrationExecutionTest.php new file mode 100644 index 0000000..ca9c619 --- /dev/null +++ b/tests/integration/Migrations/MigrationExecutionTest.php @@ -0,0 +1,241 @@ + [ + 'table' => $this->suffix . '_history', + ], + ]; + } + + protected function setUp(): void { + parent::setUp(); + $this->container->register(MigrationsProvider::class); + $this->entries = $this->privateTable($this->suffix . '_entries'); + $this->history = $this->privateTable($this->suffix . '_history'); + } + + /** + * A recorded declaration is never invoked while applying or previewing its successor. + */ + public function test_applied_declarations_are_not_replayed(): void { + $create = new class($this->suffix . '_entries') extends Migration { + public int $calls = 0; + + /** + * Receive this test's table name. + */ + public function __construct( + private readonly string $table, + ) { + } + + /** + * Count how often the declaration is evaluated. + */ + public function up(Blueprint $schema): void { + $this->calls++; + $schema->create($this->table)->bigIncrements(); + } + }; + $migrator = $this->runner($create); + $migrator->migrate('1'); + $migrator->preview(); + $migrator->migrate(); + $migrator->rollback(); + $this->assertSame(1, $create->calls); + } + + /** + * Missing history is visible while new work can proceed on existing tables. + */ + public function test_missing_applied_files_do_not_block_forward_work_or_an_unrelated_rollback(): void { + $migrator = $this->runner(); + $migrator->migrate('1'); + $this->observer->insert($this->history, [ + 'version' => '2', + ]); + $migrator->migrate(); + $this->assertSame([ + '1', + '2', + '3', + ], $this->versions()); + $this->assertSame('missing', $migrator->status()[1]->state()); + $migrator->rollbackTo('2'); + $this->assertSame([ + '1', + '2', + ], $this->versions()); + $this->assertSame([], $migrator->rollbackTo('2')); + } + + /** + * Validate the entire selected reversal before dropping any columns or tables. + */ + public function test_missing_selected_files_stop_rollback_and_refresh_before_any_reversal(): void { + $migrator = $this->runner(); + $migrator->migrate(); + $this->observer->insert($this->history, [ + 'version' => '2', + ]); + + foreach ([ + 'rollback', + 'refresh', + ] as $operation) { + try { + $operation === 'rollback' ? $migrator->rollback(3) : $migrator->refresh(); + $this->fail('The missing middle declaration must stop the entire reversal.'); + } catch (MigrationInterrupted $failure) { + $this->assertStringContainsString('before reversing it: 2', $failure->getMessage()); + } + + $this->assertSame([ + '1', + '2', + '3', + ], $this->versions()); + $this->assertSame([], $this->observer->fetchFirstColumn('SELECT note FROM ' . $this->entries)); + } + } + + /** + * Earlier DDL can commit before a later statement fails; neither retry nor history hides it. + */ + public function test_partial_ddl_failure_leaves_work_unrecorded_and_requires_repair(): void { + $alter = new class($this->suffix) extends Migration { + /** + * Receive the isolated table prefix. + */ + public function __construct( + private readonly string $suffix, + ) { + } + + /** + * Add a column before an intentionally invalid relationship. + */ + public function up(Blueprint $schema): void { + $schema->table($this->suffix . '_entries')->string('note')->nullable(); + $schema->table($this->suffix . '_entries')->foreignKey('missing', 'id')->references($this->suffix . '_absent', 'id'); + } + }; + $migrator = $this->runner(alter: $alter); + $migrator->migrate('1'); + $this->observer->insert($this->entries, [ + 'id' => 1, + ]); + + try { + $migrator->migrate(); + $this->fail('The absent referenced table must fail the second DDL statement.'); + } catch (Exception) { + $this->assertSame([ + null, + ], $this->observer->fetchFirstColumn('SELECT note FROM ' . $this->entries)); + } + + $this->assertSame([ + '1', + ], $this->versions()); + + try { + $migrator->migrate(); + $this->fail('Retry must not silently adopt the already-created column.'); + } catch (MigrationInterrupted $failure) { + $this->assertStringContainsString('note already exists', $failure->getMessage()); + } + + $this->assertSame([ + '1', + ], $this->versions()); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->entries)); + } + + /** + * Compose two ordinary declarations with a gap for unavailable history. + */ + private function runner(?Migration $create = null, ?Migration $alter = null): Migrator { + $create ??= new class($this->suffix . '_entries') extends Migration { + /** + * Receive this test's table name. + */ + public function __construct( + private readonly string $table, + ) { + } + + /** + * Create the application table. + */ + public function up(Blueprint $schema): void { + $schema->create($this->table)->bigIncrements(); + } + + /** + * Remove the application table. + */ + public function down(Blueprint $schema): void { + $schema->drop($this->table); + } + }; + $alter ??= new class($this->suffix . '_entries') extends Migration { + /** + * Receive this test's table name. + */ + public function __construct( + private readonly string $table, + ) { + } + + /** + * Add a note to existing entries. + */ + public function up(Blueprint $schema): void { + $schema->table($this->table)->string('note')->nullable(); + } + + /** + * Remove this migration's column. + */ + public function down(Blueprint $schema): void { + $schema->table($this->table)->dropColumn('note'); + } + }; + $this->container->mergeArrayVar(MigrationsProvider::MIGRATIONS, [ + new MigrationRegistration('1', $create), + new MigrationRegistration('3', $alter), + ]); + + return $this->container->get(Migrator::class); + } + + /** + * Read the recorded identities from the observer session. + * + * @return list + */ + private function versions(): array { + return $this->observer->fetchFirstColumn('SELECT version FROM ' . $this->history . ' ORDER BY version'); + } +} diff --git a/tests/integration/Migrations/RenameMigrationTest.php b/tests/integration/Migrations/RenameMigrationTest.php index 72ccc0c..6077c08 100644 --- a/tests/integration/Migrations/RenameMigrationTest.php +++ b/tests/integration/Migrations/RenameMigrationTest.php @@ -4,8 +4,8 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; -use StellarWP\Foundation\Migrations\Exceptions\IncompatibleSchema; use StellarWP\Foundation\Migrations\Exceptions\LedgerFailure; +use StellarWP\Foundation\Migrations\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Migrations\Migration; use StellarWP\Foundation\Migrations\MigrationsProvider; use StellarWP\Foundation\Migrations\Migrator; @@ -17,7 +17,7 @@ use StellarWP\Foundation\Tests\Support\Fixtures\Database\DatabaseTestCase; /** - * Explicit renames preserve rows and remain recoverable after an interrupted history write. + * Explicit renames preserve rows, current attributes, and relationships. */ final class RenameMigrationTest extends DatabaseTestCase { @@ -72,40 +72,6 @@ public function test_table_rename_preserves_rows_and_unrelated_schema_in_both_di $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); } - /** - * Column rename retries without ddl after history write failure. - * - * @param class-string $strategy - * - * @dataProvider renameStrategies - */ - #[DataProvider('renameStrategies')] - public function test_column_rename_retries_without_ddl_after_history_write_failure(string $strategy): void { - $this->container->singleton(ColumnRename::class, $strategy); - $migrator = $this->migrations($this->createReports(), $this->renameTitle()); - $migrator->migrate('1'); - $this->insertReport(); - $this->failHistoryWrite($migrator); - $steps = $migrator->migrate(); - $this->assertSame([], $steps[0]->sql); - $this->assertSame('Original', $this->observer->fetchOne('SELECT headline FROM ' . $this->reports)); - $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->history . " WHERE version = '2'")); - } - - /** - * Table rename retries without ddl after history write failure. - */ - public function test_table_rename_retries_without_ddl_after_history_write_failure(): void { - $migrator = $this->migrations($this->createReports(), $this->renameReports()); - $migrator->migrate('1'); - $this->insertReport(); - $this->failHistoryWrite($migrator); - $steps = $migrator->migrate(); - $this->assertSame([], $steps[0]->sql); - $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->archive)); - $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->history . " WHERE version = '2'")); - } - /** * Column rename rejects existing source and destination. */ @@ -113,7 +79,7 @@ public function test_column_rename_rejects_existing_source_and_destination(): vo $migrator = $this->migrations($this->createReports(), $this->renameTitle()); $migrator->migrate('1'); $this->observer->executeStatement('ALTER TABLE ' . $this->reports . ' ADD headline VARCHAR(80) NOT NULL'); - $this->expectException(IncompatibleSchema::class); + $this->expectException(MigrationInterrupted::class); $migrator->migrate(); } @@ -124,7 +90,7 @@ public function test_table_rename_rejects_existing_source_and_destination(): voi $migrator = $this->migrations($this->createReports(), $this->renameReports()); $migrator->migrate('1'); $this->observer->executeStatement('CREATE TABLE ' . $this->archive . ' LIKE ' . $this->reports); - $this->expectException(IncompatibleSchema::class); + $this->expectException(InvalidArgumentException::class); $migrator->migrate(); } @@ -135,7 +101,7 @@ public function test_column_rename_rejects_missing_source_and_destination(): voi $migrator = $this->migrations($this->createReports(), $this->renameTitle()); $migrator->migrate('1'); $this->observer->executeStatement('ALTER TABLE ' . $this->reports . ' DROP COLUMN title'); - $this->expectException(IncompatibleSchema::class); + $this->expectException(MigrationInterrupted::class); $migrator->migrate(); } @@ -146,7 +112,7 @@ public function test_table_rename_rejects_missing_source_and_destination(): void $migrator = $this->migrations($this->createReports(), $this->renameReports()); $migrator->migrate('1'); $this->observer->executeStatement('DROP TABLE ' . $this->reports); - $this->expectException(IncompatibleSchema::class); + $this->expectException(InvalidArgumentException::class); $migrator->migrate(); } @@ -217,8 +183,8 @@ public function down(Blueprint $schema): void { $migrator = $this->migrations($this->createReports(), $rename); $migrator->migrate('1'); $this->insertReport(); - $this->failHistoryWrite($migrator); - $this->assertSame([], $migrator->migrate()[0]->sql); + $migrator->migrate(); + $this->assertSame([], $migrator->migrate()); $this->assertSame('2026-01-02 03:04:05.123456', $this->observer->fetchOne('SELECT modified_at FROM ' . $this->archive)); $column = $this->observer->fetchAssociative( "SELECT DATETIME_PRECISION, EXTRA @@ -313,8 +279,8 @@ public function up(Blueprint $schema): void { 'id' => 2, 'report_id' => 1, ]); - $this->failHistoryWrite($migrator, '3'); - $this->assertSame([], $migrator->migrate('3')[0]->sql); + $migrator->migrate('3'); + $this->assertSame([], $migrator->migrate('3')); $this->assertSame(1, (int) $this->observer->fetchOne('SELECT archive_id FROM ' . $archiveItems)); $migrator->rollback(); $this->assertSame(1, (int) $this->observer->fetchOne('SELECT report_id FROM ' . $items)); @@ -327,30 +293,6 @@ public function up(Blueprint $schema): void { $this->assertSame([], $this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_archive_items')->getForeignKeys()); } - /** - * Resume only the missing suffix of a chained rename sequence, then reverse it. - * - * @param class-string $strategy - * - * @dataProvider renameStrategies - */ - #[DataProvider('renameStrategies')] - public function test_partial_chained_renames_resume_and_roll_back_without_losing_rows(string $strategy): void { - $this->container->singleton(ColumnRename::class, $strategy); - $migrator = $this->migrations($this->createReports(), $this->chainedRename()); - $migrator->migrate('1'); - $this->insertReport(); - $sql = $migrator->preview()[0]->sql; - $this->assertCount(3, $sql); - $this->observer->executeStatement($sql[0]); - $this->observer->executeStatement($sql[1]); - $steps = $migrator->migrate(); - $this->assertCount(1, $steps[0]->sql); - $this->assertSame('Original', $this->observer->fetchOne('SELECT headline FROM ' . $this->archive)); - $migrator->rollback(); - $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); - } - /** * Preview simulates names for later migrations without changing the live tables or ledger. * @@ -394,23 +336,16 @@ public function up(Blueprint $schema): void { } /** - * An incompatible source definition must stop the migration before any rename executes. + * A rename preserves the live definition without checking historical declarations. */ - public function test_schema_drift_is_rejected_before_the_first_rename(): void { + public function test_rename_preserves_the_current_column_definition(): void { $migrator = $this->migrations($this->createReports(), $this->chainedRename()); $migrator->migrate('1'); $this->insertReport(); $this->observer->executeStatement('ALTER TABLE ' . $this->reports . " MODIFY title VARCHAR(255) NOT NULL DEFAULT ''"); - - try { - $migrator->migrate(); - $this->fail('Schema drift must fail before renaming the live table.'); - } catch (IncompatibleSchema) { - $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); - $this->assertFalse($this->observer->createSchemaManager()->tablesExist([ - $this->source->prefix . $this->suffix . '_archive', - ])); - } + $migrator->migrate(); + $this->assertSame('Original', $this->observer->fetchOne('SELECT headline FROM ' . $this->archive)); + $this->assertSame(255, $this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_archive')->getColumn('headline')->getLength()); } /** @@ -489,9 +424,9 @@ public function up(Blueprint $schema): void { } /** - * Swapping column names cannot be safely distinguished from an interrupted retry. + * Explicit rename sequences may swap column names through a temporary name. */ - public function test_column_name_cycles_are_rejected_before_sql_executes(): void { + public function test_column_name_cycles_execute_in_declaration_order(): void { $swap = new class($this->suffix) extends Migration { /** * Identify this test's private tables. @@ -515,19 +450,15 @@ public function up(Blueprint $schema): void { $migrator->migrate('1'); $this->insertReport(); - try { - $migrator->migrate(); - $this->fail('A rename cycle must be rejected before modifying the live schema.'); - } catch (InvalidArgumentException) { - $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); - $this->assertSame('2026-01-02 03:04:05.123456', $this->observer->fetchOne('SELECT updated_at FROM ' . $this->reports)); - } + $migrator->migrate(); + $this->assertSame('Original', $this->observer->fetchOne('SELECT updated_at FROM ' . $this->reports)); + $this->assertSame('2026-01-02 03:04:05.123456', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); } /** - * Reusing a source name requires a later migration so retries remain unambiguous. + * A renamed source may be reused by a later operation in the same migration. */ - public function test_renaming_and_recreating_the_source_column_in_one_migration_is_rejected(): void { + public function test_renaming_and_recreating_the_source_column(): void { $reuse = new class($this->suffix) extends Migration { /** * Identify this test's private tables. @@ -550,51 +481,15 @@ public function up(Blueprint $schema): void { $migrator->migrate('1'); $this->insertReport(); - try { - $migrator->migrate(); - $this->fail('Reusing the source name must fail before modifying the live schema.'); - } catch (InvalidArgumentException) { - $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); - $this->assertFalse($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_reports')->hasColumn('headline')); - } - } - - /** - * A failed rollback history deletion must not repeat already completed inverse renames. - * - * @param class-string $strategy - * - * @dataProvider renameStrategies - */ - #[DataProvider('renameStrategies')] - public function test_chained_rollback_retries_without_ddl_after_history_delete_failure(string $strategy): void { - $this->container->singleton(ColumnRename::class, $strategy); - $migrator = $this->migrations($this->createReports(), $this->chainedRename()); - $migrator->migrate('1'); - $this->insertReport(); $migrator->migrate(); - $trigger = $this->observer->quoteSingleIdentifier($this->suffix . '_reject_delete'); - $this->observer->executeStatement("CREATE TRIGGER $trigger BEFORE DELETE ON {$this->history} FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ledger rejected'"); - - try { - $migrator->rollback(); - $this->fail('The history deletion must fail after the inverse renames complete.'); - } catch (LedgerFailure) { - $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); - } finally { - $this->observer->executeStatement('DROP TRIGGER ' . $trigger); - } - - $steps = $migrator->rollback(); - $this->assertSame([], $steps[0]->sql); - $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); - $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->history)); + $this->assertSame('Original', $this->observer->fetchOne('SELECT headline FROM ' . $this->reports)); + $this->assertSame('', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); } /** - * An unrelated final rename does not make an earlier name-swap safe to resume. + * Later renames follow an earlier name swap in the declared order. */ - public function test_cycle_followed_by_another_rename_is_rejected_before_sql(): void { + public function test_cycle_followed_by_another_rename_executes_in_order(): void { $swap = new class($this->suffix) extends Migration { /** * Identify this test's private tables. @@ -619,14 +514,10 @@ public function up(Blueprint $schema): void { $migrator->migrate('1'); $this->insertReport(); - try { - $migrator->migrate(); - $this->fail('An ambiguous intermediate rename state must be rejected before SQL.'); - } catch (InvalidArgumentException) { - $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); - $this->assertSame('Other', $this->observer->fetchOne('SELECT subtitle FROM ' . $this->reports)); - $this->assertSame('2026-01-02 03:04:05.123456', $this->observer->fetchOne('SELECT updated_at FROM ' . $this->reports)); - } + $migrator->migrate(); + $this->assertSame('Other', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + $this->assertSame('Original', $this->observer->fetchOne('SELECT subtitle FROM ' . $this->reports)); + $this->assertSame('2026-01-02 03:04:05.123456', $this->observer->fetchOne('SELECT modified_at FROM ' . $this->reports)); } /** @@ -667,7 +558,7 @@ public function up(Blueprint $schema): void { /** * A rename can change the destination definition while preserving the existing value. */ - public function test_column_rename_and_destination_definition_change_resume_together(): void { + public function test_column_rename_and_destination_definition_change_execute_together(): void { $rename = new class($this->suffix) extends Migration { /** * Identify this test's private tables. @@ -698,8 +589,6 @@ public function down(Blueprint $schema): void { $migrator = $this->migrations($this->createReports(), $rename); $migrator->migrate('1'); $this->insertReport(); - $sql = $migrator->preview()[0]->sql; - $this->observer->executeStatement($sql[0]); $migrator->migrate(); $this->assertSame('Original', $this->observer->fetchOne('SELECT headline FROM ' . $this->reports)); $column = $this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_reports')->getColumn('headline'); @@ -709,6 +598,26 @@ public function down(Blueprint $schema): void { $this->assertSame('Original', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); } + /** + * A completed rename with missing history is reported for operator repair. + */ + public function test_interrupted_rename_is_not_repeated_or_recorded_on_retry(): void { + $migrator = $this->migrations($this->createReports(), $this->renameTitle()); + $migrator->migrate('1'); + $this->insertReport(); + $this->failHistoryWrite($migrator); + + try { + $migrator->migrate(); + $this->fail('The old column is gone; repair must precede another run.'); + } catch (MigrationInterrupted $failure) { + $this->assertStringContainsString('title does not exist', $failure->getMessage()); + } + + $this->assertSame('Original', $this->observer->fetchOne('SELECT headline FROM ' . $this->reports)); + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->history} WHERE version = '2'")); + } + private function chainedRename(): Migration { return new class($this->suffix) extends Migration { /** @@ -764,7 +673,7 @@ public function up(Blueprint $schema): void { } /** - * Exercise both SQL implementations with the same recovery guarantees. + * Exercise both SQL implementations with the same rename and preservation behavior. * * @return iterable}> */ From 7bd7e9f685fa40cbb58b34067e9642668a609322 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 24 Sep 2026 21:18:39 -0600 Subject: [PATCH 24/53] Add migration history marking and focused WP-CLI commands --- AGENTS.md | 2 + .../Make/Database/MigrationCommand.php | 2 +- .../src/content/docs/components/database.mdx | 2 +- .../content/docs/components/migrations.mdx | 94 +-- src/Migrations/Cli/MarkAppliedCommand.php | 103 ++++ src/Migrations/Cli/MarkPendingCommand.php | 71 +++ src/Migrations/Cli/Migrate.php | 152 ----- src/Migrations/Cli/MigrationOutput.php | 39 ++ src/Migrations/Cli/RefreshCommand.php | 64 +++ src/Migrations/Cli/RollbackCommand.php | 98 ++++ src/Migrations/Cli/RunCommand.php | 85 +++ src/Migrations/Cli/StatusCommand.php | 75 +++ src/Migrations/MigrationsProvider.php | 18 +- src/Migrations/Migrator.php | 72 +++ src/Migrations/README.md | 5 +- .../register-wpcli-migrate-command.php | 34 +- .../Migrations/DeclarativeMigratorTest.php | 2 +- .../Migrations/ForeignKeyMigrationTest.php | 2 +- .../Migrations/MigrationHistoryTest.php | 402 +++++++++++++ .../Database/Cli/DatabaseMigrateCest.php | 540 +++++++++++++++++- 20 files changed, 1633 insertions(+), 229 deletions(-) create mode 100644 src/Migrations/Cli/MarkAppliedCommand.php create mode 100644 src/Migrations/Cli/MarkPendingCommand.php delete mode 100644 src/Migrations/Cli/Migrate.php create mode 100644 src/Migrations/Cli/MigrationOutput.php create mode 100644 src/Migrations/Cli/RefreshCommand.php create mode 100644 src/Migrations/Cli/RollbackCommand.php create mode 100644 src/Migrations/Cli/RunCommand.php create mode 100644 src/Migrations/Cli/StatusCommand.php create mode 100644 tests/integration/Migrations/MigrationHistoryTest.php diff --git a/AGENTS.md b/AGENTS.md index 049a1b2..f438bcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,8 @@ Generated migration files return anonymous subclasses of `Migrations\Migration`. ## CLI Tooling Boundary +Give independently invoked CLI operations separate `Command` classes and colon-separated names such as `migrate:run`, `migrate:rollback`, and `migrate:status`. Each command declares only its own arguments and options, owns its validation and confirmations, and delegates application work to injected services. Avoid selecting unrelated operations through flags or positional dispatch inside one command. Reuse the existing command base for the relevant CLI; share focused collaborators such as output formatting when multiple commands need them, without adding a base class solely for common constructors or metadata. Update help, documentation, generator instructions, and real CLI tests together when command names or options change. + The monorepo's own maintenance commands and their private collaborators live under `dev/Cli/Commands/`, registered by `dev/Cli/MonorepoProvider.php`. They are wired directly in `dev/bin/foundation`, invoked through `composer run foundation`. Shared CLI infrastructure belongs in `src/Cli/`. Keep the root `config.php` convention for consuming projects; do not add a monorepo configuration file to register Foundation's own tooling. The Foundation executable reads optional root `config.php` and automatically loads `\Tooling\Tooling_Provider` through Composer. A custom `cli.tooling_provider_path` selects a project-relative or absolute Composer-mapped provider file instead. Keep prerequisite package providers in `cli.providers`; register them before the project tooling provider and resolve commands afterward. Tooling providers configure developer commands, independently of application/WordPress bootstrap. diff --git a/src/Cli/Commands/Make/Database/MigrationCommand.php b/src/Cli/Commands/Make/Database/MigrationCommand.php index 38ef5d3..da11958 100644 --- a/src/Cli/Commands/Make/Database/MigrationCommand.php +++ b/src/Cli/Commands/Make/Database/MigrationCommand.php @@ -60,7 +60,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $output->writeln(sprintf('Created: %s', $migration->relativePath)); - $output->writeln('Complete up() and down(), then preview with wp migrate --run --dry-run.'); + $output->writeln('Complete up() and down(), then preview with wp migrate:run --dry-run.'); $warning = $this->runtimeDependencyWarning(); if ($warning !== null) { diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index caaf7db..e9ec122 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -96,7 +96,7 @@ $report = $this->reports->query() `Table` is a supported base class. Inherit its constructor and implement `unprefixedName()`; application query methods may live on the subclass or in a composed repository. During 2.x, Foundation keeps its final method set (`name`, `quotedName`, `query`, `insert`, `insertGetId`, `update`, and `delete`) unchanged. New conveniences must avoid colliding with application subclass methods; they can be offered through separate collaborators. -A shared container may be reused after `switch_to_blog()` between complete operations. Tables resolve the active prefix when called; a query already built retains its original table name. Build a fresh query after switching sites. Run migrations separately for each site, for example `wp --url=https://site.example your-plugin migrate --run`. +A shared container may be reused after `switch_to_blog()` between complete operations. Tables resolve the active prefix when called; a query already built retains its original table name. Build a fresh query after switching sites. Run migrations separately for each site, for example `wp --url=https://site.example your-plugin migrate:run`. Transactions and migrations capture their starting scope and reject detected site or connection changes. Never switch sites, even temporarily, during those operations. diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index 731b60b..c198a68 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -57,7 +57,7 @@ private const array PROVIDERS = [ ]; ``` -`DatabaseProvider` supplies the shared connection. `MigrationsProvider` configures discovery, history, and migration execution; `WPCliProvider` enables `wp migrate`. Applications that invoke `Migrator` programmatically can omit `WPCliProvider`. Register application providers afterward, before resolving migration services. +`DatabaseProvider` supplies the shared connection. `MigrationsProvider` configures discovery, history, and migration execution; `WPCliProvider` enables the `wp migrate:*` commands. Applications that invoke `Migrator` programmatically can omit `WPCliProvider`. Register application providers afterward, before resolving migration services. The default migration directory is `db/migrations`. Each PHP file returns an anonymous migration object. Migration files need no namespace or Composer autoload mapping; these examples use `Plugin\\` mapped to `src/` for the application table class. Keep migrations in the production archive even though the generator is a development dependency. @@ -120,12 +120,12 @@ The filename supplies the persistent ID `20260923000001_create_reports_table`. T ### Preview and apply ```shell -wp your-plugin migrate --run --dry-run -wp your-plugin migrate --run -wp your-plugin migrate +wp your-plugin migrate:run --dry-run +wp your-plugin migrate:run +wp your-plugin migrate:status ``` -Review the previewed SQL before applying it. `--run` applies all pending migrations; the last command shows their status. The first run creates the migration ledger automatically. `your-plugin` is the configured WP-CLI command prefix. +Review the previewed SQL before applying it. `migrate:run` applies all pending migrations; the last command shows their status. The first run creates the migration ledger automatically. `your-plugin` is the configured WP-CLI command prefix. Foundation loads new migration files automatically from the configured directory. You do not add each migration to a provider list. @@ -174,15 +174,15 @@ The generated `down()` initially throws `IrreversibleMigration`. Replace it with Leave the original migration and `Reports_Table` unchanged. Preview and apply the new migration with the same commands: ```shell -wp your-plugin migrate --run --dry-run -wp your-plugin migrate --run -wp your-plugin migrate +wp your-plugin migrate:run --dry-run +wp your-plugin migrate:run +wp your-plugin migrate:status ``` Existing reports have `NULL` in `published_at` until your application writes a value. :::note[Generate migrations before applying them] -`--create=your_plugin_reports` declares ownership of a new table. `--table=your_plugin_reports` scaffolds a later alteration. Both take unprefixed database names, independently of PHP table classes. Generation does not inspect the live database. When applying an alteration, an earlier migration must already declare the table in the schema history. +`--create=your_plugin_reports` declares ownership of a new table. `--table=your_plugin_reports` scaffolds a later alteration. Both take unprefixed database names, independently of PHP table classes. Generation does not inspect the live database. The table must exist when applying an alteration. For fresh installations, create it in an earlier migration; for an existing installation, follow the [adoption workflow](#adopt-an-existing-installation). ::: ### Roll back a change @@ -190,10 +190,10 @@ Existing reports have `NULL` in `published_at` until your application writes a v To reverse the highest applied migration, inspect its `down()` and run: ```shell -wp your-plugin migrate --rollback +wp your-plugin migrate:rollback ``` -In this example that removes `published_at` while retaining the reports table. Running `--run` afterward reapplies the column; values deleted by rollback do not return. Reversing the initial migration drops the complete reports table. [Deployment commands](#deploy-and-recover) cover targets, multiple reversals, and interrupted operations. +In this example that removes `published_at` while retaining the reports table. Running `migrate:run` afterward reapplies the column; values deleted by rollback do not return. Reversing the initial migration drops the complete reports table. [Deployment commands](#deploy-and-recover) cover targets, multiple reversals, and interrupted operations. :::caution[Keep applied history immutable] Once a migration has been applied anywhere, retain its filename and declarations. Create another migration for a later change. Renaming its filename changes its recorded identity. Moving a file between configured directories preserves identity when its basename stays unchanged. Keep historical table names literal; referencing a constant on a current application class would couple history to later refactors. @@ -291,11 +291,11 @@ Column renames work across the supported database versions. Foundation uses nati Preview and apply the migration: ```shell -wp your-plugin migrate --run --dry-run -wp your-plugin migrate --run +wp your-plugin migrate:run --dry-run +wp your-plugin migrate:run ``` -Update application queries to use `headline` when deploying this schema change. Leave the earlier migration that created `title` unchanged. Running `--rollback` reverses the rename through `down()`, preserving the values under `title` again. +Update application queries to use `headline` when deploying this schema change. Leave the earlier migration that created `title` unchanged. Running `migrate:rollback` reverses the rename through `down()`, preserving the values under `title` again. ### Rename a table @@ -323,7 +323,7 @@ public function down( Blueprint $schema ): void { Remove the generated irreversible exception import and annotation. Both arguments are unprefixed names; Foundation applies the current WordPress site prefix. The destination must be unused. The rename preserves existing rows, indexes, and foreign-key relationships. -Preview and apply with `wp your-plugin migrate --run --dry-run` and `wp your-plugin migrate --run`. Update the application table class's `unprefixedName()` to return `'your_plugin_articles'`, and use that name in new migrations. Keep all earlier migration filenames and historical table names unchanged. Coordinate these application changes with migration execution; application queries must use the name currently present in the database. +Preview and apply with `wp your-plugin migrate:run --dry-run` and `wp your-plugin migrate:run`. Update the application table class's `unprefixedName()` to return `'your_plugin_articles'`, and use that name in new migrations. Keep all earlier migration filenames and historical table names unchanged. Coordinate these application changes with migration execution; application queries must use the name currently present in the database. If you recreate the old table while the renamed table still exists, choose different logical foreign-key names for the new table. For example, use `current_order` on the new table when the archived table retains `order`. Constraint names must be unique across the database on supported MySQL and MariaDB versions. @@ -403,8 +403,8 @@ Declare columns and indexes explicitly. The local and referenced column types mu Preview and apply as usual: ```shell -wp your-plugin migrate --run --dry-run -wp your-plugin migrate --run +wp your-plugin migrate:run --dry-run +wp your-plugin migrate:run ``` The database now rejects an item whose `order_id` has no matching order. Deleting an order also deletes its items because this example chooses `cascadeOnDelete()`. @@ -528,17 +528,20 @@ Programmatic installation or upgrade code injects `Migrator` and calls `$migrato | Operation | Command | | --- | --- | -| Inspect pending, applied, and missing migrations | `wp your-plugin migrate` | -| Preview pending SQL | `wp your-plugin migrate --run --dry-run` | -| Apply pending migrations | `wp your-plugin migrate --run` | -| Reconcile to a registered ID | `wp your-plugin migrate --run --to=` | -| Reverse the highest applied ID | `wp your-plugin migrate --rollback` | -| Reverse several applied IDs | `wp your-plugin migrate --rollback --step=2` | -| Reverse applied IDs above a target | `wp your-plugin migrate --rollback --to=` | -| Reverse all applied migrations | `wp your-plugin migrate --rollback --to=0 --yes` | -| Reverse and rerun everything | `wp your-plugin migrate --refresh` | - -Programmatic equivalents are `Migrator::status()`, `preview($target)`, `migrate($target)`, `rollback($steps)`, `rollbackTo($target)`, and `refresh()`. Migration operations return step objects containing the stable ID, direction, SQL, and whether a forward data callback was involved. To add a readable status description, implement `DescribesMigration` alongside `Migration`: +| Inspect pending, applied, and missing migrations | `wp your-plugin migrate:status` | +| Preview pending SQL | `wp your-plugin migrate:run --dry-run` | +| Apply pending migrations | `wp your-plugin migrate:run` | +| Reconcile to a registered ID | `wp your-plugin migrate:run --to=` | +| Reverse the highest applied ID | `wp your-plugin migrate:rollback` | +| Reverse several applied IDs | `wp your-plugin migrate:rollback --step=2` | +| Reverse applied IDs above a target | `wp your-plugin migrate:rollback --to=` | +| Reverse all applied migrations | `wp your-plugin migrate:rollback --to=0 --yes` | +| Reverse and rerun everything | `wp your-plugin migrate:refresh` | +| Record an already completed migration | `wp your-plugin migrate:mark-applied ` | +| Record all pending migrations as completed | `wp your-plugin migrate:mark-applied --all` | +| Remove a migration's history entry | `wp your-plugin migrate:mark-pending ` | + +Programmatic equivalents are `Migrator::status()`, `preview($target)`, `migrate($target)`, `rollback($steps)`, `rollbackTo($target)`, and `refresh()`. Preview and execution operations return step objects containing the stable ID, direction, SQL, and whether a forward data callback was involved. To add a readable status description, implement `DescribesMigration` alongside `Migration`: ```php use StellarWP\Foundation\Migrations\Contracts\DescribesMigration; @@ -549,12 +552,26 @@ public function describe(): string { } ``` -`migrate($target)` and `--run --to=` reverse applied IDs above the target in descending order, then apply pending IDs through it in ascending order. `latest` applies all pending migrations. `rollbackTo($target)` and `--rollback --to=` only reverse applied IDs above the target; any pending IDs, including the target itself, remain pending. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied files appear in status but do not block new forward migrations. Before rollback, Foundation checks that every migration selected for reversal is available; unrelated missing files do not block it. Refresh requires every applied declaration because it reverses all migrations. Retain migration files for fresh installations and rollbacks; deleting history is not a supported schema-squashing workflow. +`migrate($target)` and `migrate:run --to=` reverse applied IDs above the target in descending order, then apply pending IDs through it in ascending order. `latest` applies all pending migrations. `rollbackTo($target)` and `migrate:rollback --to=` only reverse applied IDs above the target; any pending IDs, including the target itself, remain pending. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied files appear in status but do not block new forward migrations. Before rollback, Foundation checks that every migration selected for reversal is available; unrelated missing files do not block it. Refresh requires every applied declaration because it reverses all migrations. Retain migration files for fresh installations and rollbacks; deleting history is not a supported schema-squashing workflow. :::danger[Rollback can delete data] -`down()` is application code. Reversing an initial migration drops its complete table, including rows and additions made later. Review the inverse and take the application's required backup before rolling back. `--refresh`, `--run --to=0`, and `--rollback --to=0` prompt for confirmation because they reverse every applied migration. Use `--yes` only when that data loss is intended. `--run --to=0 --dry-run` previews the reversal without prompting or executing it. +`down()` is application code. Reversing an initial migration drops its complete table, including rows and additions made later. Review the inverse and take the application's required backup before rolling back. `migrate:refresh`, `migrate:run --to=0`, and `migrate:rollback --to=0` prompt for confirmation because they reverse every applied migration. Use `--yes` only when that data loss is intended. `migrate:run --to=0 --dry-run` previews the reversal without prompting or executing it. ::: +### Adopt an existing installation + +For a fresh installation, run `wp your-plugin migrate:run` to create the schema and perform any data transformations. When adopting an existing installation, inspect its schema and data against the migration declarations. Mark each migration whose complete effects are already present: + +```shell +wp your-plugin migrate:mark-applied 20260923000001_create_reports_table +``` + +Use `wp your-plugin migrate:mark-applied --all` only after verifying that **every currently pending migration's schema and data effects** are fulfilled. Then inspect status and preview the next run before enabling automatic upgrades. Later migrations continue through the normal `migrate:run` workflow. + +Marking changes history under the same database advisory lock used for migration execution. It may create the ledger, but does not invoke `up()`, `down()`, or data callbacks, plan schema changes, or verify the live database. You own verification of the recorded effects. Each marking command prompts for confirmation; use `--yes` for an already reviewed operation. + +Programmatic callers inject `Migrator` and use `markApplied(string $id): void`, `markAllApplied(): void`, or `markPending(string $id): void`. `markApplied()` requires a registered ID. Repeating it preserves the existing history entry and its timestamp. `markAllApplied()` records all currently registered pending IDs atomically in one transaction, preserving existing entries. A marked migration participates in normal rollback: reversing it invokes its `down()` declaration, with the same potential for data loss as an executed migration. + ### Interrupted schema changes MySQL and MariaDB DDL can commit before a later statement, data callback, or history write fails. Foundation stops on failure and leaves that migration unrecorded. Earlier migrations that completed remain recorded, and earlier statements within the failed migration may already have changed the database. @@ -571,12 +588,21 @@ Pause automatic upgrade attempts for this application and site. Inspect the fail If the ledger itself is wrong, pause all application upgrade triggers and migration workers for the affected site and take a backup. Restore any missing declarations needed to understand or reverse the affected work, then compare the ledger's exact IDs with the live schema and each migration's schema and data effects. -Use your database administration tool against the configured physical ledger table, including its WordPress site prefix. For a confirmed bookkeeping error: +After a manual schema change or a failed history write, verify or finish the migration's complete schema and data work before recording it: + +```shell +wp your-plugin migrate:mark-applied 20260923000001_create_reports_table +``` + +If its complete inverse has already been performed, verify that recorded dependent migrations remain valid, then remove the history entry: + +```shell +wp your-plugin migrate:mark-pending 20260923000001_create_reports_table +``` -- Insert the migration's exact `version` only after verifying that its complete `up()` and any `migrate()` data callback already succeeded. The ledger supplies `applied_at` automatically. -- Delete its `version` row only after verifying that its complete inverse has already been performed and that recorded dependent migrations remain valid. +`markPending()` accepts a valid migration ID even when its declaration is missing and does nothing if the history entry is absent. A registered migration becomes pending and can run again. A missing migration disappears from status when its history is removed; restore its file before it can run again. -These are deliberate manual SQL changes, not schema repairs. Do not record an incomplete migration merely to suppress an error. Verify or finish its intended schema and data work first. After correcting history, inspect status and preview the next run before resuming upgrades. Keep an operational record of the repair. +After correcting history, inspect status and preview the next run before resuming upgrades. Keep an operational record of the repair. ### Concurrent upgrades diff --git a/src/Migrations/Cli/MarkAppliedCommand.php b/src/Migrations/Cli/MarkAppliedCommand.php new file mode 100644 index 0000000..d12d9b7 --- /dev/null +++ b/src/Migrations/Cli/MarkAppliedCommand.php @@ -0,0 +1,103 @@ +migrator->status() as $status) { + if ($status->migration === null) { + continue; + } + + WP_CLI::line($status->id . ' (' . $status->state() . ')'); + } + + WP_CLI::confirm('Record all pending migrations as applied? Confirm that their complete schema and data work already exists.', $assocArgs); + $this->migrator->markAllApplied(); + WP_CLI::success('All registered migrations are recorded as applied. No migration work was executed.'); + + return self::SUCCESS; + } + + WP_CLI::confirm('Record ' . $id . ' as applied? Confirm that its complete schema and data work already exists.', $assocArgs); + $this->migrator->markApplied((string) $id); + WP_CLI::success('Recorded ' . $id . ' as applied. No migration work was executed.'); + + return self::SUCCESS; + } + + /** + * {@inheritDoc} + */ + protected function subcommand(): string { + return 'migrate:mark-applied'; + } + + /** + * {@inheritDoc} + */ + protected function description(): string { + return 'Record one migration or all pending migrations as already applied.'; + } + + /** + * {@inheritDoc} + */ + protected function arguments(): array { + return [ + [ + 'type' => self::POSITIONAL, + 'name' => 'id', + 'description' => 'Exact migration ID to mark; omit only with --all.', + 'optional' => true, + ], + [ + 'type' => self::FLAG, + 'name' => 'all', + 'description' => 'Mark every registered pending migration applied without executing its work.', + 'optional' => true, + ], + [ + 'type' => self::FLAG, + 'name' => 'yes', + 'description' => 'Confirm without prompting.', + 'optional' => true, + ], + ]; + } +} diff --git a/src/Migrations/Cli/MarkPendingCommand.php b/src/Migrations/Cli/MarkPendingCommand.php new file mode 100644 index 0000000..ee73fb8 --- /dev/null +++ b/src/Migrations/Cli/MarkPendingCommand.php @@ -0,0 +1,71 @@ +migrator->markPending($id); + WP_CLI::success('Removed the applied record for ' . $id . '. No migration work was executed.'); + + return self::SUCCESS; + } + + /** + * {@inheritDoc} + */ + protected function subcommand(): string { + return 'migrate:mark-pending'; + } + + /** + * {@inheritDoc} + */ + protected function description(): string { + return 'Remove the applied history record for one migration.'; + } + + /** + * {@inheritDoc} + */ + protected function arguments(): array { + return [ + [ + 'type' => self::POSITIONAL, + 'name' => 'id', + 'description' => 'Exact migration ID whose applied record should be removed.', + 'optional' => false, + ], + [ + 'type' => self::FLAG, + 'name' => 'yes', + 'description' => 'Confirm without prompting.', + 'optional' => true, + ], + ]; + } +} diff --git a/src/Migrations/Cli/Migrate.php b/src/Migrations/Cli/Migrate.php deleted file mode 100644 index fa72d01..0000000 --- a/src/Migrations/Cli/Migrate.php +++ /dev/null @@ -1,152 +0,0 @@ - $args - * @param array $assocArgs - * - * @throws \Throwable When migration planning, execution, or storage fails. - */ - public function runCommand(array $args = [], array $assocArgs = []): int { - $operations = array_values(array_filter(['run', 'rollback', 'refresh'], static fn (string $flag): bool => (bool) get_flag_value($assocArgs, $flag, false))); - - if (count($operations) > 1) { - WP_CLI::error('Choose only one of --run, --rollback, or --refresh.'); - } - - $operation = $operations[0] ?? null; - $dryRun = (bool) get_flag_value($assocArgs, 'dry-run', false); - - if ($dryRun && $operation !== 'run') { - WP_CLI::error('--dry-run requires --run.'); - } - - if (isset($assocArgs['to']) && ! in_array($operation, ['run', 'rollback'], true)) { - WP_CLI::error('--to requires --run or --rollback.'); - } - - if (isset($assocArgs['step']) && ($operation !== 'rollback' || isset($assocArgs['to']))) { - WP_CLI::error('--step requires --rollback and cannot be combined with --to.'); - } - - $steps = filter_var($assocArgs['step'] ?? 1, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); - - if ($steps === false) { - WP_CLI::error('--step must be a positive integer.'); - - return self::ERROR; - } - - if ($operation === null) { - $this->showStatus(); - - return self::SUCCESS; - } - - $target = (string) ($assocArgs['to'] ?? Migrator::LATEST); - - if (! $dryRun && $target === Migrator::NONE) { - WP_CLI::confirm('Roll back all migrations? This can permanently delete application data.', $assocArgs); - } - - if ($operation === 'refresh') { - WP_CLI::confirm('Roll back and rerun all migrations? This can permanently delete application data.', $assocArgs); - $result = $this->migrator->refresh(); - } elseif ($operation === 'rollback') { - $result = isset($assocArgs['to']) ? $this->migrator->rollbackTo($target) : $this->migrator->rollback($steps); - } else { - $result = $dryRun ? $this->migrator->preview($target) : $this->migrator->migrate($target); - } - - $this->showSteps($result, $dryRun); - - return self::SUCCESS; - } - - /** - * {@inheritDoc} - */ - protected function subcommand(): string { - return 'migrate'; - } - - /** - * {@inheritDoc} - */ - protected function description(): string { - return 'Inspect, preview, apply, and reverse database migrations.'; - } - - /** - * {@inheritDoc} - */ - protected function arguments(): array { - return [ - ['type' => self::FLAG, 'name' => 'run', 'description' => 'Apply pending migrations, or reconcile to --to.', 'optional' => true], - ['type' => self::FLAG, 'name' => 'rollback', 'description' => 'Reverse the highest applied ID, or select --step or --to.', 'optional' => true], - ['type' => self::FLAG, 'name' => 'refresh', 'description' => 'Reverse and reapply all migrations.', 'optional' => true], - ['type' => self::FLAG, 'name' => 'dry-run', 'description' => 'Preview --run SQL without executing SQL or data callbacks.', 'optional' => true], - ['type' => self::ASSOCIATIVE, 'name' => 'to', 'description' => 'Target migration ID, 0 for none, or latest for all.', 'optional' => true], - ['type' => self::ASSOCIATIVE, 'name' => 'step', 'description' => 'Positive number of applied IDs to reverse.', 'optional' => true], - ['type' => self::FLAG, 'name' => 'yes', 'description' => 'Confirm refresh or --to=0 without prompting.', 'optional' => true], - ]; - } - - private function showStatus(): void { - format_items('table', array_map(static fn (MigrationStatus $status): array => [ - 'id' => $status->id, - 'migration' => $status->migration ?? '(missing)', - 'status' => $status->state(), - 'applied_at' => $status->appliedAt ?? '', - 'description' => $status->description, - ], $this->migrator->status()), ['id', 'migration', 'status', 'applied_at', 'description']); - } - - /** - * @param list $steps - */ - private function showSteps(array $steps, bool $preview): void { - foreach ($steps as $step) { - WP_CLI::line(($step->reverse ? 'Down ' : 'Up ') . $step->id); - - if (! $preview) { - continue; - } - - foreach ($step->sql as $sql) { - WP_CLI::line($sql . ';'); - } - - if ($step->hasDataStep) { - WP_CLI::line('Data callback will run after schema changes.'); - } - } - - WP_CLI::success(sprintf('%s %d migration steps.', $preview ? 'Previewed' : 'Completed', count($steps))); - } -} diff --git a/src/Migrations/Cli/MigrationOutput.php b/src/Migrations/Cli/MigrationOutput.php new file mode 100644 index 0000000..cce018e --- /dev/null +++ b/src/Migrations/Cli/MigrationOutput.php @@ -0,0 +1,39 @@ + $steps + */ + public function showSteps(array $steps, bool $preview = false): void { + foreach ($steps as $step) { + WP_CLI::line(($step->reverse ? 'Down ' : 'Up ') . $step->id); + + if (! $preview) { + continue; + } + + foreach ($step->sql as $sql) { + WP_CLI::line($sql . ';'); + } + + if ($step->hasDataStep) { + WP_CLI::line('Data callback will run after schema changes.'); + } + } + + WP_CLI::success(sprintf('%s %d migration steps.', $preview ? 'Previewed' : 'Completed', count($steps))); + } +} diff --git a/src/Migrations/Cli/RefreshCommand.php b/src/Migrations/Cli/RefreshCommand.php new file mode 100644 index 0000000..a6a59e3 --- /dev/null +++ b/src/Migrations/Cli/RefreshCommand.php @@ -0,0 +1,64 @@ +output->showSteps($this->migrator->refresh()); + + return self::SUCCESS; + } + + /** + * {@inheritDoc} + */ + protected function subcommand(): string { + return 'migrate:refresh'; + } + + /** + * {@inheritDoc} + */ + protected function description(): string { + return 'Reverse and reapply all migrations.'; + } + + /** + * {@inheritDoc} + */ + protected function arguments(): array { + return [ + [ + 'type' => self::FLAG, + 'name' => 'yes', + 'description' => 'Confirm without prompting.', + 'optional' => true, + ], + ]; + } +} diff --git a/src/Migrations/Cli/RollbackCommand.php b/src/Migrations/Cli/RollbackCommand.php new file mode 100644 index 0000000..f465b95 --- /dev/null +++ b/src/Migrations/Cli/RollbackCommand.php @@ -0,0 +1,98 @@ + [ + 'min_range' => 1, + ], + ]); + + if ($steps === false) { + WP_CLI::error('--step must be a positive integer.'); + + return self::ERROR; + } + + $target = (string) ($assocArgs['to'] ?? Migrator::LATEST); + + if ($target === Migrator::NONE) { + WP_CLI::confirm('Roll back all migrations? This can permanently delete application data.', $assocArgs); + } + + $result = isset($assocArgs['to']) ? $this->migrator->rollbackTo($target) : $this->migrator->rollback($steps); + $this->output->showSteps($result); + + return self::SUCCESS; + } + + /** + * {@inheritDoc} + */ + protected function subcommand(): string { + return 'migrate:rollback'; + } + + /** + * {@inheritDoc} + */ + protected function description(): string { + return 'Reverse the highest applied ID, several steps, or IDs above a target.'; + } + + /** + * {@inheritDoc} + */ + protected function arguments(): array { + return [ + [ + 'type' => self::ASSOCIATIVE, + 'name' => 'step', + 'description' => 'Positive number of applied IDs to reverse; defaults to one.', + 'optional' => true, + ], + [ + 'type' => self::ASSOCIATIVE, + 'name' => 'to', + 'description' => 'Reverse applied IDs above this target; 0 reverses all.', + 'optional' => true, + ], + [ + 'type' => self::FLAG, + 'name' => 'yes', + 'description' => 'Confirm without prompting.', + 'optional' => true, + ], + ]; + } +} diff --git a/src/Migrations/Cli/RunCommand.php b/src/Migrations/Cli/RunCommand.php new file mode 100644 index 0000000..16a0c12 --- /dev/null +++ b/src/Migrations/Cli/RunCommand.php @@ -0,0 +1,85 @@ +migrator->preview($target) : $this->migrator->migrate($target); + $this->output->showSteps($steps, $dryRun); + + return self::SUCCESS; + } + + /** + * {@inheritDoc} + */ + protected function subcommand(): string { + return 'migrate:run'; + } + + /** + * {@inheritDoc} + */ + protected function description(): string { + return 'Apply pending migrations, or reconcile to a target ID.'; + } + + /** + * {@inheritDoc} + */ + protected function arguments(): array { + return [ + [ + 'type' => self::ASSOCIATIVE, + 'name' => 'to', + 'description' => 'Target migration ID, 0 for none, or latest for all.', + 'optional' => true, + ], + [ + 'type' => self::FLAG, + 'name' => 'dry-run', + 'description' => 'Preview SQL without executing SQL or data callbacks.', + 'optional' => true, + ], + [ + 'type' => self::FLAG, + 'name' => 'yes', + 'description' => 'Confirm without prompting.', + 'optional' => true, + ], + ]; + } +} diff --git a/src/Migrations/Cli/StatusCommand.php b/src/Migrations/Cli/StatusCommand.php new file mode 100644 index 0000000..e02ba35 --- /dev/null +++ b/src/Migrations/Cli/StatusCommand.php @@ -0,0 +1,75 @@ + [ + 'id' => $status->id, + 'migration' => $status->migration ?? '(missing)', + 'status' => $status->state(), + 'applied_at' => $status->appliedAt ?? '', + 'description' => $status->description, + ], $this->migrator->status()), [ + 'id', + 'migration', + 'status', + 'applied_at', + 'description', + ]); + + return self::SUCCESS; + } + + /** + * {@inheritDoc} + */ + protected function subcommand(): string { + return 'migrate:status'; + } + + /** + * {@inheritDoc} + */ + protected function description(): string { + return 'Inspect pending, applied, and missing migrations.'; + } + + /** + * {@inheritDoc} + */ + protected function arguments(): array { + return []; + } +} diff --git a/src/Migrations/MigrationsProvider.php b/src/Migrations/MigrationsProvider.php index 75abb0f..de60d6c 100644 --- a/src/Migrations/MigrationsProvider.php +++ b/src/Migrations/MigrationsProvider.php @@ -6,7 +6,13 @@ use StellarWP\Foundation\Container\Contracts\Provider; use StellarWP\Foundation\Container\Contracts\Resolver as C; use StellarWP\Foundation\Container\Traits\ResolvesFoundationPrefix; -use StellarWP\Foundation\Migrations\Cli\Migrate; +use StellarWP\Foundation\Migrations\Cli\MarkAppliedCommand; +use StellarWP\Foundation\Migrations\Cli\MarkPendingCommand; +use StellarWP\Foundation\Migrations\Cli\MigrationOutput; +use StellarWP\Foundation\Migrations\Cli\RefreshCommand; +use StellarWP\Foundation\Migrations\Cli\RollbackCommand; +use StellarWP\Foundation\Migrations\Cli\RunCommand; +use StellarWP\Foundation\Migrations\Cli\StatusCommand; use StellarWP\Foundation\Migrations\Schema\Factories\MigrationComparatorFactory; use StellarWP\Foundation\Migrations\Schema\RenamePlanner; use StellarWP\Foundation\Migrations\Schema\Renames\Contracts\ColumnRename; @@ -18,7 +24,7 @@ use wpdb; /** - * Wire migration discovery, schema planning, history, and the WordPress migration command. + * Wire migration discovery, schema planning, history, and WordPress migration commands. */ final class MigrationsProvider extends Provider { @@ -36,8 +42,14 @@ public function register(): void { $this->registerTables(); $this->registerMigrations(); + $this->container->singleton(MigrationOutput::class); $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ - $c->get(Migrate::class), + $c->get(StatusCommand::class), + $c->get(RunCommand::class), + $c->get(RollbackCommand::class), + $c->get(RefreshCommand::class), + $c->get(MarkAppliedCommand::class), + $c->get(MarkPendingCommand::class), ]); } diff --git a/src/Migrations/Migrator.php b/src/Migrations/Migrator.php index ea01977..5a6c890 100644 --- a/src/Migrations/Migrator.php +++ b/src/Migrations/Migrator.php @@ -15,6 +15,7 @@ use StellarWP\Foundation\Migrations\Exceptions\MigrationAlreadyRunning; use StellarWP\Foundation\Migrations\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Migrations\Schema\SchemaPlanner; +use StellarWP\Foundation\Migrations\ValueObjects\Id; use StellarWP\Foundation\Migrations\ValueObjects\MigrationStatus; use StellarWP\Foundation\Migrations\ValueObjects\Step; use Throwable; @@ -107,6 +108,77 @@ public function refresh(): array { return $this->locked(fn (): array => array_merge($this->run(self::NONE, true), $this->run(self::LATEST, true))); } + /** + * Record a registered migration whose complete schema and data work already exists. + * + * Does not execute or verify the migration. An existing record retains its timestamp. + * The migration remains eligible for ordinary rollback through its down() declaration. + * + * @throws InvalidArgumentException When the migration is not registered. + * @throws Throwable When history storage or lock ownership fails. + */ + public function markApplied(string $id): void { + $this->migrations->get($id); + $this->locked(fn () => $this->recordApplied([ + $id, + ])); + } + + /** + * Record every currently pending migration without executing or verifying its work. + * + * Select pending IDs under the migration lock and record them in one transaction. + * Existing history is preserved. Only use this when all pending schema and data work + * has already been completed; future rollbacks still execute the normal inverses. + * + * @throws Throwable When history storage, transaction completion, or lock ownership fails. + */ + public function markAllApplied(): void { + $this->locked(fn () => $this->recordApplied($this->migrations->ids())); + } + + /** + * Remove an applied record after its work has been deliberately undone outside the runner. + * + * Does not execute down(). An absent record is a no-op. IDs without a declaration can + * be removed, but only registered migrations become available for forward execution. + * + * @throws Exceptions\InvalidMigrationId When the ID is invalid or reserved. + * @throws Throwable When history storage or lock ownership fails. + */ + public function markPending(string $id): void { + $id = (new Id($id))->value; + $this->locked(function () use ($id): void { + if (array_key_exists($id, $this->history->applied())) { + $this->history->remove($id); + } + }); + } + + /** + * Record the selected pending identities while the caller holds the migration lock. + * + * @param list $ids + */ + private function recordApplied(array $ids): void { + $applied = $this->history->applied(); + $pending = array_filter($ids, static fn (string $id): bool => ! array_key_exists($id, $applied)); + + if ($pending === []) { + return; + } + + // Initialize before the transaction because creating the ledger commits MySQL DDL. + $this->history->initialize(); + $this->db->transactional(function () use ($pending): void { + foreach ($pending as $id) { + $this->history->record($id); + } + + $this->session->check(); + }); + } + /** * One advisory lock per site ledger: independent plugins and sites never block each other. * diff --git a/src/Migrations/README.md b/src/Migrations/README.md index 9e381c4..d321a3e 100644 --- a/src/Migrations/README.md +++ b/src/Migrations/README.md @@ -16,9 +16,10 @@ composer require stellarwp/foundation-migrations ``` Register `DatabaseProvider` followed by `MigrationsProvider`. Register `WPCliProvider` -first to enable the migration command, or inject `Migrator` for programmatic upgrades. +first to enable the `migrate:*` WP-CLI commands, or inject `Migrator` for programmatic upgrades. ## Documentation See the [Migrations guide](https://foundation.nexcess.dev/components/migrations/) -for setup, generating tables and migrations, deployment, rollback, and handling interrupted upgrades. +for setup, generating tables and migrations, deployment, adopting existing installations, +rollback, and repairing history after interrupted upgrades. diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index de1f69f..19de26c 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -4,48 +4,66 @@ use StellarWP\Foundation\Container\ContainerFactory; use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\DatabaseProvider; -use StellarWP\Foundation\Migrations\Cli\Migrate; use StellarWP\Foundation\Migrations\Contracts\Migration; use StellarWP\Foundation\Migrations\MigrationsProvider; use StellarWP\Foundation\Migrations\Schema\Blueprint; use StellarWP\Foundation\Migrations\ValueObjects\MigrationRegistration; use StellarWP\Foundation\WPCli\CommandContext; use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; +use StellarWP\Foundation\WPCli\WPCliProvider; if (! class_exists(WP_CLI::class)) { return; } + require_once dirname(__DIR__, 4) . '/vendor/autoload.php'; WP_CLI::add_hook('after_wp_load', static function (): void { $container = (new ContainerFactory())->create(new ArrayConfiguration([ - 'migrations' => ['table' => 'foundation_cli_migrations'], + 'migrations' => [ + 'table' => 'foundation_cli_migrations', + ], ])); $container->register(DatabaseProvider::class); $container->register(MigrationsProvider::class); $table = new class implements Table { + /** + * Return the fixture's stable application table name. + */ public function unprefixedName(): string { return 'foundation_cli_example'; } }; $migration = new class($table) implements Migration { + /** + * Receive the application table identity. + */ public function __construct( private readonly Table $table, ) { } - public function id(): string { - return '20260623000001'; - } - + /** + * Declare the initial application table. + */ public function up(Blueprint $schema): void { $schema->create($this->table)->bigIncrements(); } + /** + * Remove the fixture's application table. + */ public function down(Blueprint $schema): void { $schema->drop($this->table); } }; - $container->mergeArrayVar(MigrationsProvider::MIGRATIONS, [new MigrationRegistration($migration->id(), $migration)]); - $container->get(Migrate::class)->register(new CommandContext(new CommandPrefix('foundation'))); + $container->mergeArrayVar(MigrationsProvider::MIGRATIONS, [ + new MigrationRegistration('20260623000001', $migration), + ]); + $context = new CommandContext(new CommandPrefix('foundation')); + + // WP-CLI is already loaded; consume the real provider contributions at this hook. + foreach ($container->get(WPCliProvider::COMMANDS) as $command) { + $command->register($context); + } }); diff --git a/tests/integration/Migrations/DeclarativeMigratorTest.php b/tests/integration/Migrations/DeclarativeMigratorTest.php index 25c5b9a..56cc1da 100644 --- a/tests/integration/Migrations/DeclarativeMigratorTest.php +++ b/tests/integration/Migrations/DeclarativeMigratorTest.php @@ -258,7 +258,7 @@ public function test_ledger_failure_requires_deliberate_repair_before_retry(): v $this->assertSame([self::CREATE], $this->history()); $this->assertSame('Keep this', $this->observer->fetchOne('SELECT note FROM ' . $this->quotedEntries)); // An operator verifies the completed schema and deliberately repairs its ledger entry. - $this->observer->insert($this->quotedHistory, ['version' => self::ALTER]); + $this->migrator()->markApplied(self::ALTER); $this->assertSame([], $this->migrator()->migrate(self::ALTER)); $this->assertSame([self::CREATE, self::ALTER], $this->history()); } diff --git a/tests/integration/Migrations/ForeignKeyMigrationTest.php b/tests/integration/Migrations/ForeignKeyMigrationTest.php index bd5d1cf..d91ce0c 100644 --- a/tests/integration/Migrations/ForeignKeyMigrationTest.php +++ b/tests/integration/Migrations/ForeignKeyMigrationTest.php @@ -292,7 +292,7 @@ public function test_removal_keeps_the_automatically_supplied_index_and_data(): $before = array_keys($this->observer->createSchemaManager()->introspectTable($tableName)->getIndexes()); $migrator->migrate(); $after = array_keys($this->observer->createSchemaManager()->introspectTable($tableName)->getIndexes()); - $this->assertSame($before, $after); + $this->assertEqualsCanonicalizing($before, $after); $this->assertCount(2, $after); $this->assertSame([], $this->constraints()); $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->items)); diff --git a/tests/integration/Migrations/MigrationHistoryTest.php b/tests/integration/Migrations/MigrationHistoryTest.php new file mode 100644 index 0000000..0fedb06 --- /dev/null +++ b/tests/integration/Migrations/MigrationHistoryTest.php @@ -0,0 +1,402 @@ + [ + 'table' => $this->suffix . '_history', + ], + ]; + } + + /** + * Wire the production provider and track its ledger for cleanup. + */ + protected function setUp(): void { + parent::setUp(); + $this->container->register(MigrationsProvider::class); + $this->historyName = $this->source->prefix . $this->suffix . '_history'; + $this->quotedHistory = $this->privateTable($this->suffix . '_history'); + } + + /** + * An adopted table keeps its data while later declarations execute normally. + */ + public function test_adoption_preserves_rows_and_allows_later_real_alteration(): void { + $table = new EntriesTable($this->suffix); + $runner = $this->runner( + new MigrationRegistration(CreateEntries::ID, new CreateEntries($table)), + new MigrationRegistration(AddEntryNote::ID, new AddEntryNote($table)), + ); + + $runner->markApplied(CreateEntries::ID); + $this->assertOriginal(); + $steps = $runner->migrate(); + $this->assertCount(1, $steps); + $this->assertSame(AddEntryNote::ID, $steps[0]->id); + $this->assertTrue($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix)->hasColumn('note')); + $this->assertOriginal(); + $this->assertSame([], $runner->migrate()); + } + + /** + * Marking never evaluates migration declarations, descriptions, or data callbacks. + */ + public function test_marking_skips_all_application_callbacks(): void { + $runner = $this->runner(new MigrationRegistration('one', $this->unexecutableMigration())); + + $runner->markApplied('one'); + $this->assertSame([ + 'one', + ], array_keys($this->history())); + $runner->markPending('one'); + $this->assertSame([], $this->history()); + $runner->markAllApplied(); + $this->assertSame([ + 'one', + ], array_keys($this->history())); + $this->assertOriginal(); + } + + /** + * Repeated adoption preserves the original timestamp and removal is repeatable. + */ + public function test_repeated_marking_preserves_timestamps(): void { + $runner = $this->runner(new MigrationRegistration('one', $this->unexecutableMigration())); + $runner->markApplied('one'); + $this->observer->update($this->quotedHistory, [ + 'applied_at' => '2001-02-03 04:05:06.123456', + ], [ + 'version' => 'one', + ]); + $original = $this->history(); + + $runner->markApplied('one'); + $runner->markAllApplied(); + $this->assertSame($original, $this->history()); + $runner->markPending('one'); + $runner->markPending('one'); + $this->assertSame([], $this->history()); + } + + /** + * The all-pending operation only inserts registered identities missing from history. + */ + public function test_mark_all_preserves_existing_and_unregistered_history(): void { + $migration = $this->unexecutableMigration(); + $runner = $this->runner( + new MigrationRegistration('first', $migration), + new MigrationRegistration('second', $migration), + new MigrationRegistration('third', $migration), + ); + $runner->markApplied('second'); + $this->observer->insert($this->quotedHistory, [ + 'version' => 'missing', + 'applied_at' => '2001-02-03 04:05:06.123456', + ]); + $existing = $this->history(); + + $runner->markAllApplied(); + $applied = $this->history(); + $this->assertSame([ + 'first', + 'missing', + 'second', + 'third', + ], array_keys($applied)); + $this->assertSame($existing['second'], $applied['second']); + $this->assertSame($existing['missing'], $applied['missing']); + $runner->markAllApplied(); + $this->assertSame($applied, $this->history()); + } + + /** + * A failure after the first batch insert cannot leave a partially adopted history. + */ + public function test_mark_all_rolls_back_earlier_inserts_when_a_later_insert_fails(): void { + $migration = $this->unexecutableMigration(); + $runner = $this->runner( + new MigrationRegistration('first', $migration), + new MigrationRegistration('second', $migration), + new MigrationRegistration('third', $migration), + ); + $runner->markApplied('third'); + $existing = $this->history(); + $trigger = $this->observer->getDatabasePlatform()->quoteSingleIdentifier($this->suffix . '_reject_second'); + $this->observer->executeStatement("CREATE TRIGGER {$trigger} + BEFORE INSERT ON {$this->quotedHistory} + FOR EACH ROW + BEGIN + IF NEW.version = 'second' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Second history insert rejected'; + END IF; + END"); + + try { + $runner->markAllApplied(); + $this->fail('The second insert must fail.'); + } catch (Throwable $failure) { + $this->assertStringContainsString('Second history insert rejected', $failure->getMessage()); + } finally { + $this->observer->executeStatement('DROP TRIGGER ' . $trigger); + } + + $this->assertSame($existing, $this->history()); + $this->assertFalse($this->db->isTransactionActive()); + $runner->markAllApplied(); + $this->assertCount(3, $this->history()); + } + + /** + * Exact identifiers preserve case distinctions and PHP numeric key handling. + */ + public function test_marking_uses_exact_case_sensitive_and_numeric_ids(): void { + $migration = $this->unexecutableMigration(); + $runner = $this->runner( + new MigrationRegistration('12', $migration), + new MigrationRegistration('Case', $migration), + new MigrationRegistration('case', $migration), + ); + + $runner->markApplied('12'); + $runner->markApplied('Case'); + $this->assertSame([ + '12', + 'Case', + ], array_map('strval', array_keys($this->history()))); + $runner->markAllApplied(); + $runner->markPending('Case'); + $this->assertSame([ + '12', + 'case', + ], array_map('strval', array_keys($this->history()))); + $runner->markPending('12'); + $this->assertSame([ + 'case', + ], array_keys($this->history())); + } + + /** + * Applied records can be removed even when their declarations are unavailable. + */ + public function test_mark_pending_removes_ledger_only_ids_idempotently(): void { + $runner = $this->runner(new MigrationRegistration('one', $this->unexecutableMigration())); + $runner->markApplied('one'); + $this->observer->insert($this->quotedHistory, [ + 'version' => 'missing', + ]); + + $runner->markPending('missing'); + $runner->markPending('missing'); + $this->assertSame([ + 'one', + ], array_keys($this->history())); + } + + /** + * Removing absent history does not create a ledger. + */ + public function test_mark_pending_without_history_does_not_create_storage(): void { + $runner = $this->runner(); + $runner->markPending('missing'); + $runner->markPending('missing'); + $this->assertHistoryAbsent(); + } + + /** + * An empty registration set has no pending records to adopt. + */ + public function test_mark_all_without_registered_migrations_leaves_storage_absent(): void { + $runner = $this->runner(); + $runner->markAllApplied(); + $this->assertHistoryAbsent(); + $this->assertOriginal(); + } + + /** + * A manually reversed migration can execute again once its history is removed. + */ + public function test_manual_reversal_can_be_marked_pending_and_applied_again(): void { + $table = new EntriesTable($this->suffix); + $runner = $this->runner( + new MigrationRegistration(CreateEntries::ID, new CreateEntries($table)), + new MigrationRegistration(AddEntryNote::ID, new AddEntryNote($table)), + ); + $runner->markApplied(CreateEntries::ID); + $runner->migrate(); + $this->observer->executeStatement("ALTER TABLE {$this->table} DROP COLUMN note"); + + $runner->markPending(AddEntryNote::ID); + $steps = $runner->migrate(); + $this->assertCount(1, $steps); + $this->assertSame(AddEntryNote::ID, $steps[0]->id); + $this->assertTrue($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix)->hasColumn('note')); + $this->assertOriginal(); + } + + /** + * An adoption request must identify a registered migration. + */ + public function test_mark_applied_rejects_unknown_ids_without_creating_history(): void { + try { + $this->runner()->markApplied('missing'); + $this->fail('Unregistered migrations cannot be marked applied.'); + } catch (InvalidArgumentException) { + $this->assertHistoryAbsent(); + } + } + + /** + * The removal boundary rejects invalid ledger identities and target aliases. + */ + public function test_mark_pending_rejects_invalid_ids_without_creating_history(): void { + $runner = $this->runner(); + + foreach ([ + '', + ' padded', + '0', + 'latest', + str_repeat('x', 192), + ] as $id) { + try { + $runner->markPending($id); + $this->fail('Invalid history identities must be rejected.'); + } catch (InvalidMigrationId) { + $this->assertHistoryAbsent(); + } + } + } + + /** + * Adoption retains the ordinary rollback behavior of the registered declaration. + */ + public function test_marked_migration_is_reversed_by_normal_rollback(): void { + $runner = $this->runner(new MigrationRegistration(CreateEntries::ID, new CreateEntries(new EntriesTable($this->suffix)))); + $runner->markApplied(CreateEntries::ID); + + $steps = $runner->rollback(); + $this->assertCount(1, $steps); + $this->assertSame(CreateEntries::ID, $steps[0]->id); + $this->assertTrue($steps[0]->reverse); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([ + $this->source->prefix . $this->suffix, + ])); + $this->assertSame([], $this->history()); + } + + /** + * Every history mutation competes for the same lock as normal migrations. + */ + public function test_another_session_holding_the_lock_blocks_each_marking_operation(): void { + $runner = $this->runner(new MigrationRegistration('one', $this->unexecutableMigration())); + $name = hash('sha256', constant('DB_NAME') . "\0" . $this->historyName); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [ + $name, + ])); + + try { + foreach ([ + static fn () => $runner->markApplied('one'), + static fn () => $runner->markAllApplied(), + static fn () => $runner->markPending('one'), + ] as $operation) { + try { + $operation(); + $this->fail('History mutation must fail during contention.'); + } catch (MigrationAlreadyRunning) { + $this->assertHistoryAbsent(); + } + } + } finally { + $this->observer->fetchOne('SELECT RELEASE_LOCK(?)', [ + $name, + ]); + } + } + + private function runner(MigrationRegistration ...$registrations): Migrator { + $this->container->mergeArrayVar(MigrationsProvider::MIGRATIONS, static fn (): array => $registrations); + + return $this->container->get(Migrator::class); + } + + /** + * @return array + */ + private function history(): array { + return $this->observer->fetchAllKeyValue("SELECT + version, + applied_at + FROM {$this->quotedHistory} + ORDER BY version"); + } + + private function assertHistoryAbsent(): void { + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([ + $this->historyName, + ])); + } + + private function unexecutableMigration(): Migration { + return new class implements DescribesMigration, MigratesData, Migration { + /** + * Fail if marking evaluates a forward declaration. + */ + public function up(Blueprint $schema): void { + throw new RuntimeException('Marking must not call up().'); + } + + /** + * Fail if marking evaluates a reverse declaration. + */ + public function down(Blueprint $schema): void { + throw new RuntimeException('Marking must not call down().'); + } + + /** + * Fail if marking executes data migration work. + */ + public function migrate(DataMigrationContext $context): void { + throw new RuntimeException('Marking must not call migrate().'); + } + + /** + * Fail if marking evaluates a display description. + */ + public function describe(): string { + throw new RuntimeException('Marking must not call describe().'); + } + }; + } +} diff --git a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php index 18569eb..fb85d7c 100644 --- a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -1,96 +1,584 @@ dropTables($I); } + /** + * Remove application and ledger tables after each scenario. + */ public function _after(WPCLITester $I): void { $this->dropTables($I); } + /** + * Inspect, preview, execute, and reverse one migration. + */ public function test_it_previews_runs_and_rolls_back_migrations(WPCLITester $I): void { - $I->cli(['foundation', 'migrate']); + $I->cli([ + 'foundation', + 'migrate:status', + ]); $I->seeResultCodeIs(0); $I->seeInShellOutput('pending'); $I->seeInShellOutput("20260623000001\t20260623000001\tpending"); - $I->cli(['foundation', 'migrate', '--run', '--dry-run']); + $I->cli([ + 'foundation', + 'migrate:run', + '--dry-run', + ]); $I->seeResultCodeIs(0); $I->seeInShellOutput('CREATE TABLE'); $I->seeInShellOutput('Previewed 1 migration steps.'); - $I->cli(['foundation', 'migrate']); + $I->cli([ + 'foundation', + 'migrate:status', + ]); $I->seeInShellOutput('pending'); - $I->cli(['foundation', 'migrate', '--run']); + $I->cli([ + 'foundation', + 'migrate:run', + ]); $I->seeResultCodeIs(0); $I->seeInShellOutput('Completed 1 migration steps.'); - $I->cli(['foundation', 'migrate']); + $I->cli([ + 'foundation', + 'migrate:status', + ]); $I->seeInShellOutput('20260623000001'); $I->seeInShellOutput('applied'); - $I->cli(['foundation', 'migrate', '--rollback', '--step=1']); + $I->cli([ + 'foundation', + 'migrate:rollback', + '--step=1', + ]); $I->seeResultCodeIs(0); $I->seeInShellOutput('Down 20260623000001'); - $I->cli(['foundation', 'migrate']); + $I->cli([ + 'foundation', + 'migrate:status', + ]); $I->seeInShellOutput('pending'); } + /** + * Apply a target version and refresh its complete history. + */ public function test_it_refreshes_and_targets_versions(WPCLITester $I): void { - $I->cli(['foundation', 'migrate', '--run', '--to=20260623000001']); + $I->cli([ + 'foundation', + 'migrate:run', + '--to=20260623000001', + ]); $I->seeResultCodeIs(0); - $I->cli(['foundation', 'migrate', '--refresh', '--yes']); + $I->cli([ + 'foundation', + 'migrate:refresh', + '--yes', + ]); $I->seeResultCodeIs(0); $I->seeInShellOutput('Completed 2 migration steps.'); - $I->cli(['foundation', 'migrate', '--rollback', '--to=0', '--yes']); + $I->cli([ + 'foundation', + 'migrate:rollback', + '--to=0', + '--yes', + ]); $I->seeResultCodeIs(0); - $I->cli(['foundation', 'migrate']); + $I->cli([ + 'foundation', + 'migrate:status', + ]); $I->seeInShellOutput('pending'); } + /** + * A rollback target must not trigger forward execution. + */ public function test_target_rollback_does_not_apply_a_pending_target(WPCLITester $I): void { - $I->cli(['foundation', 'migrate', '--rollback', '--to=20260623000001']); + $I->cli([ + 'foundation', + 'migrate:rollback', + '--to=20260623000001', + ]); $I->seeResultCodeIs(0); $I->seeInShellOutput('Completed 0 migration steps.'); - $I->cli(['foundation', 'migrate']); + $I->cli([ + 'foundation', + 'migrate:status', + ]); $I->seeInShellOutput('pending'); } + /** + * Declined full reversals preserve history while previews never prompt. + */ public function test_full_reversals_require_confirmation_but_previews_do_not(WPCLITester $I): void { - foreach (['--run', '--rollback'] as $operation) { - $I->cli(['foundation', 'migrate', '--run']); + foreach ([ + 'migrate:run', + 'migrate:rollback', + ] as $operation) { + $I->cli([ + 'foundation', + 'migrate:run', + ]); $I->seeResultCodeIs(0); - $I->cli(['foundation', 'migrate', $operation, '--to=0'], null, "n\n"); + $I->cli([ + 'foundation', + $operation, + '--to=0', + ], null, "n\n"); $I->seeInShellOutput('Roll back all migrations?'); - $I->cli(['foundation', 'migrate']); + $I->cli([ + 'foundation', + 'migrate:status', + ]); $I->seeInShellOutput('applied'); - $I->cli(['foundation', 'migrate', '--run', '--to=0', '--dry-run']); + $I->cli([ + 'foundation', + 'migrate:run', + '--to=0', + '--dry-run', + ]); $I->seeResultCodeIs(0); $I->seeInShellOutput('Previewed 1 migration steps.'); - $I->cli(['foundation', 'migrate', $operation, '--to=0', '--yes']); + $I->cli([ + 'foundation', + $operation, + '--to=0', + '--yes', + ]); $I->seeResultCodeIs(0); $I->seeInShellOutput('Completed 1 migration steps.'); - $I->cli(['foundation', 'migrate']); + $I->cli([ + 'foundation', + 'migrate:status', + ]); $I->seeInShellOutput('pending'); } } - public function test_invalid_options_fail_before_migration_execution(WPCLITester $I): void { - foreach ([['--run', '--refresh'], ['--dry-run'], ['--rollback', '--step=0'], ['--rollback', '--step=1', '--to=0']] as $flags) { - $I->cli(['foundation', 'migrate', ...$flags]); + /** + * Commands reject arguments and flags outside their own synopsis. + */ + public function test_unknown_or_wrong_command_options_are_rejected(WPCLITester $I): void { + foreach ([ + [ + 'migrate:status', + '--dry-run', + ], + [ + 'migrate:status', + 'unexpected', + ], + [ + 'migrate:run', + '--step=1', + ], + [ + 'migrate:rollback', + '--dry-run', + ], + [ + 'migrate:refresh', + '--to=0', + ], + [ + 'migrate:mark-applied', + '--all', + '--dry-run', + ], + [ + 'migrate:mark-pending', + '20260623000001', + '--all', + ], + [ + 'migrate:run', + '--unknown-option', + ], + ] as $args) { + $I->cli([ + 'foundation', + ...$args, + ]); + $I->seeResultCodeIs(1); + Assert::assertStringContainsString( + $args[0] === 'migrate:status' + ? 'migrate:status does not accept arguments or options.' + : 'Parameter errors:', + $I->grabLastShellErrorOutput(), + ); + } + + $I->cli([ + 'foundation', + 'migrate:status', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('pending'); + } + + /** + * Rollback validates positive steps and exclusive target selection. + */ + public function test_rollback_rejects_invalid_step_and_target_combinations(WPCLITester $I): void { + foreach ([ + [ + '--step=0', + ], + [ + '--step=-1', + ], + [ + '--step=abc', + ], + [ + '--step=1', + '--to=0', + ], + ] as $args) { + $I->cli([ + 'foundation', + 'migrate:rollback', + ...$args, + '--yes', + ]); $I->seeResultCodeIs(1); + Assert::assertStringContainsString('--step', $I->grabLastShellErrorOutput()); } - $I->cli(['foundation', 'migrate']); + + $I->cli([ + 'foundation', + 'migrate:status', + ]); + $I->seeInShellOutput('pending'); + } + + /** + * Adopt an existing table and prove history removal preserves its rows. + */ + public function test_marking_adopts_existing_work_and_removes_only_history(WPCLITester $I): void { + $I->cli([ + 'eval', + <<<'PHP' + global $wpdb; + $wpdb->query("CREATE TABLE {$wpdb->prefix}foundation_cli_example ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY + ) ENGINE=InnoDB"); + $wpdb->insert($wpdb->prefix . 'foundation_cli_example', [ + 'id' => 42, + ]); + PHP, + ]); + $I->seeResultCodeIs(0); + $I->cli([ + 'foundation', + 'migrate:mark-applied', + '20260623000001', + '--yes', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Recorded 20260623000001 as applied.'); + $I->cli([ + 'foundation', + 'migrate:run', + ]); $I->seeResultCodeIs(0); + $I->seeInShellOutput('Completed 0 migration steps.'); + $I->cli([ + 'foundation', + 'migrate:mark-pending', + '20260623000001', + '--yes', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Removed the applied record for 20260623000001.'); + $I->cli([ + 'foundation', + 'migrate:status', + ]); $I->seeInShellOutput('pending'); + $I->cli([ + 'eval', + <<<'PHP' + global $wpdb; + echo $wpdb->get_var("SELECT id + FROM {$wpdb->prefix}foundation_cli_example"); + PHP, + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('42'); + } + + /** + * Confirm bulk bookkeeping without executing the pending create declaration. + */ + public function test_mark_all_requires_confirmation_and_skips_application_schema(WPCLITester $I): void { + $I->cli([ + 'foundation', + 'migrate:mark-applied', + '--all', + ], null, "n\n"); + $I->seeInShellOutput('20260623000001 (pending)'); + $I->seeInShellOutput('Record all pending migrations as applied?'); + $I->cli([ + 'foundation', + 'migrate:status', + ]); + $I->seeInShellOutput('pending'); + + for ($attempt = 0; $attempt < 2; $attempt++) { + $I->cli([ + 'foundation', + 'migrate:mark-applied', + '--all', + '--yes', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('All registered migrations are recorded as applied.'); + } + + $I->cli([ + 'eval', + <<<'PHP' + global $wpdb; + $table = $wpdb->get_var($wpdb->prepare( + 'SHOW TABLES LIKE %s', + $wpdb->esc_like($wpdb->prefix . 'foundation_cli_example'), + )); + echo $table === null ? 'No application table created' : 'Unexpected table'; + PHP, + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('No application table created'); + } + + /** + * Invalid history requests must leave the migration pending. + */ + public function test_history_marking_rejects_ambiguous_or_unknown_requests(WPCLITester $I): void { + foreach ([ + [ + 'migrate:status', + '--all', + ], + [ + 'migrate:mark-applied', + ], + [ + 'migrate:mark-applied', + 'unknown', + ], + [ + 'migrate:mark-applied', + '20260623000001', + '--all', + ], + [ + 'migrate:mark-pending', + ], + [ + 'migrate:mark-pending', + '--all', + ], + [ + 'migrate:mark-pending', + '0', + ], + [ + 'migrate:mark-applied', + '--all', + '--run', + ], + [ + 'migrate:mark-applied', + '--all', + '--dry-run', + ], + [ + 'migrate:mark-pending', + '20260623000001', + '--to=0', + ], + ] as $args) { + $I->cli([ + 'foundation', + ...$args, + '--yes', + ]); + $I->seeResultCodeIs(1); + } + + $I->cli([ + 'foundation', + 'migrate:status', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('pending'); + } + + /** + * Single-record marking leaves history unchanged when confirmation is declined. + */ + public function test_declining_single_history_operations_preserves_status(WPCLITester $I): void { + $I->cli([ + 'foundation', + 'migrate:mark-applied', + '20260623000001', + ], null, "n\n"); + $I->seeInShellOutput('Record 20260623000001 as applied?'); + $I->cli([ + 'foundation', + 'migrate:status', + ]); + $I->seeInShellOutput('pending'); + $I->cli([ + 'foundation', + 'migrate:run', + ]); + $I->seeResultCodeIs(0); + $I->cli([ + 'foundation', + 'migrate:mark-pending', + '20260623000001', + ], null, "n\n"); + $I->seeInShellOutput('Remove the applied record for 20260623000001?'); + $I->cli([ + 'foundation', + 'migrate:status', + ]); + $I->seeInShellOutput('applied'); + } + + /** + * A declined refresh preserves application rows that its rollback would delete. + */ + public function test_declining_refresh_preserves_application_rows(WPCLITester $I): void { + $I->cli([ + 'foundation', + 'migrate:run', + ]); + $I->seeResultCodeIs(0); + $I->cli([ + 'eval', + <<<'PHP' + global $wpdb; + $wpdb->insert($wpdb->prefix . 'foundation_cli_example', [ + 'id' => 42, + ]); + PHP, + ]); + $I->seeResultCodeIs(0); + $I->cli([ + 'foundation', + 'migrate:refresh', + ], null, "n\n"); + $I->seeInShellOutput('Roll back and rerun all migrations?'); + $I->cli([ + 'eval', + <<<'PHP' + global $wpdb; + echo $wpdb->get_var("SELECT id + FROM {$wpdb->prefix}foundation_cli_example"); + PHP, + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('42'); + $I->cli([ + 'foundation', + 'migrate:status', + ]); + $I->seeInShellOutput('applied'); + } + + /** + * Each registered command exposes its own help and relevant options. + */ + public function test_each_command_has_its_own_help_synopsis(WPCLITester $I): void { + foreach ([ + 'migrate:status' => [], + 'migrate:run' => [ + '--to', + '--dry-run', + ], + 'migrate:rollback' => [ + '--step', + '--to', + ], + 'migrate:refresh' => [ + '--yes', + ], + 'migrate:mark-applied' => [ + '', + '--all', + ], + 'migrate:mark-pending' => [ + '', + ], + ] as $command => $options) { + $I->cli([ + 'help', + 'foundation', + $command, + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('SYNOPSIS'); + $I->seeInShellOutput('wp foundation ' . $command); + + foreach ($options as $option) { + $I->seeInShellOutput($option); + } + } + } + + /** + * The replaced multipurpose command and its flag form are not retained as aliases. + */ + public function test_old_migrate_command_is_not_registered(WPCLITester $I): void { + foreach ([ + [], + [ + '--run', + ], + [ + 'mark-applied', + '20260623000001', + '--yes', + ], + ] as $args) { + $I->cli([ + 'foundation', + 'migrate', + ...$args, + ]); + $I->seeResultCodeIs(1); + Assert::assertStringContainsString('is not a registered subcommand', $I->grabLastShellErrorOutput()); + } } private function dropTables(WPCLITester $I): void { - $I->cli(['eval', <<<'PHP' + $I->cli([ + 'eval', + <<<'PHP' global $wpdb; $prefix = $wpdb->prefix; + if ($wpdb->query("DROP TABLE IF EXISTS {$prefix}foundation_cli_migrations, {$prefix}foundation_cli_example") === false) { WP_CLI::error($wpdb->last_error); } - PHP]); + PHP, + ]); $I->seeResultCodeIs(0); } } From 0c56e2ed05a4f4eb6d375d41394aa442bbbbeb78 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 25 Sep 2026 08:53:14 -0600 Subject: [PATCH 25/53] Add wp nx migrate:reset command --- .../content/docs/components/migrations.mdx | 8 +- src/Migrations/Cli/ResetCommand.php | 64 ++++++++++++ src/Migrations/MigrationsProvider.php | 2 + .../Database/Cli/DatabaseMigrateCest.php | 97 +++++++++++++++++-- 4 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 src/Migrations/Cli/ResetCommand.php diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index c198a68..210d091 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -535,12 +535,14 @@ Programmatic installation or upgrade code injects `Migrator` and calls `$migrato | Reverse the highest applied ID | `wp your-plugin migrate:rollback` | | Reverse several applied IDs | `wp your-plugin migrate:rollback --step=2` | | Reverse applied IDs above a target | `wp your-plugin migrate:rollback --to=` | -| Reverse all applied migrations | `wp your-plugin migrate:rollback --to=0 --yes` | +| Reverse all applied migrations | `wp your-plugin migrate:reset` | | Reverse and rerun everything | `wp your-plugin migrate:refresh` | | Record an already completed migration | `wp your-plugin migrate:mark-applied ` | | Record all pending migrations as completed | `wp your-plugin migrate:mark-applied --all` | | Remove a migration's history entry | `wp your-plugin migrate:mark-pending ` | +`migrate:reset` calls every applied migration's `down()` in descending ID order and removes each successfully reversed entry from history. It leaves migrations pending without rerunning them; `migrate:refresh` reverses and then reapplies them. Reset is equivalent to `migrate:rollback --to=0`, or `$migrator->rollbackTo(Migrator::NONE)` in application code. It executes the declared inverses rather than dropping every table in the database. If an inverse fails, execution stops; inspect and repair any partial changes before retrying. + Programmatic equivalents are `Migrator::status()`, `preview($target)`, `migrate($target)`, `rollback($steps)`, `rollbackTo($target)`, and `refresh()`. Preview and execution operations return step objects containing the stable ID, direction, SQL, and whether a forward data callback was involved. To add a readable status description, implement `DescribesMigration` alongside `Migration`: ```php @@ -552,10 +554,10 @@ public function describe(): string { } ``` -`migrate($target)` and `migrate:run --to=` reverse applied IDs above the target in descending order, then apply pending IDs through it in ascending order. `latest` applies all pending migrations. `rollbackTo($target)` and `migrate:rollback --to=` only reverse applied IDs above the target; any pending IDs, including the target itself, remain pending. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied files appear in status but do not block new forward migrations. Before rollback, Foundation checks that every migration selected for reversal is available; unrelated missing files do not block it. Refresh requires every applied declaration because it reverses all migrations. Retain migration files for fresh installations and rollbacks; deleting history is not a supported schema-squashing workflow. +`migrate($target)` and `migrate:run --to=` reverse applied IDs above the target in descending order, then apply pending IDs through it in ascending order. `latest` applies all pending migrations. `rollbackTo($target)` and `migrate:rollback --to=` only reverse applied IDs above the target; any pending IDs, including the target itself, remain pending. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied files appear in status but do not block new forward migrations. Before rollback, Foundation checks that every migration selected for reversal is available; unrelated missing files do not block it. Reset and refresh require every applied declaration because they reverse all migrations. Retain migration files for fresh installations and rollbacks; deleting history is not a supported schema-squashing workflow. :::danger[Rollback can delete data] -`down()` is application code. Reversing an initial migration drops its complete table, including rows and additions made later. Review the inverse and take the application's required backup before rolling back. `migrate:refresh`, `migrate:run --to=0`, and `migrate:rollback --to=0` prompt for confirmation because they reverse every applied migration. Use `--yes` only when that data loss is intended. `migrate:run --to=0 --dry-run` previews the reversal without prompting or executing it. +`down()` is application code. Reversing an initial migration drops its complete table, including rows and additions made later. Review the inverse and take the application's required backup before rolling back. `migrate:reset`, `migrate:refresh`, `migrate:run --to=0`, and `migrate:rollback --to=0` prompt for confirmation because they reverse every applied migration. Use `--yes` only when that data loss is intended. `migrate:run --to=0 --dry-run` previews the reversal without prompting or executing it. ::: ### Adopt an existing installation diff --git a/src/Migrations/Cli/ResetCommand.php b/src/Migrations/Cli/ResetCommand.php new file mode 100644 index 0000000..32aba8d --- /dev/null +++ b/src/Migrations/Cli/ResetCommand.php @@ -0,0 +1,64 @@ +output->showSteps($this->migrator->rollbackTo(Migrator::NONE)); + + return self::SUCCESS; + } + + /** + * {@inheritDoc} + */ + protected function subcommand(): string { + return 'migrate:reset'; + } + + /** + * {@inheritDoc} + */ + protected function description(): string { + return 'Reverse all applied migrations without rerunning them.'; + } + + /** + * {@inheritDoc} + */ + protected function arguments(): array { + return [ + [ + 'type' => self::FLAG, + 'name' => 'yes', + 'description' => 'Confirm without prompting.', + 'optional' => true, + ], + ]; + } +} diff --git a/src/Migrations/MigrationsProvider.php b/src/Migrations/MigrationsProvider.php index de60d6c..236019b 100644 --- a/src/Migrations/MigrationsProvider.php +++ b/src/Migrations/MigrationsProvider.php @@ -10,6 +10,7 @@ use StellarWP\Foundation\Migrations\Cli\MarkPendingCommand; use StellarWP\Foundation\Migrations\Cli\MigrationOutput; use StellarWP\Foundation\Migrations\Cli\RefreshCommand; +use StellarWP\Foundation\Migrations\Cli\ResetCommand; use StellarWP\Foundation\Migrations\Cli\RollbackCommand; use StellarWP\Foundation\Migrations\Cli\RunCommand; use StellarWP\Foundation\Migrations\Cli\StatusCommand; @@ -47,6 +48,7 @@ public function register(): void { $c->get(StatusCommand::class), $c->get(RunCommand::class), $c->get(RollbackCommand::class), + $c->get(ResetCommand::class), $c->get(RefreshCommand::class), $c->get(MarkAppliedCommand::class), $c->get(MarkPendingCommand::class), diff --git a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php index fb85d7c..9998c3d 100644 --- a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -3,7 +3,7 @@ use PHPUnit\Framework\Assert; /** - * Exercise the six migration commands through their registered WP-CLI synopses. + * Exercise migration commands through their registered WP-CLI synopses. */ final class DatabaseMigrateCest { @@ -102,6 +102,65 @@ public function test_it_refreshes_and_targets_versions(WPCLITester $I): void { $I->seeInShellOutput('pending'); } + /** + * Reset reverses applied work, preserves WordPress, and never runs pending work. + */ + public function test_reset_reverses_migrations_without_reapplying_them(WPCLITester $I): void { + $I->cli([ + 'foundation', + 'migrate:reset', + '--yes', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Completed 0 migration steps.'); + $I->cli([ + 'foundation', + 'migrate:run', + ]); + $I->seeResultCodeIs(0); + $I->cli([ + 'foundation', + 'migrate:reset', + '--yes', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Down 20260623000001'); + $I->seeInShellOutput('Completed 1 migration steps.'); + $I->cli([ + 'foundation', + 'migrate:status', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('pending'); + $I->cli([ + 'eval', + <<<'PHP' + global $wpdb; + $table = $wpdb->get_var($wpdb->prepare( + 'SHOW TABLES LIKE %s', + $wpdb->esc_like($wpdb->prefix . 'foundation_cli_example'), + )); + echo $table === null ? 'Application table removed' : 'Unexpected table'; + echo get_option('siteurl') ? ' WordPress preserved' : ' WordPress missing'; + PHP, + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Application table removed WordPress preserved'); + $I->cli([ + 'foundation', + 'migrate:reset', + '--yes', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Completed 0 migration steps.'); + $I->cli([ + 'foundation', + 'migrate:run', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Completed 1 migration steps.'); + } + /** * A rollback target must not trigger forward execution. */ @@ -189,6 +248,18 @@ public function test_unknown_or_wrong_command_options_are_rejected(WPCLITester $ 'migrate:rollback', '--dry-run', ], + [ + 'migrate:reset', + '--step=1', + ], + [ + 'migrate:reset', + '--to=0', + ], + [ + 'migrate:reset', + '--dry-run', + ], [ 'migrate:refresh', '--to=0', @@ -462,9 +533,9 @@ public function test_declining_single_history_operations_preserves_status(WPCLIT } /** - * A declined refresh preserves application rows that its rollback would delete. + * Declined reset and refresh commands preserve application rows and history. */ - public function test_declining_refresh_preserves_application_rows(WPCLITester $I): void { + public function test_declining_reset_and_refresh_preserves_application_rows(WPCLITester $I): void { $I->cli([ 'foundation', 'migrate:run', @@ -480,11 +551,18 @@ public function test_declining_refresh_preserves_application_rows(WPCLITester $I PHP, ]); $I->seeResultCodeIs(0); - $I->cli([ - 'foundation', - 'migrate:refresh', - ], null, "n\n"); - $I->seeInShellOutput('Roll back and rerun all migrations?'); + + foreach ([ + 'migrate:reset' => 'Roll back all migrations?', + 'migrate:refresh' => 'Roll back and rerun all migrations?', + ] as $command => $confirmation) { + $I->cli([ + 'foundation', + $command, + ], null, "n\n"); + $I->seeInShellOutput($confirmation); + } + $I->cli([ 'eval', <<<'PHP' @@ -516,6 +594,9 @@ public function test_each_command_has_its_own_help_synopsis(WPCLITester $I): voi '--step', '--to', ], + 'migrate:reset' => [ + '--yes', + ], 'migrate:refresh' => [ '--yes', ], From 150747980f0719d19012b39d09a89604ca953df0 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 25 Sep 2026 08:59:37 -0600 Subject: [PATCH 26/53] Improve migration cli docs --- .../content/docs/components/migrations.mdx | 194 +++++++++++++++--- 1 file changed, 161 insertions(+), 33 deletions(-) diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index 210d091..b62f99e 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -119,13 +119,15 @@ The filename supplies the persistent ID `20260923000001_create_reports_table`. T ### Preview and apply +Review the generated SQL, apply the pending migration, then check its recorded status: + ```shell wp your-plugin migrate:run --dry-run wp your-plugin migrate:run wp your-plugin migrate:status ``` -Review the previewed SQL before applying it. `migrate:run` applies all pending migrations; the last command shows their status. The first run creates the migration ledger automatically. `your-plugin` is the configured WP-CLI command prefix. +The first run creates the migration ledger automatically. `your-plugin` is the configured WP-CLI command prefix. Foundation loads new migration files automatically from the configured directory. You do not add each migration to a provider list. @@ -193,12 +195,126 @@ To reverse the highest applied migration, inspect its `down()` and run: wp your-plugin migrate:rollback ``` -In this example that removes `published_at` while retaining the reports table. Running `migrate:run` afterward reapplies the column; values deleted by rollback do not return. Reversing the initial migration drops the complete reports table. [Deployment commands](#deploy-and-recover) cover targets, multiple reversals, and interrupted operations. +In this example that removes `published_at` while retaining the reports table. Running `migrate:run` afterward reapplies the column; values deleted by rollback do not return. Reversing the initial migration drops the complete reports table. [Migration commands](#run-and-reverse-migrations) cover targets, multiple reversals, reset, and refresh. :::caution[Keep applied history immutable] Once a migration has been applied anywhere, retain its filename and declarations. Create another migration for a later change. Renaming its filename changes its recorded identity. Moving a file between configured directories preserves identity when its basename stays unchanged. Keep historical table names literal; referencing a constant on a current application class would couple history to later refactors. ::: +## Run and reverse migrations + +The examples use the `your-plugin` command prefix configured during setup. A complete WordPress application using Foundation's default prefix runs the same commands as `wp nx migrate:*`. + +### Run pending migrations + +Apply all pending migrations in ascending ID order: + +```shell +wp your-plugin migrate:run +``` + +Foundation holds the application's migration lock and records each migration after its schema and data work succeeds. The first run creates the history table. If no migrations are pending, the command completes without changing the schema. + +### Check migration status + +See which migrations are applied, pending, or recorded in history with a missing declaration: + +```shell +wp your-plugin migrate:status +``` + +Status includes each migration's ID, application timestamp, and optional [description](#describe-a-migration). A missing applied file does not block new forward migrations, but it must be restored before reversing that migration. + +### Preview migration SQL + +Review the SQL for pending migrations before applying them: + +```shell +wp your-plugin migrate:run --dry-run +``` + +Preview does not change the schema or execute data callbacks. It reports when a data callback will run. The database can change between preview and execution, so review failures even after a successful preview. + +### Migrate to a specific version + +Bring the schema to a selected migration by reversing applied IDs above it, then applying pending IDs through it. The target is the migration's filename without `.php`. + +A target below the current version can delete data through `down()`. Preview the target first: + +```shell +wp your-plugin migrate:run --to=20260924000001_add_published_at --dry-run +``` + +After reviewing the planned changes, apply the target: + +```shell +wp your-plugin migrate:run --to=20260924000001_add_published_at +``` + +IDs determine order across all migration directories. Choose a target that respects your migration dependencies, including older IDs contributed by newly enabled packages. Omit `--to`, or use `--to=latest`, to apply every pending migration. + +### Roll back migrations + +Reverse the highest applied migration ID by running its `down()` method: + +```shell +wp your-plugin migrate:rollback +``` + +To reverse several applied migrations, specify a count: + +```shell +wp your-plugin migrate:rollback --step=2 +``` + +To reverse all applied IDs above a specific target, use `--to`. This leaves the target in place if already applied and never applies pending migrations: + +```shell +wp your-plugin migrate:rollback --to=20260923000001_create_reports_table +``` + +Choose either `--step` or `--to`. Rollback counts migration IDs, not deployment batches. Foundation checks that the declarations selected for reversal exist; unrelated missing files do not block the operation. + +:::danger[Rollback can delete data] +Review each selected migration's `down()` and take the application's required backup before running it. An inverse may remove columns or entire tables, including their rows. Reapplying the migration recreates the schema but does not restore deleted data. A failed inverse stops execution; follow [failure recovery](#interrupted-schema-changes) before retrying. +::: + +### Reset all migrations + +Reverse every applied migration and leave the migrations pending: + +```shell +wp your-plugin migrate:reset +``` + +Reset runs `down()` in descending ID order and removes each successfully reversed entry from history. It does not rerun migrations. All applied declarations must be available, and an irreversible or failed inverse stops the reset. + +The command asks for confirmation because the inverses can delete application data. For an already reviewed operation, skip the prompt with `--yes`: + +```shell +wp your-plugin migrate:reset --yes +``` + +`migrate:rollback --to=0` provides the same reversal. To preview all inverses without executing them or prompting: + +```shell +wp your-plugin migrate:run --to=0 --dry-run +``` + +### Roll back and rerun migrations + +Rebuild the schema by reversing all applied migrations, then running all migrations again: + +```shell +wp your-plugin migrate:refresh +``` + +Refresh holds one migration lock across reversal and reapplication. Like reset, it requires every applied declaration and working `down()` methods. It asks for confirmation because reversing migrations can delete data; use `--yes` after reviewing the operation: + +```shell +wp your-plugin migrate:refresh --yes +``` + ## Write schema changes `up()` and `down()` declare schema changes. Foundation evaluates the selected declaration during planning, including previews. Keep queries and application work in the separate [data callback](#transform-existing-data). @@ -524,41 +640,25 @@ Foundation runs this callback after schema changes and before writing history. I ## Deploy and recover -Programmatic installation or upgrade code injects `Migrator` and calls `$migrator->migrate()` at its chosen upgrade boundary. A public plugin should run this after WordPress and its providers are ready, and record its installed application version only after migration succeeds. Run the same upgrade workflow for each affected site. +### Run migrations from application code -| Operation | Command | -| --- | --- | -| Inspect pending, applied, and missing migrations | `wp your-plugin migrate:status` | -| Preview pending SQL | `wp your-plugin migrate:run --dry-run` | -| Apply pending migrations | `wp your-plugin migrate:run` | -| Reconcile to a registered ID | `wp your-plugin migrate:run --to=` | -| Reverse the highest applied ID | `wp your-plugin migrate:rollback` | -| Reverse several applied IDs | `wp your-plugin migrate:rollback --step=2` | -| Reverse applied IDs above a target | `wp your-plugin migrate:rollback --to=` | -| Reverse all applied migrations | `wp your-plugin migrate:reset` | -| Reverse and rerun everything | `wp your-plugin migrate:refresh` | -| Record an already completed migration | `wp your-plugin migrate:mark-applied ` | -| Record all pending migrations as completed | `wp your-plugin migrate:mark-applied --all` | -| Remove a migration's history entry | `wp your-plugin migrate:mark-pending ` | - -`migrate:reset` calls every applied migration's `down()` in descending ID order and removes each successfully reversed entry from history. It leaves migrations pending without rerunning them; `migrate:refresh` reverses and then reapplies them. Reset is equivalent to `migrate:rollback --to=0`, or `$migrator->rollbackTo(Migrator::NONE)` in application code. It executes the declared inverses rather than dropping every table in the database. If an inverse fails, execution stops; inspect and repair any partial changes before retrying. - -Programmatic equivalents are `Migrator::status()`, `preview($target)`, `migrate($target)`, `rollback($steps)`, `rollbackTo($target)`, and `refresh()`. Preview and execution operations return step objects containing the stable ID, direction, SQL, and whether a forward data callback was involved. To add a readable status description, implement `DescribesMigration` alongside `Migration`: +Inject `StellarWP\Foundation\Migrations\Migrator` into the service that owns installation or upgrades. Once WordPress and the application's providers are ready, run pending migrations: ```php -use StellarWP\Foundation\Migrations\Contracts\DescribesMigration; - -// Add DescribesMigration to the migration's implements list. -public function describe(): string { - return 'Create report storage'; -} +$migrator->migrate(); ``` -`migrate($target)` and `migrate:run --to=` reverse applied IDs above the target in descending order, then apply pending IDs through it in ascending order. `latest` applies all pending migrations. `rollbackTo($target)` and `migrate:rollback --to=` only reverse applied IDs above the target; any pending IDs, including the target itself, remain pending. Rollback counts IDs, not deployment batches. Developers own dependencies and choosing a safe target, especially when a newly enabled package adds an older ID. Missing applied files appear in status but do not block new forward migrations. Before rollback, Foundation checks that every migration selected for reversal is available; unrelated missing files do not block it. Reset and refresh require every applied declaration because they reverse all migrations. Retain migration files for fresh installations and rollbacks; deleting history is not a supported schema-squashing workflow. +Record the installed application version only after this succeeds. Run the same upgrade workflow for each affected WordPress site, switching sites only between complete migration operations. -:::danger[Rollback can delete data] -`down()` is application code. Reversing an initial migration drops its complete table, including rows and additions made later. Review the inverse and take the application's required backup before rolling back. `migrate:reset`, `migrate:refresh`, `migrate:run --to=0`, and `migrate:rollback --to=0` prompt for confirmation because they reverse every applied migration. Use `--yes` only when that data loss is intended. `migrate:run --to=0 --dry-run` previews the reversal without prompting or executing it. -::: +Use `status()` to inspect history and `preview($target)` to inspect planned steps. `migrate($target)` selects a target, `rollback($steps)` reverses a count of applied IDs, and `rollbackTo($target)` reverses only IDs above a target. To reset all applied migrations programmatically: + +```php +use StellarWP\Foundation\Migrations\Migrator; + +$migrator->rollbackTo( Migrator::NONE ); +``` + +Use `refresh()` to reverse and reapply all migrations. Programmatic calls do not prompt for confirmation; the application owns that decision. Preview and execution operations return step objects with the ID, direction, SQL, and whether a forward data callback was involved. ### Adopt an existing installation @@ -568,10 +668,25 @@ For a fresh installation, run `wp your-plugin migrate:run` to create the schema wp your-plugin migrate:mark-applied 20260923000001_create_reports_table ``` -Use `wp your-plugin migrate:mark-applied --all` only after verifying that **every currently pending migration's schema and data effects** are fulfilled. Then inspect status and preview the next run before enabling automatic upgrades. Later migrations continue through the normal `migrate:run` workflow. - Marking changes history under the same database advisory lock used for migration execution. It may create the ledger, but does not invoke `up()`, `down()`, or data callbacks, plan schema changes, or verify the live database. You own verification of the recorded effects. Each marking command prompts for confirmation; use `--yes` for an already reviewed operation. +#### Mark all pending migrations as applied + +When adopting a complete existing schema, verify that **every currently pending migration's schema and data effects** are fulfilled, then record them together: + +```shell +wp your-plugin migrate:mark-applied --all +``` + +Check the recorded history and preview the next run before enabling automatic upgrades: + +```shell +wp your-plugin migrate:status +wp your-plugin migrate:run --dry-run +``` + +Later migrations continue through the normal `migrate:run` workflow. + Programmatic callers inject `Migrator` and use `markApplied(string $id): void`, `markAllApplied(): void`, or `markPending(string $id): void`. `markApplied()` requires a registered ID. Repeating it preserves the existing history entry and its timestamp. `markAllApplied()` records all currently registered pending IDs atomically in one transaction, preserving existing entries. A marked migration participates in normal rollback: reversing it invokes its `down()` declaration, with the same potential for data loss as an executed migration. ### Interrupted schema changes @@ -688,6 +803,19 @@ Extend `Migration` and implement `up()`; override `down()` when a safe inverse e The registration supplies the ID. IDs are compared in ascending byte order. They must be unique, nonblank, unpadded, and no more than 191 bytes; `0` and `latest` are reserved targets. Choose IDs whose lexical order respects dependencies. Applications using only explicit contributions can omit discovery configuration. +### Describe a migration + +Add `DescribesMigration` to a migration to show a readable description in `migrate:status`: + +```php +use StellarWP\Foundation\Migrations\Contracts\DescribesMigration; + +// Add DescribesMigration to the migration's implements list. +public function describe(): string { + return 'Create report storage'; +} +``` + ### Customize stubs and test migrations Copy the package stubs into `foundation/stubs/database/` to customize generated code. Start with the [CLI stub guide](/tooling/foundation-cli/#override-package-stubs). Table namespaces remain configurable through generator settings; migration placement is controlled by `migrations.path`. From 94e4d5578fb99ac9dbb8259b9d2378f4f8215728 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 25 Sep 2026 09:14:04 -0600 Subject: [PATCH 27/53] Order migration guide by status, preview, and execution --- .../content/docs/components/migrations.mdx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index b62f99e..88ac5c1 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -205,16 +205,6 @@ Once a migration has been applied anywhere, retain its filename and declarations The examples use the `your-plugin` command prefix configured during setup. A complete WordPress application using Foundation's default prefix runs the same commands as `wp nx migrate:*`. -### Run pending migrations - -Apply all pending migrations in ascending ID order: - -```shell -wp your-plugin migrate:run -``` - -Foundation holds the application's migration lock and records each migration after its schema and data work succeeds. The first run creates the history table. If no migrations are pending, the command completes without changing the schema. - ### Check migration status See which migrations are applied, pending, or recorded in history with a missing declaration: @@ -235,6 +225,16 @@ wp your-plugin migrate:run --dry-run Preview does not change the schema or execute data callbacks. It reports when a data callback will run. The database can change between preview and execution, so review failures even after a successful preview. +### Run pending migrations + +Apply all pending migrations in ascending ID order: + +```shell +wp your-plugin migrate:run +``` + +Foundation holds the application's migration lock and records each migration after its schema and data work succeeds. The first run creates the history table. If no migrations are pending, the command completes without changing the schema. + ### Migrate to a specific version Bring the schema to a selected migration by reversing applied IDs above it, then applying pending IDs through it. The target is the migration's filename without `.php`. From 9168a40763b18a37491438f2819e33c0fd0c0374 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 25 Sep 2026 09:20:22 -0600 Subject: [PATCH 28/53] Remove double registration of provider guards --- dev/Cli/MonorepoProvider.php | 9 +-------- src/Cli/CliProvider.php | 7 ------- .../src/content/docs/components/container.mdx | 16 +--------------- .../src/content/docs/components/shutdown.mdx | 9 +-------- .../content/docs/tooling/custom-generators.mdx | 8 -------- src/Shutdown/ShutdownProvider.php | 8 -------- src/WPCli/WPCliProvider.php | 8 -------- .../custom-generator/dev/Configured_Provider.php | 8 -------- .../src/Tooling/Tooling_Provider.php | 8 -------- .../Fixtures/Shutdown/Product_Cache_Provider.php | 7 ------- tests/Unit/Cli/CliProviderTest.php | 4 +--- tests/integration/WPCli/WPCliProviderTest.php | 16 ---------------- tests/wpunit/Shutdown/ShutdownProviderTest.php | 10 ---------- 13 files changed, 4 insertions(+), 114 deletions(-) diff --git a/dev/Cli/MonorepoProvider.php b/dev/Cli/MonorepoProvider.php index c57d885..7abb766 100644 --- a/dev/Cli/MonorepoProvider.php +++ b/dev/Cli/MonorepoProvider.php @@ -19,18 +19,11 @@ */ final class MonorepoProvider extends Provider { - private bool $registered = false; - /** - * Wire repository maintenance and contribute its commands once. + * Wire repository maintenance and contribute its commands. */ public function register(): void { - if ($this->registered) { - return; - } - $this->registerPackageCommand(); - $this->registered = true; } /** diff --git a/src/Cli/CliProvider.php b/src/Cli/CliProvider.php index d851c12..cbc3b89 100644 --- a/src/Cli/CliProvider.php +++ b/src/Cli/CliProvider.php @@ -38,23 +38,16 @@ final class CliProvider extends Provider private const string ROOT_PATH = self::class . '.root_path'; - private bool $registered = false; - /** * Register the CLI application and every built-in command feature. */ public function register(): void { - if ($this->registered) { - return; - } - $this->registerRootPath(); $this->registerProcess(); $this->registerGeneration(); $this->registerDatabaseCommands(); $this->registerWpCliCommand(); $this->registerApplication(); - $this->registered = true; } /** diff --git a/src/Docs/src/content/docs/components/container.mdx b/src/Docs/src/content/docs/components/container.mdx index c807622..9171040 100644 --- a/src/Docs/src/content/docs/components/container.mdx +++ b/src/Docs/src/content/docs/components/container.mdx @@ -165,15 +165,8 @@ final class Report_Provider extends Provider { public const string EXPORTERS = 'your-plugin.report.exporters'; - private bool $registered = false; - public function register(): void { - if ( $this->registered ) { - return; - } - $this->register_exporter_collection(); - $this->registered = true; } private function register_exporter_collection(): void { @@ -210,15 +203,8 @@ use Plugin\Report\Report_Provider; */ final class Csv_Provider extends Provider { - private bool $registered = false; - public function register(): void { - if ( $this->registered ) { - return; - } - $this->register_csv_exporter(); - $this->registered = true; } private function register_csv_exporter(): void { @@ -232,7 +218,7 @@ final class Csv_Provider extends Provider { } ``` -The registration guard keeps each provider's contribution unchanged if it is registered again. +Register each provider once per container, in dependency order, before resolving application services. Repeated registration can duplicate contributions or hooks; it is not a way to reset or reconfigure the container. :::caution[Complete registration before resolving the collection] Register every contributing provider before resolving services that consume the collection. An existing service will not be rebuilt when a later provider adds another contribution. diff --git a/src/Docs/src/content/docs/components/shutdown.mdx b/src/Docs/src/content/docs/components/shutdown.mdx index 8bddbf1..bdbfd9e 100644 --- a/src/Docs/src/content/docs/components/shutdown.mdx +++ b/src/Docs/src/content/docs/components/shutdown.mdx @@ -138,15 +138,8 @@ final class Provider extends Service_Provider { private const int WRITE_PRIORITY = 100; - private bool $registered = false; - public function register(): void { - if ( $this->registered ) { - return; - } - $this->register_cache_writer(); - $this->registered = true; } private function register_cache_writer(): void { @@ -173,7 +166,7 @@ final class Provider extends Service_Provider { } ``` -The registration guard prevents duplicate task contributions. Register the buffer as a singleton so application services and the shutdown writer receive the same instance. Data collected during the request is then available when the writer is resolved at shutdown. The contextual bindings supply the writer's `$cache_key` and `$ttl` constructor arguments. +Register the buffer as a singleton so application services and the shutdown writer receive the same instance. Data collected during the request is then available when the writer is resolved at shutdown. The contextual bindings supply the writer's `$cache_key` and `$ttl` constructor arguments. `Task` is an immutable value object pairing one `Terminable` service with its priority. Lower values run first. Tasks with the same priority retain provider contribution order. diff --git a/src/Docs/src/content/docs/tooling/custom-generators.mdx b/src/Docs/src/content/docs/tooling/custom-generators.mdx index b2a1ca9..b3f0a8e 100644 --- a/src/Docs/src/content/docs/tooling/custom-generators.mdx +++ b/src/Docs/src/content/docs/tooling/custom-generators.mdx @@ -79,21 +79,13 @@ use StellarWP\Foundation\Container\Contracts\Resolver as C; */ final class Tooling_Provider extends Provider { - private bool $registered = false; - /** * Wire the project's commands before the console application is resolved. */ public function register(): void { - if ( $this->registered ) { - return; - } - $this->container->mergeArrayVar( CliProvider::COMMANDS, static fn ( C $c ): array => [ $c->get( Report_Command::class ), ] ); - - $this->registered = true; } } ``` diff --git a/src/Shutdown/ShutdownProvider.php b/src/Shutdown/ShutdownProvider.php index 88c925a..19e2f7a 100644 --- a/src/Shutdown/ShutdownProvider.php +++ b/src/Shutdown/ShutdownProvider.php @@ -13,18 +13,10 @@ final class ShutdownProvider extends Provider { public const string TASKS = self::class . '.tasks'; - private bool $registered = false; - /** * Register contributed shutdown tasks and connect the shared runner to WordPress. */ public function register(): void { - // Repeated provider registration must not duplicate definitions or the WordPress hook. - if ($this->registered) { - return; - } - - $this->registered = true; $this->container->mergeArrayVar(self::TASKS, []); $this->container->when(Runner::class) diff --git a/src/WPCli/WPCliProvider.php b/src/WPCli/WPCliProvider.php index 28dc462..9c1a7ed 100644 --- a/src/WPCli/WPCliProvider.php +++ b/src/WPCli/WPCliProvider.php @@ -22,18 +22,12 @@ final class WPCliProvider extends Provider public const string COMMANDS = self::class . '.commands'; - private bool $registered = false; - /** * Register command configuration and defer command resolution until WP-CLI initializes. * * @throws InvalidArgumentException When the configured Foundation prefix is invalid. */ public function register(): void { - if ($this->registered) { - return; - } - $foundationPrefix = $this->foundationPrefix(); $commandPrefix = $this->config->get('wpcli.command_prefix') ?? $foundationPrefix; @@ -48,8 +42,6 @@ public function register(): void { add_action('cli_init', function (): void { $this->registerCommands(); }, 0, 0); - - $this->registered = true; } /** diff --git a/tests/Support/Fixtures/Cli/custom-generator/dev/Configured_Provider.php b/tests/Support/Fixtures/Cli/custom-generator/dev/Configured_Provider.php index d12e468..a8a66f0 100644 --- a/tests/Support/Fixtures/Cli/custom-generator/dev/Configured_Provider.php +++ b/tests/Support/Fixtures/Cli/custom-generator/dev/Configured_Provider.php @@ -14,16 +14,10 @@ */ final class Configured_Provider extends Provider { - private bool $registered = false; - /** * Wire the project's commands before the console application is resolved. */ public function register(): void { - if ($this->registered) { - return; - } - $this->container->when(Prefix_Command::class) ->needs('$prefix') ->give($this->config->get('foundation.prefix', 'nx')); @@ -33,7 +27,5 @@ public function register(): void { $c->get(Report_Command::class), $c->get(Prefix_Command::class), ]); - - $this->registered = true; } } diff --git a/tests/Support/Fixtures/Cli/custom-generator/src/Tooling/Tooling_Provider.php b/tests/Support/Fixtures/Cli/custom-generator/src/Tooling/Tooling_Provider.php index 6a10dc5..3ed3d74 100644 --- a/tests/Support/Fixtures/Cli/custom-generator/src/Tooling/Tooling_Provider.php +++ b/tests/Support/Fixtures/Cli/custom-generator/src/Tooling/Tooling_Provider.php @@ -12,20 +12,12 @@ */ final class Tooling_Provider extends Provider { - private bool $registered = false; - /** * Wire the project's commands before the console application is resolved. */ public function register(): void { - if ($this->registered) { - return; - } - $this->container->mergeArrayVar(CliProvider::COMMANDS, static fn (C $c): array => [ $c->get(Report_Command::class), ]); - - $this->registered = true; } } diff --git a/tests/Support/Fixtures/Shutdown/Product_Cache_Provider.php b/tests/Support/Fixtures/Shutdown/Product_Cache_Provider.php index 6fb1399..a29e53b 100644 --- a/tests/Support/Fixtures/Shutdown/Product_Cache_Provider.php +++ b/tests/Support/Fixtures/Shutdown/Product_Cache_Provider.php @@ -13,19 +13,12 @@ */ final class Product_Cache_Provider extends Provider { - private bool $registered = false; - /** * Register the shared buffer, cache settings, and shutdown task. * * @throws ContainerException When service bindings cannot be registered. */ public function register(): void { - if ($this->registered) { - return; - } - - $this->registered = true; $this->container->singleton(Product_Cache_Buffer::class); $this->container->when(Product_Cache_Writer::class) diff --git a/tests/Unit/Cli/CliProviderTest.php b/tests/Unit/Cli/CliProviderTest.php index 4810075..49083d3 100644 --- a/tests/Unit/Cli/CliProviderTest.php +++ b/tests/Unit/Cli/CliProviderTest.php @@ -24,14 +24,13 @@ final class CliProviderTest extends TestCase { - public function test_maintenance_registration_is_idempotent_and_preserves_the_process_runner(): void { + public function test_maintenance_registration_preserves_the_process_runner(): void { $container = (new ContainerFactory())->create(new ArrayConfiguration()); $container->register(CliProvider::class); $runner = $this->createMock(ProcessRunner::class); $runner->expects($this->never())->method('run'); $container->singleton(ProcessRunner::class, $runner); $container->register(MonorepoProvider::class); - $container->register(MonorepoProvider::class); $command = new CommandTester($container->get(Application::class)->find('package:create')); $this->assertSame(0, $command->execute(['package' => 'Log'], ['interactive' => false])); @@ -42,7 +41,6 @@ public function test_maintenance_registration_is_idempotent_and_preserves_the_pr public function test_it_registers_cli_services(): void { $container = (new ContainerFactory())->create(new ArrayConfiguration()); $container->register(CliProvider::class); - $container->register(CliProvider::class); $this->assertInstanceOf(Application::class, $container->get(Application::class)); $this->assertInstanceOf(MigrationCommand::class, $container->get(MigrationCommand::class)); diff --git a/tests/integration/WPCli/WPCliProviderTest.php b/tests/integration/WPCli/WPCliProviderTest.php index 8d9cf5c..28c235b 100644 --- a/tests/integration/WPCli/WPCliProviderTest.php +++ b/tests/integration/WPCli/WPCliProviderTest.php @@ -95,22 +95,6 @@ public function test_it_registers_configured_commands_on_cli_init(): void { $this->assertSame(1, RecordingCommand::$registrationCount); } - public function test_it_registers_commands_only_once_when_the_provider_is_registered_repeatedly(): void { - RecordingCommand::$registered = false; - RecordingCommand::$registeredName = null; - RecordingCommand::$registrationCount = 0; - $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ - $c->get(RecordingCommand::class), - ]); - - $this->container->register(WPCliProvider::class); - $this->container->register(WPCliProvider::class); - - do_action('cli_init'); - - $this->assertSame(1, RecordingCommand::$registrationCount); - } - public function test_it_rejects_invalid_commands_before_registering_any_command(): void { RecordingCommand::$registered = false; RecordingCommand::$registeredName = null; diff --git a/tests/wpunit/Shutdown/ShutdownProviderTest.php b/tests/wpunit/Shutdown/ShutdownProviderTest.php index fc60f65..8813edc 100644 --- a/tests/wpunit/Shutdown/ShutdownProviderTest.php +++ b/tests/wpunit/Shutdown/ShutdownProviderTest.php @@ -42,15 +42,6 @@ public function test_it_registers_a_singleton_runner_with_contributed_tasks(): v $this->assertSame(['terminated'], $calls); } - public function test_duplicate_provider_registration_does_not_replace_the_runner(): void { - $this->container->register(ShutdownProvider::class); - $runner = $this->container->get(ShutdownRunner::class); - - $this->container->register(ShutdownProvider::class); - - $this->assertSame($runner, $this->container->get(ShutdownRunner::class)); - } - public function test_it_injects_a_registered_psr_logger(): void { $handler = new TestHandler(); @@ -65,7 +56,6 @@ public function test_it_injects_a_registered_psr_logger(): void { public function test_it_runs_contributed_tasks_on_wordpress_shutdown(): void { $calls = []; - $this->container->register(ShutdownProvider::class); $this->container->register(ShutdownProvider::class); $this->container->mergeArrayVar(ShutdownProvider::TASKS, [ From f23645324bd6bbb4bf905bc515c33d166efa353e Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 25 Sep 2026 09:43:19 -0600 Subject: [PATCH 29/53] Add count, deleteAll and truncate --- AGENTS.md | 8 +- src/Database/Table/Table.php | 41 +++++- .../src/content/docs/components/database.mdx | 2 +- .../components/database/query-builder.mdx | 50 ++++++- tests/integration/Database/TableTest.php | 134 ++++++++++++++++++ 5 files changed, 227 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f438bcd..c2e609c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,13 +47,13 @@ Before recommending that consuming code manually manage Foundation infrastructur Distinguish APIs consumers call, contracts they implement, base classes they extend, and Foundation-owned internals. Document supported extension points and mark implementation-only types `@internal`; PHP visibility alone is not a compatibility policy. Account for constructors, protected hooks, returned types, generated code, configuration, persistent identifiers, and failure behavior as well as method signatures. Parameter names used by PHP named arguments, argument units, callback expectations, return meanings, and side effects are compatibility commitments too. -Before exposing a widely used operation, define its durable responsibility and observable behavior in a representative consumer example. Walk through a plausible future requirement and explain how existing calls can remain unchanged; do not implement speculative extensions. Within a supported release line, preserve that operation's meaning. Add materially different behavior through a distinct method or capability rather than silently changing results, retries, failure handling, or lifecycle guarantees. Widespread use is acceptable when Foundation deliberately owns this stable promise; it is not by itself a reason to reject a convenience object that owns meaningful behavior. Preserve representative consumer calls and their behavioral assertions as compatibility tests during internal refactors. +Define an operation's responsibility and observable behavior through representative consumer examples. Check how a plausible future requirement could fit without breaking those calls; do not implement speculative extensions. Within a supported release line, preserve documented results, failure handling, retries, and lifecycle guarantees. Use consumer examples as compatibility tests during internal refactors. Widespread use alone is not a reason to reject a useful convenience. When an API invokes application callbacks, account for callbacks catching exceptions and continuing. Define which failures are terminal, which exception takes precedence, and whether exposed handles remain usable after failure or completion. A normal callback return must not turn a terminal infrastructure failure into success. -Design contracts as the smallest coherent capabilities with meaningful substitution boundaries, not the fewest possible methods. Keep backend-specific behavior off general contracts. Use separate contracts for optional capabilities; where a provider supplies an implementation, bind its supported contracts coherently so application replacements work. Keep conveniences on concrete classes unless they are reusable capabilities, and keep internal helpers off the supported API. +Design contracts around coherent capabilities and meaningful substitution. Separate optional capabilities when they need independent implementations, and bind supported contracts coherently so application replacements work. Keep backend-specific behavior off general contracts and internal helpers off the supported API; a convenience does not need an interface solely because it is public. -Within a supported release line, do not add required methods to consumer-implemented interfaces or abstract classes, or infrastructure constructor requirements that consuming subclasses must mirror. Add optional behavior through separate capabilities or internally owned construction/lifecycle mechanisms. Generated application classes should declare application concerns while Foundation owns its infrastructure. Additions to a supported base class can collide with consumer methods even when they are concrete; review that compatibility risk. Keep Table's current seven final methods unchanged throughout 2.x and offer additional conveniences through separate collaborators. +Within a supported release line, preserve compatibility for consumer implementations and subclasses. Required methods, constructor changes, and new base-class methods can break consuming code; evaluate that impact before changing them. Generated application classes should declare application concerns while Foundation owns its infrastructure. ## Code Organization and Construction @@ -171,7 +171,7 @@ Register infrastructure providers and top-level feature providers from the appli Use Doctrine's native `Connection`, query builder, results, exceptions, and schema objects where they supply the required behavior. Retire Foundation query builders, execution facades, SQL renderers, and gateway/capability contracts that merely duplicate those APIs as their callers are converted. Keep contracts for meaningful Foundation-owned policies and supported variations. Exposed Doctrine types and behavior are intentional compatibility commitments; do not imply that replacing Doctrine will be transparent to consumers. Preserve Foundation-specific exceptions for scope, migration, and managed-transaction failures. -Application table classes should extend `StellarWP\Foundation\Database\Table\Table`. Inject the concrete table for ordinary table-scoped queries and writes; retain `query()`, `insert()`, `insertGetId()`, `update()`, and `delete()` conveniences with deliberate return and failure semantics. `query()` creates a fresh native Doctrine builder with its starting table configured. Do not inject a reusable mutable builder into a repository. Inject the shared Doctrine `Connection` for transaction boundaries, specialized SQL, or cross-table work that needs it. Add table conveniences when they own demonstrated reusable behavior, not merely to forward every Doctrine method. Repositories are application query organization, not a required layer for every table. +Application table classes extend `StellarWP\Foundation\Database\Table\Table`. Inject the concrete table for ordinary reads and writes, and the shared Doctrine `Connection` for transactions or cross-table work. `query()` creates a fresh native builder with the table configured; do not share a mutable builder between operations. Add conveniences for demonstrated common usage with clear return and failure semantics. Repositories organize application queries when useful; they are not required for every table. Every table implementation returns a stable unprefixed name from `unprefixedName()`. Resolve and validate physical names through `TableNameResolver` at the point of use, applying the current `DatabaseScope`; application tables and repositories must not repeat WordPress prefix handling or physical-name validation. Application query entry points use table objects. Migration schema entry points accept historical unprefixed strings, resolved and validated by the same naming policy; table objects remain accepted for deliberate programmatic use. Native Doctrine and raw SQL use their resolved, quoted names. Generated table classes inherit the base class's infrastructure constructor and declare only application concerns. Keep historical schema definitions in migrations, independent of runtime table classes. diff --git a/src/Database/Table/Table.php b/src/Database/Table/Table.php index e817c4d..2e09a70 100644 --- a/src/Database/Table/Table.php +++ b/src/Database/Table/Table.php @@ -64,6 +64,16 @@ final public function query(?string $alias = null): QueryBuilder { return $this->db->createQueryBuilder()->from($this->quotedName(), $alias); } + /** + * Count all rows in this table at the current site. + * + * @throws DatabaseException When the physical name or managed session is invalid. + * @throws Exception When the count cannot be read. + */ + final public function count(): int { + return (int) $this->query()->select('COUNT(*)')->fetchOne(); + } + /** * Insert a row and return its affected-row count. * @@ -123,9 +133,38 @@ final public function update(array $data, array $where, array $types = []): int| */ final public function delete(array $where, array $types = []): int|string { if ($where === []) { - throw new InvalidArgumentException('Table deletions require criteria; use the connection for an intentional whole-table delete.'); + throw new InvalidArgumentException('Table deletions require criteria; use deleteAll() for an intentional whole-table delete.'); } return $this->db->delete($this->quotedName(), $where, $types); } + + /** + * Delete all rows, returning the affected-row count and preserving foreign-key enforcement. + * + * Participates in the caller's transaction and does not reset auto-increment. + * + * @throws DatabaseException When the physical name or managed session is invalid. + * @throws Exception When deletion fails, including a missing table or foreign-key restriction. + */ + final public function deleteAll(): int|string { + return $this->db->executeStatement('DELETE FROM ' . $this->quotedName()); + } + + /** + * Truncate this table and reset auto-increment outside a transaction. + * + * Truncation implicitly commits and cannot be rolled back. Foreign-key checks remain enabled. + * + * @throws DatabaseException When a transaction is active or the physical name or managed session is invalid. + * @throws Exception When truncation fails, including a missing table or foreign-key restriction. + */ + final public function truncate(): void { + if ($this->db->isTransactionActive()) { + throw new DatabaseException('Table truncation cannot run inside a transaction; use deleteAll() instead.'); + } + + $sql = $this->db->getDatabasePlatform()->getTruncateTableSQL($this->quotedName()); + $this->db->executeStatement($sql); + } } diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index e9ec122..f7bf82e 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -94,7 +94,7 @@ $report = $this->reports->query() ## Multisite and customization -`Table` is a supported base class. Inherit its constructor and implement `unprefixedName()`; application query methods may live on the subclass or in a composed repository. During 2.x, Foundation keeps its final method set (`name`, `quotedName`, `query`, `insert`, `insertGetId`, `update`, and `delete`) unchanged. New conveniences must avoid colliding with application subclass methods; they can be offered through separate collaborators. +`Table` is a supported base class. Inherit its constructor and implement `unprefixedName()`; application query methods may live on the subclass or in a composed repository. Foundation preserves supported behavior and subclass compatibility within a release line, including when considering new base-class methods. A shared container may be reused after `switch_to_blog()` between complete operations. Tables resolve the active prefix when called; a query already built retains its original table name. Build a fresh query after switching sites. Run migrations separately for each site, for example `wp --url=https://site.example your-plugin migrate:run`. diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index 9724083..c0d5920 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -51,7 +51,7 @@ $rows = $this->reports->query() ->setMaxResults( 50 ) ->fetchAllAssociative(); -$count = $this->reports->query()->select( 'COUNT(*)' )->fetchOne(); +$count = $this->reports->count(); ``` Doctrine supplies `fetchAssociative()` (one row or `false`), `fetchAllAssociative()` (rows), `fetchOne()` (one value or `false`), and `executeQuery()` for a native result. Database drivers may return numeric values as strings. Convert results according to your application contract. @@ -64,7 +64,7 @@ $this->reports->update( [ 'status' => 'ready' ], [ 'id' => $id ] ); $this->reports->delete( [ 'id' => $id ] ); ``` -`insert()`, `update()`, and `delete()` return Doctrine's affected-row count (`int|string`). `insertGetId()` returns the generated identifier (`int|string`) without truncation. Each accepts an optional `$types` map for Doctrine parameter types. Update and delete criteria must be nonempty; use the connection for an intentional whole-table operation. +`insert()`, `update()`, and `delete()` return Doctrine's affected-row count (`int|string`). `insertGetId()` returns the generated identifier (`int|string`) without truncation. Each accepts an optional `$types` map for Doctrine parameter types. Update and delete criteria must be nonempty. Use `deleteAll()` for an intentional whole-table deletion, or the connection for a whole-table update. Inject `Doctrine\DBAL\Connection` for joins, specialized SQL, and operations spanning tables. Resolve physical names through each table's `quotedName()`: @@ -77,6 +77,52 @@ $db->executeStatement( Queries, results, types, and DBAL exceptions are supported native Doctrine APIs. See [Doctrine's query builder reference](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.4/reference/query-builder.html) for expressions and advanced usage. +## Count or clear a table + +### Count all rows + +Get the number of rows as an integer. An empty table returns `0`; a missing table throws an exception. + +```php +$count = $this->reports->count(); +``` + +For a filtered count, use `query()->select( 'COUNT(*)' )` with the required conditions. + +### Delete all rows + +Remove every row and receive the affected-row count (`int|string`): + +```php +$deleted = $this->reports->deleteAll(); +``` + +`deleteAll()` preserves the auto-increment sequence and applies the table's foreign-key rules and delete triggers. With InnoDB, deletion participates in the caller's transaction. For example, clear and replace data atomically: + +```php +$this->db->transactional( function () use ( $rows ): void { + $this->reports->deleteAll(); + + foreach ( $rows as $row ) { + $this->reports->insert( $row ); + } +} ); +``` + +An insertion failure rolls back the deletion and preserves the previous rows. + +### Truncate a table + +Remove all rows and reset auto-increment: + +```php +$this->reports->truncate(); +``` + +`truncate()` returns no value. MySQL and MariaDB truncation implicitly commits and cannot be rolled back, so Foundation rejects this method with `DatabaseException` when the shared connection has an active transaction. Use `deleteAll()` for transactional replacement. Truncation does not run delete triggers, and references from other tables can prevent it. + +Both clearing methods keep foreign-key checks enabled and throw if the table is missing. + ## Choose a transaction boundary Inject the shared connection into the service that owns the complete unit of work. Collaborating repositories can continue to use their injected tables. diff --git a/tests/integration/Database/TableTest.php b/tests/integration/Database/TableTest.php index 1da6767..d27a09f 100644 --- a/tests/integration/Database/TableTest.php +++ b/tests/integration/Database/TableTest.php @@ -3,8 +3,12 @@ namespace StellarWP\Foundation\Tests\Integration\Database; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Exception\DriverException; +use Doctrine\DBAL\Exception\TableNotFoundException; use Doctrine\DBAL\Types\Types; use InvalidArgumentException; +use RuntimeException; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Tests\Support\Fixtures\Database\Consumer\Entry_Table; use StellarWP\Foundation\Tests\Support\Fixtures\Database\DatabaseTestCase; @@ -49,6 +53,136 @@ public function test_queries_are_fresh_and_capture_the_site_when_built(): void { $this->assertStringContainsString($this->entries->name(), $this->entries->query()->select('id')->getSQL()); } + public function test_count_and_delete_all_preserve_the_identity_sequence(): void { + $this->assertSame(0, $this->entries->count()); + $this->entries->insert([ + 'id' => 40, + 'name' => 'Original', + ]); + $this->entries->insert([ + 'name' => 'Another', + ]); + + $this->assertSame(2, $this->entries->count()); + $this->assertSame(2, (int) $this->entries->deleteAll()); + $this->assertSame(0, $this->entries->count()); + $this->assertSame(0, (int) $this->entries->deleteAll()); + $this->assertSame(42, (int) $this->entries->insertGetId([ + 'name' => 'Next', + ])); + } + + public function test_delete_all_rolls_back_with_the_callers_transaction(): void { + $this->entries->insert([ + 'name' => 'Original', + ]); + + try { + $this->db->transactional(function (): void { + $this->entries->deleteAll(); + $this->assertSame(0, $this->entries->count()); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->entries->quotedName())); + $this->entries->insert([ + 'name' => 'Replacement', + ]); + + throw new RuntimeException('Cancel replacement'); + }); + } catch (RuntimeException $failure) { + $this->assertSame('Cancel replacement', $failure->getMessage()); + } + + $this->assertSame([ + 'Original', + ], $this->entries->query()->select('name')->fetchFirstColumn()); + } + + public function test_truncate_empties_the_table_and_resets_auto_increment(): void { + $this->entries->insert([ + 'id' => 40, + 'name' => 'Original', + ]); + $this->entries->truncate(); + + $this->assertSame(0, $this->entries->count()); + $this->assertSame(1, (int) $this->entries->insertGetId([ + 'name' => 'First', + ])); + } + + public function test_truncate_rejects_a_transaction_before_committing_or_deleting_rows(): void { + $this->entries->insert([ + 'name' => 'Original', + ]); + $this->db->beginTransaction(); + $this->entries->insert([ + 'name' => 'Uncommitted', + ]); + + try { + $this->entries->truncate(); + $this->fail('Truncation must reject an active transaction.'); + } catch (DatabaseException $failure) { + $this->assertStringContainsString('use deleteAll()', $failure->getMessage()); + $this->assertTrue($this->db->isTransactionActive()); + $this->assertSame(2, $this->entries->count()); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->entries->quotedName())); + } finally { + $this->db->rollBack(); + } + + $this->assertSame([ + 'Original', + ], $this->entries->query()->select('name')->fetchFirstColumn()); + } + + public function test_whole_table_deletions_preserve_foreign_key_enforcement(): void { + $children = $this->privateTable($this->suffix . '_children'); + $entries = $this->entries->quotedName(); + $this->observer->executeStatement("CREATE TABLE {$children} ( + id INT PRIMARY KEY, + entry_id BIGINT UNSIGNED NOT NULL, + FOREIGN KEY (entry_id) REFERENCES {$entries} (id) ON DELETE RESTRICT + ) ENGINE=InnoDB"); + $id = $this->entries->insertGetId([ + 'name' => 'Referenced', + ]); + $this->observer->insert($children, [ + 'id' => 1, + 'entry_id' => $id, + ]); + + foreach ([ + 'deleteAll', + 'truncate', + ] as $method) { + try { + $this->entries->$method(); + $this->fail('Foreign-key restrictions must be enforced.'); + } catch (DriverException) { + $this->assertSame(1, $this->entries->count()); + $this->assertSame(1, (int) $this->db->fetchOne('SELECT @@SESSION.foreign_key_checks')); + } + } + } + + public function test_whole_table_operations_report_a_missing_table(): void { + $this->observer->executeStatement('DROP TABLE ' . $this->entries->quotedName()); + + foreach ([ + 'count', + 'deleteAll', + 'truncate', + ] as $method) { + try { + $this->entries->$method(); + $this->fail('A missing table must not look empty.'); + } catch (TableNotFoundException $failure) { + $this->assertStringContainsString($this->entries->name(), $failure->getMessage()); + } + } + } + public function test_empty_mutation_criteria_are_rejected(): void { foreach (['update', 'delete'] as $method) { try { From 92b44a549242d9ffaac82fc6e36501b348ee1125 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 25 Sep 2026 10:11:30 -0600 Subject: [PATCH 30/53] Capture and output exceptions for our WP CLI commands --- .../content/docs/components/migrations.mdx | 10 ++ .../src/content/docs/components/wp-cli.mdx | 12 ++ src/WPCli/Command.php | 13 +- tests/Support/Fixtures/WPCli/TestCommand.php | 11 +- .../Fixtures/WPCli/TestWpCliLogger.php | 9 ++ .../WPCli/register-failing-command.php | 13 ++ tests/Unit/WPCli/CommandFailureTest.php | 148 ++++++++++++++++++ tests/wpcli/CommandFailureCest.php | 49 ++++++ 8 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 tests/Support/Fixtures/WPCli/register-failing-command.php create mode 100644 tests/Unit/WPCli/CommandFailureTest.php create mode 100644 tests/wpcli/CommandFailureCest.php diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index 88ac5c1..1bcbbdb 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -235,6 +235,16 @@ wp your-plugin migrate:run Foundation holds the application's migration lock and records each migration after its schema and data work succeeds. The first run creates the history table. If no migrations are pending, the command completes without changing the schema. +### Inspect a command failure + +Migration commands display exceptions as `Error: ` and exit with status `1`. Add `--debug` when running a command to include the original exception chain and stack trace: + +```shell +wp your-plugin migrate:run --debug +``` + +Execution stops at the failed migration. Schema changes may already have committed, so inspect and repair the failure before running it again. `--debug` changes diagnostic output; the command still executes its requested operation. + ### Migrate to a specific version Bring the schema to a selected migration by reversing applied IDs above it, then applying pending IDs through it. The target is the migration's filename without `.php`. diff --git a/src/Docs/src/content/docs/components/wp-cli.mdx b/src/Docs/src/content/docs/components/wp-cli.mdx index 54592af..34791d5 100644 --- a/src/Docs/src/content/docs/components/wp-cli.mdx +++ b/src/Docs/src/content/docs/components/wp-cli.mdx @@ -317,6 +317,18 @@ wp help your-plugin catalog:sync Use `WP_CLI::error()` for invalid input or an operation that cannot continue; it stops the command with a failing exit code. Use `WP_CLI::warning()` when execution can continue. Returning any nonzero status from `runCommand()` also causes the Foundation command wrapper to halt with that status. ::: +### Read command failures + +An exception escaping `runCommand()` is displayed as `Error: ` and exits with status `1`. Command classes can let service exceptions propagate to this shared presentation boundary. + +Add `--debug` to include the exception class, source location, stack trace, and previous exceptions: + +```shell +wp your-plugin catalog:sync staging --debug +``` + +Explicit `WP_CLI::error()` and `WP_CLI::halt()` calls retain their exit behavior. PHP errors such as `TypeError` remain with the host error handler. Calling `runCommand()` directly still throws its original exceptions. + ### Release runtime caches between batches Long-running commands can call the inherited `clearRuntimeCache()` method after each completed batch. For a command with an injected importer that yields batches, the loop inside `runCommand()` can look like this: diff --git a/src/WPCli/Command.php b/src/WPCli/Command.php index 43771d9..567c7cd 100644 --- a/src/WPCli/Command.php +++ b/src/WPCli/Command.php @@ -2,9 +2,11 @@ namespace StellarWP\Foundation\WPCli; +use Exception; use StellarWP\Foundation\WPCli\Contracts\RegistrableCommand; use StellarWP\Foundation\WPCli\Exceptions\CommandAlreadyRegistered; use WP_CLI; +use WP_CLI\ExitException; use WP_CLI_Command; /** @@ -68,7 +70,16 @@ public function register(CommandContext $context): void { $deferredBefore = WP_CLI::get_deferred_additions(); $registered = WP_CLI::add_command($name, function (array $args, array $assocArgs): void { - $status = $this->runCommand(array_values($args), $assocArgs); + try { + $status = $this->runCommand(array_values($args), $assocArgs); + } catch (ExitException $exit) { + throw $exit; + } catch (Exception $failure) { + WP_CLI::debug((string) $failure); + WP_CLI::error($failure->getMessage()); + + return; + } if ($status !== self::SUCCESS) { WP_CLI::halt($status); diff --git a/tests/Support/Fixtures/WPCli/TestCommand.php b/tests/Support/Fixtures/WPCli/TestCommand.php index 63549fb..94c5f63 100644 --- a/tests/Support/Fixtures/WPCli/TestCommand.php +++ b/tests/Support/Fixtures/WPCli/TestCommand.php @@ -4,9 +4,14 @@ use StellarWP\Foundation\WPCli\Command; use StellarWP\Foundation\WPCli\CommandContext; +use Throwable; final class TestCommand extends Command { + public ?Throwable $failure = null; + + public int $status = 0; + /** * @var list */ @@ -35,7 +40,11 @@ public function runCommand(array $args = [], array $assocArgs = []): int { $this->args = $args; $this->assocArgs = $assocArgs; - return self::SUCCESS; + if ($this->failure !== null) { + throw $this->failure; + } + + return $this->status; } public function name(CommandContext $context): string { diff --git a/tests/Support/Fixtures/WPCli/TestWpCliLogger.php b/tests/Support/Fixtures/WPCli/TestWpCliLogger.php index 8dcc0c6..b888295 100644 --- a/tests/Support/Fixtures/WPCli/TestWpCliLogger.php +++ b/tests/Support/Fixtures/WPCli/TestWpCliLogger.php @@ -6,6 +6,11 @@ final class TestWpCliLogger extends Regular { + /** + * @var list + */ + public array $errorMessages = []; + /** * @var list */ @@ -52,6 +57,10 @@ public function warning($message): void { $this->warningMessages[] = $message; } + public function error($message): void { + $this->errorMessages[] = $message; + } + public function debug($message, $group = false): void { $this->debugMessages[] = $message; $this->debugGroups[] = $group; diff --git a/tests/Support/Fixtures/WPCli/register-failing-command.php b/tests/Support/Fixtures/WPCli/register-failing-command.php new file mode 100644 index 0000000..54950c9 --- /dev/null +++ b/tests/Support/Fixtures/WPCli/register-failing-command.php @@ -0,0 +1,13 @@ +failure = new RuntimeException('Import could not complete.', 42, new RuntimeException('Database rejected the row.')); + $command->register(new CommandContext(new CommandPrefix('foundation-failure'))); +}); diff --git a/tests/Unit/WPCli/CommandFailureTest.php b/tests/Unit/WPCli/CommandFailureTest.php new file mode 100644 index 0000000..e00beaf --- /dev/null +++ b/tests/Unit/WPCli/CommandFailureTest.php @@ -0,0 +1,148 @@ +logger = WP_CLI::get_logger(); + $this->captureExit = (bool) (new ReflectionProperty(WP_CLI::class, 'capture_exit'))->getValue(); + } + + protected function tearDown(): void { + WP_CLI::set_logger($this->logger); + (new ReflectionProperty(WP_CLI::class, 'capture_exit'))->setValue(null, $this->captureExit); + parent::tearDown(); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_registered_exceptions_are_presented_with_original_debug_details(): void { + $command = new TestCommand(); + $command->failure = new RuntimeException('Import failed.', 42, new RuntimeException('Database rejected the row.')); + $logger = new TestWpCliLogger(); + $callback = $this->registerCommand($command, $logger); + + try { + $callback([], []); + $this->fail('A command exception must halt execution.'); + } catch (ExitException $exit) { + $this->assertSame(1, $exit->getCode()); + } + + $this->assertSame([ + 'Import failed.', + ], $logger->errorMessages); + $this->assertSame([ + (string) $command->failure, + ], $logger->debugMessages); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_wp_cli_exit_exceptions_keep_their_identity_and_status(): void { + $command = new TestCommand(); + $command->failure = new ExitException('', 7); + $logger = new TestWpCliLogger(); + $callback = $this->registerCommand($command, $logger); + + try { + $callback([], []); + $this->fail('WP-CLI exit control must propagate.'); + } catch (ExitException $exit) { + $this->assertSame($command->failure, $exit); + $this->assertSame(7, $exit->getCode()); + } + + $this->assertSame([], $logger->errorMessages); + $this->assertSame([], $logger->debugMessages); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_returned_failure_status_is_preserved(): void { + $command = new TestCommand(); + $command->status = 7; + $logger = new TestWpCliLogger(); + $callback = $this->registerCommand($command, $logger); + + try { + $callback([], []); + $this->fail('A nonzero status must halt execution.'); + } catch (ExitException $exit) { + $this->assertSame(7, $exit->getCode()); + } + + $this->assertSame([], $logger->errorMessages); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_php_errors_remain_available_to_the_host_error_handler(): void { + $command = new TestCommand(); + $command->failure = new Error('Programming error.'); + $logger = new TestWpCliLogger(); + $callback = $this->registerCommand($command, $logger); + + try { + $callback([], []); + $this->fail('PHP errors must propagate.'); + } catch (Error $failure) { + $this->assertSame($command->failure, $failure); + } + + $this->assertSame([], $logger->errorMessages); + } + + public function test_direct_command_calls_preserve_the_original_exception(): void { + $command = new TestCommand(); + $command->failure = new RuntimeException('Direct failure.'); + + try { + $command->runCommand(); + $this->fail('Direct calls must preserve their exception.'); + } catch (RuntimeException $failure) { + $this->assertSame($command->failure, $failure); + } + } + + /** + * Capture the registered callback and WP-CLI output without exiting the test process. + */ + private function registerCommand(TestCommand $command, TestWpCliLogger $logger): Closure { + if (! defined('WP_CLI')) { + define('WP_CLI', true); + } + + if (! defined('WP_CLI_ROOT')) { + define('WP_CLI_ROOT', dirname(__DIR__, 3) . '/vendor/wp-cli/wp-cli'); + } + + require_once WP_CLI_ROOT . '/php/utils.php'; + WP_CLI::set_logger($logger); + (new ReflectionProperty(WP_CLI::class, 'capture_exit'))->setValue(null, true); + $command->register(new CommandContext(new CommandPrefix('foundation-failure'))); + $logger->debugMessages = []; + + return WP_CLI::get_deferred_additions()['foundation-failure example']['callable']; + } +} diff --git a/tests/wpcli/CommandFailureCest.php b/tests/wpcli/CommandFailureCest.php new file mode 100644 index 0000000..96c6231 --- /dev/null +++ b/tests/wpcli/CommandFailureCest.php @@ -0,0 +1,49 @@ +cli([ + '--no-debug', + '--require=' . dirname(__DIR__) . '/Support/Fixtures/WPCli/register-failing-command.php', + 'foundation-failure', + 'example', + 'value', + ]); + $I->seeResultCodeIs(1); + $error = $I->grabLastShellErrorOutput(); + + Assert::assertStringContainsString('Error: Import could not complete.', $error); + Assert::assertStringNotContainsString('Stack trace:', $error); + Assert::assertStringNotContainsString('Database rejected the row.', $error); + Assert::assertStringNotContainsString('Fatal error', $error); + } + + /** + * Debug mode includes the original exception chain and stack trace. + */ + public function test_debug_mode_includes_the_original_failure_details(WPCLITester $I): void { + $I->cli([ + '--debug', + '--require=' . dirname(__DIR__) . '/Support/Fixtures/WPCli/register-failing-command.php', + 'foundation-failure', + 'example', + 'value', + ]); + $I->seeResultCodeIs(1); + $error = $I->grabLastShellErrorOutput(); + + Assert::assertStringContainsString('Error: Import could not complete.', $error); + Assert::assertStringContainsString('Database rejected the row.', $error); + Assert::assertStringContainsString('Stack trace:', $error); + Assert::assertStringContainsString('RuntimeException', $error); + } +} From ebc38a608568c743c19620d3b0e1d395ade2b913 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 25 Sep 2026 16:42:52 -0600 Subject: [PATCH 31/53] Add fluent database queries and consistent table writes --- AGENTS.md | 10 +- dev/Experiments/Query/QUERY-BUILDER-DESIGN.md | 461 +++++++++++++++ src/Database/DatabaseProvider.php | 4 + src/Database/Query/Compiler.php | 229 ++++++++ src/Database/Query/Database.php | 60 ++ src/Database/Query/Executor.php | 136 +++++ src/Database/Query/IdentifierQuoter.php | 76 +++ src/Database/Query/InsertWriter.php | 163 ++++++ src/Database/Query/JoinClause.php | 47 ++ src/Database/Query/Query.php | 396 +++++++++++++ src/Database/Query/TableReferenceResolver.php | 77 +++ src/Database/Query/Upsert/AliasBuilder.php | 38 ++ .../Query/Upsert/Contracts/Builder.php | 18 + src/Database/Query/Upsert/ServerBuilder.php | 52 ++ src/Database/Query/Upsert/ValueBuilder.php | 36 ++ src/Database/Query/ValueObjects/Fragment.php | 22 + .../Query/ValueObjects/ParameterSet.php | 25 + src/Database/Query/ValueObjects/Predicate.php | 20 + .../Query/ValueObjects/QueryState.php | 33 ++ src/Database/Query/ValueObjects/Selection.php | 23 + .../Query/ValueObjects/TableReference.php | 38 ++ src/Database/Query/WhereGroup.php | 243 ++++++++ src/Database/Table/Table.php | 85 +-- .../src/content/docs/components/database.mdx | 13 +- .../components/database/query-builder.mdx | 246 ++++++-- src/LockDatabase/Tables/LockTable.php | 8 +- src/Migrations/Tables/MigrationTable.php | 8 +- tests/Unit/Database/Query/ExecutorTest.php | 80 +++ .../Database/Query/IdentifierQuoterTest.php | 84 +++ .../Unit/Database/Query/InsertWriterTest.php | 298 ++++++++++ .../Database/Query/Upsert/BuilderTest.php | 95 ++++ tests/Unit/Database/Query/WhereGroupTest.php | 204 +++++++ tests/integration/Database/QueryTest.php | 525 ++++++++++++++++++ .../Database/QueryValidationTest.php | 208 +++++++ tests/integration/Database/TableTest.php | 230 +++++++- .../LockDatabase/LockDatabaseProviderTest.php | 8 +- .../Migrations/AdvisoryMigrationTest.php | 3 +- 37 files changed, 4179 insertions(+), 123 deletions(-) create mode 100644 dev/Experiments/Query/QUERY-BUILDER-DESIGN.md create mode 100644 src/Database/Query/Compiler.php create mode 100644 src/Database/Query/Database.php create mode 100644 src/Database/Query/Executor.php create mode 100644 src/Database/Query/IdentifierQuoter.php create mode 100644 src/Database/Query/InsertWriter.php create mode 100644 src/Database/Query/JoinClause.php create mode 100644 src/Database/Query/Query.php create mode 100644 src/Database/Query/TableReferenceResolver.php create mode 100644 src/Database/Query/Upsert/AliasBuilder.php create mode 100644 src/Database/Query/Upsert/Contracts/Builder.php create mode 100644 src/Database/Query/Upsert/ServerBuilder.php create mode 100644 src/Database/Query/Upsert/ValueBuilder.php create mode 100644 src/Database/Query/ValueObjects/Fragment.php create mode 100644 src/Database/Query/ValueObjects/ParameterSet.php create mode 100644 src/Database/Query/ValueObjects/Predicate.php create mode 100644 src/Database/Query/ValueObjects/QueryState.php create mode 100644 src/Database/Query/ValueObjects/Selection.php create mode 100644 src/Database/Query/ValueObjects/TableReference.php create mode 100644 src/Database/Query/WhereGroup.php create mode 100644 tests/Unit/Database/Query/ExecutorTest.php create mode 100644 tests/Unit/Database/Query/IdentifierQuoterTest.php create mode 100644 tests/Unit/Database/Query/InsertWriterTest.php create mode 100644 tests/Unit/Database/Query/Upsert/BuilderTest.php create mode 100644 tests/Unit/Database/Query/WhereGroupTest.php create mode 100644 tests/integration/Database/QueryTest.php create mode 100644 tests/integration/Database/QueryValidationTest.php rename tests/{Unit => integration}/LockDatabase/LockDatabaseProviderTest.php (90%) diff --git a/AGENTS.md b/AGENTS.md index c2e609c..204a13d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,8 @@ Encapsulate meaningful closed state behind named factories and predicates such a ## Source Conventions +Choose concise names that communicate an object's role in context. Use established terminology and let namespaces supply context; add qualifiers only to resolve actual ambiguity. Constructor arguments and properties should use short, clear role names such as `Compiler $compiler`, `InsertWriter $writer`, and `IdentifierQuoter $quoter`, without mechanically repeating the full class name. Use singular names for individual objects and plural names for collections. Judge names in constructors and ordinary calls, not only in isolation; avoid obscure abbreviations. + Format SQL for readability inside multiline strings. Put each selected column on its own line when selecting multiple columns, each SQL clause on its own line, and additional `AND`/`OR` conditions on separate indented lines. Do not pack column lists or multiple predicates onto long lines. Write every non-empty PHP array literal across multiple lines, including single-element arrays, nested arrays, return values, and arguments. Put each element on its own line, include a trailing comma, and place the closing bracket on its own line. Apply this consistently in source code, tests, stubs, and documentation examples. Empty arrays may remain `[]`. @@ -167,13 +169,13 @@ Register infrastructure providers and top-level feature providers from the appli ## Database -`stellarwp/foundation-database` provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress transaction behavior. `stellarwp/foundation-migrations` owns migration discovery, schema declarations and planning, history, failure handling, and migration commands. Developers use Doctrine directly for database expressions and advanced operations. Doctrine owns query construction, parameter binding, execution, result handling, schema inspection, and platform SQL generation. Foundation owns provider defaults, WordPress connection integration, resource naming, and operational guarantees. Foundation 2.0 targets Doctrine DBAL `^4.4` on PHP 8.3, with its MySQL 5.7.9+ and MariaDB 10.4.3+ server minimums. Keep these requirements visible in the Database and Migrations guides. DBAL 3 support is deferred until the Rector downgrade feature is implemented; it is not a Foundation 2.0 release requirement. Keep dependency-major differences confined to internal connection and schema boundaries, and validate dependency behavior separately from Rector syntax conversion before widening constraints. Future DBAL major lines require a deliberate compatibility decision. +`stellarwp/foundation-database` provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress transaction behavior. `stellarwp/foundation-migrations` owns migration discovery, schema declarations and planning, history, failure handling, and migration commands. Developers use Doctrine directly for database expressions and advanced operations. Foundation owns the bounded fluent query API and its SQL compilation. Doctrine owns connection execution, parameter binding, native result handling, schema inspection, and platform schema SQL generation. Foundation owns provider defaults, WordPress connection integration, resource naming, and operational guarantees. Foundation 2.0 targets Doctrine DBAL `^4.4` on PHP 8.3, with its MySQL 5.7.9+ and MariaDB 10.4.3+ server minimums. Keep these requirements visible in the Database and Migrations guides. DBAL 3 support is deferred until the Rector downgrade feature is implemented; it is not a Foundation 2.0 release requirement. Keep dependency-major differences confined to internal connection and schema boundaries, and validate dependency behavior separately from Rector syntax conversion before widening constraints. Future DBAL major lines require a deliberate compatibility decision. -Use Doctrine's native `Connection`, query builder, results, exceptions, and schema objects where they supply the required behavior. Retire Foundation query builders, execution facades, SQL renderers, and gateway/capability contracts that merely duplicate those APIs as their callers are converted. Keep contracts for meaningful Foundation-owned policies and supported variations. Exposed Doctrine types and behavior are intentional compatibility commitments; do not imply that replacing Doctrine will be transparent to consumers. Preserve Foundation-specific exceptions for scope, migration, and managed-transaction failures. +Use Foundation's fluent query builder for ordinary application queries, including escaped substring searches and multi-row writes. Keep its initial surface bounded to demonstrated consumer requirements; Laravel-inspired names do not imply full Laravel parity. Use Doctrine's native `Connection`, query builder, results, exceptions, and schema objects for advanced operations and escape hatches. Do not introduce execution facades or gateway contracts that merely forward native APIs. Keep contracts for meaningful Foundation-owned policies and supported variations. Exposed Doctrine types and behavior are intentional compatibility commitments; do not imply that replacing Doctrine will be transparent to consumers. Preserve Foundation-specific exceptions for scope, migration, and managed-transaction failures. -Application table classes extend `StellarWP\Foundation\Database\Table\Table`. Inject the concrete table for ordinary reads and writes, and the shared Doctrine `Connection` for transactions or cross-table work. `query()` creates a fresh native builder with the table configured; do not share a mutable builder between operations. Add conveniences for demonstrated common usage with clear return and failure semantics. Repositories organize application queries when useful; they are not required for every table. +Application table classes extend `StellarWP\Foundation\Database\Table\Table`. Inject the concrete table for ordinary reads and writes, and the shared Doctrine `Connection` for transactions or cross-table work. `query()` creates a fresh Foundation `Database\Query\Query` with the table configured; do not share a mutable builder between operations. Add conveniences for demonstrated common usage with clear return and failure semantics. Repositories organize application queries when useful; they are not required for every table. -Every table implementation returns a stable unprefixed name from `unprefixedName()`. Resolve and validate physical names through `TableNameResolver` at the point of use, applying the current `DatabaseScope`; application tables and repositories must not repeat WordPress prefix handling or physical-name validation. Application query entry points use table objects. Migration schema entry points accept historical unprefixed strings, resolved and validated by the same naming policy; table objects remain accepted for deliberate programmatic use. Native Doctrine and raw SQL use their resolved, quoted names. Generated table classes inherit the base class's infrastructure constructor and declare only application concerns. Keep historical schema definitions in migrations, independent of runtime table classes. +Every table implementation returns a stable unprefixed name from `unprefixedName()`. Resolve and validate physical names through `TableNameResolver` at the point of use, applying the current `DatabaseScope`; application tables and repositories must not repeat WordPress prefix handling or physical-name validation. Application query entry points ordinarily use table objects. The injected `Database\Query\Database` also accepts unprefixed application names and resolved `Query\ValueObjects\TableReference` values, and resolves WordPress core table references through `wordpress()`. Migration schema entry points accept historical unprefixed strings, resolved and validated by the same naming policy; table objects remain accepted for deliberate programmatic use. Native Doctrine and raw SQL use their resolved, quoted names. Generated table classes inherit the base class's infrastructure constructor and declare only application concerns. Keep historical schema definitions in migrations, independent of runtime table classes. Providers supply one shared managed Doctrine connection per container so tables, repositories, and transaction-owning services participate in the same session. Keep WordPress's native mysqli access inside the connection integration; route Foundation database execution through Doctrine rather than `wpdb::query()` or `dbDelta()`. Applications choose the transaction boundary; Foundation owns cleanup, terminal failure tracking, and commit acknowledgement. Never replay part of a failed transaction. A caught database execution failure must prevent successful completion, an escaping business exception must survive cleanup failure, and an uncertain commit must be reported distinctly. Nested success remains provisional until the outer transaction commits. A replacement connection must satisfy these documented guarantees, not merely extend Doctrine's connection class. diff --git a/dev/Experiments/Query/QUERY-BUILDER-DESIGN.md b/dev/Experiments/Query/QUERY-BUILDER-DESIGN.md new file mode 100644 index 0000000..f0b4fe1 --- /dev/null +++ b/dev/Experiments/Query/QUERY-BUILDER-DESIGN.md @@ -0,0 +1,461 @@ +# Foundation query builder: design + +Revised September 25, 2026 after review. Accepted design for the bounded first implementation in `src/Database/Query`. It replaces the native Doctrine `QueryBuilder` currently returned by `Table::query()` with a Laravel-shaped Foundation builder over Foundation's existing managed connection. + +The method reference records future API candidates inspired by Laravel and `stellarwp/db`, using familiar names where their semantics fit. It is not a promise of full parity or a release checklist. The bounded first slice below is the current implementation scope; review complete classes before expanding it. + +## Summary + +**Architecture.** The builder collects query intent, collects validated conditional fragments, and compiles SQL with ordered positional bindings. Execution goes through the injected Doctrine `Connection`, so every query runs inside Foundation's connection integration: the borrowed WordPress mysqli session, managed transactions, terminal-failure tracking, and commit acknowledgement. Foundation owns those guarantees, built on DBAL; the builder must preserve them, not re-implement them. + +Compiling SQL ourselves rather than driving Doctrine's `QueryBuilder` is a hypothesis for the slice to test. The motivation is that several required features have no Doctrine builder equivalent (multi-row insert, upsert, escaped `LIKE`, one condition model shared by every statement type), so part of the compiler is ours either way. + +| Component | Responsibility | +| --- | --- | +| `Query`, `WhereGroup`, `JoinClause` | Collect valid query intent; reject invalid combinations at the call | +| `WhereGroup` | Share conditional grammar across query filters, groups, and join-value predicates | +| `Compiler` | Produce SQL and ordered bindings from query state | +| Dialect strategy | Handle demonstrated MySQL and MariaDB syntax differences | +| `Executor` | Execute through the injected managed connection | + +**DBAL compatibility research.** Foundation 2.0 requires DBAL 4.4 on PHP 8.3. DBAL 3 support remains deferred until the Rector downgrade work. This comparison informs that later work; it does not establish support. Execution and fetching have the same call shapes in DBAL 3.10 and 4.4. The differences stay in `Executor` and the dialect: + +| API | DBAL 3.10 | DBAL 4.4 | Handling | +| --- | --- | --- | --- | +| `executeQuery`, `executeStatement` | Same parameters | Same parameters | Native affected rows stay `int\|string`; bounded insert/upsert totals are `int` | +| `fetchAllAssociative`, `fetchAssociative`, `fetchOne`, `fetchFirstColumn` | Same | Same, natively typed | None | +| `ParameterType` | Class of `int` constants | Enum | Same source syntax works on both; the type appears only in PHPDoc | +| `ArrayParameterType` | Class of `int` constants | Enum | Not used; the compiler expands `IN (?, ?, ?)` itself | +| `lastInsertId()` | Optional name; may return `false` | No argument; throws when no identity exists | Wrapped once in `Executor` | +| `quoteSingleIdentifier()`, `escapeStringForLike()` | Present | Present | None | +| MariaDB 10.5.2+ platform | `MariaDb1052Platform` | `MariaDB1052Platform` | Case-insensitive class names; checks live in the dialect strategy | + +The fluent query surface primarily uses Foundation types and native PHP values. Explicit Doctrine type conversions use the shared connection; table conveniences delegate to the fluent query implementation. Exception names are shared across both researched majors. Proving the PHP 7.4 release requires running the Rector-transformed code on PHP 7.4, not only DBAL 3 on PHP 8. + +**Packaging.** The builder belongs in `foundation-database`. `Table::query()` returns it, it adds no dependency, and it cannot run without Database's connection. A later model or ORM layer, with its own lifecycle and scope, would justify `foundation-models` on top. + +## Consumer code + +The portal's ordinary candidate lookup and atomic replacement keep their existing schema and business behavior. The repository still receives its concrete `Entries_Table` and the shared Doctrine `Connection` through its constructor. + +```php +/** + * @param list $tokens + * @return list> + */ +public function find_candidates( array $tokens, int $limit = 800 ): array { + if ( $tokens === [] ) { + return []; + } + + return $this->entries->query() + ->select( 'ent_num', 'name', 'name_normalized', 'sdn_type', 'program', 'source' ) + ->whereContainsAny( 'name_normalized', $tokens ) + ->limit( $limit ) + ->get(); +} + +/** + * Replace validated import rows without changing truncation or transaction behavior. + * + * @param list> $rows + */ +public function replace( array $rows ): void { + $this->db->transactional( function () use ( $rows ): void { + $this->entries->deleteAll(); + + foreach ( array_chunk( $rows, 500 ) as $chunk ) { + $values = []; + + foreach ( $chunk as $row ) { + $values[] = [ + 'ent_num' => $row['ent_num'], + 'name' => mb_substr( $row['name'], 0, 350 ), + 'name_normalized' => mb_substr( $row['name_normalized'], 0, 191 ), + 'sdn_type' => mb_substr( $row['sdn_type'], 0, 30 ), + 'program' => mb_substr( $row['program'], 0, 255 ), + 'source' => $row['source'], + ]; + } + + $this->entries->query()->insert( $values ); + } + } ); +} +``` + +The deployed entries table has no `updated_at` column and no unique key on `ent_num`. This example does not introduce either. Upsert is a separate query capability for tables with appropriate existing primary or unique keys, not a replacement for this import. + +## Package layout + +The bounded implementation belongs in `src/Database/Query`. `Database` lives in that namespace, and `TableReference` lives under `Query\ValueObjects`. `Table::query()` returns a fresh `Query`. `Table::insert()` and `insertGetId()` delegate to the same query operations. `insert()` accepts one row or many, returns an integer count, and treats empty input as no work. `insertGetId()` accepts exactly one row, inserts database defaults for empty data, and preserves its generated ID as `int|string`. Explicit Doctrine insert types remain available through the shared connection. Upsert belongs on the query. + +The consumer surface comprises `Database`, `Query`, `WhereGroup`, `JoinClause`, `TableReference`, and existing `Table` methods. SQL compilation, naming helpers, execution adapters, and compiled values are implementation details marked `@internal`. + +## Representative implementation shape + +An injected `StellarWP\Foundation\Database\Query\Database` resolves `table( Table|string|TableReference $table, ?string $alias = null )` into a fresh query. A table object provides the same query through `query( ?string $alias = null )`. Name resolution captures the current scope before execution; the shared connection still owns transactions and terminal failures. + +`Query` owns mutable query intent for one consumer operation. Terminal operations compile without changing it, so `count()` followed by `get()` observes the same conditions and row shape. They call the injected Doctrine connection's ordinary query or statement execution methods and preserve native execution exceptions. + +`WhereGroup` owns the conditional grammar: two- and three-argument comparisons, associative equalities, nested closures, IN, NULL, and escaped substring predicates. It validates operators before handling null, compiles nested groups to immutable `Statement` fragments, and preserves binding order. A nested group's later mutation cannot alter an already captured statement. `JoinClause` adds column-to-column ON conditions and reuses that same value-condition grammar. + +This bounded design needs neither a separate class per predicate nor an independently replaceable execution service merely to forward a Doctrine method. Extract collaborators only when they own naming, compilation, dialect policy, or another demonstrated responsibility. Future predicates can extend the grammar without requiring a condition-node hierarchy now. + +## Composition rules + +Terminal operations follow one policy based on whether the query shapes its result rows. Nothing a caller supplied is silently dropped. + +**Row-shaping state** includes `distinct()`, `groupBy()`, `having()`, `limit()`, and `offset()`. Treat every `selectRaw()` projection as row-shaping too: opaque SQL may contain an aggregate or another expression that changes cardinality. Preserve it without parsing SQL. Calling `select()` replaces the complete projection and clears that raw-projection state. Future union support would also be row-shaping. Ordering is row-shaping only in combination with `limit()` or `offset()`, because it then decides which rows are kept. + +### Reads that return rows + +`get()` and `first()` run the query as written. `value()` and `pluck()` are future candidates. `first()` sets `LIMIT 1` on the outermost query and keeps any offset. + +### Aggregates and existence + +1. **Without row-shaping state or raw projections**, the aggregate runs directly against the query's tables, joins, and conditions. The selection is replaced by the aggregate, and ordering is omitted because it cannot change the result. + + ```sql + SELECT COUNT(*) FROM `wp_entries` WHERE `source` = ? + ``` + +2. **With row-shaping state**, the complete original query becomes a derived table, and the aggregate runs over it. Selection, ordering, limit, offset, grouping, having, and unions are all kept inside. + + ```php + $query->select( 'amount as payment' )->orderBy( 'payment', 'desc' )->limit( 10 )->max( 'payment' ); + ``` + + ```sql + SELECT MAX(`payment`) FROM ( + SELECT `amount` AS `payment` FROM `wp_payments` ORDER BY `payment` DESC LIMIT 10 + ) AS `aggregate` + ``` + + Preserve every explicit projection, including on a query shaped only by limit or offset. An `orderBy()` may depend on its selected alias. An implicit projection can be reduced to the aggregate's required column, or `1` for count, only when doing so preserves the query's meaning. + + With `distinct()`, `groupBy()`, `having()`, or `selectRaw()`, the projection defines the rows. A column aggregate must refer to an output column or alias of the derived table. Structured selections can be checked where unambiguous; raw SQL remains opaque, and the database reports a missing output column. Do not implement a SQL parser to infer raw expression aliases. A shaped join needs an explicit projection to avoid duplicate derived-table column names. + +3. **`exists()`** wraps the original query unchanged: `SELECT EXISTS( )`. Without row-shaping state or raw projections, the inner selection is reduced to `1`. In particular, `selectRaw( 'COUNT(*) AS total' )->exists()` is true even for an empty source table: the selected aggregate produces one result row. Replacing that projection with `1` would incorrectly return false. With any row-shaping state, the selection stays, so a `having()` that refers to a selected alias still works: + + ```php + $query->selectRaw( 'program, COUNT(*) AS total' ) + ->groupBy( 'program' ) + ->having( 'total', '>', 1 ) + ->exists(); + ``` + + ```sql + SELECT EXISTS( + SELECT program, COUNT(*) AS total FROM `wp_entries` GROUP BY `program` HAVING `total` > ? + ) + ``` + +This deliberately differs from Laravel, whose aggregate keeps `LIMIT` on the outer query, so `count()` ignores it and returns only the first group's count after `groupBy()`. + +### Writes + +`update()` and `delete()` compile to single-table statements. They keep conditions, ordering, and limit, which MySQL supports on single-table writes. They throw when the query has joins, an explicit selection, `distinct()`, `groupBy()`, `having()`, `union()`, or `offset()`, because no single-table write can honor them. + +Insert and upsert reject accumulated conditions, explicit projections, joins, distinct, grouping, having, ordering, limits, and offsets, including for empty input. They must not silently discard query intent. Begin a fresh query for these operations. + +## Semantics + +Implemented behavior becomes a 2.x compatibility commitment at release. Methods outside the first slice remain design candidates. + +| Call | Behavior | +| --- | --- | +| `where( 'col', $value )` | Equality | +| `where( 'col', $operator, $value )` | Operator from a closed list: `=`, `!=`, `<>`, `<`, `<=`, `>`, `>=`. Anything else throws at the call | +| `where( 'col', null )` or `'='`, `null` | `IS NULL` | +| `where( 'col', '!=', null )` or `'<>'` | `IS NOT NULL` | +| `where( 'col', '>', null )` and other ordering operators | Throws, naming `whereNull()` and `whereNotNull()` | +| `whereIn( 'col', [] )` | Matches nothing; `whereNotIn( 'col', [] )` matches everything | +| `whereContains`, `whereStartsWith`, `whereEndsWith`, `whereContainsAny`, `whereContainsAll` | Needle wildcards escaped; an empty needle list matches nothing for `Any` and everything for `All` | +| `whereLike()` | Laravel semantics: the value is a pattern, and escaping it is the caller's responsibility | +| Identifiers | Column and table arguments accept `name`, `qualifier.name`, `qualifier.*`, and `name as alias`, where each part matches `[A-Za-z_][A-Za-z0-9_]*`. Every part is quoted. Anything else throws, so a request-supplied sort column cannot inject SQL | +| `bool` values | Bound as integers. A PHP `false` bound as a string becomes `''`, which strict MySQL rejects for integer columns | +| `DateTimeInterface` values | Formatted as `Y-m-d H:i:s.u`. A column with less precision than the value adjusts it: MySQL rounds unless `TIME_TRUNCATE_FRACTIONAL` is set, so a `DATETIME(0)` column may round up a second, while MariaDB truncates | +| Decimals | Pass decimal strings. Floats are bound through PHP's float-to-string conversion, which cannot represent every decimal exactly; the docs say to use strings for money and other exact values | +| `null` values | SQL `NULL` | +| `limit()`, `offset()` | Validated non-negative integers rendered as literals | +| Affected-row counts | `int\|string`, as DBAL and the current `Table` return | +| `update()`, `delete()` without conditions | Throw. This guards against an omitted filter; it does not prove a write is bounded, because `whereNotIn( 'id', [] )` explicitly matches everything. Full-table deletes use `Table::deleteAll()`; `truncate()` remains separate because it resets identity and is not transactional | +| `insert( [] )`, `upsert( [] )` | Return 0 without querying | +| Multi-row `insert()` and `upsert()` | Every row must have the same column set, in any order; columns are normalized to one order, and a mismatch throws before any SQL | +| Large multi-row writes | Split into statements under MySQL's 65,535-placeholder limit. Each statement is atomic; the set is not. A failure in a later statement leaves earlier ones committed unless the caller wraps the write in `transactional()`, and the docs say so beside the method | +| `upsert()` | See below | + +### Upsert + +`upsert()` compiles to one `INSERT ... ON DUPLICATE KEY UPDATE` per statement and follows native MySQL and MariaDB semantics: a row conflicts when it matches any existing primary or unique key, and the database, not the caller, chooses which. With a primary key `id` and a unique `email`, a row whose `id` already exists updates that row even if its `email` is new. + +Laravel 13's signature is `upsert( $values, $uniqueBy, $update = null )`. Its MySQL grammar never reads `$uniqueBy`, yet Laravel 13 throws when it is empty; with `$update` omitted it updates every column, including the unique ones. + +Accepted Foundation signature: `upsert( array $values, array $update )`. There is no conflict-target parameter, because none of the supported engines honors one, and `$update` is required, because updating every column including keys is rarely intended. This is a deliberate break from Laravel parity (decision 3). A true conflict-target guarantee, such as checking the target key before writing, would be a separate design, not a validation added here. + +`VALUES( col )` in the update clause works on MySQL 5.7 through 8.4 and every supported MariaDB version; MySQL 8.0.20+ deprecates it in favor of row aliases, which MariaDB does not support. That difference is the first concrete case for the dialect strategy. + +## Naming boundary + +Joins and the `Database` entry point make WordPress core tables ordinary targets. The builder distinguishes three kinds of name, chosen by how the name enters, never inferred from the string: + +| Kind | How it enters | Resolution | +| --- | --- | --- | +| Application table | A `Table` object, or an unprefixed name such as `'your_plugin_orders as o'` | `TableNameResolver` with the current `DatabaseScope`, exactly as today | +| WordPress core table | A `TableReference` from `$database->wordpress( 'users' )` | WordPress's own `$wpdb` table properties, which apply the site or network prefix and honor `CUSTOM_USER_TABLE` and `CUSTOM_USER_META_TABLE` | +| Resolved physical name | Carried inside a `TableReference` | Validated and quoted, never re-prefixed | + +`wordpress()` returns a `TableReference`: an immutable value holding the resolved physical name, with `as( string $alias )` returning a copy carrying an alias. It is not a query. It resolves when called, against the current site, which matches the existing rule that built queries keep the names resolved when they were built. Unknown core names throw. + +`Database::table()` accepts an application `Table`, an unprefixed application name, or a `TableReference`. `join()` also accepts a `Table`, an unprefixed application name, or a `TableReference`. Pass a table object directly; its `name()` is already physical and must not be passed back as an unprefixed string. + +Querying a core table: + +```php +$drafts = $this->database->table( $this->database->wordpress( 'posts' ) ) + ->where( 'post_status', 'draft' ) + ->count(); +``` + +Joining application and core tables: + +```php +$orders = $this->database->table( 'your_plugin_orders as o' ) + ->join( $this->database->wordpress( 'users' )->as( 'u' ), 'u.ID', '=', 'o.user_id' ) + ->leftJoin( 'your_plugin_refunds as r', static fn ( JoinClause $join ) => $join + ->on( 'r.order_id', '=', 'o.id' ) + ->where( 'r.status', 'approved' ) ) + ->select( 'o.id', 'o.total', 'u.user_email', 'r.amount as refunded' ) + ->where( 'o.status', 'paid' ) + ->orderBy( 'o.created_at', 'desc' ) + ->get(); +``` + +On a multisite subsite, `wordpress( 'users' )` resolves to the network's shared users table, or to `CUSTOM_USER_TABLE` when defined, while the two application tables resolve with the subsite's prefix. + +## Method reference + +This comparison is a design backlog, not the implemented API or a promise of full Laravel compatibility. Only the first-slice table below is authorized for the current implementation. Laravel's names and argument order inform candidate APIs; Foundation's documented semantics govern its own methods. `stellarwp/db` contributes vocabulary only; none of its implementation is reused. + +Status meanings: + +- **Core**: candidate for later expansion; only the first-slice table identifies current scope. +- **Later**: deferred candidate requiring its own design and verification. +- **Excluded**: deliberately not offered, with the reason. + +### Entry points + +| Laravel | `stellarwp/db` | Foundation | Status | +| --- | --- | --- | --- | +| `DB::table( 'posts' )` | `DB::table( 'posts', 'alias' )` | `$database->table( 'your_plugin_reports as r' )` for application tables; `$database->table( $database->wordpress( 'posts' ) )` for core tables | Core | +| Model or repository query | none | `$table->query( 'alias' )` | Core, existing | +| `from()` | `from( $table, $alias )` | `from()` | Core | +| `fromSub()`, `fromRaw()` | none | Same names | Later | + +`Database` is an ordinary injected object, not a static facade. + +### Selecting + +| Laravel | `stellarwp/db` | Status | +| --- | --- | --- | +| `select()`, `addSelect()`, `distinct()` | `select()`, `distinct()` | Core | +| `selectRaw( $sql, $bindings )` | `selectRaw( $sql, ...$args )` | Core, raw policy | +| `selectSub( $query, $as )` | none | Later | + +### Conditions + +Every method has Laravel's `or` twin. + +| Laravel | `stellarwp/db` | Status | +| --- | --- | --- | +| `where()` with a value, an operator and value, a Closure, or an array | `where( $column, $value, $operator )` with the operator last | Core, Laravel's order | +| `whereNot( Closure )` | none | Core | +| `whereIn()`, `whereNotIn()` | same | Core; subquery form Later | +| `whereNull()`, `whereNotNull()` | `whereIsNull()`, `whereIsNotNull()` | Core, Laravel names | +| `whereBetween( $column, $bounds )`, `whereNotBetween()` | min and max as separate arguments | Core, Laravel's array form | +| `whereBetweenColumns()`, `whereNotBetweenColumns()`, `whereColumn()` | none | Core | +| `whereLike( $column, $pattern, $caseSensitive = false )`, `whereNotLike()` | `whereLike()`, which wraps the value in `%` unless it contains one | Core, Laravel semantics | +| none | none | Foundation additions, Core: `whereContains()`, `whereStartsWith()`, `whereEndsWith()`, `whereContainsAny()`, `whereContainsAll()`, escaped | +| `whereAny()`, `whereAll()`, `whereNone()` | none | Core | +| `whereExists()`, `whereNotExists()` | same | Core | +| `whereRaw( $sql, $bindings )` | `whereRaw( $sql, ...$args )` | Core, raw policy | +| `whereDate()`, `whereYear()`, `whereMonth()`, `whereDay()`, `whereTime()` | none | Core | +| `whereBinary()`, `whereNotBinary()` | none | Later. Laravel 13; byte-exact comparison regardless of collation | +| `whereValueBetween()`, `whereNullSafeEquals()`, `whereRowValues()` | none | Later | +| `whereJsonContains()` and the JSON family | none | Later. MariaDB stores JSON as `LONGTEXT`; needs per-engine tests | +| `whereFullText()` | none | Later. Requires a full-text index | +| `wherePast()`, `whereToday()`, and the relative-date family | none | Excluded for now. Depends on the WordPress site timezone versus UTC storage | +| `whereIntegerInRaw()` | none | Excluded. A Laravel performance workaround | +| `whereVector...()` | none | Excluded. PostgreSQL only | +| Dynamic `whereEmail()` | none | Excluded. Defeats autocompletion | + +### Joins + +| Laravel | `stellarwp/db` | Status | +| --- | --- | --- | +| `join( 'posts as p', 'p.ID', '=', 'o.post_id' )` | `innerJoin( $table, $column1, $column2, $alias )` | Core, Laravel's shape | +| `leftJoin()`, `rightJoin()`, `crossJoin()` | `leftJoin()`, `rightJoin()` | Core | +| `join( $table, Closure )` with `on()`, `orOn()`, `where()` | `join( Closure )` | Core | +| `joinWhere()`, `leftJoinWhere()`, `rightJoinWhere()` | none | Core | +| `straightJoin()`, `straightJoinWhere()`, `straightJoinSub()` | none | Later. Laravel 13; MySQL optimizer join-order hint | +| `joinSub()`, `leftJoinSub()`, `rightJoinSub()`, `crossJoinSub()` | none | Later. Derived-table reports | +| none | `joinRaw( $sql, ...$args )` | Later, raw policy | +| `joinLateral()` | none | Excluded. MySQL 8.0.14+ only | + +### WordPress meta + +| Laravel | `stellarwp/db` | Status | +| --- | --- | --- | +| none | `attachMeta()`, `configureMetaTable()` | Later. Pivot meta keys into columns through joins, with a freshly designed API | + +### Grouping + +| Laravel | `stellarwp/db` | Status | +| --- | --- | --- | +| `groupBy()`, `groupByRaw()` | `groupBy()` | Core; raw form under the raw policy | +| `having()`, `havingNull()`, `havingNotNull()`, `havingBetween()`, `havingNotBetween()` | `having()` | Core | +| `havingRaw( $sql, $bindings )` | `havingRaw()` | Core, raw policy | +| none | `havingCount()`, `havingSum()`, `havingAvg()`, `havingMin()`, `havingMax()` | Excluded. Written as `selectRaw()` plus `having()` | +| `groupLimit()` | none | Excluded. Needs window functions, absent in MySQL 5.7 | + +### Ordering and paging + +| Laravel | `stellarwp/db` | Status | +| --- | --- | --- | +| `orderBy()`, `orderByDesc()`, `latest()`, `oldest()`, `reorder()`, `reorderDesc()`, `inRandomOrder()` | `orderBy()` | Core | +| `orderByRaw()` | none | Core, raw policy | +| `limit()`, `take()`, `offset()`, `skip()`, `forPage()`, `forPageAfterId()`, `forPageBeforeId()` | `limit()`, `offset()` | Core | +| `useIndex()`, `forceIndex()`, `ignoreIndex()` | none | Later | +| `orderByVectorDistance()` | none | Excluded | + +### Unions + +| Laravel | `stellarwp/db` | Status | +| --- | --- | --- | +| `union()`, `unionAll()` | same | Core | + +### Reading results + +| Laravel | `stellarwp/db` | Status | +| --- | --- | --- | +| `get()`, all rows | `getAll()`; its `get()` returns one row | Core, Laravel meaning | +| `first()`, `firstOrFail()`, `sole()`, `find( $id )` | `get()` | Core | +| `value()`, `soleValue()`, `pluck()`, `implode()` | none | Core | +| `exists()`, `doesntExist()`, `existsOr()`, `doesntExistOr()` | none | Core | +| `count()`, `min()`, `max()`, `sum()`, `avg()`, `average()`, `aggregate()` | same five aggregates | Core, with Foundation's composition rules | +| `chunk()`, `chunkById()`, `chunkByIdDesc()`, `each()`, `eachById()` | none | Core | +| `lazy()`, `lazyById()`, `lazyByIdDesc()` | none | Core. Each issues bounded, separate queries | +| `cursor()` | none | Excluded. DBAL's mysqli driver buffers every result with `store_result()`, so a single-query generator saves no memory; `lazyById()` is the bounded alternative | +| `paginate()`, `simplePaginate()`, `cursorPaginate()` | none | Excluded. Laravel's paginators depend on its request and URL generation | +| `fetchUsing()` | none | Excluded. Laravel 13; PDO fetch modes | + +### Writing + +| Laravel | `stellarwp/db` | Status | +| --- | --- | --- | +| `insert( $row )` or `insert( $rows )` | `insert()` through `wpdb::insert()` | Core, Laravel's forms, returning affected rows | +| `insertGetId()`, `insertOrIgnore()`, `insertUsing()`, `insertOrIgnoreUsing()` | none | Core | +| `upsert()` | `upsert( $data, $match )`, a read then a write | Core, Foundation signature; see Upsert | +| `update()`, `increment()`, `decrement()`, `incrementEach()`, `decrementEach()` | `update()` through `wpdb::update()` | Core | +| `delete()` | `delete()` | Core | +| Laravel has none | none | `Table::deleteAll()`, existing: the explicit, transactional full-table delete | +| `truncate()` | none | Core, existing on `Table` | +| `insertOrIgnoreReturning()` | none | Excluded. Laravel 13; MySQL has no `RETURNING` | +| `updateOrInsert()` | none | Excluded. A non-atomic read then write | +| `updateFrom()` | none | Excluded. PostgreSQL only | + +### Locking + +| Laravel | `stellarwp/db` | Status | +| --- | --- | --- | +| `lockForUpdate()`, `sharedLock()`, `lock()` | none | Later. Meaningful only inside a managed transaction | + +### Composition and inspection + +| Laravel | `stellarwp/db` | Status | +| --- | --- | --- | +| `when()`, `unless()`, `tap()`, `pipe()` | none | Core | +| `clone()`, `cloneWithout()`, `newQuery()` | none | Core | +| `toSql()`, `toRawSql()`, `getBindings()` | `getSQL()` | Core; `toRawSql()` for debugging only | +| `dump()`, `dd()`, `dumpRawSql()`, `ddRawSql()` | none | Excluded. Log `toRawSql()` through the application's logger | +| `raw()` expressions, `macro()`, `beforeQuery()`, `afterQuery()`, `timeout()`, `useWritePdo()` | none | Excluded | + +### Raw SQL policy + +The first slice implements `selectRaw()` only. The future candidates are `whereRaw()`, `havingRaw()`, `orderByRaw()`, and `groupByRaw()`. They keep Laravel's `( string $sql, array $bindings = [] )` signature, every value goes through `$bindings`, and the docs present them after the safe alternatives. Laravel's `DB::raw()` expression objects stay excluded. + +## Differences from Laravel + +- Results are plain PHP arrays, not `Collection` objects, keeping `illuminate/support` and its global helpers out of public plugins. +- Identifiers are validated and always quoted. +- Aggregates and `exists()` follow the composition rules above: queries with row-shaping state are wrapped whole, rather than aggregated in the outer query as Laravel does. +- `upsert()` has no conflict-target argument and requires the update column list. +- `update()` and `delete()` without conditions throw; `deleteAll()` is the explicit full-table delete. +- `cursor()` is excluded because the underlying driver buffers results. + +## Migrating from `stellarwp/db` + +These calls keep a familiar name but change meaning: + +| `stellarwp/db` call | Meaning there | Foundation equivalent | +| --- | --- | --- | +| `get()` | First row | `first()` | +| `getAll()` | All rows | `get()` | +| `whereLike( 'col', 'term' )` | Contains `term` | `whereContains( 'col', 'term' )` | +| `where( 'col', $value, '>' )` | Operator last | `where( 'col', '>', $value )` | +| `leftJoin( $table, $a, $b, $alias )` | Implicit `=` | `leftJoin( "{$table} as {$alias}", $a, '=', $b )` | +| `whereIsNull( 'col' )` | | `whereNull( 'col' )` | +| `havingCount( 'id', '>', 1 )` | | `selectRaw( 'COUNT(id) as total' )->having( 'total', '>', 1 )` | +| `upsert( $row, $match )` | Read, then write by `$match` | `upsert( $rows, $update )`, which matches by the table's own keys | +| `DB::table( 'posts' )` | Static facade, site prefix | `$this->database->table( $this->database->wordpress( 'posts' ) )` | + +## Decisions before the first slice + +Accepted decisions for the first slice; no further confirmation is required. + +1. **Row shape: associative arrays.** They match DBAL and the current `Table`, need no conversion, and give array-shape docblocks something to describe. The `stellarwp/db` replacement needs a migration guide regardless, because `get()` changes meaning, so object rows would not remove that work. +2. **Write counts:** `insert()` and `upsert()` return integer totals rather than Laravel's insertion `bool`. Update/delete counts retain `int|string`. Generated IDs from `insertGetId()` retain `int|string` independently of affected-row counts. +3. **Upsert: `upsert( $rows, $update )`.** No conflict-target argument, because no supported engine honors one; the update list is required. +4. **Core tables: `$database->wordpress( $name )` returning a `TableReference`**, accepted by `table()` and `join()`. +5. **Chunked writes: split automatically**, with the caller-owned transaction boundary documented beside `insert()` and `upsert()`. + +## Scope + +The first slice is the current delivery scope. The broader method reference is a backlog, not a required parity project. Excluded methods remain excluded unless a demonstrated requirement changes the decision. + +## First slice + +The slice is small enough to review as complete classes, and wide enough to exercise every architectural boundary: + +| Area | Methods | +| --- | --- | +| Entry | `$database->table()` with an application `Table`, an unprefixed name, and a `TableReference`; `wordpress()` for `posts` and `users` | +| Filtering | `where()` in all forms, nested closures, `orWhere()`, `whereIn()`, `whereNull()`, `whereContainsAny()` | +| Joins | `join()` against a core table, and `leftJoin()` with a closure `ON` clause | +| Shaping | `select()`, `selectRaw()` with bindings, `distinct()`, `groupBy()`, `having()`, `orderBy()`, `limit()`, `offset()` | +| Reading | `get()`, `first()`, `count()`, `max()`, `exists()` | +| Inspection | `toSql()`, `getBindings()` | +| Writing | single-row and multi-row `insert()`, single-row `insertGetId()`, `update()`, `delete()`, `upsert()` | + +Unions, subqueries, chunking methods, and the rest of the method reference wait for expansion after review. + +Implement this slice directly in `src/Database/Query` for unreleased Foundation 2.0 and return it from `Table::query()`. The design artifact remains under `dev/Experiments/Query`; it is not the runtime package location. Update existing consumers and compatibility tests directly, without an adapter preserving the abandoned native-builder return type. + +Verification: + +1. Port the portal's `Entry_Repository` as the consumer fixture, including its transactional replacement. +2. Unit-test the compiler's SQL and binding order, using Laravel 13's query-builder tests as the reference for grouping and null handling, and the composition rules as the specification where Foundation differs. +3. Test the composition combinations the slice implements: aggregates and `exists()` on a plain query, on a join, with `orderBy()` plus `limit()`, with `offset()`, with `distinct()`, and with `groupBy()` plus a `having()` on a selected alias. Include `max()` over an ordered, limited query with an explicit selected alias, an ungrouped raw aggregate over an empty source, resetting raw projection state through `select()`, and an unavailable aggregate output column. SQL reports missing outputs from opaque raw projections. +4. Integration-test on MariaDB and MySQL: escaped needles containing `%`, `_`, and `!`; empty lists; boolean binding under strict mode; fractional-second dates; a multi-row insert that fails in a later chunk, inside and outside a transaction; an upsert conflicting on the primary key while naming a new unique value; and a subsite join against `users`, including a `CUSTOM_USER_TABLE` install. +5. Test the architecture hypothesis: implement `get()` with conditions and one join a second time over Doctrine's `QueryBuilder`, and compare the two in the review. +6. Review the complete classes and repository code before expanding toward the method reference. + +The PHP 7.4 lane is deferred until the separate Rector downgrade work; it is not a Foundation 2.0 release requirement. + +## Changes this requires elsewhere + +- **AGENTS.md Database section:** establish the Foundation builder as the ordinary query API and retain Doctrine for the shared connection, execution, schema tools, and advanced native operations. +- **Table surface:** `query()` changes its return type while keeping its alias argument, table insertion delegates to the fluent implementation with the same single-row/bulk semantics and value normalization. Both entry points expose `insertGetId()` for exactly one insertion, including defaults-only rows; explicit insert types use the native connection. `upsert()` lives on `Query`. `count()`, `deleteAll()`, and `truncate()` keep their current meaning. All of this lands before the 2.0 tag. +- **Docs:** `components/database/query-builder.mdx` rewritten around the new API. +- **Migrations:** unaffected; it uses the connection and schema tools directly. diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index 74d6a3f..7ba98e0 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -15,6 +15,8 @@ use StellarWP\Foundation\Database\Contracts\DatabaseScope; use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Database\Exceptions\DatabaseException; +use StellarWP\Foundation\Database\Query\Upsert\Contracts\Builder; +use StellarWP\Foundation\Database\Query\Upsert\ServerBuilder; use StellarWP\Foundation\Database\Scope\SiteScope; use wpdb; @@ -38,6 +40,8 @@ public function register(): void { }); $this->container->singleton(DatabaseScope::class, SiteScope::class); $this->container->singleton(TableNameResolver::class, Table\TableNameResolver::class); + $this->container->singleton(Builder::class, ServerBuilder::class); + $this->container->singleton(Query\Database::class); $this->container->singleton(WordPressSession::class); $this->container->singleton(AdvisorySession::class, static fn (C $c): WordPressSession => $c->get(WordPressSession::class)); $this->container->singleton(Connection::class, static function (C $c): Connection { diff --git a/src/Database/Query/Compiler.php b/src/Database/Query/Compiler.php new file mode 100644 index 0000000..453588b --- /dev/null +++ b/src/Database/Query/Compiler.php @@ -0,0 +1,229 @@ +|null $selection + */ + public function select(QueryState $state, ?array $selection = null, ?int $maximum = null, bool $ordered = true): Fragment { + $selected = $selection ?? ($state->selection === [] ? [ + new Selection(new Fragment('*'), '*', true), + ] : $state->selection); + $bindings = []; + $columns = []; + + foreach ($selected as $column) { + $columns[] = $column->fragment->sql; + array_push($bindings, ...$column->fragment->bindings); + } + + $sql = 'SELECT ' . ($state->distinct ? 'DISTINCT ' : '') . implode(', ', $columns) + . "\nFROM " . $this->resolver->sql($state->table); + + foreach ($state->joins as $join) { + $sql .= "\n" . $join->sql; + array_push($bindings, ...$join->bindings); + } + + if ($state->where->sql !== '') { + $sql .= "\nWHERE " . $state->where->sql; + array_push($bindings, ...$state->where->bindings); + } + + if ($state->groups !== []) { + $sql .= "\nGROUP BY " . implode(', ', $state->groups); + } + + if ($state->having->sql !== '') { + $sql .= "\nHAVING " . $state->having->sql; + array_push($bindings, ...$state->having->bindings); + } + + $limit = $maximum === null ? $state->limit : min($state->limit ?? $maximum, $maximum); + $sql .= $this->tail($ordered ? $state->orders : [], $limit, $state->offset); + + return new Fragment($sql, $bindings); + } + + /** + * Aggregate the query's result rows while preserving shaped projections and aliases. + * + * @throws InvalidArgumentException When a column is invalid, absent from an explicit projection, or ambiguous in a shaped join. + */ + public function aggregate(QueryState $state, string $function, string $column): Fragment { + $quoted = $column === '*' ? '*' : $this->quoter->column($column); + + if (! $this->shaped($state)) { + return $this->select($state, [ + new Selection(new Fragment($function . '(' . $quoted . ')')), + ], ordered: false); + } + + $selection = $state->selection; + + if ($selection === [] && ! $state->distinct && $state->groups === [] && $state->having->sql === '') { + $selection = [ + new Selection(new Fragment($column === '*' ? '1' : $quoted)), + ]; + } + + if ($state->joins !== [] && ($selection === [] || in_array(true, array_column($selection, 'wildcard'), true))) { + throw new InvalidArgumentException('Select distinct output columns before aggregating a shaped join.'); + } + + $parts = explode('.', $column); + $name = $parts[count($parts) - 1]; + $outputColumns = array_column($state->selection, 'name'); + + if ( + $column !== '*' + && ! in_array(null, $outputColumns, true) + && $outputColumns !== [] + && ! in_array('*', $outputColumns, true) + && ! in_array(strtolower($name), $outputColumns, true) + ) { + throw new InvalidArgumentException('Aggregate column "' . $name . '" is not selected. Include it in select() or use its selected alias.'); + } + + $inner = $this->select($state, $selection === [] ? null : $selection); + $output = $column === '*' ? '*' : $this->quoter->quote($name); + + return new Fragment( + 'SELECT ' . $function . '(' . $output . ")\nFROM (\n" . $inner->sql . "\n) AS `foundation_rows`", + $inner->bindings, + ); + } + + /** + * Preserve shaped selections because HAVING may depend on their aliases. + */ + public function exists(QueryState $state): Fragment { + $inner = $this->select($state, $this->shaped($state) ? null : [ + new Selection(new Fragment('1')), + ], ordered: $this->shaped($state)); + + return new Fragment("SELECT EXISTS(\n" . $inner->sql . "\n)", $inner->bindings); + } + + /** + * Compile a filtered, single-table update. + * + * @param array $values + * + * @throws InvalidArgumentException When values are empty or the query cannot be used for an update. + */ + public function update(QueryState $state, array $values): Fragment { + $this->writable($state); + + if ($values === []) { + throw new InvalidArgumentException('An update requires at least one column value.'); + } + + $columns = []; + + foreach (array_keys($values) as $column) { + $columns[] = $this->quoter->quote($column) . ' = ?'; + } + + return new Fragment( + 'UPDATE ' . $this->resolver->sql($state->table) + . "\nSET " . implode(', ', $columns) + . "\nWHERE " . $state->where->sql + . $this->tail($state->orders, $state->limit, null), + array_merge(array_values($values), $state->where->bindings), + ); + } + + /** + * Compile a filtered, single-table delete. + * + * @throws InvalidArgumentException When the query cannot be used for a delete. + */ + public function delete(QueryState $state): Fragment { + $this->writable($state); + + if ($state->table->alias !== null) { + throw new InvalidArgumentException('Delete from an unaliased table for compatibility with supported MySQL and MariaDB versions.'); + } + + return new Fragment( + 'DELETE FROM ' . $this->resolver->sql($state->table) + . "\nWHERE " . $state->where->sql + . $this->tail($state->orders, $state->limit, null), + $state->where->bindings, + ); + } + + /** + * Reject insert state that cannot affect the inserted rows. + * + * @throws InvalidArgumentException When query options would be ignored by an insert. + */ + public function insertable(QueryState $state): void { + if ($state->selection !== [] || $state->joins !== [] || $state->where->sql !== '' || $this->shaped($state) || $state->orders !== []) { + throw new InvalidArgumentException('Start a fresh table query before inserting or upserting rows.'); + } + } + + /** + * Identify selections whose result shape must survive aggregate wrapping. + */ + private function shaped(QueryState $state): bool { + return in_array(null, array_column($state->selection, 'name'), true) || $state->distinct || $state->groups !== [] || $state->having->sql !== '' || $state->limit !== null || $state->offset !== null; + } + + /** + * Reject writes whose filters or query options cannot be honored. + */ + private function writable(QueryState $state): void { + if ($state->where->sql === '') { + throw new InvalidArgumentException('Add a condition before updating or deleting; use Table::deleteAll() for an intentional full-table delete.'); + } + + if ($state->selection !== [] || $state->joins !== [] || $state->distinct || $state->groups !== [] || $state->having->sql !== '' || $state->offset !== null) { + throw new InvalidArgumentException('Updates and deletes support conditions, ordering, and limit only.'); + } + } + + /** + * Append ordering and pagination supported by MySQL and MariaDB. + * + * @param list $orders + */ + private function tail(array $orders, ?int $limit, ?int $offset): string { + $sql = $orders === [] ? '' : "\nORDER BY " . implode(', ', $orders); + + if ($limit !== null || $offset !== null) { + $sql .= "\nLIMIT " . ($limit ?? '18446744073709551615'); + } + + if ($offset !== null) { + $sql .= ' OFFSET ' . $offset; + } + + return $sql; + } +} diff --git a/src/Database/Query/Database.php b/src/Database/Query/Database.php new file mode 100644 index 0000000..98c5d9f --- /dev/null +++ b/src/Database/Query/Database.php @@ -0,0 +1,60 @@ +resolver->resolve($table); + + if ($alias !== null) { + $reference = $reference->as($alias); + } + + return new Query( + $this->compiler, + $this->executor, + $this->writer, + $this->resolver, + $reference, + $this->quoter, + ); + } + + /** + * Capture a WordPress core table, including its global or custom naming policy. + * + * @throws InvalidArgumentException When WordPress does not own the requested table. + */ + public function wordpress(string $name): TableReference { + return $this->resolver->wordpress($name); + } +} diff --git a/src/Database/Query/Executor.php b/src/Database/Query/Executor.php new file mode 100644 index 0000000..120b8cb --- /dev/null +++ b/src/Database/Query/Executor.php @@ -0,0 +1,136 @@ +> + */ + public function rows(Fragment $statement): array { + $parameters = $this->parameters($statement); + + return $this->connection->fetchAllAssociative($statement->sql, $parameters->values, $parameters->types); + } + + /** + * Fetch the first row, or false when no row matches. + * + * @throws InvalidArgumentException For unsupported bound values. + * @throws Exception When execution fails. + * + * @return array|false + */ + public function first(Fragment $statement): array|false { + $parameters = $this->parameters($statement); + + return $this->connection->fetchAssociative($statement->sql, $parameters->values, $parameters->types); + } + + /** + * Fetch the first column, preserving DBAL's false result for no row. + * + * @throws InvalidArgumentException For unsupported bound values. + * @throws Exception When execution fails. + */ + public function value(Fragment $statement): mixed { + $parameters = $this->parameters($statement); + + return $this->connection->fetchOne($statement->sql, $parameters->values, $parameters->types); + } + + /** + * Execute a write and preserve DBAL's affected-row count. + * + * @throws InvalidArgumentException For unsupported bound values. + * @throws Exception When execution fails. + */ + public function statement(Fragment $statement): int|string { + $parameters = $this->parameters($statement); + + return $this->connection->executeStatement($statement->sql, $parameters->values, $parameters->types); + } + + /** + * Read the identifier generated by the immediately preceding single-row insert. + * + * @throws Exception When no generated identifier is available or the connection fails. + */ + public function lastInsertId(): int|string { + return $this->connection->lastInsertId(); + } + + /** + * Normalize a bound value without accessing the database. + * + * Dates retain their timezone's wall time and microseconds. Pass decimal strings + * for exact values; floating-point values use PHP's string conversion. + * + * @throws InvalidArgumentException For arrays, resources, or unsupported objects. + */ + public function normalize(mixed $value): int|string|null { + if ($value instanceof DateTimeInterface) { + return $value->format('Y-m-d H:i:s.u'); + } + + if (is_bool($value)) { + return (int) $value; + } + + if (is_float($value)) { + return (string) $value; + } + + if ($value === null || is_int($value) || is_string($value)) { + return $value; + } + + throw new InvalidArgumentException('Unsupported query value: ' . get_debug_type($value) . '.'); + } + + /** + * Prepare values and explicit binding types for one execution. + */ + private function parameters(Fragment $statement): ParameterSet { + $bindings = []; + $types = []; + + foreach ($statement->bindings as $value) { + $value = $this->normalize($value); + $bindings[] = $value; + $types[] = match (true) { + $value === null => ParameterType::NULL, + is_int($value) => ParameterType::INTEGER, + default => ParameterType::STRING, + }; + } + + return new ParameterSet($bindings, $types); + } +} diff --git a/src/Database/Query/IdentifierQuoter.php b/src/Database/Query/IdentifierQuoter.php new file mode 100644 index 0000000..7f3ad81 --- /dev/null +++ b/src/Database/Query/IdentifierQuoter.php @@ -0,0 +1,76 @@ + 2) { + throw new InvalidArgumentException('Use column or qualifier.column: ' . $name); + } + + $quoted = []; + + foreach ($parts as $position => $part) { + $quoted[] = $wildcard && $part === '*' && $position === count($parts) - 1 + ? '*' + : $this->quote($part); + } + + return implode('.', $quoted); + } + + /** + * Quote a selected column and its optional output alias. + * + * @throws InvalidArgumentException When the column or its output alias is invalid. + */ + public function selection(string $name): Selection { + $parts = preg_split('/\s+as\s+/i', $name, 2); + + if ($parts === false) { + throw new InvalidArgumentException('Invalid selection: ' . $name); + } + + $wildcard = $parts[0] === '*' || str_ends_with($parts[0], '.*'); + + if (isset($parts[1]) && $wildcard) { + throw new InvalidArgumentException('A wildcard selection cannot have an output alias.'); + } + + $sql = $this->column($parts[0], true) . (isset($parts[1]) ? ' AS ' . $this->quote($parts[1]) : ''); + $column = explode('.', $parts[0]); + $output = $parts[1] ?? $column[count($column) - 1]; + + return new Selection(new Fragment($sql), strtolower($output), $wildcard); + } +} diff --git a/src/Database/Query/InsertWriter.php b/src/Database/Query/InsertWriter.php new file mode 100644 index 0000000..920dadb --- /dev/null +++ b/src/Database/Query/InsertWriter.php @@ -0,0 +1,163 @@ + 65535) { + throw new InvalidArgumentException('The parameter limit must be between 1 and 65535.'); + } + } + + /** + * Insert one row or many, optionally updating named columns on a key conflict. + * + * Every row is validated before any write. Chunked writes require a caller-owned + * transaction for atomicity across statements. No transaction is opened here. + * The total is an integer: an insert or upsert affects at most two rows per + * supplied row, so an in-memory input cannot exhaust the integer range. + * + * @param array|list> $rows + * @param list $update + * + * @throws InvalidArgumentException For invalid columns, values, or mismatched rows. + * @throws Exception When execution fails, including in a later chunk. + */ + public function insert(TableReference $table, array $rows, array $update = []): int { + if ($table->alias !== null) { + throw new InvalidArgumentException('Insert targets cannot have an alias.'); + } + + if ($rows === []) { + return 0; + } + + $rows = array_is_list($rows) ? $rows : [ + $rows, + ]; + $normalized = $this->normalizeRows($rows); + $columns = array_keys($normalized[0]); + + if (count($columns) > $this->parameterLimit) { + throw new InvalidArgumentException('One insert row exceeds the statement parameter limit.'); + } + + foreach ($update as $column) { + if (! in_array($column, $columns, true)) { + throw new InvalidArgumentException('Upsert update columns must occur in the inserted rows.'); + } + } + + $suffix = $update === [] ? '' : $this->builder->build($update, $table->name); + $prefix = 'INSERT INTO ' . $this->quoter->quote($table->name) . ' (' . implode(', ', array_map($this->quoter->quote(...), $columns)) . ') VALUES '; + $placeholder = '(' . implode(', ', array_fill(0, count($columns), '?')) . ')'; + $chunkSize = intdiv($this->parameterLimit, count($columns)); + $total = 0; + + for ($offset = 0, $rowCount = count($normalized); $offset < $rowCount; $offset += $chunkSize) { + $chunk = array_slice($normalized, $offset, $chunkSize); + $bindings = []; + + foreach ($chunk as $row) { + array_push($bindings, ...array_values($row)); + } + + $sql = $prefix . implode(', ', array_fill(0, count($chunk), $placeholder)) . $suffix; + $total += (int) $this->executor->statement(new Fragment($sql, $bindings)); + } + + return $total; + } + + /** + * Insert one row, including a defaults-only row, before reading its generated ID. + * + * @param array $data Unvalidated input that must describe exactly one row. + * + * @throws InvalidArgumentException For aliased targets, row lists, or invalid columns and values. + * @throws Exception When insertion fails or no generated identifier is available. + */ + public function insertGetId(TableReference $table, array $data): int|string { + if ($table->alias !== null) { + throw new InvalidArgumentException('Insert targets cannot have an alias.'); + } + + if ($data !== [] && array_is_list($data)) { + throw new InvalidArgumentException('insertGetId() requires one column-to-value row, not a list of rows.'); + } + + if ($data === []) { + $this->executor->statement(new Fragment('INSERT INTO ' . $this->quoter->quote($table->name) . ' () VALUES ()')); + } else { + $this->insert($table, $data); + } + + return $this->executor->lastInsertId(); + } + + /** + * Validate all rows and normalize their values into a consistent column order. + * + * @param non-empty-list $rows + * + * @return non-empty-list> + */ + private function normalizeRows(array $rows): array { + $normalized = []; + $columns = null; + + foreach ($rows as $row) { + if (! is_array($row) || $row === []) { + throw new InvalidArgumentException('Every insert row must be a nonempty column-to-value array.'); + } + + if ($columns === null) { + $columns = array_keys($row); + + foreach ($columns as $column) { + if (! is_string($column)) { + throw new InvalidArgumentException('Insert columns must be unqualified identifiers.'); + } + + $this->quoter->quote($column); + } + } + + if (count($row) !== count($columns) || array_diff($columns, array_keys($row)) !== []) { + throw new InvalidArgumentException('Every insert row must have the same column set.'); + } + + $values = []; + + foreach ($columns as $column) { + $values[$column] = $this->executor->normalize($row[$column]); + } + + $normalized[] = $values; + } + + return $normalized; + } +} diff --git a/src/Database/Query/JoinClause.php b/src/Database/Query/JoinClause.php new file mode 100644 index 0000000..5527221 --- /dev/null +++ b/src/Database/Query/JoinClause.php @@ -0,0 +1,47 @@ +quoter->column($first) . ' ' . $this->operator($operator) . ' ' . $this->quoter->column($second), + ); + + return $this->add($condition, 'AND'); + } + + /** + * Permit an alternative relationship between two columns. + * + * @throws InvalidArgumentException When a column or comparison operator is invalid. + */ + public function orOn(string $first, string $operator, string $second): self { + $condition = new Fragment( + $this->quoter->column($first) . ' ' . $this->operator($operator) . ' ' . $this->quoter->column($second), + ); + + return $this->add($condition, 'OR'); + } + + /** + * Preserve column comparisons inside nested join conditions. + * + * @internal + */ + protected function newGroup(): self { + return new self($this->quoter); + } +} diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php new file mode 100644 index 0000000..cbe2dcf --- /dev/null +++ b/src/Database/Query/Query.php @@ -0,0 +1,396 @@ + + */ + private array $selection = []; + + /** + * @var list + */ + private array $joins = []; + + /** + * @var list + */ + private array $groups = []; + + /** + * @var list + */ + private array $orders = []; + private bool $distinct = false; + private ?int $limit = null; + private ?int $offset = null; + private WhereGroup $having; + + /** + * Receive shared collaborators and this query's resolved source. + * + * @internal Obtain fresh queries from a Table or Database. + */ + public function __construct( + private readonly Compiler $compiler, + private readonly Executor $executor, + private readonly InsertWriter $writer, + private readonly TableReferenceResolver $resolver, + private readonly TableReference $table, + IdentifierQuoter $quoter, + ) { + $this->having = new WhereGroup($quoter); + parent::__construct($quoter); + } + + /** + * Select named columns, qualified columns, wildcards, or columns with aliases. + * + * @throws InvalidArgumentException When a column or output alias is invalid. + */ + public function select(string ...$columns): self { + $this->selection = array_map( + $this->quoter->selection(...), + array_values($columns), + ); + + return $this; + } + + /** + * Add a trusted SQL projection with positional values supplied separately. + * + * @param list $bindings + */ + public function selectRaw(string $sql, array $bindings = []): self { + $this->selection[] = new Selection(new Fragment($sql, $bindings)); + + return $this; + } + + /** + * Return distinct selected rows. + */ + public function distinct(): self { + $this->distinct = true; + + return $this; + } + + /** + * Require a matching row using column relationships or a join closure. + * + * @param string|Closure(JoinClause): mixed $first + * + * @throws InvalidArgumentException When a table, column, or join condition is invalid. + * @throws DatabaseException When the application table name is invalid. + */ + public function join(Table|string|TableReference $table, string|Closure $first, ?string $operator = null, ?string $second = null): self { + return $this->addJoin('INNER', $table, $first, $operator, $second); + } + + /** + * Include the source row even when no joined row matches. + * + * @param string|Closure(JoinClause): mixed $first + * + * @throws InvalidArgumentException When a table, column, or join condition is invalid. + * @throws DatabaseException When the application table name is invalid. + */ + public function leftJoin(Table|string|TableReference $table, string|Closure $first, ?string $operator = null, ?string $second = null): self { + return $this->addJoin('LEFT', $table, $first, $operator, $second); + } + + /** + * Group results by the supplied columns. + * + * @throws InvalidArgumentException When a grouping column is invalid. + */ + public function groupBy(string ...$columns): self { + array_push($this->groups, ...array_map($this->quoter->column(...), $columns)); + + return $this; + } + + /** + * Filter grouped results by a column or selected alias. + * + * @throws InvalidArgumentException When a column or comparison is invalid. + */ + public function having(string $column, mixed $operator = null, mixed $value = null): self { + if (func_num_args() === 2) { + $this->having->where($column, $operator); + } else { + $this->having->where($column, $operator, $value); + } + + return $this; + } + + /** + * Order by a column or selected alias. + * + * @throws InvalidArgumentException When the direction is not asc or desc. + */ + public function orderBy(string $column, string $direction = 'asc'): self { + $direction = strtoupper($direction); + + if (! in_array($direction, [ + 'ASC', + 'DESC', + ], true)) { + throw new InvalidArgumentException('Order direction must be asc or desc.'); + } + + $this->orders[] = $this->quoter->column($column) . ' ' . $direction; + + return $this; + } + + /** + * Limit the result to a non-negative number of rows. + * + * @throws InvalidArgumentException When the row limit is negative. + */ + public function limit(int $rows): self { + if ($rows < 0) { + throw new InvalidArgumentException('The row limit cannot be negative.'); + } + + $this->limit = $rows; + + return $this; + } + + /** + * Skip a non-negative number of result rows. + * + * @throws InvalidArgumentException When the row offset is negative. + */ + public function offset(int $rows): self { + if ($rows < 0) { + throw new InvalidArgumentException('The row offset cannot be negative.'); + } + + $this->offset = $rows; + + return $this; + } + + /** + * Read all selected rows as associative arrays. + * + * @throws Exception When execution fails. + * @throws InvalidArgumentException For unsupported bound values. + * + * @return list> + */ + public function get(): array { + return $this->executor->rows($this->compiler->select($this->state())); + } + + /** + * Read the first selected row, or null when the query is empty. + * + * @throws Exception When execution fails. + * @throws InvalidArgumentException For unsupported bound values. + * + * @return array|null + */ + public function first(): ?array { + $result = $this->executor->first($this->compiler->select($this->state(), maximum: 1)); + + return $result === false ? null : $result; + } + + /** + * Count result rows, including the effects of grouping, distinctness, and paging. + * + * @throws Exception When execution fails. + * @throws InvalidArgumentException For unsupported bound values or ambiguous shaped join projections. + */ + public function count(): int { + return (int) $this->executor->value($this->compiler->aggregate($this->state(), 'COUNT', '*')); + } + + /** + * Read the maximum column value, or null for an empty result. + * + * @throws Exception When execution fails, including unresolved raw projection columns. + * @throws InvalidArgumentException For invalid or unselected columns, unsupported values, or ambiguous shaped join projections. + */ + public function max(string $column): mixed { + return $this->executor->value($this->compiler->aggregate($this->state(), 'MAX', $column)); + } + + /** + * Determine whether this query returns any rows. + * + * @throws Exception When execution fails. + * @throws InvalidArgumentException For unsupported bound values. + */ + public function exists(): bool { + return (bool) $this->executor->value($this->compiler->exists($this->state())); + } + + /** + * Insert one row or a list of rows; wrap large writes in transactional() for atomicity. + * + * @param array|list> $rows + * + * @throws InvalidArgumentException When rows or accumulated query options are invalid. + * @throws Exception When a statement fails; earlier chunks remain unless inside a transaction. + */ + public function insert(array $rows): int { + $this->compiler->insertable($this->state()); + + return $this->writer->insert($this->table, $rows); + } + + /** + * Insert exactly one row and return its generated identifier without truncation. + * + * Empty data inserts one row using the database defaults. + * + * @param array $data + * + * @throws InvalidArgumentException When data is not a single row or query options are invalid. + * @throws Exception When insertion fails or no generated identifier is available. + */ + public function insertGetId(array $data): int|string { + $this->compiler->insertable($this->state()); + + return $this->writer->insertGetId($this->table, $data); + } + + /** + * Insert rows, updating selected columns on any primary or unique-key conflict. + * + * @param array|list> $rows + * @param list $update Columns to replace with incoming values. + * + * @throws InvalidArgumentException When rows, update columns, or query options are invalid. + * @throws Exception When a statement fails; use transactional() for atomic multi-chunk writes. + */ + public function upsert(array $rows, array $update): int { + $this->compiler->insertable($this->state()); + + if ($update === []) { + throw new InvalidArgumentException('An upsert requires at least one update column.'); + } + + return $this->writer->insert($this->table, $rows, $update); + } + + /** + * Update rows matching this query's conditions. + * + * @param array $values + * + * @throws InvalidArgumentException When filtering is omitted or query options cannot be honored. + * @throws Exception When execution fails. + */ + public function update(array $values): int|string { + return $this->executor->statement($this->compiler->update($this->state(), $values)); + } + + /** + * Delete rows matching this query's conditions. + * + * @throws InvalidArgumentException When filtering is omitted or query options cannot be honored. + * @throws Exception When execution fails. + */ + public function delete(): int|string { + return $this->executor->statement($this->compiler->delete($this->state())); + } + + /** + * Inspect parameterized SELECT SQL without executing it. + */ + public function toSql(): string { + return $this->compiler->select($this->state())->sql; + } + + /** + * Inspect SELECT values in placeholder order. + * + * @return list + */ + public function getBindings(): array { + return $this->compiler->select($this->state())->bindings; + } + + /** + * Keep a cloned builder's grouped conditions independent. + */ + public function __clone() { + $this->having = clone $this->having; + } + + /** + * Capture an immutable snapshot for compilation. + */ + private function state(): QueryState { + return new QueryState( + $this->table, + $this->selection, + $this->joins, + $this->condition(), + $this->distinct, + $this->groups, + $this->having->condition(), + $this->orders, + $this->limit, + $this->offset, + ); + } + + /** + * Capture a completed join without retaining its mutable clause builder. + * + * @param string|Closure(JoinClause): mixed $first + */ + private function addJoin(string $type, Table|string|TableReference $table, string|Closure $first, ?string $operator, ?string $second): self { + $clause = new JoinClause($this->quoter); + + if ($first instanceof Closure) { + $first($clause); + } else { + if ($operator === null || $second === null) { + throw new InvalidArgumentException('A join requires two column names and a comparison operator.'); + } + + $clause->on($first, $operator, $second); + } + + $condition = $clause->condition(); + + if ($condition->sql === '') { + throw new InvalidArgumentException('A join requires at least one ON condition.'); + } + + $this->joins[] = new Fragment( + $type . ' JOIN ' . $this->resolver->sql($this->resolver->resolve($table)) . ' ON ' . $condition->sql, + $condition->bindings, + ); + + return $this; + } +} diff --git a/src/Database/Query/TableReferenceResolver.php b/src/Database/Query/TableReferenceResolver.php new file mode 100644 index 0000000..70fa53b --- /dev/null +++ b/src/Database/Query/TableReferenceResolver.php @@ -0,0 +1,77 @@ +quoter->quote($table->name) . ($table->alias === null ? '' : ' AS ' . $this->quoter->quote($table->alias)); + } + + /** + * Resolve application tables once when they enter a query. + * + * @throws InvalidArgumentException When an alias is invalid. + * @throws DatabaseException When the application table name is invalid. + */ + public function resolve(Table|string|TableReference $table): TableReference { + if ($table instanceof TableReference) { + return $table; + } + + $alias = null; + + if (is_string($table)) { + $parts = preg_split('/\s+as\s+/i', $table, 2); + + if ($parts === false) { + throw new InvalidArgumentException('Invalid table name: ' . $table); + } + + $table = $parts[0]; + $alias = $parts[1] ?? null; + } + + return new TableReference($this->resolver->tableName($table), $alias); + } + + /** + * Resolve a known core table, including multisite and custom user-table settings. + * + * @throws InvalidArgumentException When WordPress does not own the requested table. + */ + public function wordpress(string $name): TableReference { + $tables = $this->wpdb->tables('all'); + + if (! isset($tables[$name])) { + throw new InvalidArgumentException('Unknown WordPress table: ' . $name); + } + + return new TableReference($tables[$name]); + } +} diff --git a/src/Database/Query/Upsert/AliasBuilder.php b/src/Database/Query/Upsert/AliasBuilder.php new file mode 100644 index 0000000..07b9dde --- /dev/null +++ b/src/Database/Query/Upsert/AliasBuilder.php @@ -0,0 +1,38 @@ +quoter->quote($alias); + $assignments = []; + + foreach ($columns as $column) { + $quoted = $this->quoter->quote($column); + $assignments[] = $quoted . ' = ' . $quotedAlias . '.' . $quoted; + } + + return ' AS ' . $quotedAlias . ' ON DUPLICATE KEY UPDATE ' . implode(', ', $assignments); + } +} diff --git a/src/Database/Query/Upsert/Contracts/Builder.php b/src/Database/Query/Upsert/Contracts/Builder.php new file mode 100644 index 0000000..fafdf61 --- /dev/null +++ b/src/Database/Query/Upsert/Contracts/Builder.php @@ -0,0 +1,18 @@ + $columns + */ + public function build(array $columns, string $table): string; +} diff --git a/src/Database/Query/Upsert/ServerBuilder.php b/src/Database/Query/Upsert/ServerBuilder.php new file mode 100644 index 0000000..18a3cd2 --- /dev/null +++ b/src/Database/Query/Upsert/ServerBuilder.php @@ -0,0 +1,52 @@ +builder === null) { + $platform = $this->connection->getDatabasePlatform(); + + if (! $platform instanceof AbstractMySQLPlatform) { + throw new LogicException('The query builder supports MySQL and MariaDB only.'); + } + + $this->builder = $platform instanceof MariaDBPlatform || version_compare(explode('-', $this->connection->getServerVersion(), 2)[0], '8.0.19', '<') + ? $this->valueBuilder + : $this->aliasBuilder; + } + + return $this->builder->build($columns, $table); + } +} diff --git a/src/Database/Query/Upsert/ValueBuilder.php b/src/Database/Query/Upsert/ValueBuilder.php new file mode 100644 index 0000000..18698ee --- /dev/null +++ b/src/Database/Query/Upsert/ValueBuilder.php @@ -0,0 +1,36 @@ +quoter->quote($column); + $assignments[] = $quoted . ' = VALUES(' . $quoted . ')'; + } + + return ' ON DUPLICATE KEY UPDATE ' . implode(', ', $assignments); + } +} diff --git a/src/Database/Query/ValueObjects/Fragment.php b/src/Database/Query/ValueObjects/Fragment.php new file mode 100644 index 0000000..5ef6a2c --- /dev/null +++ b/src/Database/Query/ValueObjects/Fragment.php @@ -0,0 +1,22 @@ + $bindings + */ + public function __construct( + public string $sql, + public array $bindings = [], + ) { + } +} diff --git a/src/Database/Query/ValueObjects/ParameterSet.php b/src/Database/Query/ValueObjects/ParameterSet.php new file mode 100644 index 0000000..9d8d1b3 --- /dev/null +++ b/src/Database/Query/ValueObjects/ParameterSet.php @@ -0,0 +1,25 @@ + $values + * @param list $types + */ + public function __construct( + public array $values, + public array $types, + ) { + } +} diff --git a/src/Database/Query/ValueObjects/Predicate.php b/src/Database/Query/ValueObjects/Predicate.php new file mode 100644 index 0000000..6585da4 --- /dev/null +++ b/src/Database/Query/ValueObjects/Predicate.php @@ -0,0 +1,20 @@ + $selection + * @param list $joins + * @param list $groups + * @param list $orders + */ + public function __construct( + public TableReference $table, + public array $selection, + public array $joins, + public Fragment $where, + public bool $distinct, + public array $groups, + public Fragment $having, + public array $orders, + public ?int $limit, + public ?int $offset, + ) { + } +} diff --git a/src/Database/Query/ValueObjects/Selection.php b/src/Database/Query/ValueObjects/Selection.php new file mode 100644 index 0000000..517fd3c --- /dev/null +++ b/src/Database/Query/ValueObjects/Selection.php @@ -0,0 +1,23 @@ + 64) { + throw new InvalidArgumentException('Physical table names must contain at most 64 ASCII letters, numbers, or underscores.'); + } + + if ($alias !== null && preg_match('/\A[A-Za-z0-9_]+\z/', $alias) !== 1) { + throw new InvalidArgumentException('Invalid table alias: ' . $alias); + } + } + + /** + * Return this table with a query-local alias. + * + * @throws InvalidArgumentException When the alias is invalid. + */ + public function as(string $alias): self { + return new self($this->name, $alias); + } +} diff --git a/src/Database/Query/WhereGroup.php b/src/Database/Query/WhereGroup.php new file mode 100644 index 0000000..e74ffa6 --- /dev/null +++ b/src/Database/Query/WhereGroup.php @@ -0,0 +1,243 @@ + + */ + private array $predicates = []; + + /** + * Receive identifier quoting shared by all conditions. + * + * @internal Groups are produced by the owning query. + */ + public function __construct( + protected readonly IdentifierQuoter $quoter, + ) { + } + + /** + * Add equality, an explicit comparison, or a nested condition group. + * + * @param string|Closure((static is JoinClause ? JoinClause : self)): mixed|array $column Array criteria are validated to require string column names. + * + * @throws InvalidArgumentException When a comparison cannot be represented safely. + */ + public function where(string|Closure|array $column, mixed $operator = null, mixed $value = null): static { + return $this->comparison($column, $operator, $value, func_num_args(), 'AND'); + } + + /** + * Add an alternative comparison or nested group. + * + * @param string|Closure((static is JoinClause ? JoinClause : self)): mixed|array $column Array criteria are validated to require string column names. + * + * @throws InvalidArgumentException When a comparison cannot be represented safely. + */ + public function orWhere(string|Closure|array $column, mixed $operator = null, mixed $value = null): static { + return $this->comparison($column, $operator, $value, func_num_args(), 'OR'); + } + + /** + * Match one of the supplied values; an empty list matches no rows. + * + * @param list $values + * + * @throws InvalidArgumentException When the column is invalid. + */ + public function whereIn(string $column, array $values): static { + return $this->in($column, $values, false); + } + + /** + * Exclude supplied values; an empty list matches all rows. + * + * @param list $values + * + * @throws InvalidArgumentException When the column is invalid. + */ + public function whereNotIn(string $column, array $values): static { + return $this->in($column, $values, true); + } + + /** + * Match a missing value. + * + * @throws InvalidArgumentException When the column is invalid. + */ + public function whereNull(string $column): static { + return $this->add(new Fragment($this->quoter->column($column) . ' IS NULL'), 'AND'); + } + + /** + * Match a present value. + * + * @throws InvalidArgumentException When the column is invalid. + */ + public function whereNotNull(string $column): static { + return $this->add(new Fragment($this->quoter->column($column) . ' IS NOT NULL'), 'AND'); + } + + /** + * Match any literal substring, escaping SQL wildcard characters in every needle. + * + * @param list $needles An empty list matches no rows. + * + * @throws InvalidArgumentException When the column is invalid. + */ + public function whereContainsAny(string $column, array $needles): static { + $name = $this->quoter->column($column); + $patterns = []; + + foreach ($needles as $needle) { + $patterns[] = '%' . strtr($needle, [ + '!' => '!!', + '%' => '!%', + '_' => '!_', + ]) . '%'; + } + + $sql = $patterns === [] ? '0 = 1' : '(' . implode(' OR ', array_fill(0, count($patterns), $name . " LIKE ? ESCAPE '!'")) . ')'; + + return $this->add(new Fragment($sql, $patterns), 'AND'); + } + + /** + * Render this group in declaration order. + * + * @internal + */ + public function condition(): Fragment { + $sql = ''; + $bindings = []; + + foreach ($this->predicates as $predicate) { + $sql .= ($sql === '' ? '' : ' ' . $predicate->boolean . ' ') . $predicate->fragment->sql; + array_push($bindings, ...$predicate->fragment->bindings); + } + + return new Fragment($sql, $bindings); + } + + /** + * Add one already validated predicate. + * + * @internal + */ + protected function add(Fragment $fragment, string $boolean): static { + $this->predicates[] = new Predicate($fragment, $boolean); + + return $this; + } + + /** + * Validate comparison operators before interpreting null values. + * + * @internal + */ + protected function operator(mixed $operator): string { + if (! is_string($operator) || ! in_array($operator, [ + '=', + '!=', + '<>', + '<', + '<=', + '>', + '>=', + ], true)) { + throw new InvalidArgumentException('Use a supported comparison operator: =, !=, <>, <, <=, >, >=.'); + } + + return $operator; + } + + /** + * Create a nested condition group with this clause's supported operations. + * + * @internal + */ + protected function newGroup(): self { + return new self($this->quoter); + } + + /** + * Normalize the public comparison forms at their shared boundary. + * + * @param string|Closure((static is JoinClause ? JoinClause : self)): mixed|array $column Array criteria are validated to require string column names. + */ + private function comparison(string|Closure|array $column, mixed $operator, mixed $value, int $arguments, string $boolean): static { + if ($column instanceof Closure || is_array($column)) { + $group = $this->newGroup(); + + if ($column instanceof Closure) { + $column($group); + } else { + foreach ($column as $name => $equal) { + if (! is_string($name)) { + throw new InvalidArgumentException('Criteria must use column names as string keys.'); + } + + $group->where($name, $equal); + } + } + + $fragment = $group->condition(); + + if ($fragment->sql === '') { + throw new InvalidArgumentException('A condition group must contain at least one condition.'); + } + + return $this->add(new Fragment('(' . $fragment->sql . ')', $fragment->bindings), $boolean); + } + + if ($arguments === 2) { + $value = $operator; + $operator = '='; + } + + $operator = $this->operator($operator); + $name = $this->quoter->column($column); + + if ($value !== null) { + return $this->add(new Fragment($name . ' ' . $operator . ' ?', [ + $value, + ]), $boolean); + } + + if (! in_array($operator, [ + '=', + '!=', + '<>', + ], true)) { + throw new InvalidArgumentException('Use whereNull() or whereNotNull() when comparing null.'); + } + + return $this->add(new Fragment($name . ($operator === '=' ? ' IS NULL' : ' IS NOT NULL')), $boolean); + } + + /** + * Build list membership without delegating array expansion to the driver. + * + * @param list $values + */ + private function in(string $column, array $values, bool $negated): static { + $name = $this->quoter->column($column); + + if ($values === []) { + return $this->add(new Fragment($negated ? '1 = 1' : '0 = 1'), 'AND'); + } + + return $this->add(new Fragment($name . ($negated ? ' NOT IN (' : ' IN (') . implode(', ', array_fill(0, count($values), '?')) . ')', $values), 'AND'); + } +} diff --git a/src/Database/Table/Table.php b/src/Database/Table/Table.php index 2e09a70..e214dab 100644 --- a/src/Database/Table/Table.php +++ b/src/Database/Table/Table.php @@ -4,13 +4,12 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception; -use Doctrine\DBAL\ParameterType; -use Doctrine\DBAL\Query\QueryBuilder; -use Doctrine\DBAL\Types\Type; use InvalidArgumentException; use StellarWP\Foundation\Database\Contracts\Table as TableContract; use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Database\Exceptions\DatabaseException; +use StellarWP\Foundation\Database\Query\Database; +use StellarWP\Foundation\Database\Query\Query; /** * An application table with ordinary reads and writes on the shared connection. @@ -23,8 +22,9 @@ * Receive the application's shared connection and table-name policy. */ public function __construct( - private Connection $db, - private TableNameResolver $names, + private Connection $connection, + private TableNameResolver $resolver, + private Database $db, ) { } @@ -41,7 +41,7 @@ abstract public function unprefixedName(): string; * @return non-empty-string */ final public function name(): string { - return $this->names->tableName($this); + return $this->resolver->tableName($this); } /** @@ -51,17 +51,17 @@ final public function name(): string { * @throws Exception When the platform cannot be determined. */ final public function quotedName(): string { - return $this->db->getDatabasePlatform()->quoteSingleIdentifier($this->name()); + return $this->connection->getDatabasePlatform()->quoteSingleIdentifier($this->name()); } /** - * Start a fresh native query with this table as its source. + * Start a fresh fluent query with this table as its source. * * @throws DatabaseException When the physical name is invalid. * @throws Exception When the platform cannot be determined. */ - final public function query(?string $alias = null): QueryBuilder { - return $this->db->createQueryBuilder()->from($this->quotedName(), $alias); + final public function query(?string $alias = null): Query { + return $this->db->table($this, $alias); } /** @@ -71,72 +71,73 @@ final public function query(?string $alias = null): QueryBuilder { * @throws Exception When the count cannot be read. */ final public function count(): int { - return (int) $this->query()->select('COUNT(*)')->fetchOne(); + return $this->query()->count(); } /** - * Insert a row and return its affected-row count. + * Insert one row or a list of rows and return the total affected-row count. * - * @param array $data Application-owned column names and bound values. - * @param array $types + * Empty input performs no write. Use a caller-owned transaction when a bulk + * insert must be atomic across multiple statements. * - * @throws DatabaseException When the physical name is invalid. - * @throws Exception When insertion fails. + * @param array|list> $rows + * + * @throws InvalidArgumentException When columns, values, or row shapes are invalid. + * @throws DatabaseException When the physical name or managed session is invalid. + * @throws Exception When insertion fails, including in a later chunk. */ - final public function insert(array $data, array $types = []): int|string { - return $this->db->insert($this->quotedName(), $data, $types); + final public function insert(array $rows): int { + return $this->query()->insert($rows); } /** - * Insert a row and return its generated identifier without truncation. + * Insert exactly one row and return its generated identifier without truncation. * - * @param array $data - * @param array $types + * Empty data inserts one row using the database defaults. * - * @throws DatabaseException When the physical name is invalid. - * @throws Exception When insertion fails or no generated identifier is available. + * @param array $data + * + * @throws InvalidArgumentException When data is a row list or contains invalid columns or values. + * @throws DatabaseException When the physical name or managed session is invalid. + * @throws Exception When insertion fails or no generated identifier is available. */ - final public function insertGetId(array $data, array $types = []): int|string { - $this->insert($data, $types); - - return $this->db->lastInsertId(); + final public function insertGetId(array $data): int|string { + return $this->query()->insertGetId($data); } /** * Update rows matching equality criteria, returning the affected-row count. * - * @param array $data - * @param array $where - * @param array $types + * @param array $data + * @param array $where * - * @throws InvalidArgumentException When criteria are empty. + * @throws InvalidArgumentException When criteria are empty or columns and values are invalid. * @throws DatabaseException When the physical name is invalid. * @throws Exception When the update fails. */ - final public function update(array $data, array $where, array $types = []): int|string { + final public function update(array $data, array $where): int|string { if ($where === []) { throw new InvalidArgumentException('Table updates require criteria; use the connection for an intentional whole-table update.'); } - return $this->db->update($this->quotedName(), $data, $where, $types); + return $this->query()->where($where)->update($data); } /** * Delete rows matching equality criteria, returning the affected-row count. * - * @param array $where - * @param array $types + * @param array $where * - * @throws InvalidArgumentException When criteria are empty. + * @throws InvalidArgumentException When criteria are empty or columns and values are invalid. * @throws DatabaseException When the physical name is invalid. * @throws Exception When deletion fails. */ - final public function delete(array $where, array $types = []): int|string { + final public function delete(array $where): int|string { if ($where === []) { throw new InvalidArgumentException('Table deletions require criteria; use deleteAll() for an intentional whole-table delete.'); } - return $this->db->delete($this->quotedName(), $where, $types); + return $this->query()->where($where)->delete(); } /** @@ -148,7 +149,7 @@ final public function delete(array $where, array $types = []): int|string { * @throws Exception When deletion fails, including a missing table or foreign-key restriction. */ final public function deleteAll(): int|string { - return $this->db->executeStatement('DELETE FROM ' . $this->quotedName()); + return $this->connection->executeStatement('DELETE FROM ' . $this->quotedName()); } /** @@ -160,11 +161,11 @@ final public function deleteAll(): int|string { * @throws Exception When truncation fails, including a missing table or foreign-key restriction. */ final public function truncate(): void { - if ($this->db->isTransactionActive()) { + if ($this->connection->isTransactionActive()) { throw new DatabaseException('Table truncation cannot run inside a transaction; use deleteAll() instead.'); } - $sql = $this->db->getDatabasePlatform()->getTruncateTableSQL($this->quotedName()); - $this->db->executeStatement($sql); + $sql = $this->connection->getDatabasePlatform()->getTruncateTableSQL($this->quotedName()); + $this->connection->executeStatement($sql); } } diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index f7bf82e..e1dfcd3 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -7,7 +7,7 @@ sidebar: import { CardGrid, LinkCard } from '@astrojs/starlight/components'; -Foundation Database provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress transactions. Use Doctrine directly for expressions, joins, parameter binding, results, and advanced operations. +Foundation Database provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress transactions. Use Foundation's fluent queries for ordinary reads, joins, and writes. Use the shared Doctrine connection directly for transactions, specialized SQL, and native database operations. ## Get started @@ -81,13 +81,14 @@ final readonly class Reports_Table extends Table { Inject `Reports_Table` into your service and call its operations: ```php -$id = $this->reports->insertGetId( [ 'title' => 'Weekly report' ] ); +$id = $this->reports->insertGetId( [ + 'title' => 'Weekly report', +] ); $report = $this->reports->query() ->select( 'id', 'title' ) - ->where( 'id = :id' ) - ->setParameter( 'id', $id ) - ->fetchAssociative(); + ->where( 'id', $id ) + ->first(); ``` [Create the table through a migration](/components/migrations/), then use the [query and transaction guide](/components/database/query-builder/) for results, writes, and failure handling. @@ -104,7 +105,7 @@ Applications can replace `DatabaseScope` and `TableNameResolver` when they own a - + diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index c0d5920..56336cd 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -1,18 +1,16 @@ --- title: Queries and Transactions -description: Query application tables with Doctrine DBAL and choose reliable transaction boundaries. +description: Query application tables with Foundation's fluent builder and choose reliable transaction boundaries. sidebar: order: 2 --- -[Register DatabaseProvider](/components/database/) and inject your concrete table into the service that owns its queries. A table can wrap an existing WordPress or third-party table without owning its migrations. +[Register DatabaseProvider](/components/database/) and inject your concrete table into the service that owns its queries. Foundation's fluent builder binds values and quotes structured identifiers, then executes through the shared Doctrine connection. A table can wrap an existing WordPress or third-party table without owning its migrations. -## Read and write rows +## Read rows A repository is an optional way to organize reusable application queries: -In `src/Report/Report_Repository.php`: - ```php title="Report_Repository.php" |false + * Find one report, or return null when it does not exist. + * + * @return array|null */ - public function find( int $id ): array|false { + public function find( int $id ): ?array { return $this->reports->query() ->select( 'id', 'title' ) - ->where( 'id = :id' ) - ->setParameter( 'id', $id ) - ->fetchAssociative(); + ->where( 'id', $id ) + ->first(); } } ``` -`query()` returns a fresh native Doctrine builder with the table configured as its source. Use `query('report')` for an alias. Supply SQL expressions yourself and bind external values using `setParameter()` or `setParameters()`. SQL fragments, column names, sorting, and aliases must come from application-owned choices, not request input. +`query()` returns a fresh `StellarWP\Foundation\Database\Query\Query`. Use `query( 'report' )` for an alias. `get()` returns a list of associative arrays; `first()` returns one associative array or `null`. Driver-returned numeric values can be strings, so convert results according to your application contract. ```php $rows = $this->reports->query() ->select( 'id', 'title' ) - ->where( 'status = :status' ) - ->setParameter( 'status', 'ready' ) - ->orderBy( 'id', 'DESC' ) - ->setMaxResults( 50 ) - ->fetchAllAssociative(); + ->where( 'status', 'ready' ) + ->orderBy( 'id', 'desc' ) + ->limit( 50 ) + ->get(); +``` + +The initial builder is deliberately bounded. It supplies the operations described here; familiar Laravel-style names do not imply the whole Laravel API is available. + +### Conditions and literal substring searches + +Use two arguments for equality, or three for an explicit comparison. Supported comparison operators are `=`, `!=`, `<>`, `<`, `<=`, `>`, and `>=`. Comparing equality to `null` means `IS NULL`; inequality means `IS NOT NULL`. Ordering comparisons against null are rejected. + +Group alternatives with a callback receiving `WhereGroup`: + +```php +use StellarWP\Foundation\Database\Query\WhereGroup; + +$rows = $this->reports->query() + ->where( 'status', 'ready' ) + ->where( static fn ( WhereGroup $where ) => $where + ->where( 'priority', '>', 5 ) + ->orWhere( 'owner_id', $ownerId ) ) + ->get(); +``` -$count = $this->reports->count(); +`where()` also accepts an associative map of equalities. `whereIn()` accepts a list of values, and an empty list matches no rows. `whereNull()` expresses a null test directly. + +```php +$rows = $this->reports->query() + ->where( [ + 'status' => 'ready', + 'owner_id' => $ownerId, + ] ) + ->whereContainsAny( 'title', $searchTerms ) + ->limit( 50 ) + ->get(); ``` -Doctrine supplies `fetchAssociative()` (one row or `false`), `fetchAllAssociative()` (rows), `fetchOne()` (one value or `false`), and `executeQuery()` for a native result. Database drivers may return numeric values as strings. Convert results according to your application contract. +`whereContainsAny()` matches any literal substring. Percent signs, underscores, and the escape character in search terms are escaped before values are bound. An empty term list matches nothing. Structured column names, aliases, and sort directions are validated; choose an application allowlist when users should be limited to particular columns. -Common writes stay table-scoped: +### Grouping and aggregates + +`count()` returns an integer, `exists()` returns a boolean, and `max( 'column' )` returns the driver's scalar value or null when no value exists. Terminal reads do not alter the builder, so you can count and then fetch the same query. ```php -$id = $this->reports->insertGetId( [ 'title' => 'Weekly', 'status' => 'draft' ] ); -$this->reports->update( [ 'status' => 'ready' ], [ 'id' => $id ] ); -$this->reports->delete( [ 'id' => $id ] ); +$query = $this->reports->query()->where( 'status', 'ready' ); +$count = $query->count(); +$rows = $query->get(); ``` -`insert()`, `update()`, and `delete()` return Doctrine's affected-row count (`int|string`). `insertGetId()` returns the generated identifier (`int|string`) without truncation. Each accepts an optional `$types` map for Doctrine parameter types. Update and delete criteria must be nonempty. Use `deleteAll()` for an intentional whole-table deletion, or the connection for a whole-table update. +`distinct()`, `groupBy()`, `having()`, `limit()`, and `offset()` shape the rows seen by aggregates. Foundation aggregates a derived table when those operations are present. In particular, `limit( 10 )->count()` counts at most ten rows, and a grouped count counts groups. Explicit projections and their aliases remain intact, including aliases used by ordering. + +```php +$largest = $this->reports->query() + ->select( 'score as report_score' ) + ->orderBy( 'report_score', 'desc' ) + ->limit( 10 ) + ->max( 'report_score' ); +``` -Inject `Doctrine\DBAL\Connection` for joins, specialized SQL, and operations spanning tables. Resolve physical names through each table's `quotedName()`: +For grouped reports, use application-owned SQL expressions through `selectRaw()` and pass external values in its bindings array: ```php -$db->executeStatement( - 'UPDATE ' . $reports->quotedName() . ' SET status = ? WHERE id = ?', - [ 'ready', $id ], -); +$groups = $this->reports->query() + ->selectRaw( 'status, COUNT(*) AS total' ) + ->groupBy( 'status' ) + ->having( 'total', '>', 1 ) + ->get(); ``` -Queries, results, types, and DBAL exceptions are supported native Doctrine APIs. See [Doctrine's query builder reference](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.4/reference/query-builder.html) for expressions and advanced usage. +Raw projections are opaque: Foundation preserves every `selectRaw()` projection when counting, finding a maximum, or checking existence. For example, `selectRaw( 'COUNT(*) AS total' )->exists()` returns true even when the source table is empty, because that aggregate produces one result row. Calling `select()` replaces the projection and clears this raw-projection behavior. -## Count or clear a table +A shaped column aggregate must name an output column or alias of the inner query. For example, `select( 'name' )->limit( 10 )->max( 'amount' )` throws `InvalidArgumentException` because `amount` is not selected. Include `amount` in the projection, or use its selected alias. Foundation leaves the projection, grouping, and distinctness unchanged. Foundation does not parse raw expressions to discover their outputs; invalid raw output references fail through the database. Shaped joins require an explicit projection to avoid duplicate column names in a derived table. + +## Join application and WordPress tables -### Count all rows +Inject `StellarWP\Foundation\Database\Query\Database` when a query needs a table entry point independent of a concrete application table. `DatabaseProvider` supplies its dependencies automatically. -Get the number of rows as an integer. An empty table returns `0`; a missing table throws an exception. +`table()` accepts a `Table`, an unprefixed application name, or a `Query\ValueObjects\TableReference`, with an optional alias argument. `wordpress()` returns a resolved reference to a known WordPress core table. It honors WordPress's site and network table properties, including custom users tables. References are resolved when created; build a fresh query after switching sites between operations. ```php -$count = $this->reports->count(); +$rows = $this->db->table( $this->reports, 'r' ) + ->join( $this->db->wordpress( 'users' )->as( 'u' ), 'u.ID', '=', 'r.owner_id' ) + ->select( 'r.id', 'r.title', 'u.user_email' ) + ->where( 'r.status', 'ready' ) + ->get(); ``` -For a filtered count, use `query()->select( 'COUNT(*)' )` with the required conditions. +`join()` and `leftJoin()` accept the same table kinds. A callback receives `JoinClause` for grouped ON conditions and bound value predicates. Pass application table objects directly; passing their physical `name()` as an unprefixed string would prefix the name again. -### Delete all rows +## Write rows -Remove every row and receive the affected-row count (`int|string`): +Insert one row and retrieve its generated identifier: ```php -$deleted = $this->reports->deleteAll(); +$id = $this->reports->insertGetId( [ + 'title' => 'Weekly', + 'status' => 'draft', +] ); + +$this->reports->update( [ + 'status' => 'ready', +], [ + 'id' => $id, +] ); ``` -`deleteAll()` preserves the auto-increment sequence and applies the table's foreign-key rules and delete triggers. With InnoDB, deletion participates in the caller's transaction. For example, clear and replace data atomically: +`insert()` accepts one associative row or a list of rows and returns the total affected-row count as `int`. The table convenience and fluent query use the same implementation, including value normalization and bulk insertion: ```php -$this->db->transactional( function () use ( $rows ): void { - $this->reports->deleteAll(); +$this->reports->insert( [ + [ + 'title' => 'Weekly', + 'status' => 'draft', + ], + [ + 'title' => 'Monthly', + 'status' => 'draft', + ], +] ); + +$this->reports->query() + ->where( 'id', $id ) + ->update( [ + 'status' => 'ready', + ] ); + +$this->reports->query()->where( 'id', $id )->delete(); +``` - foreach ( $rows as $row ) { - $this->reports->insert( $row ); - } +You can also call `$this->reports->query()->insert( $rows )`; it has the same behavior as `$this->reports->insert( $rows )`. Empty input returns zero without inserting a row. + +`insertGetId()` accepts exactly one associative row and returns its generated ID as `int|string`. Use it on the table or a fresh query. Passing `[]` explicitly inserts one row using database defaults and returns that new ID. A list of rows is rejected, and insertion or ID retrieval failures throw. + +`Table::update( $values, $criteria )` and `delete( $criteria )` delegate to filtered queries and return affected-row counts as `int|string`. Criteria are column-to-value equality comparisons; null matches `IS NULL`. Both methods reject empty criteria. Table and fluent writes share value normalization. + +For writes that require explicit Doctrine conversions, such as a JSON type or a custom type, use the shared connection’s `insert()`, `update()`, or `delete()`: + +```php +use Doctrine\DBAL\Types\Types; + +$this->connection->insert( $this->reports->quotedName(), [ + 'payload' => [ + 'enabled' => true, + ], +], [ + 'payload' => Types::JSON, +] ); +``` + +Insert, upsert, and delete queries use an unaliased target table. Start them with `$table->query()` without an alias. + +Query `insert()` and `upsert()` return total affected rows as `int`; `update()` and `delete()` preserve Doctrine’s `int|string` affected-row counts. Rows in a multi-row write must have identical column sets, although their key order may differ. Empty insert and upsert inputs return zero. Boolean values are bound as integers; use decimal strings for exact amounts. Date/time values are formatted with microseconds, and the target column and database determine retained precision. + +MySQL normally rounds fractional seconds when writing to a lower-precision column, which can advance the stored date near midnight. Use `DATETIME(6)` to preserve microseconds, or pass `$date->format('Y-m-d H:i:s')` to deliberately discard them before writing to a second-precision column. + +Large writes split into multiple statements within the parameter limit. Each statement is atomic, but the complete write needs a caller-owned transaction to roll back earlier chunks if a later chunk fails. + +`update()` and `delete()` require conditions, retain supported ordering and limits, and reject joins, projections, distinct, grouping, having, or offsets. `insert()`, `insertGetId()`, and `upsert()` reject previously accumulated query conditions, projections, ordering, limits, and other shaping state, even for empty input. Start from a fresh query for those operations. + +### Upsert using existing keys + +`upsert( $rows, $update )` uses the table's existing primary and unique keys. The required update list names the columns to change on conflict. There is no conflict-target argument: MySQL and MariaDB choose conflicts from all existing primary and unique keys. + +For a table whose schema already defines `external_id` as unique: + +```php +$this->reports->query()->upsert( [ + [ + 'external_id' => 'weekly-2026-09-25', + 'title' => 'Weekly report', + 'status' => 'ready', + ], +], update: [ + 'title', + 'status', +] ); +``` + +A normal nonunique index does not create upsert conflict semantics. Upsert does not alter your schema, and a multi-statement upsert has the same caller-owned transaction boundary as insert. + +## Count or clear a table + +`$this->reports->count()` counts all rows; use `query()->where( ... )->count()` for a filtered count. + +`deleteAll()` explicitly deletes every row, preserves the auto-increment sequence, and follows foreign-key rules and delete triggers. With InnoDB, deletion participates in the caller's transaction: + +```php +$this->connection->transactional( function () use ( $rows ): void { + $this->reports->deleteAll(); + $this->reports->query()->insert( $rows ); } ); ``` An insertion failure rolls back the deletion and preserves the previous rows. -### Truncate a table +`truncate()` removes all rows and resets auto-increment. It returns no value. MySQL and MariaDB truncation implicitly commits and cannot be rolled back, so Foundation rejects it with `DatabaseException` inside an active shared transaction. Truncation does not run delete triggers, and references from other tables can prevent it. Both clearing methods keep foreign-key checks enabled and fail if the table is missing. + +## Native SQL and inspection -Remove all rows and reset auto-increment: +`toSql()` and `getBindings()` inspect the compiled read query without executing it. Bindings remain separate from SQL. + +Inject `Doctrine\DBAL\Connection` for specialized SQL or native Doctrine builders and results. Resolve application table names through `quotedName()` and bind external values: ```php -$this->reports->truncate(); +$connection->executeStatement( + 'UPDATE ' . $reports->quotedName() . ' + SET status = ? + WHERE id = ?', + [ + 'ready', + $id, + ], +); ``` -`truncate()` returns no value. MySQL and MariaDB truncation implicitly commits and cannot be rolled back, so Foundation rejects this method with `DatabaseException` when the shared connection has an active transaction. Use `deleteAll()` for transactional replacement. Truncation does not run delete triggers, and references from other tables can prevent it. - -Both clearing methods keep foreign-key checks enabled and throw if the table is missing. +Native Doctrine execution exceptions remain unchanged. Foundation validation failures reject invalid query construction before execution. Keep raw SQL and expressions application-owned; `selectRaw()` does not make untrusted SQL safe. ## Choose a transaction boundary @@ -134,14 +273,17 @@ use Plugin\Database\Tables\Reports_Table; final readonly class Create_Report { public function __construct( - private Connection $db, + private Connection $connection, private Reports_Table $reports, ) { } public function create( string $title ): int|string { - return $this->db->transactional( function () use ( $title ): int|string { - return $this->reports->insertGetId( [ 'title' => $title, 'status' => 'draft' ] ); + return $this->connection->transactional( function () use ( $title ): int|string { + return $this->reports->insertGetId( [ + 'title' => $title, + 'status' => 'draft', + ] ); } ); } } diff --git a/src/LockDatabase/Tables/LockTable.php b/src/LockDatabase/Tables/LockTable.php index 8ab2dc6..617353e 100644 --- a/src/LockDatabase/Tables/LockTable.php +++ b/src/LockDatabase/Tables/LockTable.php @@ -4,6 +4,7 @@ use Doctrine\DBAL\Connection; use StellarWP\Foundation\Database\Contracts\TableNameResolver; +use StellarWP\Foundation\Database\Query\Database; use StellarWP\Foundation\Database\Table\Table; /** @@ -18,10 +19,11 @@ */ public function __construct( private string $unprefixedTableName, - Connection $db, - TableNameResolver $names, + Connection $connection, + TableNameResolver $resolver, + Database $db, ) { - parent::__construct($db, $names); + parent::__construct($connection, $resolver, $db); } /** diff --git a/src/Migrations/Tables/MigrationTable.php b/src/Migrations/Tables/MigrationTable.php index 4b4b23f..7a5e0a2 100644 --- a/src/Migrations/Tables/MigrationTable.php +++ b/src/Migrations/Tables/MigrationTable.php @@ -4,6 +4,7 @@ use Doctrine\DBAL\Connection; use StellarWP\Foundation\Database\Contracts\TableNameResolver; +use StellarWP\Foundation\Database\Query\Database; use StellarWP\Foundation\Database\Table\Table; /** @@ -18,10 +19,11 @@ */ public function __construct( private string $unprefixedTableName, - Connection $db, - TableNameResolver $names, + Connection $connection, + TableNameResolver $resolver, + Database $db, ) { - parent::__construct($db, $names); + parent::__construct($connection, $resolver, $db); } /** diff --git a/tests/Unit/Database/Query/ExecutorTest.php b/tests/Unit/Database/Query/ExecutorTest.php new file mode 100644 index 0000000..1a903c3 --- /dev/null +++ b/tests/Unit/Database/Query/ExecutorTest.php @@ -0,0 +1,80 @@ +createMock(Connection::class); + $connection->expects($this->once())->method('executeStatement')->with('INSERT', [ + null, + 0, + 1, + 3, + '4.5', + '6.70', + '2026-09-25 12:34:56.123456', + ], [ + ParameterType::NULL, + ParameterType::INTEGER, + ParameterType::INTEGER, + ParameterType::INTEGER, + ParameterType::STRING, + ParameterType::STRING, + ParameterType::STRING, + ])->willReturn('9223372036854775808'); + $executor = new Executor($connection); + $this->assertSame('9223372036854775808', $executor->statement(new Fragment('INSERT', [ + null, + false, + true, + 3, + 4.5, + '6.70', + new DateTimeImmutable('2026-09-25 12:34:56.123456+08:00'), + ]))); + } + + public function test_unsupported_values_fail_before_execution(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('executeStatement'); + $this->expectException(InvalidArgumentException::class); + (new Executor($connection))->statement(new Fragment('INSERT', [ + new stdClass(), + ])); + } + + public function test_reads_preserve_dbal_results(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('fetchAllAssociative')->with('SELECT', [ + 1, + ], [ + ParameterType::INTEGER, + ])->willReturn([ + [ + 'id' => '12', + ], + ]); + $connection->expects($this->once())->method('fetchAssociative')->willReturn(false); + $connection->expects($this->once())->method('fetchOne')->willReturn(null); + $executor = new Executor($connection); + $this->assertSame([ + [ + 'id' => '12', + ], + ], $executor->rows(new Fragment('SELECT', [ + true, + ]))); + $this->assertFalse($executor->first(new Fragment('SELECT'))); + $this->assertNull($executor->value(new Fragment('SELECT'))); + } +} diff --git a/tests/Unit/Database/Query/IdentifierQuoterTest.php b/tests/Unit/Database/Query/IdentifierQuoterTest.php new file mode 100644 index 0000000..c8ef4c9 --- /dev/null +++ b/tests/Unit/Database/Query/IdentifierQuoterTest.php @@ -0,0 +1,84 @@ +assertSame('`e`.`name` AS `display_name`', $quoter->selection('e.name AS display_name')->fragment->sql); + $this->assertSame('`e`.*', $quoter->selection('e.*')->fragment->sql); + $this->assertSame('*', $quoter->selection('*')->fragment->sql); + $this->assertSame('`2_entries`', $quoter->quote('2_entries')); + } + + public function test_selections_keep_sql_output_names_and_wildcards_together(): void { + $quoter = new IdentifierQuoter(); + $alias = $quoter->selection("e.Name\tAs\tDisplay_Name"); + $this->assertSame('`e`.`Name` AS `Display_Name`', $alias->fragment->sql); + $this->assertSame('display_name', $alias->name); + $this->assertFalse($alias->wildcard); + $this->assertSame('name', $quoter->selection('e.Name')->name); + + foreach ([ + '*', + 'e.*', + ] as $column) { + $selection = $quoter->selection($column); + $this->assertSame('*', $selection->name); + $this->assertTrue($selection->wildcard); + } + } + + /** + * @return iterable + */ + public static function invalidColumns(): iterable { + yield 'blank' => [ + '', + ]; + + yield 'expression' => [ + 'name DESC; DROP TABLE entries', + ]; + + yield 'embedded quote' => [ + 'name`', + ]; + + yield 'database qualified' => [ + 'db.table.column', + ]; + + yield 'missing column' => [ + 'e.', + ]; + + yield 'wildcard predicate' => [ + 'e.*', + ]; + + yield 'sql comment' => [ + 'id--', + ]; + } + + /** + * @dataProvider invalidColumns + */ + #[DataProvider('invalidColumns')] + public function test_identifiers_reject_sql_syntax(string $column): void { + $this->expectException(InvalidArgumentException::class); + (new IdentifierQuoter())->column($column); + } + + public function test_wildcards_cannot_have_output_aliases(): void { + $this->expectException(InvalidArgumentException::class); + (new IdentifierQuoter())->selection('e.* as output'); + } +} diff --git a/tests/Unit/Database/Query/InsertWriterTest.php b/tests/Unit/Database/Query/InsertWriterTest.php new file mode 100644 index 0000000..6929bd1 --- /dev/null +++ b/tests/Unit/Database/Query/InsertWriterTest.php @@ -0,0 +1,298 @@ +createMock(Connection::class); + $calls = []; + $connection->expects($this->exactly(2))->method('executeStatement')->willReturnCallback( + static function (string $sql, array $bindings) use (&$calls): int { + $calls[] = [ + $sql, + $bindings, + ]; + + return intdiv(count($bindings), 2); + }, + ); + $writer = $this->writer($connection, 4); + $this->assertSame(3, $writer->insert(new TableReference('wp_entries'), [ + [ + 'id' => 1, + 'name' => 'one', + ], + [ + 'name' => 'two', + 'id' => 2, + ], + [ + 'id' => 3, + 'name' => 'three', + ], + ], [ + 'name', + ])); + $this->assertSame('INSERT INTO `wp_entries` (`id`, `name`) VALUES (?, ?), (?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)', $calls[0][0]); + $this->assertSame([ + 1, + 'one', + 2, + 'two', + ], $calls[0][1]); + $this->assertSame([ + 3, + 'three', + ], $calls[1][1]); + } + + public function test_later_invalid_row_is_rejected_before_any_write(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('executeStatement'); + $writer = $this->writer($connection, 1); + $this->expectException(InvalidArgumentException::class); + $writer->insert(new TableReference('wp_entries'), [ + [ + 'id' => 1, + ], + [ + 'id' => new stdClass(), + ], + ]); + } + + public function test_mismatched_columns_are_rejected_before_any_write(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('executeStatement'); + $writer = $this->writer($connection, 1); + $this->expectException(InvalidArgumentException::class); + $writer->insert(new TableReference('wp_entries'), [ + [ + 'id' => 1, + ], + [ + 'name' => 'two', + ], + ]); + } + + public function test_a_later_chunk_failure_escapes_without_owning_transactions(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('beginTransaction'); + $connection->expects($this->never())->method('commit'); + $calls = 0; + $connection->expects($this->exactly(2))->method('executeStatement')->willReturnCallback( + static function () use (&$calls): int { + if (++$calls === 2) { + throw new RuntimeException('second chunk failed'); + } + + return 1; + }, + ); + $writer = $this->writer($connection, 1); + $this->expectExceptionMessage('second chunk failed'); + $writer->insert(new TableReference('wp_entries'), [ + [ + 'id' => 1, + ], + [ + 'id' => 2, + ], + ]); + } + + public function test_upsert_sums_string_counts_across_chunks(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->exactly(2))->method('executeStatement')->willReturnOnConsecutiveCalls('2', '1'); + $writer = $this->writer($connection, 1); + $this->assertSame(3, $writer->insert(new TableReference('wp_entries'), [ + [ + 'id' => 1, + ], + [ + 'id' => 2, + ], + ], [ + 'id', + ])); + } + + public function test_empty_insert_performs_no_database_work(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('executeStatement'); + $this->assertSame(0, $this->writer($connection)->insert(new TableReference('wp_entries'), [])); + } + + public function test_single_associative_row_preserves_affected_rows(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('executeStatement')->with('INSERT INTO `wp_entries` (`id`) VALUES (?)', [ + 7, + ])->willReturn('1'); + $this->assertSame(1, $this->writer($connection)->insert(new TableReference('wp_entries'), [ + 'id' => 7, + ])); + } + + public function test_defaults_only_insert_reads_the_new_id_after_execution(): void { + $connection = $this->createMock(Connection::class); + $inserted = false; + $connection->expects($this->once())->method('executeStatement') + ->with('INSERT INTO `wp_entries` () VALUES ()', [], []) + ->willReturnCallback(static function () use (&$inserted): int { + $inserted = true; + + return 1; + }); + $connection->expects($this->once())->method('lastInsertId')->willReturnCallback(function () use (&$inserted): string { + $this->assertTrue($inserted); + + return '9223372036854775808'; + }); + $this->assertSame('9223372036854775808', $this->writer($connection)->insertGetId(new TableReference('wp_entries'), [])); + } + + public function test_failed_single_row_insert_does_not_read_an_old_id(): void { + $connection = $this->createMock(Connection::class); + $failure = new RuntimeException('Insert failed'); + $connection->expects($this->once())->method('executeStatement')->willThrowException($failure); + $connection->expects($this->never())->method('lastInsertId'); + $this->expectExceptionObject($failure); + $this->writer($connection)->insertGetId(new TableReference('wp_entries'), [ + 'name' => 'Failed', + ]); + } + + public function test_generated_id_rejects_bulk_input_before_any_write(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('executeStatement'); + $connection->expects($this->never())->method('lastInsertId'); + $this->expectException(InvalidArgumentException::class); + $this->writer($connection)->insertGetId(new TableReference('wp_entries'), [ + [ + 'name' => 'One', + ], + ]); + } + + public function test_defaults_only_insert_rejects_an_aliased_target(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('executeStatement'); + $connection->expects($this->never())->method('lastInsertId'); + $this->expectException(InvalidArgumentException::class); + $this->writer($connection)->insertGetId(new TableReference('wp_entries', 'e'), []); + } + + /** + * @return iterable, list, int}> + */ + public static function invalidRows(): iterable { + yield 'empty row' => [ + [ + [], + ], + [], + 65535, + ]; + + yield 'qualified column' => [ + [ + 'e.id' => 1, + ], + [], + 65535, + ]; + + yield 'positional values' => [ + [ + 1, + ], + [], + 65535, + ]; + + yield 'mixed named and numeric columns' => [ + [ + 'name' => 'one', + 0 => 'two', + ], + [], + 65535, + ]; + + yield 'invalid parameter budget' => [ + [ + 'id' => 1, + ], + [], + 0, + ]; + + yield 'missing update column' => [ + [ + 'id' => 1, + ], + [ + 'name', + ], + 65535, + ]; + + yield 'single row exceeds limit' => [ + [ + 'id' => 1, + 'name' => 'one', + ], + [], + 1, + ]; + } + + /** + * @param array|list> $rows + * @param list $update + * + * @dataProvider invalidRows + */ + #[DataProvider('invalidRows')] + public function test_invalid_input_is_rejected_before_writing(array $rows, array $update, int $limit): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('executeStatement'); + $this->expectException(InvalidArgumentException::class); + $this->writer($connection, $limit)->insert(new TableReference('wp_entries'), $rows, $update); + } + + public function test_an_aliased_insert_is_rejected_even_when_there_are_no_rows(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('executeStatement'); + $this->expectException(InvalidArgumentException::class); + $this->writer($connection)->insert(new TableReference('wp_entries', 'e'), []); + } + + /** + * Compose the writer with the test connection and parameter budget. + */ + private function writer(Connection $connection, int $parameterLimit = 65535): InsertWriter { + $quoter = new IdentifierQuoter(); + + return new InsertWriter( + new Executor($connection), + new ValueBuilder($quoter), + $quoter, + $parameterLimit, + ); + } +} diff --git a/tests/Unit/Database/Query/Upsert/BuilderTest.php b/tests/Unit/Database/Query/Upsert/BuilderTest.php new file mode 100644 index 0000000..29db2de --- /dev/null +++ b/tests/Unit/Database/Query/Upsert/BuilderTest.php @@ -0,0 +1,95 @@ +assertSame(' AS `foundation_incoming` ON DUPLICATE KEY UPDATE `name` = `foundation_incoming`.`name`, `updated_at` = `foundation_incoming`.`updated_at`', (new AliasBuilder(new IdentifierQuoter()))->build($columns, 'wp_entries')); + $this->assertSame(' ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `updated_at` = VALUES(`updated_at`)', (new ValueBuilder(new IdentifierQuoter()))->build($columns, 'wp_entries')); + } + + public function test_mysql_incoming_alias_differs_from_target_table(): void { + $this->assertSame(' AS `_foundation_incoming` ON DUPLICATE KEY UPDATE `name` = `_foundation_incoming`.`name`', (new AliasBuilder(new IdentifierQuoter()))->build([ + 'name', + ], 'foundation_incoming')); + } + + public function test_constructing_builder_does_not_connect(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('getDatabasePlatform'); + $connection->expects($this->never())->method('getServerVersion'); + $this->assertInstanceOf(ServerBuilder::class, $this->builder($connection)); + } + + public function test_mysql_8019_selects_aliases_and_discovers_once(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('getDatabasePlatform')->willReturn(new MySQLPlatform()); + $connection->expects($this->once())->method('getServerVersion')->willReturn('8.0.19-commercial'); + $builder = $this->builder($connection); + $columns = [ + 'name', + ]; + $this->assertSame((new AliasBuilder(new IdentifierQuoter()))->build($columns, 'wp_entries'), $builder->build($columns, 'wp_entries')); + $this->assertStringContainsString('`other`', $builder->build([ + 'other', + ], 'wp_entries')); + } + + public function test_old_mysql_uses_values(): void { + $connection = $this->createMock(Connection::class); + $connection->method('getDatabasePlatform')->willReturn(new MySQLPlatform()); + $connection->method('getServerVersion')->willReturn('8.0.18'); + $columns = [ + 'name', + ]; + $this->assertSame((new ValueBuilder(new IdentifierQuoter()))->build($columns, 'wp_entries'), $this->builder($connection)->build($columns, 'wp_entries')); + } + + public function test_mariadb_uses_values_without_inspecting_version(): void { + $connection = $this->createMock(Connection::class); + $connection->method('getDatabasePlatform')->willReturn(new MariaDBPlatform()); + $connection->expects($this->never())->method('getServerVersion'); + $columns = [ + 'name', + ]; + $this->assertSame((new ValueBuilder(new IdentifierQuoter()))->build($columns, 'wp_entries'), $this->builder($connection)->build($columns, 'wp_entries')); + } + + public function test_other_platforms_are_rejected(): void { + $connection = $this->createMock(Connection::class); + $connection->method('getDatabasePlatform')->willReturn(new SQLitePlatform()); + $this->expectException(LogicException::class); + $this->builder($connection)->build([ + 'name', + ], 'wp_entries'); + } + + /** + * Compose lazy strategy selection with the test connection. + */ + private function builder(Connection $connection): ServerBuilder { + $quoter = new IdentifierQuoter(); + + return new ServerBuilder( + $connection, + new AliasBuilder($quoter), + new ValueBuilder($quoter), + ); + } +} diff --git a/tests/Unit/Database/Query/WhereGroupTest.php b/tests/Unit/Database/Query/WhereGroupTest.php new file mode 100644 index 0000000..ec75511 --- /dev/null +++ b/tests/Unit/Database/Query/WhereGroupTest.php @@ -0,0 +1,204 @@ +where('e.source', 'sdn')->where( + static fn (WhereGroup $alternatives) => $alternatives->where('e.name', 'first')->orWhere('e.name', 'second'), + )->whereIn('e.id', [ + 3, + 7, + ]); + $this->assertSame('`e`.`source` = ? AND (`e`.`name` = ? OR `e`.`name` = ?) AND `e`.`id` IN (?, ?)', $group->condition()->sql); + $this->assertSame([ + 'sdn', + 'first', + 'second', + 3, + 7, + ], $group->condition()->bindings); + } + + public function test_substring_needles_escape_wildcards_without_reinterpreting_inserted_escapes(): void { + $group = new WhereGroup(new IdentifierQuoter()); + $group->whereContainsAny('name', [ + '!%_', + "x' OR 1 = 1 --", + '', + ]); + $this->assertSame("(`name` LIKE ? ESCAPE '!' OR `name` LIKE ? ESCAPE '!' OR `name` LIKE ? ESCAPE '!')", $group->condition()->sql); + $this->assertSame([ + '%!!!%!_%', + "%x' OR 1 = 1 --%", + '%%', + ], $group->condition()->bindings); + } + + public function test_empty_sets_and_empty_needle_sets_have_explicit_semantics(): void { + $group = new WhereGroup(new IdentifierQuoter()); + $group->whereIn('id', [])->whereNotIn('id', [])->whereContainsAny('name', []); + $this->assertSame('0 = 1 AND 1 = 1 AND 0 = 1', $group->condition()->sql); + $this->assertSame([], $group->condition()->bindings); + } + + public function test_null_equalities_do_not_bind_null_as_an_ordinary_comparison(): void { + $group = new WhereGroup(new IdentifierQuoter()); + $group->where('a', null)->where('b', '!=', null)->where('c', '<>', null); + $this->assertSame('`a` IS NULL AND `b` IS NOT NULL AND `c` IS NOT NULL', $group->condition()->sql); + $this->assertSame([], $group->condition()->bindings); + } + + /** + * @return iterable + */ + public static function invalidNullOperators(): iterable { + yield 'ordering' => [ + '>', + ]; + + yield 'injection' => [ + '= 1 OR id =', + ]; + + yield 'unsupported' => [ + 'LIKE', + ]; + } + + /** + * @dataProvider invalidNullOperators + */ + #[DataProvider('invalidNullOperators')] + public function test_null_never_bypasses_operator_validation(string $operator): void { + $group = new WhereGroup(new IdentifierQuoter()); + $this->expectException(InvalidArgumentException::class); + $group->where('id', $operator, null); + } + + public function test_failed_group_does_not_mutate_existing_conditions(): void { + $group = new WhereGroup(new IdentifierQuoter()); + $group->where('id', 1); + + try { + $group->where(static function (WhereGroup $nested): void { + $nested->where('status', 'active')->where('deleted', '>', null); + }); + $this->fail('Invalid null ordering must throw.'); + } catch (InvalidArgumentException) { + $this->assertSame('`id` = ?', $group->condition()->sql); + $this->assertSame([ + 1, + ], $group->condition()->bindings); + } + } + + public function test_numeric_criteria_keys_are_rejected_without_changing_existing_conditions(): void { + $group = new WhereGroup(new IdentifierQuoter()); + $group->where('id', 1); + + try { + $group->where([ + 'status' => 'active', + 0 => 'x', + ]); + $this->fail('Criteria with numeric keys must throw.'); + } catch (InvalidArgumentException $failure) { + $this->assertSame('Criteria must use column names as string keys.', $failure->getMessage()); + $this->assertSame('`id` = ?', $group->condition()->sql); + $this->assertSame([ + 1, + ], $group->condition()->bindings); + } + } + + public function test_an_empty_nested_group_is_rejected(): void { + $group = new WhereGroup(new IdentifierQuoter()); + $this->expectException(InvalidArgumentException::class); + $group->where(static function (WhereGroup $nested): void { + }); + } + + public function test_nested_join_groups_keep_column_comparisons_and_binding_order(): void { + $join = new JoinClause(new IdentifierQuoter()); + $join->where(static fn (JoinClause $group) => $group + ->on('e.owner_id', '=', 'u.id') + ->orWhere(static fn (JoinClause $nested) => $nested + ->on('e.fallback_id', '=', 'u.id') + ->where('u.active', true))) + ->where('e.status', 'open'); + + $this->assertSame('(`e`.`owner_id` = `u`.`id` OR (`e`.`fallback_id` = `u`.`id` AND `u`.`active` = ?)) AND `e`.`status` = ?', $join->condition()->sql); + $this->assertSame([ + true, + 'open', + ], $join->condition()->bindings); + } + + public function test_join_distinguishes_column_relationships_from_bound_values(): void { + $join = new JoinClause(new IdentifierQuoter()); + $join->on('e.owner_id', '=', 'u.ID')->where('u.user_status', 0)->orOn('e.fallback_id', '=', 'u.ID'); + $this->assertSame('`e`.`owner_id` = `u`.`ID` AND `u`.`user_status` = ? OR `e`.`fallback_id` = `u`.`ID`', $join->condition()->sql); + $this->assertSame([ + 0, + ], $join->condition()->bindings); + } + + public function test_native_doctrine_builder_expresses_the_same_grouped_join_with_explicit_identifiers(): void { + $connection = $this->createMock(Connection::class); + $connection->method('getDatabasePlatform')->willReturn(new MySQLPlatform()); + $native = new QueryBuilder($connection); + $expressions = new ExpressionBuilder($connection); + $native->select('`e`.`id`') + ->from('`wp_entries`', '`e`') + ->innerJoin('`e`', '`wp_users`', '`u`', $expressions->eq('`e`.`owner_id`', '`u`.`ID`')) + ->where($expressions->and( + $expressions->eq('`e`.`source`', '?'), + $expressions->or($expressions->eq('`u`.`user_status`', '?'), $expressions->isNull('`u`.`user_status`')), + )) + ->setParameters([ + 'sdn', + 0, + ], [ + ParameterType::STRING, + ParameterType::INTEGER, + ]); + $conditions = new WhereGroup(new IdentifierQuoter()); + $conditions->where('e.source', 'sdn')->where( + static fn (WhereGroup $nested) => $nested->where('u.user_status', 0)->orWhere('u.user_status', null), + ); + $join = new JoinClause(new IdentifierQuoter()); + $join->on('e.owner_id', '=', 'u.ID'); + $this->assertSame('SELECT `e`.`id` FROM `wp_entries` `e` INNER JOIN `wp_users` `u` ON `e`.`owner_id` = `u`.`ID` WHERE (`e`.`source` = ?) AND ((`u`.`user_status` = ?) OR (`u`.`user_status` IS NULL))', $native->getSQL()); + $this->assertSame('`e`.`source` = ? AND (`u`.`user_status` = ? OR `u`.`user_status` IS NULL)', $conditions->condition()->sql); + $this->assertSame($native->getParameters(), $conditions->condition()->bindings); + $this->assertSame('`e`.`owner_id` = `u`.`ID`', $join->condition()->sql); + $rows = [ + [ + 'id' => 7, + ], + ]; + $result = $this->createMock(Result::class); + $result->expects($this->once())->method('fetchAllAssociative')->willReturn($rows); + $connection->expects($this->once())->method('executeQuery')->with($native->getSQL(), $conditions->condition()->bindings, [ + ParameterType::STRING, + ParameterType::INTEGER, + ])->willReturn($result); + $this->assertSame($rows, $native->fetchAllAssociative()); + } +} diff --git a/tests/integration/Database/QueryTest.php b/tests/integration/Database/QueryTest.php new file mode 100644 index 0000000..7c2db72 --- /dev/null +++ b/tests/integration/Database/QueryTest.php @@ -0,0 +1,525 @@ +queries = $this->container->get(Database::class); + $this->entries = $this->container->get(Entry_Table::class); + $this->tables[] = $this->entries->quotedName(); + $this->observer->executeStatement('CREATE TABLE ' . $this->entries->quotedName() . ' ( + id INT PRIMARY KEY, + name VARCHAR(191) NOT NULL, + email VARCHAR(191) NULL UNIQUE, + category VARCHAR(30) NULL, + amount DECIMAL(12, 2) NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME(6) NULL + ) ENGINE=InnoDB'); + } + + /** + * Bound values, nested predicates, arrays and nulls compose without raw SQL. + */ + public function test_conditions_compose_and_terminal_reads_leave_the_query_unchanged(): void { + $this->seedEntries(); + $query = $this->entries->query('e') + ->select('e.id', 'e.name') + ->where([ + 'e.active' => true, + ]) + ->where(static fn (WhereGroup $group) => $group + ->where('e.category', 'a') + ->orWhere('e.name', 'Third')) + ->where('e.id', '>=', 1) + ->orderBy('e.id'); + $sql = $query->toSql(); + $this->assertSame(2, $query->count()); + $this->assertTrue($query->exists()); + $this->assertSame('First', $query->first()['name'] ?? null); + $this->assertCount(2, $query->get()); + $this->assertSame($sql, $query->toSql()); + $this->assertSame(1, $this->entries->query()->whereNull('category')->count()); + $this->assertSame(2, $this->entries->query()->where('category', '!=', null)->count()); + $this->assertSame(1, $this->entries->query()->where('category', null)->count()); + $this->assertSame(2, $this->entries->query()->whereNotNull('category')->count()); + $this->assertNull($this->entries->query()->where('id', 999)->first()); + } + + /** + * Raw projections may change cardinality even without explicit grouping. + */ + public function test_raw_aggregates_preserve_their_result_row_on_an_empty_table(): void { + $query = $this->entries->query()->selectRaw('COUNT(*) AS total'); + $this->assertTrue($query->exists()); + $this->assertSame(1, $query->count()); + $this->assertSame(0, (int) $query->max('total')); + $this->assertSame(0, (int) ($query->first()['total'] ?? null)); + $this->assertSame(0, $query->select('id')->count()); + } + + /** + * Cloned grouped queries own independent HAVING state. + */ + public function test_cloned_query_conditions_do_not_change_the_original(): void { + $this->seedEntries(); + $original = $this->entries->query()->selectRaw('category, COUNT(*) AS total')->groupBy('category'); + $copy = clone $original; + $copy->having('total', '>', 1); + $this->assertSame(2, $original->count()); + $this->assertSame(1, $copy->count()); + } + + /** + * Empty membership and escaped literal search terms have explicit meanings. + */ + public function test_empty_lists_and_literal_search_patterns(): void { + $this->entries->query()->insert([ + [ + 'id' => 1, + 'name' => '100%_! complete', + ], + [ + 'id' => 2, + 'name' => '100xyz complete', + ], + ]); + $this->assertSame(0, $this->entries->query()->whereIn('id', [])->count()); + $this->assertSame(2, $this->entries->query()->whereNotIn('id', [])->count()); + $this->assertSame(0, $this->entries->query()->whereContainsAny('name', [])->count()); + $this->assertSame('100%_! complete', $this->entries->query()->whereContainsAny('name', [ + '%_!', + ])->first()['name'] ?? null); + $this->assertSame(2, $this->entries->query()->whereContainsAny('name', [ + '%_!', + 'xyz', + ])->count()); + $this->assertSame(1, $this->entries->query()->whereIn('id', [ + 2, + ])->count()); + } + + /** + * A shaped aggregate retains selected aliases and positional binding order. + */ + public function test_limited_aggregates_preserve_explicit_projections_and_offsets(): void { + $this->seedEntries(); + $query = $this->entries->query('e') + ->select('e.amount as total') + ->orderBy('total', 'desc') + ->limit(2) + ->offset(1); + $this->assertSame(2, $query->count()); + $this->assertSame(20.0, (float) $query->max('total')); + $this->assertTrue($query->exists()); + $this->assertCount(2, $query->get()); + $this->assertSame(20.0, (float) ($query->first()['total'] ?? null)); + $this->assertSame(2, $this->entries->query()->offset(1)->count()); + $this->assertSame(0, $this->entries->query()->limit(0)->count()); + $this->assertFalse($this->entries->query()->limit(0)->exists()); + $this->assertFalse($this->entries->query()->offset(10)->exists()); + $this->assertSame(30.0, (float) $this->entries->query('e')->orderBy('e.amount', 'desc')->limit(1)->max('e.amount')); + } + + /** + * Grouped and distinct aggregates count the selected rows, not base records. + */ + public function test_grouped_alias_aggregates_and_distinct_selection(): void { + $this->seedEntries(); + $this->entries->query()->where('id', 3)->update([ + 'category' => 'a', + ]); + $query = $this->entries->query() + ->selectRaw('category, COUNT(*) AS total') + ->groupBy('category') + ->having('total', '>', 1); + $this->assertSame(1, $query->count()); + $this->assertSame(3, (int) $query->max('total')); + $this->assertTrue($query->exists()); + $this->assertSame(3, (int) $query->get()[0]['total']); + $this->assertTrue($query->having('total', 3)->exists()); + $this->assertSame(1, $this->entries->query()->select('category')->distinct()->count()); + } + + /** + * Joins accept table objects and order bindings by their SQL position. + */ + public function test_join_callbacks_keep_value_conditions_inside_the_on_clause(): void { + $this->seedEntries(); + $query = $this->queries->table($this->entries, 'e') + ->leftJoin($this->entries->unprefixedName() . ' as matching', static fn (JoinClause $join) => $join + ->where(static fn (JoinClause $group) => $group + ->on('matching.id', '=', 'e.id') + ->where('matching.active', true))) + ->select('e.id', 'matching.name as matched') + ->selectRaw('? AS marker', [ + 'bound marker', + ]) + ->where('e.id', '>', 0) + ->orderBy('e.id'); + $this->assertSame([ + 'bound marker', + true, + 0, + ], $query->getBindings()); + $rows = $query->get(); + $this->assertCount(3, $rows); + $this->assertSame('bound marker', $rows[0]['marker']); + $this->assertSame('First', $rows[0]['matched']); + $this->assertNull($rows[1]['matched']); + $this->assertSame(3, $query->count()); + $this->assertSame(3, $this->queries->table($this->entries, 'e') + ->join($this->entries, $this->entries->name() . '.id', '=', 'e.id') + ->count()); + $this->assertSame(2, $query->limit(2)->count()); + } + + /** + * Supported scalar conversions work under strict SQL mode. + */ + public function test_bulk_values_normalize_columns_booleans_and_fractional_dates(): void { + $mode = $this->db->fetchOne('SELECT @@SESSION.sql_mode'); + $this->db->executeStatement("SET SESSION sql_mode = 'STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION'"); + + try { + $this->assertSame(2, (int) $this->entries->query()->insert([ + [ + 'id' => 1, + 'name' => 'False', + 'active' => false, + 'created_at' => new DateTimeImmutable('2026-09-25 12:34:56.123456'), + ], + [ + 'created_at' => null, + 'active' => true, + 'name' => 'True', + 'id' => 2, + ], + ])); + $rows = $this->entries->query()->orderBy('id')->get(); + $this->assertSame(0, (int) $rows[0]['active']); + $this->assertSame(1, (int) $rows[1]['active']); + $this->assertSame('2026-09-25 12:34:56.123456', $rows[0]['created_at']); + $this->assertNull($rows[1]['created_at']); + } finally { + $this->db->executeStatement('SET SESSION sql_mode = ?', [ + $mode, + ]); + } + } + + /** + * Bulk shape errors are rejected before any row can be inserted. + */ + public function test_mismatched_bulk_columns_do_not_write_partial_data(): void { + try { + $this->smallChunkWriter()->insert(new TableReference($this->entries->name()), [ + [ + 'id' => 1, + 'name' => 'Valid first row', + ], + [ + 'id' => 2, + 'active' => true, + ], + ]); + $this->fail('Different column sets must be rejected.'); + } catch (InvalidArgumentException) { + $this->assertSame(0, $this->entries->query()->count()); + } + } + + /** + * Upsert uses any real unique constraint and updates only the named columns. + */ + public function test_upsert_matches_primary_or_unique_keys(): void { + $this->entries->query()->insert([ + 'id' => 1, + 'name' => 'Original', + 'email' => 'original@example.test', + ]); + $this->entries->query()->upsert([ + [ + 'id' => 1, + 'name' => 'By primary key', + 'email' => 'new@example.test', + ], + ], [ + 'name', + ]); + $row = $this->entries->query()->first(); + $this->assertNotNull($row); + $this->assertSame('By primary key', $row['name']); + $this->assertSame('original@example.test', $row['email']); + $this->entries->query()->upsert([ + [ + 'id' => 2, + 'name' => 'By unique email', + 'email' => 'original@example.test', + ], + ], [ + 'name', + ]); + $this->assertSame(1, $this->entries->query()->count()); + $this->assertSame('By unique email', $this->entries->query()->first()['name'] ?? null); + } + + /** + * Single-table writes retain ordering and limits and require conditions. + */ + public function test_update_and_delete_honor_ordering_and_limits(): void { + $this->seedEntries(); + $this->assertSame(1, (int) $this->entries->query() + ->where('id', '>', 0) + ->orderBy('id', 'desc') + ->limit(1) + ->update([ + 'name' => 'Last', + ])); + $this->assertSame('Last', $this->entries->query()->where('id', 3)->first()['name'] ?? null); + $this->assertSame(1, (int) $this->entries->query()->where('id', '>', 0)->orderBy('id')->limit(1)->delete()); + $this->assertSame(2, $this->entries->query()->count()); + + try { + $this->entries->query()->delete(); + $this->fail('Unfiltered deletes must be rejected.'); + } catch (InvalidArgumentException) { + $this->assertSame(2, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->entries->quotedName())); + } + } + + /** + * WordPress resolves shared users separately from per-site application tables. + */ + public function test_wordpress_references_capture_their_site_and_keep_global_users(): void { + $this->seedEntries(); + $this->assertCount(3, $this->queries->table($this->entries, 'e') + ->leftJoin($this->queries->wordpress('users')->as('u'), 'u.ID', '=', 'e.id') + ->select('e.id', 'u.ID as user_id') + ->get()); + $originalBlog = $this->source->blogid; + $users = $this->source->users; + $old = $this->queries->table($this->queries->wordpress('posts')); + $oldSql = $old->toSql(); + + try { + $this->source->set_blog_id(98765); + $this->assertSame($users, $this->queries->wordpress('users')->name); + $this->assertSame($this->source->posts, $this->queries->wordpress('posts')->name); + $this->assertSame($oldSql, $old->toSql()); + $sql = $this->queries->table($this->entries, 'e') + ->join($this->queries->wordpress('users')->as('u'), 'u.ID', '=', 'e.id') + ->toSql(); + $this->assertStringContainsString($this->source->prefix . $this->entries->unprefixedName(), $sql); + $this->assertStringContainsString('`' . $users . '` AS `u`', $sql); + } finally { + $this->source->set_blog_id($originalBlog); + } + } + + /** + * Catching a query failure cannot turn a terminal transaction into success. + */ + public function test_caught_builder_execution_failure_rolls_back_prior_writes(): void { + $this->seedEntries(); + + try { + $this->db->transactional(function (): void { + $this->entries->query()->where('id', 1)->update([ + 'name' => 'Provisional', + ]); + + try { + $this->entries->query()->insert([ + 'id' => 1, + 'name' => 'Duplicate', + ]); + } catch (UniqueConstraintViolationException) { + } + }); + $this->fail('A caught SQL error must still prevent commit.'); + } catch (TransactionFailed) { + $this->assertSame('First', $this->observer->fetchOne('SELECT name FROM ' . $this->entries->quotedName() . ' WHERE id = 1')); + } + } + + /** + * Separate insert statements commit independently without a caller transaction. + */ + public function test_later_chunk_failure_leaves_earlier_chunk_without_a_transaction(): void { + try { + $this->smallChunkWriter()->insert(new TableReference($this->entries->name()), [ + [ + 'id' => 1, + 'name' => 'First chunk', + ], + [ + 'id' => 1, + 'name' => 'Duplicate second chunk', + ], + ]); + $this->fail('The second chunk must violate the primary key.'); + } catch (UniqueConstraintViolationException) { + $this->assertSame('First chunk', $this->observer->fetchOne('SELECT name FROM ' . $this->entries->quotedName())); + $this->assertSame(1, $this->entries->query()->count()); + } + } + + /** + * A caught error in a later chunk invalidates the caller's whole transaction. + */ + public function test_caught_later_chunk_failure_rolls_back_the_whole_import(): void { + try { + $this->db->transactional(function (): void { + try { + $this->smallChunkWriter()->insert(new TableReference($this->entries->name()), [ + [ + 'id' => 1, + 'name' => 'Provisional first chunk', + ], + [ + 'id' => 1, + 'name' => 'Duplicate second chunk', + ], + ]); + } catch (UniqueConstraintViolationException) { + } + }); + $this->fail('A caught chunk error must still prevent commit.'); + } catch (TransactionFailed) { + $this->assertSame(0, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->entries->quotedName())); + } + } + + /** + * An unsupported condition cannot exploit null rewriting to bypass validation. + */ + public function test_invalid_operator_is_rejected_even_for_null(): void { + $this->expectException(InvalidArgumentException::class); + $this->entries->query()->where('name', 'LIKE', null); + } + + /** + * A grouped aggregate cannot silently add a missing column to its projection. + */ + public function test_grouped_column_aggregate_rejects_an_unselected_column(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Aggregate column "amount" is not selected.'); + $this->entries->query()->select('category')->groupBy('category')->max('amount'); + } + + /** + * Quoted column names follow the same rules for insertion and updates. + */ + public function test_insert_and_update_accept_the_same_digit_leading_column(): void { + $this->observer->executeStatement('ALTER TABLE ' . $this->entries->quotedName() . ' ADD COLUMN `1col` VARCHAR(30)'); + $this->assertSame(1, $this->entries->query()->insert([ + 'id' => 10, + 'name' => 'Inserted', + '1col' => 'before', + ])); + $this->assertSame(1, $this->entries->query()->where('id', 10)->update([ + '1col' => 'after', + ])); + $this->assertSame('after', $this->entries->query()->select('1col')->first()['1col'] ?? null); + } + + /** + * Explicit output names, aliases and wildcards remain usable by shaped aggregates. + */ + public function test_aggregates_accept_selected_outputs_without_changing_the_projection(): void { + $this->seedEntries(); + $query = $this->entries->query('e')->select('e.amount')->limit(2); + $sql = $query->toSql(); + $this->assertSame(30.0, (float) $query->max('e.amount')); + $this->assertSame($sql, $query->toSql()); + $this->assertSame(30.0, (float) $query->select('e.amount AS Total')->max('total')); + $this->assertSame(30.0, (float) $query->select('e.*')->max('amount')); + $this->assertSame(30.0, (float) $query->select('*')->max('amount')); + $this->assertSame(30.0, (float) $query->select()->max('amount')); + } + + /** + * Select-only state cannot silently disappear from a mutation. + */ + public function test_shaped_writes_reject_unhonored_state(): void { + $this->seedEntries(); + + try { + $this->entries->query()->select('id')->where('id', 1)->update([ + 'name' => 'Must not run', + ]); + $this->fail('An update cannot honor a selection.'); + } catch (InvalidArgumentException) { + $this->assertSame('First', $this->entries->query()->where('id', 1)->first()['name'] ?? null); + } + + try { + $this->entries->query()->where('id', 999)->insert([ + 'id' => 4, + 'name' => 'Must not run', + ]); + $this->fail('An insert cannot honor a condition.'); + } catch (InvalidArgumentException) { + $this->assertSame(3, $this->entries->query()->count()); + } + } + + private function smallChunkWriter(): InsertWriter { + return new InsertWriter( + $this->container->get(Executor::class), + $this->container->get(ServerBuilder::class), + $this->container->get(IdentifierQuoter::class), + 2, + ); + } + + private function seedEntries(): void { + $this->entries->query()->insert([ + [ + 'id' => 1, + 'name' => 'First', + 'category' => 'a', + 'amount' => '10.00', + 'active' => true, + ], + [ + 'id' => 2, + 'name' => 'Second', + 'category' => 'a', + 'amount' => '30.00', + 'active' => false, + ], + [ + 'id' => 3, + 'name' => 'Third', + 'category' => null, + 'amount' => '20.00', + 'active' => true, + ], + ]); + } +} diff --git a/tests/integration/Database/QueryValidationTest.php b/tests/integration/Database/QueryValidationTest.php new file mode 100644 index 0000000..bb41d1c --- /dev/null +++ b/tests/integration/Database/QueryValidationTest.php @@ -0,0 +1,208 @@ +container->get(Database::class)->table($this->suffix) + ->selectRaw('COUNT(*) AS total') + ->where('id', 999); + $sql = $query->toSql(); + $bindings = $query->getBindings(); + $invalid = [ + 'projection' => static fn (Query $query) => $query->select('id', 'invalid column'), + 'order' => static fn (Query $query) => $query->orderBy('id', 'sideways'), + 'limit' => static fn (Query $query) => $query->limit(-1), + 'offset' => static fn (Query $query) => $query->offset(-1), + 'incomplete join' => fn (Query $query) => $query->join($this->suffix, 'id'), + 'empty join' => fn (Query $query) => $query->leftJoin($this->suffix, static function (JoinClause $join): void { + }), + ]; + + foreach ($invalid as $operation => $change) { + $this->assertRejected(static fn () => $change($query), $operation); + $this->assertSame($sql, $query->toSql(), $operation); + $this->assertSame($bindings, $query->getBindings(), $operation); + $this->assertTrue($query->exists(), $operation); + $this->assertSame(1, $query->count(), $operation); + } + + $this->assertOriginal(); + } + + /** + * Guard failures cannot clear a table or run a partially specified write. + */ + public function test_invalid_writes_leave_existing_rows_unchanged(): void { + $queries = $this->container->get(Database::class); + $invalid = [ + 'unfiltered update' => fn () => $queries->table($this->suffix)->update([ + 'name' => 'Changed', + ]), + 'unfiltered delete' => fn () => $queries->table($this->suffix)->delete(), + 'empty update' => fn () => $queries->table($this->suffix)->where('id', 1)->update([]), + 'aliased delete' => fn () => $queries->table($this->suffix, 'e')->where('e.id', 1)->delete(), + 'empty upsert columns' => fn () => $queries->table($this->suffix)->upsert([ + 'id' => 1, + 'name' => 'Changed', + ], []), + 'filtered defaults insert' => fn () => $queries->table($this->suffix)->where('id', 1)->insertGetId([]), + 'filtered empty insert' => fn () => $queries->table($this->suffix)->where('id', 1)->insert([]), + 'ordered empty insert' => fn () => $queries->table($this->suffix)->orderBy('id')->insert([]), + 'aliased empty insert' => fn () => $queries->table($this->suffix, 'e')->insert([]), + 'aliased empty upsert' => fn () => $queries->table($this->suffix, 'e')->upsert([], [ + 'name', + ]), + ]; + + foreach ($invalid as $operation => $write) { + $this->assertRejected($write, $operation); + $this->assertOriginal(); + } + + $this->assertSame('Original', $queries->table($this->suffix)->where('id', 1)->first()['name'] ?? null); + } + + /** + * Insert and update reject the same invalid unqualified column names. + */ + public function test_insert_and_update_reject_invalid_column_names(): void { + $query = $this->container->get(Database::class)->table($this->suffix); + $columns = [ + 'e.name', + 'name`', + 'name = NULL', + ]; + + foreach ($columns as $column) { + $this->assertRejected(static fn () => $query->insert([ + $column => 'changed', + ]), $column); + $this->assertRejected(static fn () => (clone $query)->where('id', 1)->update([ + $column => 'changed', + ]), $column); + } + + $this->assertOriginal(); + } + + /** + * Shaped joins need an unambiguous projection before forming a derived table. + */ + public function test_shaped_join_rejects_wildcards_and_accepts_an_explicit_projection(): void { + $query = $this->container->get(Database::class)->table($this->suffix, 'e') + ->join(new TableReference($this->source->prefix . $this->suffix, 'matching'), 'matching.id', '=', 'e.id') + ->distinct(); + + $this->assertRejected($query->count(...), 'implicit wildcard'); + $query->select('e.*'); + $this->assertRejected($query->count(...), 'qualified wildcard'); + $query->selectRaw('1 AS marker'); + $this->assertRejected($query->count(...), 'qualified wildcard with raw selection'); + $this->assertSame(1, $query->select('e.id')->count()); + $this->assertOriginal(); + } + + /** + * A known missing output fails before database execution, preserving query state. + */ + public function test_aggregates_reject_missing_explicit_outputs_without_connecting(): void { + $driver = $this->createMock(Driver::class); + $driver->expects($this->never())->method('connect'); + $this->container->singleton(Connection::class, new Connection([], $driver)); + $queries = $this->container->get(Database::class); + $cases = [ + $queries->table($this->suffix)->select('name')->limit(10), + $queries->table($this->suffix)->select('name')->distinct(), + $queries->table($this->suffix)->select('name')->groupBy('name'), + $queries->table($this->suffix)->select('amount AS total')->limit(10), + ]; + + foreach ($cases as $query) { + $sql = $query->toSql(); + + try { + $query->max('amount'); + $this->fail('An aggregate cannot read an excluded output column.'); + } catch (InvalidArgumentException $failure) { + $this->assertSame('Aggregate column "amount" is not selected. Include it in select() or use its selected alias.', $failure->getMessage()); + } + + $this->assertSame($sql, $query->toSql()); + } + } + + /** + * A zero limit remains empty when first() applies its own maximum. + */ + public function test_first_respects_an_existing_zero_limit(): void { + $query = $this->container->get(Database::class)->table($this->suffix)->limit(0); + $this->assertNull($query->first()); + $this->assertSame([], $query->get()); + $this->assertSame(0, $query->count()); + $this->assertOriginal(); + } + + /** + * Names are validated before an invalid identity can enter a query. + */ + public function test_invalid_table_references_and_unknown_wordpress_tables_are_rejected(): void { + $queries = $this->container->get(Database::class); + $this->assertRejected(static fn () => $queries->wordpress('not_a_core_table'), 'unknown core table'); + $this->assertRejected(static fn () => new TableReference(''), 'empty table name'); + $this->assertRejected(static fn () => new TableReference(str_repeat('x', 65)), 'overlong table name'); + $this->assertRejected(static fn () => new TableReference('wp_entries', 'invalid alias'), 'invalid alias'); + $this->assertSame(1, $queries->table($this->suffix)->count()); + } + + /** + * Resolving the query service and composing SQL never opens its connection. + */ + public function test_query_construction_and_compilation_do_not_connect(): void { + $driver = $this->createMock(Driver::class); + $driver->expects($this->never())->method('connect'); + $driver->expects($this->never())->method('getDatabasePlatform'); + $db = new Connection([], $driver); + $this->container->singleton(Connection::class, $db); + $query = $this->container->get(Database::class)->table($this->suffix)->where('id', 1); + $this->assertStringContainsString('`' . $this->source->prefix . $this->suffix . '`', $query->toSql()); + $this->assertSame([ + 1, + ], $query->getBindings()); + $this->assertFalse($db->isConnected()); + } + + /** + * Expect rejection while allowing the scenario to verify subsequent behavior. + * + * @param Closure(): mixed $operation + */ + private function assertRejected(Closure $operation, string $description): void { + try { + $operation(); + } catch (InvalidArgumentException) { + $this->addToAssertionCount(1); + + return; + } + + $this->fail('Expected invalid query state to be rejected: ' . $description); + } +} diff --git a/tests/integration/Database/TableTest.php b/tests/integration/Database/TableTest.php index d27a09f..a716c18 100644 --- a/tests/integration/Database/TableTest.php +++ b/tests/integration/Database/TableTest.php @@ -2,6 +2,7 @@ namespace StellarWP\Foundation\Tests\Integration\Database; +use DateTimeImmutable; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception\DriverException; use Doctrine\DBAL\Exception\TableNotFoundException; @@ -24,10 +25,10 @@ protected function setUp(): void { } public function test_ordinary_table_calls_share_the_transaction_and_bind_values(): void { - $id = $this->entries->insertGetId(['name' => "O'Reilly", 'active' => true], ['active' => Types::BOOLEAN]); - $this->assertSame("O'Reilly", $this->entries->query()->select('name')->where('id = :id')->setParameter('id', $id)->fetchOne()); + $id = $this->entries->insertGetId(['name' => "O'Reilly", 'active' => true]); + $this->assertSame("O'Reilly", ($this->entries->query()->select('name')->where('id', $id)->first()['name'] ?? null)); $this->assertSame(1, (int) $this->entries->update(['name' => 'Changed'], ['id' => $id])); - $this->assertSame(1, (int) $this->entries->query()->select('active')->fetchOne()); + $this->assertSame(1, (int) ($this->entries->query()->select('active')->first()['active'] ?? null)); try { $this->db->transactional(function (): void { @@ -42,15 +43,226 @@ public function test_ordinary_table_calls_share_the_transaction_and_bind_values( $this->assertSame(1, (int) $this->entries->delete(['id' => $id])); } + /** + * Both entry points share bulk input, scalar conversion, dates, and empty input. + */ + public function test_table_and_query_insertions_have_identical_behavior(): void { + $this->observer->executeStatement('ALTER TABLE ' . $this->entries->quotedName() . ' + ADD COLUMN amount DECIMAL(12, 2) NULL, + ADD COLUMN created_at DATETIME(6) NULL'); + + foreach ([ + $this->entries, + $this->entries->query(), + ] as $target) { + $this->assertSame(0, $target->insert([])); + $this->assertSame(1, $target->insert([ + 'name' => "O'Reilly", + 'active' => true, + 'amount' => '12.30', + 'created_at' => new DateTimeImmutable('2026-09-25 12:34:56.123456+08:00'), + ])); + $this->assertSame(2, $target->insert([ + [ + 'name' => 'Two', + 'active' => false, + ], + [ + 'active' => true, + 'name' => null, + ], + ])); + $row = $this->entries->query()->orderBy('id')->first(); + $this->assertNotNull($row); + $this->assertArrayHasKey('name', $row); + $this->assertSame("O'Reilly", $row['name']); + $this->assertSame(1, (int) $row['active']); + $this->assertSame('12.30', $row['amount']); + $this->assertSame('2026-09-25 12:34:56.123456', $row['created_at']); + $this->assertSame(3, $this->entries->count()); + $this->entries->deleteAll(); + } + } + + /** + * Table and fluent mutations share value normalization and equality semantics. + */ + public function test_table_and_query_updates_and_deletes_have_identical_behavior(): void { + $this->observer->executeStatement('ALTER TABLE ' . $this->entries->quotedName() . ' ADD COLUMN updated_at DATETIME(6) NULL'); + $first = $this->entries->insertGetId([ + 'name' => 'Table', + ]); + $second = $this->entries->insertGetId([ + 'name' => 'Query', + ]); + $updatedAt = new DateTimeImmutable('2026-09-25 12:34:56.123456+08:00'); + $values = [ + 'name' => null, + 'active' => false, + 'updated_at' => $updatedAt, + ]; + $this->assertSame(1, (int) $this->entries->update($values, [ + 'id' => $first, + 'active' => false, + 'updated_at' => null, + ])); + $this->assertSame(1, (int) $this->entries->query()->where([ + 'id' => $second, + 'active' => false, + 'updated_at' => null, + ])->update($values)); + $rows = $this->entries->query()->select('name', 'active', 'updated_at')->get(); + $this->assertCount(2, $rows); + $this->assertSame($rows[0], $rows[1]); + $this->assertSame('2026-09-25 12:34:56.123456', $rows[0]['updated_at']); + $this->assertSame(1, (int) $this->entries->delete([ + 'id' => $first, + 'name' => null, + 'active' => false, + 'updated_at' => $updatedAt, + ])); + $this->assertSame(1, (int) $this->entries->query()->where([ + 'id' => $second, + 'name' => null, + 'active' => false, + 'updated_at' => $updatedAt, + ])->delete()); + $this->assertSame(0, $this->entries->count()); + } + + /** + * Both table mutations participate in the same caller-owned transaction. + */ + public function test_table_update_and_filtered_delete_roll_back_together(): void { + $first = $this->entries->insertGetId([ + 'name' => 'First', + ]); + $second = $this->entries->insertGetId([ + 'name' => 'Second', + ]); + + try { + $this->db->transactional(function () use ($first, $second): void { + $this->assertSame(1, (int) $this->entries->update([ + 'name' => 'Changed', + ], [ + 'id' => $first, + ])); + $this->assertSame(1, (int) $this->entries->delete([ + 'id' => $second, + ])); + + throw new RuntimeException('Cancel mutations'); + }); + } catch (RuntimeException $failure) { + $this->assertSame('Cancel mutations', $failure->getMessage()); + } + + $this->assertSame([ + 'First', + 'Second', + ], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->entries->quotedName() . ' ORDER BY id')); + } + + /** + * Requesting an ID always inserts a row, including when only defaults are needed. + */ + public function test_generated_ids_follow_single_row_and_defaults_only_insertions(): void { + foreach ([ + $this->entries, + $this->entries->query(), + ] as $target) { + $previous = $target->insertGetId([ + 'name' => 'Previous', + 'active' => true, + ]); + $this->assertSame(0, $target->insert([])); + $id = $target->insertGetId([]); + $this->assertNotSame((string) $previous, (string) $id); + $row = $this->entries->query()->where('id', $id)->first(); + $this->assertNotNull($row); + $this->assertNull($row['name']); + $this->assertSame(0, (int) $row['active']); + } + + $this->assertSame(4, $this->entries->count()); + } + + /** + * Explicit Doctrine conversions remain available on the shared connection. + */ + public function test_native_insert_supports_explicit_doctrine_types(): void { + $this->observer->executeStatement('ALTER TABLE ' . $this->entries->quotedName() . ' ADD COLUMN payload TEXT'); + $this->db->insert($this->entries->quotedName(), [ + 'name' => 'Typed', + 'payload' => [ + 'enabled' => true, + ], + ], [ + 'payload' => Types::JSON, + ]); + + $row = $this->entries->query()->first(); + $this->assertNotNull($row); + $this->assertArrayHasKey('payload', $row); + $this->assertSame([ + 'enabled' => true, + ], json_decode((string) $row['payload'], true)); + $this->assertSame(1, (int) $this->db->update($this->entries->quotedName(), [ + 'payload' => [ + 'enabled' => false, + ], + ], [ + 'name' => 'Typed', + ], [ + 'payload' => Types::JSON, + ])); + $updated = $this->entries->query()->first(); + $this->assertNotNull($updated); + $this->assertArrayHasKey('payload', $updated); + $this->assertSame([ + 'enabled' => false, + ], json_decode((string) $updated['payload'], true)); + $this->assertSame(1, (int) $this->db->delete($this->entries->quotedName(), [ + 'name' => 'Typed', + ], [ + 'name' => Types::STRING, + ])); + } + + /** + * A bulk table insertion participates in the caller's existing transaction. + */ + public function test_bulk_table_insert_rolls_back_with_the_callers_transaction(): void { + try { + $this->db->transactional(function (): void { + $this->entries->insert([ + [ + 'name' => 'First', + ], + [ + 'name' => 'Second', + ], + ]); + + throw new RuntimeException('Cancel bulk insertion'); + }); + } catch (RuntimeException $failure) { + $this->assertSame('Cancel bulk insertion', $failure->getMessage()); + } + + $this->assertSame(0, $this->entries->count()); + } + public function test_queries_are_fresh_and_capture_the_site_when_built(): void { $old = $this->entries->query('entry')->select('entry.id'); $fresh = $this->entries->query(); $this->assertNotSame($old, $fresh); $oldName = $this->entries->name(); $this->source->set_prefix($this->source->prefix . 'other_'); - $this->assertStringContainsString($oldName, $old->getSQL()); + $this->assertStringContainsString($oldName, $old->toSql()); $this->assertNotSame($oldName, $this->entries->name()); - $this->assertStringContainsString($this->entries->name(), $this->entries->query()->select('id')->getSQL()); + $this->assertStringContainsString($this->entries->name(), $this->entries->query()->select('id')->toSql()); } public function test_count_and_delete_all_preserve_the_identity_sequence(): void { @@ -94,7 +306,7 @@ public function test_delete_all_rolls_back_with_the_callers_transaction(): void $this->assertSame([ 'Original', - ], $this->entries->query()->select('name')->fetchFirstColumn()); + ], array_column($this->entries->query()->select('name')->get(), 'name')); } public function test_truncate_empties_the_table_and_resets_auto_increment(): void { @@ -133,7 +345,7 @@ public function test_truncate_rejects_a_transaction_before_committing_or_deletin $this->assertSame([ 'Original', - ], $this->entries->query()->select('name')->fetchFirstColumn()); + ], array_column($this->entries->query()->select('name')->get(), 'name')); } public function test_whole_table_deletions_preserve_foreign_key_enforcement(): void { @@ -189,7 +401,7 @@ public function test_empty_mutation_criteria_are_rejected(): void { $method === 'update' ? $this->entries->update(['name' => 'All'], []) : $this->entries->delete([]); $this->fail('Expected empty criteria rejection.'); } catch (InvalidArgumentException) { - $this->assertSame(0, (int) $this->entries->query()->select('COUNT(*)')->fetchOne()); + $this->assertSame(0, (int) $this->entries->query()->count()); } } } @@ -206,7 +418,7 @@ public function test_idle_connection_replacement_supports_queries_and_transactio return 'updated'; })); - $this->assertSame('Reconnected', $this->entries->query()->select('name')->fetchOne()); + $this->assertSame('Reconnected', ($this->entries->query()->select('name')->first()['name'] ?? null)); try { $oldStatement->executeQuery(); diff --git a/tests/Unit/LockDatabase/LockDatabaseProviderTest.php b/tests/integration/LockDatabase/LockDatabaseProviderTest.php similarity index 90% rename from tests/Unit/LockDatabase/LockDatabaseProviderTest.php rename to tests/integration/LockDatabase/LockDatabaseProviderTest.php index cd6a2d5..337a392 100644 --- a/tests/Unit/LockDatabase/LockDatabaseProviderTest.php +++ b/tests/integration/LockDatabase/LockDatabaseProviderTest.php @@ -1,6 +1,6 @@ , string}> @@ -40,6 +41,7 @@ public static function tableNames(): iterable { */ #[DataProvider('tableNames')] public function test_it_uses_the_shared_database_services_and_configured_storage(array $configuration, string $name): void { + $this->container->register(DatabaseProvider::class); $db = $this->createMock(Connection::class); $db->expects($this->never())->method('executeStatement'); $scope = $this->createMock(DatabaseScope::class); diff --git a/tests/integration/Migrations/AdvisoryMigrationTest.php b/tests/integration/Migrations/AdvisoryMigrationTest.php index c0658fa..a4a20a7 100644 --- a/tests/integration/Migrations/AdvisoryMigrationTest.php +++ b/tests/integration/Migrations/AdvisoryMigrationTest.php @@ -12,6 +12,7 @@ use StellarWP\Foundation\Database\Contracts\DatabaseScope; use StellarWP\Foundation\Database\Contracts\TableNameResolver; use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Database\Query\Database; use StellarWP\Foundation\Migrations\Contracts\MigratesData; use StellarWP\Foundation\Migrations\Contracts\Migration; use StellarWP\Foundation\Migrations\DataMigrationContext; @@ -61,7 +62,7 @@ private function runner(Container $container, Closure $operation, ?string $resou $scope = $container->get(DatabaseScope::class); $names = $container->get(TableNameResolver::class); $resource ??= $scope->resolveTableName($this->suffix . '_ledger'); - $table = new MigrationTable(substr($resource, strlen($scope->resolveTableName(''))), $db, $names); + $table = new MigrationTable(substr($resource, strlen($scope->resolveTableName(''))), $db, $names, $container->get(Database::class)); $migration = new class($operation) implements MigratesData, Migration { /** @param Closure(Connection): void $operation */ public function __construct(private readonly Closure $operation) { From f68ec483190173959e4e83d584f14f2918197ab3 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 25 Sep 2026 16:47:29 -0600 Subject: [PATCH 32/53] Don't commit design junk --- dev/Experiments/Query/QUERY-BUILDER-DESIGN.md | 461 ------------------ 1 file changed, 461 deletions(-) delete mode 100644 dev/Experiments/Query/QUERY-BUILDER-DESIGN.md diff --git a/dev/Experiments/Query/QUERY-BUILDER-DESIGN.md b/dev/Experiments/Query/QUERY-BUILDER-DESIGN.md deleted file mode 100644 index f0b4fe1..0000000 --- a/dev/Experiments/Query/QUERY-BUILDER-DESIGN.md +++ /dev/null @@ -1,461 +0,0 @@ -# Foundation query builder: design - -Revised September 25, 2026 after review. Accepted design for the bounded first implementation in `src/Database/Query`. It replaces the native Doctrine `QueryBuilder` currently returned by `Table::query()` with a Laravel-shaped Foundation builder over Foundation's existing managed connection. - -The method reference records future API candidates inspired by Laravel and `stellarwp/db`, using familiar names where their semantics fit. It is not a promise of full parity or a release checklist. The bounded first slice below is the current implementation scope; review complete classes before expanding it. - -## Summary - -**Architecture.** The builder collects query intent, collects validated conditional fragments, and compiles SQL with ordered positional bindings. Execution goes through the injected Doctrine `Connection`, so every query runs inside Foundation's connection integration: the borrowed WordPress mysqli session, managed transactions, terminal-failure tracking, and commit acknowledgement. Foundation owns those guarantees, built on DBAL; the builder must preserve them, not re-implement them. - -Compiling SQL ourselves rather than driving Doctrine's `QueryBuilder` is a hypothesis for the slice to test. The motivation is that several required features have no Doctrine builder equivalent (multi-row insert, upsert, escaped `LIKE`, one condition model shared by every statement type), so part of the compiler is ours either way. - -| Component | Responsibility | -| --- | --- | -| `Query`, `WhereGroup`, `JoinClause` | Collect valid query intent; reject invalid combinations at the call | -| `WhereGroup` | Share conditional grammar across query filters, groups, and join-value predicates | -| `Compiler` | Produce SQL and ordered bindings from query state | -| Dialect strategy | Handle demonstrated MySQL and MariaDB syntax differences | -| `Executor` | Execute through the injected managed connection | - -**DBAL compatibility research.** Foundation 2.0 requires DBAL 4.4 on PHP 8.3. DBAL 3 support remains deferred until the Rector downgrade work. This comparison informs that later work; it does not establish support. Execution and fetching have the same call shapes in DBAL 3.10 and 4.4. The differences stay in `Executor` and the dialect: - -| API | DBAL 3.10 | DBAL 4.4 | Handling | -| --- | --- | --- | --- | -| `executeQuery`, `executeStatement` | Same parameters | Same parameters | Native affected rows stay `int\|string`; bounded insert/upsert totals are `int` | -| `fetchAllAssociative`, `fetchAssociative`, `fetchOne`, `fetchFirstColumn` | Same | Same, natively typed | None | -| `ParameterType` | Class of `int` constants | Enum | Same source syntax works on both; the type appears only in PHPDoc | -| `ArrayParameterType` | Class of `int` constants | Enum | Not used; the compiler expands `IN (?, ?, ?)` itself | -| `lastInsertId()` | Optional name; may return `false` | No argument; throws when no identity exists | Wrapped once in `Executor` | -| `quoteSingleIdentifier()`, `escapeStringForLike()` | Present | Present | None | -| MariaDB 10.5.2+ platform | `MariaDb1052Platform` | `MariaDB1052Platform` | Case-insensitive class names; checks live in the dialect strategy | - -The fluent query surface primarily uses Foundation types and native PHP values. Explicit Doctrine type conversions use the shared connection; table conveniences delegate to the fluent query implementation. Exception names are shared across both researched majors. Proving the PHP 7.4 release requires running the Rector-transformed code on PHP 7.4, not only DBAL 3 on PHP 8. - -**Packaging.** The builder belongs in `foundation-database`. `Table::query()` returns it, it adds no dependency, and it cannot run without Database's connection. A later model or ORM layer, with its own lifecycle and scope, would justify `foundation-models` on top. - -## Consumer code - -The portal's ordinary candidate lookup and atomic replacement keep their existing schema and business behavior. The repository still receives its concrete `Entries_Table` and the shared Doctrine `Connection` through its constructor. - -```php -/** - * @param list $tokens - * @return list> - */ -public function find_candidates( array $tokens, int $limit = 800 ): array { - if ( $tokens === [] ) { - return []; - } - - return $this->entries->query() - ->select( 'ent_num', 'name', 'name_normalized', 'sdn_type', 'program', 'source' ) - ->whereContainsAny( 'name_normalized', $tokens ) - ->limit( $limit ) - ->get(); -} - -/** - * Replace validated import rows without changing truncation or transaction behavior. - * - * @param list> $rows - */ -public function replace( array $rows ): void { - $this->db->transactional( function () use ( $rows ): void { - $this->entries->deleteAll(); - - foreach ( array_chunk( $rows, 500 ) as $chunk ) { - $values = []; - - foreach ( $chunk as $row ) { - $values[] = [ - 'ent_num' => $row['ent_num'], - 'name' => mb_substr( $row['name'], 0, 350 ), - 'name_normalized' => mb_substr( $row['name_normalized'], 0, 191 ), - 'sdn_type' => mb_substr( $row['sdn_type'], 0, 30 ), - 'program' => mb_substr( $row['program'], 0, 255 ), - 'source' => $row['source'], - ]; - } - - $this->entries->query()->insert( $values ); - } - } ); -} -``` - -The deployed entries table has no `updated_at` column and no unique key on `ent_num`. This example does not introduce either. Upsert is a separate query capability for tables with appropriate existing primary or unique keys, not a replacement for this import. - -## Package layout - -The bounded implementation belongs in `src/Database/Query`. `Database` lives in that namespace, and `TableReference` lives under `Query\ValueObjects`. `Table::query()` returns a fresh `Query`. `Table::insert()` and `insertGetId()` delegate to the same query operations. `insert()` accepts one row or many, returns an integer count, and treats empty input as no work. `insertGetId()` accepts exactly one row, inserts database defaults for empty data, and preserves its generated ID as `int|string`. Explicit Doctrine insert types remain available through the shared connection. Upsert belongs on the query. - -The consumer surface comprises `Database`, `Query`, `WhereGroup`, `JoinClause`, `TableReference`, and existing `Table` methods. SQL compilation, naming helpers, execution adapters, and compiled values are implementation details marked `@internal`. - -## Representative implementation shape - -An injected `StellarWP\Foundation\Database\Query\Database` resolves `table( Table|string|TableReference $table, ?string $alias = null )` into a fresh query. A table object provides the same query through `query( ?string $alias = null )`. Name resolution captures the current scope before execution; the shared connection still owns transactions and terminal failures. - -`Query` owns mutable query intent for one consumer operation. Terminal operations compile without changing it, so `count()` followed by `get()` observes the same conditions and row shape. They call the injected Doctrine connection's ordinary query or statement execution methods and preserve native execution exceptions. - -`WhereGroup` owns the conditional grammar: two- and three-argument comparisons, associative equalities, nested closures, IN, NULL, and escaped substring predicates. It validates operators before handling null, compiles nested groups to immutable `Statement` fragments, and preserves binding order. A nested group's later mutation cannot alter an already captured statement. `JoinClause` adds column-to-column ON conditions and reuses that same value-condition grammar. - -This bounded design needs neither a separate class per predicate nor an independently replaceable execution service merely to forward a Doctrine method. Extract collaborators only when they own naming, compilation, dialect policy, or another demonstrated responsibility. Future predicates can extend the grammar without requiring a condition-node hierarchy now. - -## Composition rules - -Terminal operations follow one policy based on whether the query shapes its result rows. Nothing a caller supplied is silently dropped. - -**Row-shaping state** includes `distinct()`, `groupBy()`, `having()`, `limit()`, and `offset()`. Treat every `selectRaw()` projection as row-shaping too: opaque SQL may contain an aggregate or another expression that changes cardinality. Preserve it without parsing SQL. Calling `select()` replaces the complete projection and clears that raw-projection state. Future union support would also be row-shaping. Ordering is row-shaping only in combination with `limit()` or `offset()`, because it then decides which rows are kept. - -### Reads that return rows - -`get()` and `first()` run the query as written. `value()` and `pluck()` are future candidates. `first()` sets `LIMIT 1` on the outermost query and keeps any offset. - -### Aggregates and existence - -1. **Without row-shaping state or raw projections**, the aggregate runs directly against the query's tables, joins, and conditions. The selection is replaced by the aggregate, and ordering is omitted because it cannot change the result. - - ```sql - SELECT COUNT(*) FROM `wp_entries` WHERE `source` = ? - ``` - -2. **With row-shaping state**, the complete original query becomes a derived table, and the aggregate runs over it. Selection, ordering, limit, offset, grouping, having, and unions are all kept inside. - - ```php - $query->select( 'amount as payment' )->orderBy( 'payment', 'desc' )->limit( 10 )->max( 'payment' ); - ``` - - ```sql - SELECT MAX(`payment`) FROM ( - SELECT `amount` AS `payment` FROM `wp_payments` ORDER BY `payment` DESC LIMIT 10 - ) AS `aggregate` - ``` - - Preserve every explicit projection, including on a query shaped only by limit or offset. An `orderBy()` may depend on its selected alias. An implicit projection can be reduced to the aggregate's required column, or `1` for count, only when doing so preserves the query's meaning. - - With `distinct()`, `groupBy()`, `having()`, or `selectRaw()`, the projection defines the rows. A column aggregate must refer to an output column or alias of the derived table. Structured selections can be checked where unambiguous; raw SQL remains opaque, and the database reports a missing output column. Do not implement a SQL parser to infer raw expression aliases. A shaped join needs an explicit projection to avoid duplicate derived-table column names. - -3. **`exists()`** wraps the original query unchanged: `SELECT EXISTS( )`. Without row-shaping state or raw projections, the inner selection is reduced to `1`. In particular, `selectRaw( 'COUNT(*) AS total' )->exists()` is true even for an empty source table: the selected aggregate produces one result row. Replacing that projection with `1` would incorrectly return false. With any row-shaping state, the selection stays, so a `having()` that refers to a selected alias still works: - - ```php - $query->selectRaw( 'program, COUNT(*) AS total' ) - ->groupBy( 'program' ) - ->having( 'total', '>', 1 ) - ->exists(); - ``` - - ```sql - SELECT EXISTS( - SELECT program, COUNT(*) AS total FROM `wp_entries` GROUP BY `program` HAVING `total` > ? - ) - ``` - -This deliberately differs from Laravel, whose aggregate keeps `LIMIT` on the outer query, so `count()` ignores it and returns only the first group's count after `groupBy()`. - -### Writes - -`update()` and `delete()` compile to single-table statements. They keep conditions, ordering, and limit, which MySQL supports on single-table writes. They throw when the query has joins, an explicit selection, `distinct()`, `groupBy()`, `having()`, `union()`, or `offset()`, because no single-table write can honor them. - -Insert and upsert reject accumulated conditions, explicit projections, joins, distinct, grouping, having, ordering, limits, and offsets, including for empty input. They must not silently discard query intent. Begin a fresh query for these operations. - -## Semantics - -Implemented behavior becomes a 2.x compatibility commitment at release. Methods outside the first slice remain design candidates. - -| Call | Behavior | -| --- | --- | -| `where( 'col', $value )` | Equality | -| `where( 'col', $operator, $value )` | Operator from a closed list: `=`, `!=`, `<>`, `<`, `<=`, `>`, `>=`. Anything else throws at the call | -| `where( 'col', null )` or `'='`, `null` | `IS NULL` | -| `where( 'col', '!=', null )` or `'<>'` | `IS NOT NULL` | -| `where( 'col', '>', null )` and other ordering operators | Throws, naming `whereNull()` and `whereNotNull()` | -| `whereIn( 'col', [] )` | Matches nothing; `whereNotIn( 'col', [] )` matches everything | -| `whereContains`, `whereStartsWith`, `whereEndsWith`, `whereContainsAny`, `whereContainsAll` | Needle wildcards escaped; an empty needle list matches nothing for `Any` and everything for `All` | -| `whereLike()` | Laravel semantics: the value is a pattern, and escaping it is the caller's responsibility | -| Identifiers | Column and table arguments accept `name`, `qualifier.name`, `qualifier.*`, and `name as alias`, where each part matches `[A-Za-z_][A-Za-z0-9_]*`. Every part is quoted. Anything else throws, so a request-supplied sort column cannot inject SQL | -| `bool` values | Bound as integers. A PHP `false` bound as a string becomes `''`, which strict MySQL rejects for integer columns | -| `DateTimeInterface` values | Formatted as `Y-m-d H:i:s.u`. A column with less precision than the value adjusts it: MySQL rounds unless `TIME_TRUNCATE_FRACTIONAL` is set, so a `DATETIME(0)` column may round up a second, while MariaDB truncates | -| Decimals | Pass decimal strings. Floats are bound through PHP's float-to-string conversion, which cannot represent every decimal exactly; the docs say to use strings for money and other exact values | -| `null` values | SQL `NULL` | -| `limit()`, `offset()` | Validated non-negative integers rendered as literals | -| Affected-row counts | `int\|string`, as DBAL and the current `Table` return | -| `update()`, `delete()` without conditions | Throw. This guards against an omitted filter; it does not prove a write is bounded, because `whereNotIn( 'id', [] )` explicitly matches everything. Full-table deletes use `Table::deleteAll()`; `truncate()` remains separate because it resets identity and is not transactional | -| `insert( [] )`, `upsert( [] )` | Return 0 without querying | -| Multi-row `insert()` and `upsert()` | Every row must have the same column set, in any order; columns are normalized to one order, and a mismatch throws before any SQL | -| Large multi-row writes | Split into statements under MySQL's 65,535-placeholder limit. Each statement is atomic; the set is not. A failure in a later statement leaves earlier ones committed unless the caller wraps the write in `transactional()`, and the docs say so beside the method | -| `upsert()` | See below | - -### Upsert - -`upsert()` compiles to one `INSERT ... ON DUPLICATE KEY UPDATE` per statement and follows native MySQL and MariaDB semantics: a row conflicts when it matches any existing primary or unique key, and the database, not the caller, chooses which. With a primary key `id` and a unique `email`, a row whose `id` already exists updates that row even if its `email` is new. - -Laravel 13's signature is `upsert( $values, $uniqueBy, $update = null )`. Its MySQL grammar never reads `$uniqueBy`, yet Laravel 13 throws when it is empty; with `$update` omitted it updates every column, including the unique ones. - -Accepted Foundation signature: `upsert( array $values, array $update )`. There is no conflict-target parameter, because none of the supported engines honors one, and `$update` is required, because updating every column including keys is rarely intended. This is a deliberate break from Laravel parity (decision 3). A true conflict-target guarantee, such as checking the target key before writing, would be a separate design, not a validation added here. - -`VALUES( col )` in the update clause works on MySQL 5.7 through 8.4 and every supported MariaDB version; MySQL 8.0.20+ deprecates it in favor of row aliases, which MariaDB does not support. That difference is the first concrete case for the dialect strategy. - -## Naming boundary - -Joins and the `Database` entry point make WordPress core tables ordinary targets. The builder distinguishes three kinds of name, chosen by how the name enters, never inferred from the string: - -| Kind | How it enters | Resolution | -| --- | --- | --- | -| Application table | A `Table` object, or an unprefixed name such as `'your_plugin_orders as o'` | `TableNameResolver` with the current `DatabaseScope`, exactly as today | -| WordPress core table | A `TableReference` from `$database->wordpress( 'users' )` | WordPress's own `$wpdb` table properties, which apply the site or network prefix and honor `CUSTOM_USER_TABLE` and `CUSTOM_USER_META_TABLE` | -| Resolved physical name | Carried inside a `TableReference` | Validated and quoted, never re-prefixed | - -`wordpress()` returns a `TableReference`: an immutable value holding the resolved physical name, with `as( string $alias )` returning a copy carrying an alias. It is not a query. It resolves when called, against the current site, which matches the existing rule that built queries keep the names resolved when they were built. Unknown core names throw. - -`Database::table()` accepts an application `Table`, an unprefixed application name, or a `TableReference`. `join()` also accepts a `Table`, an unprefixed application name, or a `TableReference`. Pass a table object directly; its `name()` is already physical and must not be passed back as an unprefixed string. - -Querying a core table: - -```php -$drafts = $this->database->table( $this->database->wordpress( 'posts' ) ) - ->where( 'post_status', 'draft' ) - ->count(); -``` - -Joining application and core tables: - -```php -$orders = $this->database->table( 'your_plugin_orders as o' ) - ->join( $this->database->wordpress( 'users' )->as( 'u' ), 'u.ID', '=', 'o.user_id' ) - ->leftJoin( 'your_plugin_refunds as r', static fn ( JoinClause $join ) => $join - ->on( 'r.order_id', '=', 'o.id' ) - ->where( 'r.status', 'approved' ) ) - ->select( 'o.id', 'o.total', 'u.user_email', 'r.amount as refunded' ) - ->where( 'o.status', 'paid' ) - ->orderBy( 'o.created_at', 'desc' ) - ->get(); -``` - -On a multisite subsite, `wordpress( 'users' )` resolves to the network's shared users table, or to `CUSTOM_USER_TABLE` when defined, while the two application tables resolve with the subsite's prefix. - -## Method reference - -This comparison is a design backlog, not the implemented API or a promise of full Laravel compatibility. Only the first-slice table below is authorized for the current implementation. Laravel's names and argument order inform candidate APIs; Foundation's documented semantics govern its own methods. `stellarwp/db` contributes vocabulary only; none of its implementation is reused. - -Status meanings: - -- **Core**: candidate for later expansion; only the first-slice table identifies current scope. -- **Later**: deferred candidate requiring its own design and verification. -- **Excluded**: deliberately not offered, with the reason. - -### Entry points - -| Laravel | `stellarwp/db` | Foundation | Status | -| --- | --- | --- | --- | -| `DB::table( 'posts' )` | `DB::table( 'posts', 'alias' )` | `$database->table( 'your_plugin_reports as r' )` for application tables; `$database->table( $database->wordpress( 'posts' ) )` for core tables | Core | -| Model or repository query | none | `$table->query( 'alias' )` | Core, existing | -| `from()` | `from( $table, $alias )` | `from()` | Core | -| `fromSub()`, `fromRaw()` | none | Same names | Later | - -`Database` is an ordinary injected object, not a static facade. - -### Selecting - -| Laravel | `stellarwp/db` | Status | -| --- | --- | --- | -| `select()`, `addSelect()`, `distinct()` | `select()`, `distinct()` | Core | -| `selectRaw( $sql, $bindings )` | `selectRaw( $sql, ...$args )` | Core, raw policy | -| `selectSub( $query, $as )` | none | Later | - -### Conditions - -Every method has Laravel's `or` twin. - -| Laravel | `stellarwp/db` | Status | -| --- | --- | --- | -| `where()` with a value, an operator and value, a Closure, or an array | `where( $column, $value, $operator )` with the operator last | Core, Laravel's order | -| `whereNot( Closure )` | none | Core | -| `whereIn()`, `whereNotIn()` | same | Core; subquery form Later | -| `whereNull()`, `whereNotNull()` | `whereIsNull()`, `whereIsNotNull()` | Core, Laravel names | -| `whereBetween( $column, $bounds )`, `whereNotBetween()` | min and max as separate arguments | Core, Laravel's array form | -| `whereBetweenColumns()`, `whereNotBetweenColumns()`, `whereColumn()` | none | Core | -| `whereLike( $column, $pattern, $caseSensitive = false )`, `whereNotLike()` | `whereLike()`, which wraps the value in `%` unless it contains one | Core, Laravel semantics | -| none | none | Foundation additions, Core: `whereContains()`, `whereStartsWith()`, `whereEndsWith()`, `whereContainsAny()`, `whereContainsAll()`, escaped | -| `whereAny()`, `whereAll()`, `whereNone()` | none | Core | -| `whereExists()`, `whereNotExists()` | same | Core | -| `whereRaw( $sql, $bindings )` | `whereRaw( $sql, ...$args )` | Core, raw policy | -| `whereDate()`, `whereYear()`, `whereMonth()`, `whereDay()`, `whereTime()` | none | Core | -| `whereBinary()`, `whereNotBinary()` | none | Later. Laravel 13; byte-exact comparison regardless of collation | -| `whereValueBetween()`, `whereNullSafeEquals()`, `whereRowValues()` | none | Later | -| `whereJsonContains()` and the JSON family | none | Later. MariaDB stores JSON as `LONGTEXT`; needs per-engine tests | -| `whereFullText()` | none | Later. Requires a full-text index | -| `wherePast()`, `whereToday()`, and the relative-date family | none | Excluded for now. Depends on the WordPress site timezone versus UTC storage | -| `whereIntegerInRaw()` | none | Excluded. A Laravel performance workaround | -| `whereVector...()` | none | Excluded. PostgreSQL only | -| Dynamic `whereEmail()` | none | Excluded. Defeats autocompletion | - -### Joins - -| Laravel | `stellarwp/db` | Status | -| --- | --- | --- | -| `join( 'posts as p', 'p.ID', '=', 'o.post_id' )` | `innerJoin( $table, $column1, $column2, $alias )` | Core, Laravel's shape | -| `leftJoin()`, `rightJoin()`, `crossJoin()` | `leftJoin()`, `rightJoin()` | Core | -| `join( $table, Closure )` with `on()`, `orOn()`, `where()` | `join( Closure )` | Core | -| `joinWhere()`, `leftJoinWhere()`, `rightJoinWhere()` | none | Core | -| `straightJoin()`, `straightJoinWhere()`, `straightJoinSub()` | none | Later. Laravel 13; MySQL optimizer join-order hint | -| `joinSub()`, `leftJoinSub()`, `rightJoinSub()`, `crossJoinSub()` | none | Later. Derived-table reports | -| none | `joinRaw( $sql, ...$args )` | Later, raw policy | -| `joinLateral()` | none | Excluded. MySQL 8.0.14+ only | - -### WordPress meta - -| Laravel | `stellarwp/db` | Status | -| --- | --- | --- | -| none | `attachMeta()`, `configureMetaTable()` | Later. Pivot meta keys into columns through joins, with a freshly designed API | - -### Grouping - -| Laravel | `stellarwp/db` | Status | -| --- | --- | --- | -| `groupBy()`, `groupByRaw()` | `groupBy()` | Core; raw form under the raw policy | -| `having()`, `havingNull()`, `havingNotNull()`, `havingBetween()`, `havingNotBetween()` | `having()` | Core | -| `havingRaw( $sql, $bindings )` | `havingRaw()` | Core, raw policy | -| none | `havingCount()`, `havingSum()`, `havingAvg()`, `havingMin()`, `havingMax()` | Excluded. Written as `selectRaw()` plus `having()` | -| `groupLimit()` | none | Excluded. Needs window functions, absent in MySQL 5.7 | - -### Ordering and paging - -| Laravel | `stellarwp/db` | Status | -| --- | --- | --- | -| `orderBy()`, `orderByDesc()`, `latest()`, `oldest()`, `reorder()`, `reorderDesc()`, `inRandomOrder()` | `orderBy()` | Core | -| `orderByRaw()` | none | Core, raw policy | -| `limit()`, `take()`, `offset()`, `skip()`, `forPage()`, `forPageAfterId()`, `forPageBeforeId()` | `limit()`, `offset()` | Core | -| `useIndex()`, `forceIndex()`, `ignoreIndex()` | none | Later | -| `orderByVectorDistance()` | none | Excluded | - -### Unions - -| Laravel | `stellarwp/db` | Status | -| --- | --- | --- | -| `union()`, `unionAll()` | same | Core | - -### Reading results - -| Laravel | `stellarwp/db` | Status | -| --- | --- | --- | -| `get()`, all rows | `getAll()`; its `get()` returns one row | Core, Laravel meaning | -| `first()`, `firstOrFail()`, `sole()`, `find( $id )` | `get()` | Core | -| `value()`, `soleValue()`, `pluck()`, `implode()` | none | Core | -| `exists()`, `doesntExist()`, `existsOr()`, `doesntExistOr()` | none | Core | -| `count()`, `min()`, `max()`, `sum()`, `avg()`, `average()`, `aggregate()` | same five aggregates | Core, with Foundation's composition rules | -| `chunk()`, `chunkById()`, `chunkByIdDesc()`, `each()`, `eachById()` | none | Core | -| `lazy()`, `lazyById()`, `lazyByIdDesc()` | none | Core. Each issues bounded, separate queries | -| `cursor()` | none | Excluded. DBAL's mysqli driver buffers every result with `store_result()`, so a single-query generator saves no memory; `lazyById()` is the bounded alternative | -| `paginate()`, `simplePaginate()`, `cursorPaginate()` | none | Excluded. Laravel's paginators depend on its request and URL generation | -| `fetchUsing()` | none | Excluded. Laravel 13; PDO fetch modes | - -### Writing - -| Laravel | `stellarwp/db` | Status | -| --- | --- | --- | -| `insert( $row )` or `insert( $rows )` | `insert()` through `wpdb::insert()` | Core, Laravel's forms, returning affected rows | -| `insertGetId()`, `insertOrIgnore()`, `insertUsing()`, `insertOrIgnoreUsing()` | none | Core | -| `upsert()` | `upsert( $data, $match )`, a read then a write | Core, Foundation signature; see Upsert | -| `update()`, `increment()`, `decrement()`, `incrementEach()`, `decrementEach()` | `update()` through `wpdb::update()` | Core | -| `delete()` | `delete()` | Core | -| Laravel has none | none | `Table::deleteAll()`, existing: the explicit, transactional full-table delete | -| `truncate()` | none | Core, existing on `Table` | -| `insertOrIgnoreReturning()` | none | Excluded. Laravel 13; MySQL has no `RETURNING` | -| `updateOrInsert()` | none | Excluded. A non-atomic read then write | -| `updateFrom()` | none | Excluded. PostgreSQL only | - -### Locking - -| Laravel | `stellarwp/db` | Status | -| --- | --- | --- | -| `lockForUpdate()`, `sharedLock()`, `lock()` | none | Later. Meaningful only inside a managed transaction | - -### Composition and inspection - -| Laravel | `stellarwp/db` | Status | -| --- | --- | --- | -| `when()`, `unless()`, `tap()`, `pipe()` | none | Core | -| `clone()`, `cloneWithout()`, `newQuery()` | none | Core | -| `toSql()`, `toRawSql()`, `getBindings()` | `getSQL()` | Core; `toRawSql()` for debugging only | -| `dump()`, `dd()`, `dumpRawSql()`, `ddRawSql()` | none | Excluded. Log `toRawSql()` through the application's logger | -| `raw()` expressions, `macro()`, `beforeQuery()`, `afterQuery()`, `timeout()`, `useWritePdo()` | none | Excluded | - -### Raw SQL policy - -The first slice implements `selectRaw()` only. The future candidates are `whereRaw()`, `havingRaw()`, `orderByRaw()`, and `groupByRaw()`. They keep Laravel's `( string $sql, array $bindings = [] )` signature, every value goes through `$bindings`, and the docs present them after the safe alternatives. Laravel's `DB::raw()` expression objects stay excluded. - -## Differences from Laravel - -- Results are plain PHP arrays, not `Collection` objects, keeping `illuminate/support` and its global helpers out of public plugins. -- Identifiers are validated and always quoted. -- Aggregates and `exists()` follow the composition rules above: queries with row-shaping state are wrapped whole, rather than aggregated in the outer query as Laravel does. -- `upsert()` has no conflict-target argument and requires the update column list. -- `update()` and `delete()` without conditions throw; `deleteAll()` is the explicit full-table delete. -- `cursor()` is excluded because the underlying driver buffers results. - -## Migrating from `stellarwp/db` - -These calls keep a familiar name but change meaning: - -| `stellarwp/db` call | Meaning there | Foundation equivalent | -| --- | --- | --- | -| `get()` | First row | `first()` | -| `getAll()` | All rows | `get()` | -| `whereLike( 'col', 'term' )` | Contains `term` | `whereContains( 'col', 'term' )` | -| `where( 'col', $value, '>' )` | Operator last | `where( 'col', '>', $value )` | -| `leftJoin( $table, $a, $b, $alias )` | Implicit `=` | `leftJoin( "{$table} as {$alias}", $a, '=', $b )` | -| `whereIsNull( 'col' )` | | `whereNull( 'col' )` | -| `havingCount( 'id', '>', 1 )` | | `selectRaw( 'COUNT(id) as total' )->having( 'total', '>', 1 )` | -| `upsert( $row, $match )` | Read, then write by `$match` | `upsert( $rows, $update )`, which matches by the table's own keys | -| `DB::table( 'posts' )` | Static facade, site prefix | `$this->database->table( $this->database->wordpress( 'posts' ) )` | - -## Decisions before the first slice - -Accepted decisions for the first slice; no further confirmation is required. - -1. **Row shape: associative arrays.** They match DBAL and the current `Table`, need no conversion, and give array-shape docblocks something to describe. The `stellarwp/db` replacement needs a migration guide regardless, because `get()` changes meaning, so object rows would not remove that work. -2. **Write counts:** `insert()` and `upsert()` return integer totals rather than Laravel's insertion `bool`. Update/delete counts retain `int|string`. Generated IDs from `insertGetId()` retain `int|string` independently of affected-row counts. -3. **Upsert: `upsert( $rows, $update )`.** No conflict-target argument, because no supported engine honors one; the update list is required. -4. **Core tables: `$database->wordpress( $name )` returning a `TableReference`**, accepted by `table()` and `join()`. -5. **Chunked writes: split automatically**, with the caller-owned transaction boundary documented beside `insert()` and `upsert()`. - -## Scope - -The first slice is the current delivery scope. The broader method reference is a backlog, not a required parity project. Excluded methods remain excluded unless a demonstrated requirement changes the decision. - -## First slice - -The slice is small enough to review as complete classes, and wide enough to exercise every architectural boundary: - -| Area | Methods | -| --- | --- | -| Entry | `$database->table()` with an application `Table`, an unprefixed name, and a `TableReference`; `wordpress()` for `posts` and `users` | -| Filtering | `where()` in all forms, nested closures, `orWhere()`, `whereIn()`, `whereNull()`, `whereContainsAny()` | -| Joins | `join()` against a core table, and `leftJoin()` with a closure `ON` clause | -| Shaping | `select()`, `selectRaw()` with bindings, `distinct()`, `groupBy()`, `having()`, `orderBy()`, `limit()`, `offset()` | -| Reading | `get()`, `first()`, `count()`, `max()`, `exists()` | -| Inspection | `toSql()`, `getBindings()` | -| Writing | single-row and multi-row `insert()`, single-row `insertGetId()`, `update()`, `delete()`, `upsert()` | - -Unions, subqueries, chunking methods, and the rest of the method reference wait for expansion after review. - -Implement this slice directly in `src/Database/Query` for unreleased Foundation 2.0 and return it from `Table::query()`. The design artifact remains under `dev/Experiments/Query`; it is not the runtime package location. Update existing consumers and compatibility tests directly, without an adapter preserving the abandoned native-builder return type. - -Verification: - -1. Port the portal's `Entry_Repository` as the consumer fixture, including its transactional replacement. -2. Unit-test the compiler's SQL and binding order, using Laravel 13's query-builder tests as the reference for grouping and null handling, and the composition rules as the specification where Foundation differs. -3. Test the composition combinations the slice implements: aggregates and `exists()` on a plain query, on a join, with `orderBy()` plus `limit()`, with `offset()`, with `distinct()`, and with `groupBy()` plus a `having()` on a selected alias. Include `max()` over an ordered, limited query with an explicit selected alias, an ungrouped raw aggregate over an empty source, resetting raw projection state through `select()`, and an unavailable aggregate output column. SQL reports missing outputs from opaque raw projections. -4. Integration-test on MariaDB and MySQL: escaped needles containing `%`, `_`, and `!`; empty lists; boolean binding under strict mode; fractional-second dates; a multi-row insert that fails in a later chunk, inside and outside a transaction; an upsert conflicting on the primary key while naming a new unique value; and a subsite join against `users`, including a `CUSTOM_USER_TABLE` install. -5. Test the architecture hypothesis: implement `get()` with conditions and one join a second time over Doctrine's `QueryBuilder`, and compare the two in the review. -6. Review the complete classes and repository code before expanding toward the method reference. - -The PHP 7.4 lane is deferred until the separate Rector downgrade work; it is not a Foundation 2.0 release requirement. - -## Changes this requires elsewhere - -- **AGENTS.md Database section:** establish the Foundation builder as the ordinary query API and retain Doctrine for the shared connection, execution, schema tools, and advanced native operations. -- **Table surface:** `query()` changes its return type while keeping its alias argument, table insertion delegates to the fluent implementation with the same single-row/bulk semantics and value normalization. Both entry points expose `insertGetId()` for exactly one insertion, including defaults-only rows; explicit insert types use the native connection. `upsert()` lives on `Query`. `count()`, `deleteAll()`, and `truncate()` keep their current meaning. All of this lands before the 2.0 tag. -- **Docs:** `components/database/query-builder.mdx` rewritten around the new API. -- **Migrations:** unaffected; it uses the connection and schema tools directly. From dc48986251fc60086b346f59073c115f8c33d75f Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 25 Sep 2026 17:04:52 -0600 Subject: [PATCH 33/53] Fix MySQL 5.7 LIMIT 0 inside of EXISTS bug --- src/Database/Query/Compiler.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Database/Query/Compiler.php b/src/Database/Query/Compiler.php index 453588b..2ee90e1 100644 --- a/src/Database/Query/Compiler.php +++ b/src/Database/Query/Compiler.php @@ -121,6 +121,11 @@ public function aggregate(QueryState $state, string $function, string $column): * Preserve shaped selections because HAVING may depend on their aliases. */ public function exists(QueryState $state): Fragment { + // MySQL 5.7 ignores LIMIT 0 inside EXISTS, and no rows can match it anyway. + if ($state->limit === 0) { + return new Fragment('SELECT 0'); + } + $inner = $this->select($state, $this->shaped($state) ? null : [ new Selection(new Fragment('1')), ], ordered: $this->shaped($state)); From 0c11e0d82a99001e062c206f3064e1365e837e33 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 28 Sep 2026 13:26:32 -0600 Subject: [PATCH 34/53] Allow statement recovery after confirmed savepoint rollback --- AGENTS.md | 2 +- .../Connection/WordPressConnection.php | 23 +- src/Database/Connection/WordPressSession.php | 48 +- .../components/database/query-builder.mdx | 53 +- .../integration/Database/TransactionTest.php | 553 +++++++++++++++++- .../Migrations/AdvisoryMigrationTest.php | 24 + 6 files changed, 688 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 204a13d..85d95d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,7 @@ Application table classes extend `StellarWP\Foundation\Database\Table\Table`. In Every table implementation returns a stable unprefixed name from `unprefixedName()`. Resolve and validate physical names through `TableNameResolver` at the point of use, applying the current `DatabaseScope`; application tables and repositories must not repeat WordPress prefix handling or physical-name validation. Application query entry points ordinarily use table objects. The injected `Database\Query\Database` also accepts unprefixed application names and resolved `Query\ValueObjects\TableReference` values, and resolves WordPress core table references through `wordpress()`. Migration schema entry points accept historical unprefixed strings, resolved and validated by the same naming policy; table objects remain accepted for deliberate programmatic use. Native Doctrine and raw SQL use their resolved, quoted names. Generated table classes inherit the base class's infrastructure constructor and declare only application concerns. Keep historical schema definitions in migrations, independent of runtime table classes. -Providers supply one shared managed Doctrine connection per container so tables, repositories, and transaction-owning services participate in the same session. Keep WordPress's native mysqli access inside the connection integration; route Foundation database execution through Doctrine rather than `wpdb::query()` or `dbDelta()`. Applications choose the transaction boundary; Foundation owns cleanup, terminal failure tracking, and commit acknowledgement. Never replay part of a failed transaction. A caught database execution failure must prevent successful completion, an escaping business exception must survive cleanup failure, and an uncertain commit must be reported distinctly. Nested success remains provisional until the outer transaction commits. A replacement connection must satisfy these documented guarantees, not merely extend Doctrine's connection class. +Providers supply one shared managed Doctrine connection per container so tables, repositories, and transaction-owning services participate in the same session. Keep WordPress's native mysqli access inside the connection integration; route Foundation database execution through Doctrine rather than `wpdb::query()` or `dbDelta()`. Applications choose the transaction boundary; Foundation owns cleanup, terminal failure tracking, and commit acknowledgement. Never replay part of a failed transaction. An unrecovered database execution failure must prevent successful completion. A statement failure may be recovered only after a nested savepoint rollback is confirmed on the original session; catching it inside the callback must not turn that callback into a successful operation. Connection loss, ownership changes, transaction-wide failures, transaction-control failures, and failed cleanup remain terminal. An escaping business exception must survive cleanup failure, and an uncertain commit must be reported distinctly. Nested success remains provisional until the outer transaction commits. A replacement connection must satisfy these documented guarantees, not merely extend Doctrine's connection class. Use Doctrine schema objects and platform tools for schema declarations, inspection, comparison, and DDL generation. Plan the current migration against affected live tables, preserving unrelated schema. Do not replay applied migration declarations or reconcile historical schema drift. Preview carries simulated schema through pending operations. Keep the public blueprint API, explicit foreign keys and renames, and supported decimal/timestamp semantics. On failure, stop and leave the migration unrecorded; DDL may have committed, so developers must inspect and repair before retrying. Do not add automatic partial-migration recovery without a demonstrated need and a bounded design. diff --git a/src/Database/Connection/WordPressConnection.php b/src/Database/Connection/WordPressConnection.php index 0bca108..5490bfc 100644 --- a/src/Database/Connection/WordPressConnection.php +++ b/src/Database/Connection/WordPressConnection.php @@ -35,6 +35,7 @@ protected function connect(): DriverConnection { if ($this->borrowed !== null && $native !== $this->borrowed) { parent::close(); } + $driver = parent::connect(); $this->borrowed = $native; @@ -95,6 +96,8 @@ public function beginTransaction(): void { try { parent::beginTransaction(); } catch (Throwable $failure) { + $session->fail($failure); + if ($outer) { $session->rollback(); parent::close(); @@ -118,6 +121,8 @@ public function commit(): void { try { parent::commit(); } catch (Throwable $failure) { + $session->fail($failure); + if ($outer) { throw new CommitOutcomeUnknown('Commit was not confirmed; the operation may have completed. Do not retry blindly.', 0, $failure); } @@ -141,14 +146,18 @@ public function isTransactionActive(): bool { /** * Roll back the pinned session even when a terminal failure prevents further SQL. * - * @throws Throwable When explicit rollback fails without an earlier database failure. + * @throws Throwable When savepoint cleanup fails or explicit rollback cannot be confirmed. */ public function rollBack(): void { $session = $this->session(); if ($this->getTransactionNestingLevel() > 1 && $session->hasFailed()) { - // A terminal SQL failure invalidates the whole transaction. Preserve its - // failure latch until the outer operation acknowledges the rollback. + if ($session->recover(fn () => parent::rollBack())) { + return; + } + + // Unrecoverable failures invalidate the whole transaction. Retain the + // failure until the outer operation acknowledges the rollback. $session->rollback(); parent::close(); @@ -156,7 +165,13 @@ public function rollBack(): void { } if ($this->getTransactionNestingLevel() > 1 || ! $session->isActive()) { - parent::rollBack(); + try { + parent::rollBack(); + } catch (Throwable $failure) { + $session->fail($failure); + + throw $failure; + } return; } diff --git a/src/Database/Connection/WordPressSession.php b/src/Database/Connection/WordPressSession.php index 5eba56d..34c5dfa 100644 --- a/src/Database/Connection/WordPressSession.php +++ b/src/Database/Connection/WordPressSession.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Database\Connection; use Closure; +use Doctrine\DBAL\Driver\Exception; use Doctrine\DBAL\Driver\Mysqli\Connection; use mysqli; use StellarWP\Foundation\Database\Contracts\AdvisorySession; @@ -209,12 +210,57 @@ public function rollback(): bool { } /** - * Report whether cleanup is following a terminal infrastructure failure. + * Report whether cleanup is following a failed operation. */ public function hasFailed(): bool { return $this->failure !== null; } + /** + * Recover a statement failure only through a confirmed savepoint rollback. + * + * The connection supplies Doctrine's nested rollback, never application work. + * Ownership and transaction-control failures remain terminal. Cleanup must + * use the same site and session, and the original savepoint must still exist. + * + * @param Closure(): void $rollback + * + * @throws Throwable When ownership changed or savepoint cleanup failed. + */ + public function recover(Closure $rollback): bool { + if (! $this->failure instanceof Exception) { + return false; + } + + // Permit Doctrine's cleanup through the normal execution and ownership guards. + $this->failure = null; + + try { + $this->check(); + $rollback(); + $this->check(); + } catch (Throwable $failure) { + $this->fail($failure); + + throw $failure; + } + + return true; + } + + /** + * Retain a transaction-control failure that savepoint rollback cannot clear. + */ + public function fail(Throwable $failure): void { + $this->advisoryLock?->fail($failure); + + if (! $this->isActive()) { + return; + } + + $this->failure = new TransactionFailed('Transaction control failed; the transaction must be rolled back.', 0, $failure); + } + /** * End the invocation's ownership and terminal-failure state. */ diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index 56336cd..abe122d 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -291,23 +291,68 @@ final readonly class Create_Report { `transactional()` returns the callback's value only after commit is acknowledged. `false` and `null` are ordinary callback results, not rollback signals. Throw an exception to cancel work. The original escaping exception is preserved if cleanup also fails. -Nested transactions use savepoints. An inner success is provisional until the outer transaction commits. A database execution failure is terminal for the entire managed transaction, even if application code catches its exception. An inner business exception can be caught after its savepoint is rolled back. +Nested transactions use savepoints. An inner success is provisional until the outer transaction commits. An inner business exception can be caught after its savepoint is rolled back. A database statement failure prevents further work until Foundation confirms rollback to the original nested savepoint. The application can then catch the failure and decide whether to continue. Foundation does not select recoverable error codes or retry the work. + +### Recover from an expected duplicate + +Wrap an insert that may conflict in a nested `transactional()` call and catch `UniqueConstraintViolationException` outside that call. Foundation attempts to roll back the nested writes before returning control to the catch block. After successful rollback, earlier outer work remains provisional and the outer transaction can continue. If cleanup fails, the original exception still reaches the catch block, but further work and commit are rejected. Savepoint rollback does not necessarily release every lock acquired during the nested work. + +For a reports table with a unique `slug`, this operation creates a report or returns the existing report's ID: + +```php +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; + +return $this->connection->transactional( function () use ( $slug, $title ): int|string { + try { + return $this->connection->transactional( + fn (): int|string => $this->reports->insertGetId( [ + 'slug' => $slug, + 'title' => $title, + ] ), + ); + } catch ( UniqueConstraintViolationException $failure ) { + $id = $this->connection->fetchOne( + "SELECT id + FROM {$this->reports->quotedName()} + WHERE slug = ? + FOR UPDATE", + [ + $slug, + ], + ); + + if ( $id === false ) { + throw $failure; + } + + return $id; + } +} ); +``` + +Under `REPEATABLE READ`, an ordinary read can retain an earlier snapshot even after another transaction commits the conflicting row; rolling back a savepoint does not refresh that snapshot. `FOR UPDATE` requests a locking read. MariaDB with [`innodb_snapshot_isolation`](https://mariadb.com/docs/server/server-usage/storage-engines/innodb/innodb-system-variables#innodb_snapshot_isolation) enabled can reject an insert or locking read against a row outside that snapshot with error 1020 and roll back the entire transaction. Let that failure escape and handle a fresh operation at the application boundary; it is not a recoverable duplicate. + +The same boundary supports other statement failures when the original savepoint survives and rollback succeeds. A lock timeout, for example, can affect only the statement or the entire transaction depending on server configuration. Foundation confirms the rollback rather than assuming the error is recoverable. Catch only failures your application knows how to handle. + +Catching a database failure inside the nested callback and returning normally still raises `TransactionFailed`; the nested call cannot report success. Explicit nested `beginTransaction()` / `rollBack()` boundaries support the same recovery, but `transactional()` handles cleanup for you. + +Without a nested boundary, a database execution failure prevents the outer transaction from committing. Transaction-wide rollbacks, including InnoDB deadlocks, destroy the savepoints needed for recovery. Connection loss, detected scope changes, transaction-control failures, and failed savepoint cleanup remain terminal. Foundation does not retry statements. Operations running under an advisory lock, including migrations, retain their stricter failure behavior and must abort after an execution failure. ### Handle failures deliberately - Ordinary SQL failures use Doctrine exceptions, such as `Doctrine\DBAL\Exception\UniqueConstraintViolationException`. -- `TransactionFailed` means a caught database failure prevented successful completion. Start fresh work at an application boundary after cleanup. +- `TransactionFailed` means a caught failure prevented an operation from completing successfully. Let it escape so cleanup rolls back the operation. After an unrecovered or terminal failure, start fresh work at an application boundary. - `CommitOutcomeUnknown` means the server did not confirm commit. The data may already be committed. Inspect durable application state or use an idempotency key before retrying. - A detected site or connection change interrupts managed work. Foundation never replays failed SQL or reconnects in the middle of a transaction. The Foundation exceptions above live under `StellarWP\Foundation\Database\Exceptions` and extend `DatabaseException`. Native Doctrine SQL exceptions retain their own hierarchy. :::caution[Keep managed work on the shared connection] -Use transactional tables. Do not issue DDL, manual transaction-control SQL, direct WordPress queries, or `switch_to_blog()` inside a managed transaction. DDL can implicitly commit MySQL transactions, and writes through other sessions are outside this transaction. External HTTP calls cannot be rolled back with database writes. +Use transactional tables. Do not issue DDL, manual transaction-control SQL, direct savepoint commands, direct WordPress queries, or `switch_to_blog()` inside a managed transaction. DDL can implicitly commit MySQL transactions, and writes through other sessions are outside this transaction. External HTTP calls cannot be rolled back with database writes. ::: Between completed operations, WordPress may replace its mysqli connection. New queries and transactions use the replacement automatically. Prepared statements from the old connection are rejected; prepare them again for the new operation. ## Test application behavior -Unit-test application decisions against your own repository contracts when useful. Use integration tests with real InnoDB tables for transaction boundaries, SQL behavior, and recovery. Test that an observer on a second connection cannot see uncommitted rows, that a failed operation leaves prior rows intact, and that caught database failures cannot produce a successful result. +Unit-test application decisions against your own repository contracts when useful. Use integration tests with real InnoDB tables for transaction boundaries, SQL behavior, and recovery. Test that an observer on a second connection cannot see uncommitted rows, that a failed operation leaves prior rows intact, and that caught database failures cannot produce a successful result without the supported savepoint recovery. diff --git a/tests/integration/Database/TransactionTest.php b/tests/integration/Database/TransactionTest.php index 1c00f38..a3ba640 100644 --- a/tests/integration/Database/TransactionTest.php +++ b/tests/integration/Database/TransactionTest.php @@ -3,6 +3,9 @@ namespace StellarWP\Foundation\Tests\Integration\Database; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Exception\InvalidFieldNameException; +use Doctrine\DBAL\Exception\LockWaitTimeoutException; +use Doctrine\DBAL\Exception\NotNullConstraintViolationException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use RuntimeException; use StellarWP\Foundation\Database\Exceptions\CommitOutcomeUnknown; @@ -289,24 +292,552 @@ public function test_site_change_prevents_commit_and_restoration_does_not_erase_ } } - public function test_caught_nested_database_failure_still_aborts_outer_work(): void { - try { + public function test_nested_unknown_column_failure_rolls_back_only_nested_work(): void { + $this->db->transactional(function (): void { + $this->db->update($this->table, [ + 'name' => 'Outer', + ], [ + 'id' => 1, + ]); + + try { + $this->db->transactional(function (): void { + $this->db->insert($this->table, [ + 'id' => 2, + 'name' => 'Discarded', + ]); + $this->db->executeQuery('SELECT missing_column FROM ' . $this->table); + }); + $this->fail('The invalid column must escape the nested operation.'); + } catch (InvalidFieldNameException) { + $this->assertSame(1, $this->db->getTransactionNestingLevel()); + } + + $this->db->insert($this->table, [ + 'id' => 3, + 'name' => 'Continued', + ]); + $this->assertOriginal(); + }); + + $this->assertSame([ + 'Outer', + 'Continued', + ], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table . ' ORDER BY id')); + } + + public function test_nested_not_null_failure_rolls_back_only_nested_work(): void { + $this->db->transactional(function (): void { + $this->db->update($this->table, [ + 'name' => 'Outer', + ], [ + 'id' => 1, + ]); + + try { + $this->db->transactional(function (): void { + $this->db->insert($this->table, [ + 'id' => 2, + 'name' => 'Discarded', + ]); + $this->db->insert($this->table, [ + 'id' => 3, + 'name' => null, + ]); + }); + $this->fail('The null constraint violation must escape the nested operation.'); + } catch (NotNullConstraintViolationException) { + $this->assertSame(1, $this->db->getTransactionNestingLevel()); + } + + $this->db->insert($this->table, [ + 'id' => 4, + 'name' => 'Continued', + ]); + $this->assertOriginal(); + }); + + $this->assertSame([ + 'Outer', + 'Continued', + ], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table . ' ORDER BY id')); + } + + public function test_nested_duplicate_rolls_back_its_writes_and_outer_work_can_continue(): void { + $result = $this->db->transactional(function (): string { + $this->db->insert($this->table, [ + 'id' => 2, + 'name' => 'Before', + ]); + + try { + $this->db->transactional(function (): void { + $this->db->insert($this->table, [ + 'id' => 3, + 'name' => 'Discarded', + ]); + $this->db->insert($this->table, [ + 'id' => 1, + 'name' => 'Duplicate', + ]); + }); + $this->fail('The duplicate must escape the nested operation.'); + } catch (UniqueConstraintViolationException) { + $this->assertSame(1, $this->db->getTransactionNestingLevel()); + } + + $this->assertOriginal(); + $this->db->insert($this->table, [ + 'id' => 4, + 'name' => 'After', + ]); + + return 'Recovered'; + }); + + $this->assertSame('Recovered', $result); + $this->assertSame([ + 'Original', + 'Before', + 'After', + ], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table . ' ORDER BY id')); + } + + public function test_duplicate_caught_inside_nested_callback_cannot_be_reported_as_success(): void { + $this->db->transactional(function (): void { + $this->db->update($this->table, [ + 'name' => 'Outer', + ], [ + 'id' => 1, + ]); + + try { + $this->db->transactional(function (): void { + $this->db->insert($this->table, [ + 'id' => 2, + 'name' => 'Discarded', + ]); + + try { + $this->db->insert($this->table, [ + 'id' => 1, + 'name' => 'Duplicate', + ]); + } catch (UniqueConstraintViolationException) { + } + }); + $this->fail('Catching the duplicate inside its callback cannot make that callback successful.'); + } catch (TransactionFailed) { + $this->assertSame(1, $this->db->getTransactionNestingLevel()); + } + + $this->db->insert($this->table, [ + 'id' => 3, + 'name' => 'Acknowledged failure', + ]); + }); + + $this->assertSame([ + 'Outer', + 'Acknowledged failure', + ], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table . ' ORDER BY id')); + } + + public function test_deep_duplicate_recovery_preserves_both_parent_savepoints(): void { + $this->db->transactional(function (): void { $this->db->transactional(function (): void { - $this->db->update($this->table, ['name' => 'Provisional'], ['id' => 1]); + $this->db->insert($this->table, [ + 'id' => 2, + 'name' => 'Parent', + ]); try { $this->db->transactional(function (): void { - $this->db->insert($this->table, ['id' => 1, 'name' => 'Duplicate']); + $this->db->insert($this->table, [ + 'id' => 3, + 'name' => 'Discarded', + ]); + $this->db->insert($this->table, [ + 'id' => 1, + 'name' => 'Duplicate', + ]); }); } catch (UniqueConstraintViolationException) { + $this->assertSame(2, $this->db->getTransactionNestingLevel()); } + + $this->db->insert($this->table, [ + 'id' => 4, + 'name' => 'Continued', + ]); }); - $this->fail('Caught nested database failure must abort the outer transaction.'); + $this->assertOriginal(); + }); + + $this->assertSame([ + 'Original', + 'Parent', + 'Continued', + ], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table . ' ORDER BY id')); + } + + public function test_outer_failure_still_rolls_back_work_after_duplicate_recovery(): void { + $failure = new RuntimeException('Later application failure'); + + try { + $this->db->transactional(function () use ($failure): void { + try { + $this->db->transactional(function (): void { + $this->db->insert($this->table, [ + 'id' => 1, + 'name' => 'Duplicate', + ]); + }); + } catch (UniqueConstraintViolationException) { + } + + $this->db->update($this->table, [ + 'name' => 'Recovered but provisional', + ], [ + 'id' => 1, + ]); + + throw $failure; + }); + } catch (Throwable $caught) { + $this->assertSame($failure, $caught); + } + + $this->assertOriginal(); + } + + public function test_recovered_duplicate_uses_a_current_read_to_find_a_competing_insert(): void { + $this->db->executeStatement('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED'); + $result = $this->db->transactional(function (): string { + $this->assertSame('Original', $this->db->fetchOne('SELECT name FROM ' . $this->table . ' WHERE id = 1')); + $this->observer->insert($this->table, [ + 'id' => 2, + 'name' => 'Competing insert', + ]); + + try { + $this->db->transactional(function (): void { + $this->db->insert($this->table, [ + 'id' => 2, + 'name' => 'Losing insert', + ]); + }); + $this->fail('The competing insert must cause a duplicate.'); + } catch (UniqueConstraintViolationException) { + $this->assertSame(1, $this->db->getTransactionNestingLevel()); + } + + return (string) $this->db->fetchOne('SELECT name FROM ' . $this->table . ' WHERE id = 2 FOR UPDATE'); + }); + + $this->assertSame('Competing insert', $result); + } + + public function test_savepoint_recovery_does_not_refresh_a_repeatable_read_snapshot(): void { + $this->db->executeStatement('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + $this->db->transactional(function (): void { + $this->assertSame('Original', $this->db->fetchOne('SELECT name FROM ' . $this->table . ' WHERE id = 1')); + $this->observer->insert($this->table, [ + 'id' => 2, + 'name' => 'Competing insert', + ]); + + try { + $this->db->transactional(function (): void { + $this->db->insert($this->table, [ + 'id' => 1, + 'name' => 'Duplicate original', + ]); + }); + $this->fail('The existing original row must cause a duplicate.'); + } catch (UniqueConstraintViolationException) { + $this->assertFalse($this->db->fetchOne('SELECT name FROM ' . $this->table . ' WHERE id = 2')); + } + }); + + $this->assertSame('Competing insert', $this->db->fetchOne('SELECT name FROM ' . $this->table . ' WHERE id = 2')); + } + + public function test_failed_savepoint_recovery_preserves_duplicate_and_prevents_outer_commit(): void { + $logger = new \Monolog\Logger('doctrine-evaluation'); + $logger->pushHandler(new \Monolog\Handler\TestHandler()); + $logger->pushProcessor(function (array $record): array { + if (str_starts_with($record['context']['sql'] ?? '', 'ROLLBACK TO SAVEPOINT')) { + $this->killConnection(); + } + + return $record; + }); + $db = $this->withLogging($logger); + + try { + @$db->transactional(function () use ($db): void { + $db->update($this->table, [ + 'name' => 'Outer', + ], [ + 'id' => 1, + ]); + + try { + $db->transactional(function () use ($db): void { + $db->insert($this->table, [ + 'id' => 1, + 'name' => 'Duplicate', + ]); + }); + $this->fail('The original duplicate must remain visible when rollback fails.'); + } catch (UniqueConstraintViolationException) { + } + }); + $this->fail('Failed savepoint cleanup must prevent the outer commit.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + public function test_scope_change_before_duplicate_recovery_remains_terminal_after_restoration(): void { + $prefix = $this->source->prefix; + + try { + $this->db->transactional(function () use ($prefix): void { + $this->db->update($this->table, [ + 'name' => 'Outer', + ], [ + 'id' => 1, + ]); + + try { + $this->db->transactional(function () use ($prefix): void { + try { + $this->db->insert($this->table, [ + 'id' => 1, + 'name' => 'Duplicate', + ]); + } catch (UniqueConstraintViolationException $failure) { + $this->source->set_prefix($prefix . 'changed_'); + + throw $failure; + } + }); + $this->fail('The duplicate must escape even if scope prevents cleanup.'); + } catch (UniqueConstraintViolationException) { + $this->source->set_prefix($prefix); + } + }); + $this->fail('Restoring scope must not erase failed savepoint cleanup.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } finally { + $this->source->set_prefix($prefix); + } + } + + public function test_manual_nested_rollback_recovers_duplicate_without_committing_outer_work(): void { + $this->db->beginTransaction(); + $this->db->update($this->table, [ + 'name' => 'Outer', + ], [ + 'id' => 1, + ]); + $this->db->beginTransaction(); + + try { + $this->db->insert($this->table, [ + 'id' => 1, + 'name' => 'Duplicate', + ]); + $this->fail('The duplicate must fail before explicit rollback.'); + } catch (UniqueConstraintViolationException) { + $this->db->rollBack(); + } + + $this->assertSame(1, $this->db->getTransactionNestingLevel()); + $this->assertOriginal(); + $this->db->insert($this->table, [ + 'id' => 2, + 'name' => 'Continued', + ]); + $this->db->commit(); + $this->assertSame([ + 'Outer', + 'Continued', + ], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table . ' ORDER BY id')); + } + + public function test_lock_timeout_recovery_respects_the_servers_rollback_setting(): void { + $wholeTransaction = (bool) $this->observer->fetchOne('SELECT @@GLOBAL.innodb_rollback_on_timeout'); + $this->db->executeStatement('SET SESSION innodb_lock_wait_timeout = 1'); + $this->observer->beginTransaction(); + $this->observer->executeStatement("UPDATE {$this->table} SET name = 'Held lock' WHERE id = 1"); + + try { + try { + $this->db->transactional(function () use ($wholeTransaction): void { + $this->db->insert($this->table, [ + 'id' => 2, + 'name' => 'Outer', + ]); + + try { + $this->db->transactional(function (): void { + $this->db->insert($this->table, [ + 'id' => 3, + 'name' => 'Discarded', + ]); + $this->db->executeStatement("UPDATE {$this->table} SET name = 'Blocked' WHERE id = 1"); + }); + $this->fail('The observer must hold the row until the statement times out.'); + } catch (LockWaitTimeoutException) { + } + + if (! $wholeTransaction) { + $this->db->insert($this->table, [ + 'id' => 4, + 'name' => 'Continued', + ]); + } + }); + $this->assertFalse($wholeTransaction, 'A server-wide transaction rollback must prevent outer commit.'); + } catch (TransactionFailed) { + $this->assertTrue($wholeTransaction, 'A statement-only timeout must permit confirmed savepoint recovery.'); + } + } finally { + $this->observer->rollBack(); + } + + $expected = $wholeTransaction ? [ + 'Original', + ] : [ + 'Original', + 'Outer', + 'Continued', + ]; + $this->assertSame($expected, $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->table . ' ORDER BY id')); + } + + public function test_missing_savepoint_cleanup_cannot_be_recovered_at_a_parent_boundary(): void { + try { + $this->db->transactional(function (): void { + $this->db->update($this->table, [ + 'name' => 'Outer', + ], [ + 'id' => 1, + ]); + + try { + $this->db->transactional(function (): void { + try { + $this->db->transactional(function (): void { + // Fault injection: remove only the innermost savepoint, leaving its parent valid. + $this->native($this->source)->query('RELEASE SAVEPOINT DOCTRINE_3'); + $this->db->executeQuery('SELECT missing_column FROM ' . $this->table); + }); + $this->fail('The original statement error must survive failed savepoint cleanup.'); + } catch (InvalidFieldNameException) { + } + }); + $this->fail('The parent boundary must remain failed.'); + } catch (TransactionFailed) { + } + }); + $this->fail('Catching both failures must not allow the outer transaction to commit.'); } catch (TransactionFailed) { $this->assertOriginal(); } } + public function test_failed_savepoint_release_cannot_be_recovered_at_a_parent_boundary(): void { + try { + $this->db->transactional(function (): void { + $this->db->update($this->table, [ + 'name' => 'Outer', + ], [ + 'id' => 1, + ]); + + try { + $this->db->transactional(function (): void { + try { + $this->db->transactional(function (): void { + // Fault injection: force DBAL's subsequent release to fail on the live session. + $this->native($this->source)->query('RELEASE SAVEPOINT DOCTRINE_3'); + }); + $this->fail('Releasing the missing savepoint must fail.'); + } catch (\Doctrine\DBAL\Exception\DriverException) { + } + }); + $this->fail('A failed transaction-control statement must remain terminal.'); + } catch (TransactionFailed) { + } + }); + $this->fail('An intact parent savepoint must not erase a transaction-control failure.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + public function test_deadlock_rollback_cannot_be_recovered_as_a_nested_failure(): void { + for ($id = 2; $id <= 21; $id++) { + $this->observer->insert($this->table, [ + 'id' => $id, + 'name' => 'Original', + ]); + } + + $this->db->executeStatement('SET SESSION innodb_lock_wait_timeout = 3'); + $this->observer->executeStatement('SET SESSION innodb_lock_wait_timeout = 3'); + $this->observer->beginTransaction(); + $this->observer->executeStatement("UPDATE {$this->table} SET name = 'Heavier transaction' WHERE id >= 2"); + $native = $this->native($this->observerSource); + $pending = false; + + try { + try { + $this->db->transactional(function () use ($native, &$pending): void { + $this->db->executeStatement("UPDATE {$this->table} SET name = 'Lighter transaction' WHERE id = 1"); + $this->assertTrue($native->query("UPDATE {$this->table} SET name = 'Waiting' WHERE id = 1", MYSQLI_ASYNC)); + $pending = true; + + try { + $this->db->transactional(function (): void { + $this->db->executeStatement("UPDATE {$this->table} SET name = 'Deadlock' WHERE id = 2"); + }); + $this->fail('The lighter transaction must be selected as the deadlock victim.'); + } catch (\Doctrine\DBAL\Exception\DeadlockException) { + } + }); + $this->fail('A caught deadlock must prevent the outer transaction from committing.'); + } catch (TransactionFailed) { + $this->assertFalse($this->db->isTransactionActive()); + } + } finally { + try { + if ($pending) { + $read = [ + $native, + ]; + $error = $read; + $reject = []; + $ready = mysqli_poll($read, $error, $reject, 5); + $this->assertSame(1, $ready, 'The competing update must finish after deadlock resolution.'); + $native->reap_async_query(); + } + } finally { + $this->observer->rollBack(); + } + } + + $this->assertSame([ + 'Original', + ], $this->observer->fetchFirstColumn('SELECT DISTINCT name FROM ' . $this->table)); + $this->assertSame(42, $this->db->transactional(static fn (): int => 42)); + } + public function test_a_business_exception_takes_precedence_over_a_caught_database_failure(): void { $business = new RuntimeException('Application failure'); @@ -427,6 +958,18 @@ public function test_a_closed_wordpress_source_is_rejected(): void { }); } + public function test_rollback_without_an_active_transaction_does_not_poison_the_next_operation(): void { + try { + $this->db->rollBack(); + $this->fail('Rollback without an active transaction must report misuse.'); + } catch (\Doctrine\DBAL\Exception\NoActiveTransaction) { + $this->assertFalse($this->db->isTransactionActive()); + } + + $this->assertSame(42, $this->db->transactional(static fn (): int => 42)); + $this->assertOriginal(); + } + public function test_native_manual_rollback_remains_available(): void { $this->db->beginTransaction(); $this->db->update($this->table, ['name' => 'Provisional'], ['id' => 1]); diff --git a/tests/integration/Migrations/AdvisoryMigrationTest.php b/tests/integration/Migrations/AdvisoryMigrationTest.php index a4a20a7..f978971 100644 --- a/tests/integration/Migrations/AdvisoryMigrationTest.php +++ b/tests/integration/Migrations/AdvisoryMigrationTest.php @@ -316,6 +316,30 @@ public function test_caught_sql_failure_remains_terminal_after_inner_transaction } } + public function test_duplicate_savepoint_recovery_cannot_clear_a_migration_failure(): void { + try { + $this->runner($this->container, function (Connection $db): void { + $db->transactional(function (Connection $db): void { + try { + $db->transactional(function (Connection $db): void { + $db->insert($this->table, [ + 'id' => 1, + 'name' => 'Duplicate', + ]); + }); + } catch (Throwable) { + // Ordinary transaction recovery must not clear the migration's failure. + } + }); + })->migrate(); + $this->fail('A migration must remain failed after its nested duplicate.'); + } catch (MigrationInterrupted) { + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertOriginal(); + $this->assertLockAvailable(); + } + } + public function test_migration_failure_releases_lock_for_a_fresh_retry(): void { try { $this->runner($this->container, static function (): void { From 847b85475934112477942785c173f7b4df07a011 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 28 Sep 2026 13:49:13 -0600 Subject: [PATCH 35/53] Add literal substring queries and clarify JSON writes --- src/Database/Query/WhereGroup.php | 13 +++++++++ .../components/database/query-builder.mdx | 22 +++++++++++++-- .../content/docs/components/migrations.mdx | 4 ++- tests/Unit/Database/Query/WhereGroupTest.php | 16 +++++++++++ tests/integration/Database/QueryTest.php | 28 +++++++++++++++++++ 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/Database/Query/WhereGroup.php b/src/Database/Query/WhereGroup.php index e74ffa6..35fed12 100644 --- a/src/Database/Query/WhereGroup.php +++ b/src/Database/Query/WhereGroup.php @@ -89,6 +89,19 @@ public function whereNotNull(string $column): static { return $this->add(new Fragment($this->quoter->column($column) . ' IS NOT NULL'), 'AND'); } + /** + * Match a literal substring, escaping SQL wildcard characters in the needle. + * + * An empty needle matches every non-null value. + * + * @throws InvalidArgumentException When the column is invalid. + */ + public function whereContains(string $column, string $needle): static { + return $this->whereContainsAny($column, [ + $needle, + ]); + } + /** * Match any literal substring, escaping SQL wildcard characters in every needle. * diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index abe122d..0be174f 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -82,7 +82,15 @@ $rows = $this->reports->query() ->get(); ``` -`whereContainsAny()` matches any literal substring. Percent signs, underscores, and the escape character in search terms are escaped before values are bound. An empty term list matches nothing. Structured column names, aliases, and sort directions are validated; choose an application allowlist when users should be limited to particular columns. +Use `whereContains()` for one literal substring: + +```php +$rows = $this->reports->query() + ->whereContains( 'title', $searchTerm ) + ->get(); +``` + +`whereContainsAny()` matches any of several literal substrings. Both methods escape percent signs, underscores, and the escape character before binding search terms. An empty term list matches nothing; an empty string matches every non-null value. Case sensitivity follows the column's collation. Structured column names, aliases, and sort directions are validated; choose an application allowlist when users should be limited to particular columns. ### Grouping and aggregates @@ -180,7 +188,17 @@ You can also call `$this->reports->query()->insert( $rows )`; it has the same be `Table::update( $values, $criteria )` and `delete( $criteria )` delegate to filtered queries and return affected-row counts as `int|string`. Criteria are column-to-value equality comparisons; null matches `IS NULL`. Both methods reject empty criteria. Table and fluent writes share value normalization. -For writes that require explicit Doctrine conversions, such as a JSON type or a custom type, use the shared connection’s `insert()`, `update()`, or `delete()`: +To write a PHP array to a JSON column through a table or fluent query, encode it first. `JSON_THROW_ON_ERROR` raises `JsonException` if encoding fails: + +```php +$this->reports->insert( [ + 'payload' => json_encode( [ + 'enabled' => true, + ], JSON_THROW_ON_ERROR ), +] ); +``` + +For writes that require explicit Doctrine conversions, such as a JSON type or a custom type, use the shared connection’s `insert()`, `update()`, or `delete()`. For example, pass the PHP array with `Types::JSON` and let Doctrine encode it: ```php use Doctrine\DBAL\Types\Types; diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index 1bcbbdb..7f1ff09 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -364,7 +364,9 @@ $table->mediumText( 'content' )->nullable(); Columns are non-nullable unless you call `nullable()`. Existing modifiers such as `default()`, `comment()`, and `change()` apply to the new types too. A `char()` length is a storage choice, not application validation that a supplied code has exactly that many characters. `useCurrent()` and `useCurrentOnUpdate()` are intended for `dateTime()` columns. -[MySQL](https://dev.mysql.com/doc/refman/8.0/en/json.html) stores JSON natively; [MariaDB](https://mariadb.com/docs/server/reference/data-types/string-data-types/json) uses text with JSON validation. Foundation preserves that distinction through alterations and renames. For compatibility with MySQL 5.7, supply a JSON value on insertion or make the column nullable instead of declaring a non-null JSON default. When writing PHP arrays through a table or Doctrine connection, bind the column as `Doctrine\DBAL\Types\Types::JSON` so Doctrine encodes it. Query results are not automatically decoded; see [queries and parameter types](/components/database/query-builder/). +[MySQL](https://dev.mysql.com/doc/refman/8.0/en/json.html) stores JSON natively; [MariaDB](https://mariadb.com/docs/server/reference/data-types/string-data-types/json) uses text with JSON validation. Foundation preserves that distinction through alterations and renames. For compatibility with MySQL 5.7, supply a JSON value on insertion or make the column nullable instead of declaring a non-null JSON default. + +When writing PHP arrays through a Foundation table or fluent query, encode them with `json_encode( $value, JSON_THROW_ON_ERROR )`. Alternatively, use the shared Doctrine connection and bind the PHP array as `Doctrine\DBAL\Types\Types::JSON` so Doctrine encodes it. Query results are not automatically decoded; see the [JSON write examples](/components/database/query-builder/#write-rows). ### Change a column diff --git a/tests/Unit/Database/Query/WhereGroupTest.php b/tests/Unit/Database/Query/WhereGroupTest.php index ec75511..d53ab34 100644 --- a/tests/Unit/Database/Query/WhereGroupTest.php +++ b/tests/Unit/Database/Query/WhereGroupTest.php @@ -57,6 +57,22 @@ public function test_empty_sets_and_empty_needle_sets_have_explicit_semantics(): $this->assertSame([], $group->condition()->bindings); } + public function test_single_substring_search_quotes_columns_and_binds_literal_values(): void { + $group = new WhereGroup(new IdentifierQuoter()); + $this->assertSame($group, $group->where('e.active', true)->whereContains('e.name', "!%_' OR 1 = 1 --")); + $this->assertSame("`e`.`active` = ? AND (`e`.`name` LIKE ? ESCAPE '!')", $group->condition()->sql); + $this->assertSame([ + true, + "%!!!%!_' OR 1 = 1 --%", + ], $group->condition()->bindings); + } + + public function test_single_substring_search_rejects_invalid_columns(): void { + $group = new WhereGroup(new IdentifierQuoter()); + $this->expectException(InvalidArgumentException::class); + $group->whereContains('name) OR 1 = 1 --', 'search'); + } + public function test_null_equalities_do_not_bind_null_as_an_ordinary_comparison(): void { $group = new WhereGroup(new IdentifierQuoter()); $group->where('a', null)->where('b', '!=', null)->where('c', '<>', null); diff --git a/tests/integration/Database/QueryTest.php b/tests/integration/Database/QueryTest.php index 7c2db72..a6a9fc1 100644 --- a/tests/integration/Database/QueryTest.php +++ b/tests/integration/Database/QueryTest.php @@ -122,6 +122,34 @@ public function test_empty_lists_and_literal_search_patterns(): void { ])->count()); } + /** + * Single substring searches bind literal text and retain SQL null semantics. + */ + public function test_single_substring_search_matches_literal_text_and_empty_strings(): void { + $this->entries->insert([ + [ + 'id' => 1, + 'name' => '100%_! complete', + 'category' => 'literal', + ], + [ + 'id' => 2, + 'name' => '100xyz complete', + 'category' => null, + ], + [ + 'id' => 3, + 'name' => "x' OR 1 = 1 --", + 'category' => '', + ], + ]); + + $this->assertSame('100%_! complete', $this->entries->query('e')->whereContains('e.name', '%_!')->first()['name'] ?? null); + $this->assertSame(1, $this->entries->query()->whereContains('name', "x' OR 1 = 1 --")->count()); + $this->assertSame(0, $this->entries->query()->whereContains('name', 'absent')->count()); + $this->assertSame(2, $this->entries->query()->whereContains('category', '')->count()); + } + /** * A shaped aggregate retains selected aliases and positional binding order. */ From e437f4610c08e243fdbedb5248528019604c0670 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 28 Sep 2026 14:00:52 -0600 Subject: [PATCH 36/53] Add sum aggregate with explicit numeric return types --- src/Database/Query/Query.php | 12 ++++ .../components/database/query-builder.mdx | 12 +++- tests/integration/Database/QueryTest.php | 65 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php index cbe2dcf..956bfd2 100644 --- a/src/Database/Query/Query.php +++ b/src/Database/Query/Query.php @@ -240,6 +240,18 @@ public function max(string $column): mixed { return $this->executor->value($this->compiler->aggregate($this->state(), 'MAX', $column)); } + /** + * Sum column values, or return zero when all values are null or absent. + * + * Preserves the driver's numeric result, including decimal strings without conversion to float. + * + * @throws Exception When execution fails, including unresolved raw projection columns. + * @throws InvalidArgumentException For invalid or unselected columns, unsupported values, or ambiguous shaped join projections. + */ + public function sum(string $column): int|float|string { + return $this->executor->value($this->compiler->aggregate($this->state(), 'SUM', $column)) ?? 0; + } + /** * Determine whether this query returns any rows. * diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index 0be174f..b48ebac 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -102,6 +102,16 @@ $count = $query->count(); $rows = $query->get(); ``` +Use `sum()` to total a numeric column: + +```php +$total = $this->reports->query() + ->where( 'status', 'ready' ) + ->sum( 'amount' ); +``` + +`sum()` returns `int|float|string`. It ignores null values and returns integer `0` when no values remain. Otherwise, it preserves the driver's numeric result: exact decimal strings stay strings, and floating-point results stay floats. Like the other terminal reads, it leaves the builder unchanged. + `distinct()`, `groupBy()`, `having()`, `limit()`, and `offset()` shape the rows seen by aggregates. Foundation aggregates a derived table when those operations are present. In particular, `limit( 10 )->count()` counts at most ten rows, and a grouped count counts groups. Explicit projections and their aliases remain intact, including aliases used by ordering. ```php @@ -122,7 +132,7 @@ $groups = $this->reports->query() ->get(); ``` -Raw projections are opaque: Foundation preserves every `selectRaw()` projection when counting, finding a maximum, or checking existence. For example, `selectRaw( 'COUNT(*) AS total' )->exists()` returns true even when the source table is empty, because that aggregate produces one result row. Calling `select()` replaces the projection and clears this raw-projection behavior. +Raw projections are opaque: Foundation preserves every `selectRaw()` projection when counting, summing, finding a maximum, or checking existence. For example, `selectRaw( 'COUNT(*) AS total' )->exists()` returns true even when the source table is empty, because that aggregate produces one result row. Calling `select()` replaces the projection and clears this raw-projection behavior. A shaped column aggregate must name an output column or alias of the inner query. For example, `select( 'name' )->limit( 10 )->max( 'amount' )` throws `InvalidArgumentException` because `amount` is not selected. Include `amount` in the projection, or use its selected alias. Foundation leaves the projection, grouping, and distinctness unchanged. Foundation does not parse raw expressions to discover their outputs; invalid raw output references fail through the database. Shaped joins require an explicit projection to avoid duplicate column names in a derived table. diff --git a/tests/integration/Database/QueryTest.php b/tests/integration/Database/QueryTest.php index a6a9fc1..8282c19 100644 --- a/tests/integration/Database/QueryTest.php +++ b/tests/integration/Database/QueryTest.php @@ -172,6 +172,71 @@ public function test_limited_aggregates_preserve_explicit_projections_and_offset $this->assertSame(30.0, (float) $this->entries->query('e')->orderBy('e.amount', 'desc')->limit(1)->max('e.amount')); } + /** + * Sums retain decimal precision and aggregate only the filtered result rows. + */ + public function test_sum_preserves_exact_decimals_and_query_state(): void { + $this->seedEntries(); + $this->entries->update([ + 'amount' => '9999999999.99', + ], [ + 'id' => 1, + ]); + $query = $this->entries->query()->where('active', true)->orderBy('id'); + $sql = $query->toSql(); + $rows = $query->get(); + + $this->assertSame('10000000019.99', $query->sum('amount')); + $this->assertSame($sql, $query->toSql()); + $this->assertSame($rows, $query->get()); + $this->assertSame('9999999999.99', $query->limit(1)->sum('amount')); + $this->assertSame('20.00', $query->offset(1)->sum('amount')); + } + + /** + * Approximate numeric expressions retain the database's floating-point result. + */ + public function test_sum_preserves_floating_point_results(): void { + $this->seedEntries(); + $this->assertSame(60.0, $this->entries->query()->selectRaw('amount * 1e0 AS approximate_amount')->sum('approximate_amount')); + } + + /** + * Empty and all-null inputs return integer zero; numeric zero keeps its database representation. + */ + public function test_sum_returns_zero_when_no_non_null_values_remain(): void { + $this->assertSame(0, $this->entries->query()->sum('amount')); + $this->seedEntries(); + $this->assertSame(0, $this->entries->query()->where('id', 999)->sum('amount')); + $this->assertSame(0, $this->entries->query()->limit(0)->sum('amount')); + $this->assertSame(0, $this->entries->query()->offset(10)->sum('amount')); + $this->assertSame(0, $this->entries->query()->selectRaw('NULL AS amount')->sum('amount')); + $this->assertSame('0.00', $this->entries->query()->selectRaw('amount - amount AS zero')->sum('zero')); + } + + /** + * Grouping, distinctness and aliases define the values supplied to a sum. + */ + public function test_sum_preserves_grouped_distinct_and_aliased_projections(): void { + $this->seedEntries(); + $this->assertSame('40.00', $this->entries->query('e')->select('e.amount AS total')->orderBy('e.id')->limit(2)->sum('total')); + $this->assertSame('1', $this->entries->query()->select('active')->distinct()->sum('active')); + $this->assertSame('40.00', $this->entries->query() + ->selectRaw('category, SUM(amount) AS total') + ->groupBy('category') + ->having('total', '>', 25) + ->sum('total')); + } + + /** + * Summing must not silently rewrite a limited query's explicit selection. + */ + public function test_sum_rejects_an_unselected_column_in_a_limited_query(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Aggregate column "amount" is not selected.'); + $this->entries->query()->select('name')->limit(10)->sum('amount'); + } + /** * Grouped and distinct aggregates count the selected rows, not base records. */ From 105c5a7cdf2d646b91be7d2578926bdd67ac491f Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 28 Sep 2026 14:06:13 -0600 Subject: [PATCH 37/53] Add single-column pluck queries --- src/Database/Query/Executor.php | 14 +++++ src/Database/Query/Query.php | 14 +++++ .../components/database/query-builder.mdx | 16 ++++++ tests/integration/Database/QueryTest.php | 53 +++++++++++++++++++ 4 files changed, 97 insertions(+) diff --git a/src/Database/Query/Executor.php b/src/Database/Query/Executor.php index 120b8cb..cb84934 100644 --- a/src/Database/Query/Executor.php +++ b/src/Database/Query/Executor.php @@ -39,6 +39,20 @@ public function rows(Fragment $statement): array { return $this->connection->fetchAllAssociative($statement->sql, $parameters->values, $parameters->types); } + /** + * Fetch the first column of every result row without converting values. + * + * @throws InvalidArgumentException For unsupported bound values. + * @throws Exception When execution fails. + * + * @return list + */ + public function column(Fragment $statement): array { + $parameters = $this->parameters($statement); + + return $this->connection->fetchFirstColumn($statement->sql, $parameters->values, $parameters->types); + } + /** * Fetch the first row, or false when no row matches. * diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php index 956bfd2..1bfa0c0 100644 --- a/src/Database/Query/Query.php +++ b/src/Database/Query/Query.php @@ -206,6 +206,20 @@ public function get(): array { return $this->executor->rows($this->compiler->select($this->state())); } + /** + * Read a column's values using a temporary single-column projection. + * + * @throws Exception When execution fails, including references to replaced projection aliases. + * @throws InvalidArgumentException For invalid columns or unsupported bound values. + * + * @return list Values retain their database types, duplicates, and nulls. + */ + public function pluck(string $column): array { + return $this->executor->column($this->compiler->select($this->state(), [ + new Selection(new Fragment($this->quoter->column($column))), + ])); + } + /** * Read the first selected row, or null when the query is empty. * diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index b48ebac..7dfc321 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -92,6 +92,22 @@ $rows = $this->reports->query() `whereContainsAny()` matches any of several literal substrings. Both methods escape percent signs, underscores, and the escape character before binding search terms. An empty term list matches nothing; an empty string matches every non-null value. Case sensitivity follows the column's collation. Structured column names, aliases, and sort directions are validated; choose an application allowlist when users should be limited to particular columns. +### Read one column + +Use `pluck()` to retrieve a plain list of column values: + +```php +$ids = $this->reports->query() + ->where( 'status', 'ready' ) + ->orderBy( 'id' ) + ->limit( 50 ) + ->pluck( 'id' ); +``` + +`pluck()` returns an array with sequential numeric keys, preserving database value types, duplicates, and nulls. No matching rows returns `[]`. Use `distinct()` when you want unique values. + +The call selects only the requested column, replacing any existing `select()` or `selectRaw()` projection for that execution. Other clauses remain in place, and the original builder is unchanged. Use a column name or qualified column such as `r.id`. Any ordering, grouping, or `having()` must remain valid with that single-column selection; aliases from replaced projections are unavailable. + ### Grouping and aggregates `count()` returns an integer, `exists()` returns a boolean, and `max( 'column' )` returns the driver's scalar value or null when no value exists. Terminal reads do not alter the builder, so you can count and then fetch the same query. diff --git a/tests/integration/Database/QueryTest.php b/tests/integration/Database/QueryTest.php index 8282c19..30cc259 100644 --- a/tests/integration/Database/QueryTest.php +++ b/tests/integration/Database/QueryTest.php @@ -69,6 +69,59 @@ public function test_conditions_compose_and_terminal_reads_leave_the_query_uncha $this->assertNull($this->entries->query()->where('id', 999)->first()); } + /** + * Column reads preserve filters, ordering and paging without changing the original projection. + */ + public function test_pluck_preserves_query_state_and_reads_a_qualified_column(): void { + $this->seedEntries(); + $query = $this->entries->query('e') + ->selectRaw('? AS marker', [ + 'original', + ]) + ->where('e.active', true) + ->orderBy('e.id', 'desc') + ->limit(1) + ->offset(1); + $sql = $query->toSql(); + $rows = $query->get(); + + $this->assertSame([ + 'First', + ], $query->pluck('e.name')); + $this->assertSame([ + '10.00', + ], $query->pluck('e.amount')); + $this->assertSame($sql, $query->toSql()); + $this->assertSame($rows, $query->get()); + } + + /** + * Column lists preserve nulls and duplicates, with empty and distinct results represented directly. + */ + public function test_pluck_preserves_nulls_duplicates_and_empty_results(): void { + $this->assertSame([], $this->entries->query()->pluck('id')); + $this->seedEntries(); + $this->assertSame([ + 'a', + 'a', + null, + ], $this->entries->query()->orderBy('id')->pluck('category')); + $this->assertSame([ + null, + 'a', + ], $this->entries->query()->distinct()->orderBy('category')->pluck('category')); + $this->assertSame([], $this->entries->query()->where('id', 999)->pluck('name')); + $this->assertSame([], $this->entries->query()->limit(0)->pluck('name')); + } + + /** + * Pluck accepts exactly one column, never a wildcard projection. + */ + public function test_pluck_rejects_a_wildcard(): void { + $this->expectException(InvalidArgumentException::class); + $this->entries->query()->pluck('*'); + } + /** * Raw projections may change cardinality even without explicit grouping. */ From 43dd43ecc5b3063556f51cb528f68b5311924c35 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 28 Sep 2026 15:00:27 -0600 Subject: [PATCH 38/53] Align binary column declarations with SQL storage types --- .../content/docs/components/migrations.mdx | 19 +++++- src/Migrations/Schema/TableBlueprint.php | 18 +++++- .../Database/Declarative/CreateEntries.php | 2 +- .../Columns/ChangeCommonColumns.php | 4 ++ .../Columns/CreateCommonColumns.php | 2 + .../Columns/RenameCommonColumns.php | 2 + .../Migrations/CommonColumnTest.php | 59 ++++++++++++++++++- 7 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index 7f1ff09..cee1f9d 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -360,7 +360,8 @@ $table->mediumText( 'content' )->nullable(); | `date( 'starts_on' )` | Calendar date, such as `2026-09-24` | | `time( 'opens_at' )` | Time with whole-second precision, such as `09:30:00` | | `dateTime( 'created_at', 6 )` | DATETIME with optional fractional-second precision from 0 to 6 | -| `binary( 'token', 16 )` | Variable-length binary storage with an explicit byte limit | +| `binary( 'token', 16 )` | Fixed-length BINARY storage, padded with zero bytes | +| `varBinary( 'payload', 255 )` | Variable-length VARBINARY storage with a maximum byte length | Columns are non-nullable unless you call `nullable()`. Existing modifiers such as `default()`, `comment()`, and `change()` apply to the new types too. A `char()` length is a storage choice, not application validation that a supplied code has exactly that many characters. `useCurrent()` and `useCurrentOnUpdate()` are intended for `dateTime()` columns. @@ -368,6 +369,19 @@ Columns are non-nullable unless you call `nullable()`. Existing modifiers such a When writing PHP arrays through a Foundation table or fluent query, encode them with `json_encode( $value, JSON_THROW_ON_ERROR )`. Alternatively, use the shared Doctrine connection and bind the PHP array as `Doctrine\DBAL\Types\Types::JSON` so Doctrine encodes it. Query results are not automatically decoded; see the [JSON write examples](/components/database/query-builder/#write-rows). +### Store binary values + +Use `binary()` for fixed-width values such as a UUID encoded into 16 bytes. Use `varBinary()` when values have different byte lengths: + +```php +$table->binary( 'token', 16 ); +$table->varBinary( 'payload', 255 )->nullable(); +``` + +Lengths are measured in bytes. `BINARY` pads shorter values with zero bytes (`0x00`), which remain present when read back. It does not validate that the application supplied exactly the declared number of bytes. `VARBINARY` stores values without padding. See [MySQL's binary type documentation](https://dev.mysql.com/doc/refman/8.0/en/binary-varbinary.html). + +Use `varBinary()` when declaring an existing `VARBINARY` column, including in a reversal that restores that type. To intentionally convert an existing column, add a migration with the desired declaration and `change()`. Converting to `BINARY` can add zero bytes that remain in the data when converted back to `VARBINARY`, so account for padding in the migration's data handling and reversal. + ### Change a column Declare the complete replacement definition and call `change()`: @@ -605,7 +619,8 @@ Omitting both produces a generic migration with an empty `up()` and an irreversi | `unsignedInteger('count')`, `unsignedBigInteger('count')` | Unsigned integers | | `boolean('active')` | Boolean storage | | `decimal('amount', 12, 4)` | Exact decimal precision and scale | -| `binary('token', 16)` | VARBINARY with an explicit length | +| `binary('token', 16)` | Fixed-length BINARY, padded with zero bytes | +| `varBinary('payload', 255)` | VARBINARY with an explicit maximum byte length | | `dateTime('updated_at', 6)` | DATETIME with fractional precision from 0 to 6 | Columns support `nullable()`, `notNull()`, `unsigned()`, `default()`, `comment()`, and `change()`. Date/time declarations additionally support `useCurrent()` and `useCurrentOnUpdate()`. Use strings for exact decimal defaults. Table declarations support `primary(...$columns)` and `comment()`. diff --git a/src/Migrations/Schema/TableBlueprint.php b/src/Migrations/Schema/TableBlueprint.php index 436342c..b7441b0 100644 --- a/src/Migrations/Schema/TableBlueprint.php +++ b/src/Migrations/Schema/TableBlueprint.php @@ -241,12 +241,26 @@ public function dateTime(string $name, ?int $precision = null): ColumnDefinition } /** - * VARBINARY of the given length. + * Declare fixed-length BINARY storage, padding shorter values with zero bytes. * * @param non-empty-string $name */ public function binary(string $name, int $length): ColumnDefinition { - return $this->column($name, Types::BINARY, ['length' => $length]); + return $this->column($name, Types::BINARY, [ + 'length' => $length, + 'fixed' => true, + ]); + } + + /** + * Declare VARBINARY storage with a maximum byte length and no padding. + * + * @param non-empty-string $name + */ + public function varBinary(string $name, int $length): ColumnDefinition { + return $this->column($name, Types::BINARY, [ + 'length' => $length, + ]); } /** diff --git a/tests/Support/Fixtures/Database/Declarative/CreateEntries.php b/tests/Support/Fixtures/Database/Declarative/CreateEntries.php index 17dbc44..2ad6c4d 100644 --- a/tests/Support/Fixtures/Database/Declarative/CreateEntries.php +++ b/tests/Support/Fixtures/Database/Declarative/CreateEntries.php @@ -24,7 +24,7 @@ public function up(Blueprint $schema): void { $entries->string('name', 50); $entries->string('status', 20)->default('active')->comment('Current processing state'); $entries->decimal('amount', 12, 4)->default('0'); - $entries->binary('token', 16)->nullable(); + $entries->varBinary('token', 16)->nullable(); $entries->dateTime('created_at', 6)->useCurrent(); $entries->dateTime('updated_at', 6)->useCurrent()->useCurrentOnUpdate(); diff --git a/tests/Support/Fixtures/Migrations/Columns/ChangeCommonColumns.php b/tests/Support/Fixtures/Migrations/Columns/ChangeCommonColumns.php index 5f5dc9e..2156402 100644 --- a/tests/Support/Fixtures/Migrations/Columns/ChangeCommonColumns.php +++ b/tests/Support/Fixtures/Migrations/Columns/ChangeCommonColumns.php @@ -29,6 +29,8 @@ public function up(Blueprint $schema): void { $table->smallInteger('attempts')->default(3)->change(); $table->char('currency', 4)->default('USDX')->comment('Extended currency')->change(); $table->mediumText('content')->comment('Required content')->change(); + $table->varBinary('token', 16)->nullable()->comment('Variable token')->change(); + $table->binary('payload', 16)->nullable()->comment('Fixed payload')->change(); } /** @@ -42,5 +44,7 @@ public function down(Blueprint $schema): void { $table->smallInteger('attempts')->unsigned()->default(0)->change(); $table->char('currency', 3)->default('USD')->comment('Currency code')->change(); $table->mediumText('content')->nullable()->comment('Article content')->change(); + $table->binary('token', 16)->nullable()->comment('Fixed bytes')->change(); + $table->varBinary('payload', 16)->nullable()->comment('Variable bytes')->change(); } } diff --git a/tests/Support/Fixtures/Migrations/Columns/CreateCommonColumns.php b/tests/Support/Fixtures/Migrations/Columns/CreateCommonColumns.php index f4bea09..deb937e 100644 --- a/tests/Support/Fixtures/Migrations/Columns/CreateCommonColumns.php +++ b/tests/Support/Fixtures/Migrations/Columns/CreateCommonColumns.php @@ -30,6 +30,8 @@ public function up(Blueprint $schema): void { $table->smallInteger('attempts')->unsigned()->default(0); $table->char('currency', 3)->default('USD')->comment('Currency code'); $table->mediumText('content')->nullable()->comment('Article content'); + $table->binary('token', 16)->nullable()->comment('Fixed bytes'); + $table->varBinary('payload', 16)->nullable()->comment('Variable bytes'); } /** diff --git a/tests/Support/Fixtures/Migrations/Columns/RenameCommonColumns.php b/tests/Support/Fixtures/Migrations/Columns/RenameCommonColumns.php index ec87b1e..d190edf 100644 --- a/tests/Support/Fixtures/Migrations/Columns/RenameCommonColumns.php +++ b/tests/Support/Fixtures/Migrations/Columns/RenameCommonColumns.php @@ -17,6 +17,8 @@ final class RenameCommonColumns extends Migration 'attempts', 'currency', 'content', + 'token', + 'payload', ]; /** diff --git a/tests/integration/Migrations/CommonColumnTest.php b/tests/integration/Migrations/CommonColumnTest.php index eb228f3..ad5c764 100644 --- a/tests/integration/Migrations/CommonColumnTest.php +++ b/tests/integration/Migrations/CommonColumnTest.php @@ -72,6 +72,41 @@ public function test_creation_preserves_types_defaults_and_data(): void { ])); } + /** + * Fixed binary values are padded; variable binary values retain every supplied byte. + */ + public function test_binary_declarations_match_sql_types_and_storage_behavior(): void { + $migrator = $this->container->get(Migrator::class); + $preview = implode("\n", $migrator->preview('1')[0]->sql); + $this->assertStringContainsString('token BINARY(16)', $preview); + $this->assertStringContainsString('payload VARBINARY(16)', $preview); + $migrator->migrate('1'); + $this->observer->insert($this->entries, [ + 'metadata' => '{}', + 'content' => 'Example', + 'token' => "a\0b", + 'payload' => "a\0b", + ]); + $row = $this->observer->fetchAssociative("SELECT + token, + payload + FROM {$this->entries} + WHERE id = 1"); + $this->assertIsArray($row); + $this->assertSame("a\0b" . str_repeat("\0", 13), $row['token']); + $this->assertSame("a\0b", $row['payload']); + $this->assertSame([], $migrator->preview('1')); + $migrator->migrate('2'); + $this->assertSame("a\0b" . str_repeat("\0", 13), $this->observer->fetchOne("SELECT payload + FROM {$this->entries} + WHERE id = 1")); + $migrator->rollback(); + $this->assertFalse($this->introspect()->getColumn('payload')->getFixed()); + $this->assertSame("a\0b" . str_repeat("\0", 13), $this->observer->fetchOne("SELECT payload + FROM {$this->entries} + WHERE id = 1")); + } + /** * Alter each complete definition, then restore the original attributes. */ @@ -93,6 +128,10 @@ public function test_alteration_and_rollback_preserve_values(): void { $this->assertSame(4, $table->getColumn('currency')->getLength()); $this->assertSame('USDX', $table->getColumn('currency')->getDefault()); $this->assertTrue($table->getColumn('content')->getNotnull()); + $this->assertFalse($table->getColumn('token')->getFixed()); + $this->assertTrue($table->getColumn('payload')->getFixed()); + $this->assertSame('Variable token', $table->getColumn('token')->getComment()); + $this->assertSame('Fixed payload', $table->getColumn('payload')->getComment()); $this->assertEntry(); $migrator->rollback(); $this->assertInitialColumns(); @@ -128,6 +167,8 @@ public function test_column_types_survive_rename(string $strategy): void { $migrator->migrate('3'); $this->assertSame([], $migrator->migrate('3')); $this->assertSame(Types::JSON, Type::lookupName($this->introspect()->getColumn('metadata_renamed')->getType())); + $this->assertFalse($this->introspect()->getColumn('token_renamed')->getFixed()); + $this->assertTrue($this->introspect()->getColumn('payload_renamed')->getFixed()); $migrator->rollbackTo('1'); $this->assertInitialColumns(); $this->assertEntry(); @@ -144,6 +185,8 @@ private function insertEntry(): void { 'attempts' => 2, 'currency' => 'CAD', 'content' => str_repeat('a', 70000), + 'token' => hex2bin('00112233445566778899aabbccddeeff'), + 'payload' => hex2bin('ffeeddccbbaa99887766554433221100'), ], [ 'metadata' => Types::JSON, ]); @@ -156,7 +199,9 @@ private function assertEntry(): void { opens_at, attempts, currency, - content + content, + token, + payload FROM {$this->entries} WHERE id = 1"); $this->assertIsArray($row); @@ -168,6 +213,8 @@ private function assertEntry(): void { $this->assertSame(2, (int) $row['attempts']); $this->assertSame('CAD', $row['currency']); $this->assertSame(str_repeat('a', 70000), $row['content']); + $this->assertSame(hex2bin('00112233445566778899aabbccddeeff'), $row['token']); + $this->assertSame(hex2bin('ffeeddccbbaa99887766554433221100'), $row['payload']); } private function assertInitialColumns(): void { @@ -185,6 +232,16 @@ private function assertInitialColumns(): void { $this->assertSame(0, (int) $table->getColumn('attempts')->getDefault()); $this->assertSame(16777215, $table->getColumn('content')->getLength()); $this->assertFalse($table->getColumn('content')->getNotnull()); + $this->assertSame(Types::BINARY, Type::lookupName($table->getColumn('token')->getType())); + $this->assertSame(Types::BINARY, Type::lookupName($table->getColumn('payload')->getType())); + $this->assertTrue($table->getColumn('token')->getFixed()); + $this->assertFalse($table->getColumn('payload')->getFixed()); + $this->assertSame(16, $table->getColumn('token')->getLength()); + $this->assertSame(16, $table->getColumn('payload')->getLength()); + $this->assertFalse($table->getColumn('token')->getNotnull()); + $this->assertFalse($table->getColumn('payload')->getNotnull()); + $this->assertSame('Fixed bytes', $table->getColumn('token')->getComment()); + $this->assertSame('Variable bytes', $table->getColumn('payload')->getComment()); } private function introspect(): Table { From e43bcc3fc78f9c1ec3192db78b328ff761b57923 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 28 Sep 2026 15:26:30 -0600 Subject: [PATCH 39/53] Add raw query conditions with positional bindings --- src/Database/Query/WhereGroup.php | 30 +++++++++ .../components/database/query-builder.mdx | 32 +++++++++- tests/Unit/Database/Query/WhereGroupTest.php | 63 +++++++++++++++++++ tests/integration/Database/QueryTest.php | 57 +++++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) diff --git a/src/Database/Query/WhereGroup.php b/src/Database/Query/WhereGroup.php index 35fed12..292d825 100644 --- a/src/Database/Query/WhereGroup.php +++ b/src/Database/Query/WhereGroup.php @@ -49,6 +49,36 @@ public function orWhere(string|Closure|array $column, mixed $operator = null, mi return $this->comparison($column, $operator, $value, func_num_args(), 'OR'); } + /** + * Add an application-owned SQL condition with positional bound values. + * + * @param list $bindings Values for ? placeholders, in SQL order. + * + * @throws InvalidArgumentException When the SQL condition is blank. + */ + public function whereRaw(string $sql, array $bindings = []): static { + if (trim($sql) === '') { + throw new InvalidArgumentException('A raw condition must contain SQL.'); + } + + return $this->add(new Fragment($sql, $bindings), 'AND'); + } + + /** + * Add an alternative application-owned SQL condition with positional bound values. + * + * @param list $bindings Values for ? placeholders, in SQL order. + * + * @throws InvalidArgumentException When the SQL condition is blank. + */ + public function orWhereRaw(string $sql, array $bindings = []): static { + if (trim($sql) === '') { + throw new InvalidArgumentException('A raw condition must contain SQL.'); + } + + return $this->add(new Fragment($sql, $bindings), 'OR'); + } + /** * Match one of the supplied values; an empty list matches no rows. * diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index 7dfc321..0c5abfc 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -92,6 +92,36 @@ $rows = $this->reports->query() `whereContainsAny()` matches any of several literal substrings. Both methods escape percent signs, underscores, and the escape character before binding search terms. An empty term list matches nothing; an empty string matches every non-null value. Case sensitivity follows the column's collation. Structured column names, aliases, and sort directions are validated; choose an application allowlist when users should be limited to particular columns. +### Use a database expression in a condition + +Use `whereRaw()` when a condition needs application-owned SQL, such as the database's current time: + +```php +$rows = $this->reports->query() + ->where( 'status', 'ready' ) + ->whereRaw( '(expires_at IS NULL OR expires_at >= NOW())' ) + ->get(); +``` + +Pass external values through positional `?` placeholders and the bindings array. They use the same value normalization and parameter types as ordinary conditions: + +```php +use StellarWP\Foundation\Database\Query\WhereGroup; + +$rows = $this->reports->query() + ->where( static fn ( WhereGroup $where ) => $where + ->whereRaw( 'amount + ? >= ?', [ + $adjustment, + $minimum, + ] ) + ->orWhereRaw( 'owner_id = ?', [ + $ownerId, + ] ) ) + ->get(); +``` + +`whereRaw()` joins with `AND`; `orWhereRaw()` joins with `OR`. SQL fragments are used as supplied, so include parentheses around alternatives within a fragment or use a grouped callback around multiple conditions. Blank fragments are rejected. Keep SQL text and identifiers application-owned: bindings protect values, not SQL assembled from user input. Raw conditions work with reads, updates, and deletes. + ### Read one column Use `pluck()` to retrieve a plain list of column values: @@ -304,7 +334,7 @@ $connection->executeStatement( ); ``` -Native Doctrine execution exceptions remain unchanged. Foundation validation failures reject invalid query construction before execution. Keep raw SQL and expressions application-owned; `selectRaw()` does not make untrusted SQL safe. +Native Doctrine execution exceptions remain unchanged. Foundation validation failures reject invalid query construction before execution. Keep raw SQL and expressions application-owned; `selectRaw()`, `whereRaw()`, and `orWhereRaw()` do not make untrusted SQL safe. ## Choose a transaction boundary diff --git a/tests/Unit/Database/Query/WhereGroupTest.php b/tests/Unit/Database/Query/WhereGroupTest.php index d53ab34..0180f5b 100644 --- a/tests/Unit/Database/Query/WhereGroupTest.php +++ b/tests/Unit/Database/Query/WhereGroupTest.php @@ -50,6 +50,69 @@ public function test_substring_needles_escape_wildcards_without_reinterpreting_i ], $group->condition()->bindings); } + public function test_raw_conditions_preserve_sql_grouping_and_binding_order(): void { + $group = new WhereGroup(new IdentifierQuoter()); + $this->assertSame($group, $group->where('active', true)->where( + static fn (WhereGroup $nested) => $nested->whereRaw('amount + ? >= ?', [ + 5, + 20, + ])->orWhereRaw('owner_id = ?', [ + 7, + ]), + )->whereRaw('(expires_at IS NULL OR expires_at >= NOW())')); + $this->assertSame('`active` = ? AND (amount + ? >= ? OR owner_id = ?) AND (expires_at IS NULL OR expires_at >= NOW())', $group->condition()->sql); + $this->assertSame([ + true, + 5, + 20, + 7, + ], $group->condition()->bindings); + } + + /** + * @return iterable + */ + public static function blankRawConditions(): iterable { + foreach ([ + 'whereRaw', + 'orWhereRaw', + ] as $method) { + yield $method . ' empty' => [ + $method, + '', + ]; + + yield $method . ' whitespace' => [ + $method, + " \n\t", + ]; + } + } + + /** + * Reject blank predicates before adding SQL or bindings to the group. + * + * @dataProvider blankRawConditions + */ + #[DataProvider('blankRawConditions')] + public function test_blank_raw_conditions_do_not_change_existing_conditions(string $method, string $sql): void { + $group = new WhereGroup(new IdentifierQuoter()); + $group->where('id', 1); + + try { + $group->$method($sql, [ + 'discarded', + ]); + $this->fail('Blank SQL must be rejected.'); + } catch (InvalidArgumentException $failure) { + $this->assertSame('A raw condition must contain SQL.', $failure->getMessage()); + $this->assertSame('`id` = ?', $group->condition()->sql); + $this->assertSame([ + 1, + ], $group->condition()->bindings); + } + } + public function test_empty_sets_and_empty_needle_sets_have_explicit_semantics(): void { $group = new WhereGroup(new IdentifierQuoter()); $group->whereIn('id', [])->whereNotIn('id', [])->whereContainsAny('name', []); diff --git a/tests/integration/Database/QueryTest.php b/tests/integration/Database/QueryTest.php index 30cc259..4f056f4 100644 --- a/tests/integration/Database/QueryTest.php +++ b/tests/integration/Database/QueryTest.php @@ -69,6 +69,63 @@ public function test_conditions_compose_and_terminal_reads_leave_the_query_uncha $this->assertNull($this->entries->query()->where('id', 999)->first()); } + /** + * Raw conditions compose with normal groups and retain bound numeric comparisons. + */ + public function test_raw_conditions_preserve_precedence_and_bind_values_by_type(): void { + $this->seedEntries(); + $query = $this->entries->query() + ->selectRaw('? AS marker, id', [ + 'bound projection', + ]) + ->where('active', true) + ->where(static fn (WhereGroup $group) => $group->whereRaw('amount > ?', [ + 15, + ])->orWhereRaw('name = ?', [ + 'First', + ])) + ->whereRaw('? <= ?', [ + 500, + 2000, + ]) + ->whereRaw('(created_at IS NULL OR created_at >= NOW())') + ->orderBy('id'); + $this->assertSame([ + [ + 'marker' => 'bound projection', + 'id' => 1, + ], + [ + 'marker' => 'bound projection', + 'id' => 3, + ], + ], $query->get()); + $this->assertSame([], $this->entries->query()->where('id', 999)->orWhereRaw('name = ?', [ + "First' OR 1 = 1 --", + ])->get()); + } + + /** + * Raw write predicates retain update-value ordering and boolean parameter normalization. + */ + public function test_raw_conditions_apply_to_updates_and_deletes(): void { + $this->seedEntries(); + $this->assertSame(2, $this->entries->query()->whereRaw('amount >= ?', [ + 20, + ])->update([ + 'category' => 'updated', + ])); + $this->assertSame(1, $this->entries->query()->whereRaw('category = ?', [ + 'updated', + ])->whereRaw('active = ?', [ + false, + ])->delete()); + $this->assertSame([ + 1, + 3, + ], $this->entries->query()->orderBy('id')->pluck('id')); + } + /** * Column reads preserve filters, ordering and paging without changing the original projection. */ From b0f967125bb83b6d6f6c3586f721d0ad4f6297ef Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 28 Sep 2026 15:37:52 -0600 Subject: [PATCH 40/53] Add raw query ordering with positional bindings --- src/Database/Query/Compiler.php | 32 +++-- src/Database/Query/Query.php | 21 +++- .../Query/ValueObjects/QueryState.php | 2 +- .../components/database/query-builder.mdx | 27 +++- tests/integration/Database/QueryTest.php | 116 ++++++++++++++++++ 5 files changed, 184 insertions(+), 14 deletions(-) diff --git a/src/Database/Query/Compiler.php b/src/Database/Query/Compiler.php index 2ee90e1..4b2eb07 100644 --- a/src/Database/Query/Compiler.php +++ b/src/Database/Query/Compiler.php @@ -63,9 +63,9 @@ public function select(QueryState $state, ?array $selection = null, ?int $maximu } $limit = $maximum === null ? $state->limit : min($state->limit ?? $maximum, $maximum); - $sql .= $this->tail($ordered ? $state->orders : [], $limit, $state->offset); + $tail = $this->tail($ordered ? $state->orders : [], $limit, $state->offset); - return new Fragment($sql, $bindings); + return new Fragment($sql . $tail->sql, array_merge($bindings, $tail->bindings)); } /** @@ -153,12 +153,14 @@ public function update(QueryState $state, array $values): Fragment { $columns[] = $this->quoter->quote($column) . ' = ?'; } + $tail = $this->tail($state->orders, $state->limit, null); + return new Fragment( 'UPDATE ' . $this->resolver->sql($state->table) . "\nSET " . implode(', ', $columns) . "\nWHERE " . $state->where->sql - . $this->tail($state->orders, $state->limit, null), - array_merge(array_values($values), $state->where->bindings), + . $tail->sql, + array_merge(array_values($values), $state->where->bindings, $tail->bindings), ); } @@ -174,11 +176,13 @@ public function delete(QueryState $state): Fragment { throw new InvalidArgumentException('Delete from an unaliased table for compatibility with supported MySQL and MariaDB versions.'); } + $tail = $this->tail($state->orders, $state->limit, null); + return new Fragment( 'DELETE FROM ' . $this->resolver->sql($state->table) . "\nWHERE " . $state->where->sql - . $this->tail($state->orders, $state->limit, null), - $state->where->bindings, + . $tail->sql, + array_merge($state->where->bindings, $tail->bindings), ); } @@ -216,10 +220,18 @@ private function writable(QueryState $state): void { /** * Append ordering and pagination supported by MySQL and MariaDB. * - * @param list $orders + * @param list $orders */ - private function tail(array $orders, ?int $limit, ?int $offset): string { - $sql = $orders === [] ? '' : "\nORDER BY " . implode(', ', $orders); + private function tail(array $orders, ?int $limit, ?int $offset): Fragment { + $expressions = []; + $bindings = []; + + foreach ($orders as $order) { + $expressions[] = $order->sql; + array_push($bindings, ...$order->bindings); + } + + $sql = $expressions === [] ? '' : "\nORDER BY " . implode(', ', $expressions); if ($limit !== null || $offset !== null) { $sql .= "\nLIMIT " . ($limit ?? '18446744073709551615'); @@ -229,6 +241,6 @@ private function tail(array $orders, ?int $limit, ?int $offset): string { $sql .= ' OFFSET ' . $offset; } - return $sql; + return new Fragment($sql, $bindings); } } diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php index 1bfa0c0..9c9e487 100644 --- a/src/Database/Query/Query.php +++ b/src/Database/Query/Query.php @@ -35,7 +35,7 @@ final class Query extends WhereGroup private array $groups = []; /** - * @var list + * @var list */ private array $orders = []; private bool $distinct = false; @@ -159,7 +159,24 @@ public function orderBy(string $column, string $direction = 'asc'): self { throw new InvalidArgumentException('Order direction must be asc or desc.'); } - $this->orders[] = $this->quoter->column($column) . ' ' . $direction; + $this->orders[] = new Fragment($this->quoter->column($column) . ' ' . $direction); + + return $this; + } + + /** + * Append an application-owned ordering expression with positional bound values. + * + * @param list $bindings Values for ? placeholders, in SQL order. + * + * @throws InvalidArgumentException When the ordering expression is blank. + */ + public function orderByRaw(string $sql, array $bindings = []): self { + if (trim($sql) === '') { + throw new InvalidArgumentException('A raw ordering expression must contain SQL.'); + } + + $this->orders[] = new Fragment($sql, $bindings); return $this; } diff --git a/src/Database/Query/ValueObjects/QueryState.php b/src/Database/Query/ValueObjects/QueryState.php index 05026de..659934e 100644 --- a/src/Database/Query/ValueObjects/QueryState.php +++ b/src/Database/Query/ValueObjects/QueryState.php @@ -15,7 +15,7 @@ * @param list $selection * @param list $joins * @param list $groups - * @param list $orders + * @param list $orders */ public function __construct( public TableReference $table, diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index 0c5abfc..32752b0 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -122,6 +122,31 @@ $rows = $this->reports->query() `whereRaw()` joins with `AND`; `orWhereRaw()` joins with `OR`. SQL fragments are used as supplied, so include parentheses around alternatives within a fragment or use a grouped callback around multiple conditions. Blank fragments are rejected. Keep SQL text and identifiers application-owned: bindings protect values, not SQL assembled from user input. Raw conditions work with reads, updates, and deletes. +### Order by a database expression + +Use `orderBy()` for ordinary columns and `orderByRaw()` when ordering requires an application-owned SQL expression. For example, sort by priority, then put reports with an expiration date before those without one within each priority: + +```php +$rows = $this->reports->query() + ->orderBy( 'priority' ) + ->orderByRaw( 'IF(expires_at IS NULL, 1, 0) ASC' ) + ->orderBy( 'expires_at' ) + ->get(); +``` + +Bind external values using the optional second argument. This example puts reports in a preferred category first, ordered by ID, followed by all remaining reports ordered by ID: + +```php +$rows = $this->reports->query() + ->orderByRaw( 'IF(category = ?, 0, 1) ASC', [ + $preferredCategory, + ] ) + ->orderBy( 'id' ) + ->get(); +``` + +Ordering calls append in declaration order and work with reads and supported single-table updates and deletes. Include `ASC` or `DESC` in the raw SQL when needed. Bindings use the same normalization as ordinary query values; they protect values, not SQL text or identifiers. Blank expressions are rejected. Simple aggregates omit ordering and its bindings. Aggregates over paginated, grouped, distinct, or raw selections retain them in the inner query. + ### Read one column Use `pluck()` to retrieve a plain list of column values: @@ -334,7 +359,7 @@ $connection->executeStatement( ); ``` -Native Doctrine execution exceptions remain unchanged. Foundation validation failures reject invalid query construction before execution. Keep raw SQL and expressions application-owned; `selectRaw()`, `whereRaw()`, and `orWhereRaw()` do not make untrusted SQL safe. +Native Doctrine execution exceptions remain unchanged. Foundation validation failures reject invalid query construction before execution. Keep raw SQL and expressions application-owned; raw query methods do not make untrusted SQL safe. ## Choose a transaction boundary diff --git a/tests/integration/Database/QueryTest.php b/tests/integration/Database/QueryTest.php index 4f056f4..0610642 100644 --- a/tests/integration/Database/QueryTest.php +++ b/tests/integration/Database/QueryTest.php @@ -126,6 +126,122 @@ public function test_raw_conditions_apply_to_updates_and_deletes(): void { ], $this->entries->query()->orderBy('id')->pluck('id')); } + /** + * Raw ordering composes with column ordering and survives repeated terminal reads. + */ + public function test_raw_ordering_preserves_order_bindings_and_query_state(): void { + $this->seedEntries(); + $query = $this->entries->query()->where('id', '>', 0) + ->orderByRaw('active = ? DESC', [ + true, + ]) + ->orderByRaw('id = ? DESC', [ + 3, + ]) + ->orderBy('id'); + $sql = $query->toSql(); + $this->assertSame([ + 3, + 1, + 2, + ], $query->pluck('id')); + $this->assertSame(3, $query->first()['id'] ?? null); + $this->assertSame(3, $query->count()); + $this->assertSame('60.00', $query->sum('amount')); + $this->assertSame('30.00', $query->max('amount')); + $this->assertTrue($query->exists()); + $this->assertSame(3, $query->get()[0]['id']); + $this->assertSame($sql, $query->toSql()); + + $query->limit(1); + $this->assertSame(1, $query->count()); + $this->assertSame('20.00', $query->sum('amount')); + $this->assertSame('20.00', $query->max('amount')); + $this->assertTrue($query->exists()); + $query->offset(1); + $this->assertSame('10.00', $query->sum('amount')); + } + + /** + * Ordering parameters follow projection, join, filter and HAVING parameters. + */ + public function test_raw_ordering_follows_all_other_read_bindings(): void { + $this->seedEntries(); + $query = $this->entries->query('e') + ->selectRaw('? AS marker, e.category, SUM(e.amount) AS total', [ + 'bound projection', + ]) + ->join('foundation_consumer_entries as other', static fn (JoinClause $join) => $join + ->on('e.id', '=', 'other.id')->where('other.id', '>', 0)) + ->where('e.amount', '>=', 10) + ->groupBy('e.category') + ->having('total', '>', 15) + ->orderByRaw('e.category <=> ? DESC', [ + null, + ]); + $this->assertSame([ + [ + 'marker' => 'bound projection', + 'category' => null, + 'total' => '20.00', + ], + [ + 'marker' => 'bound projection', + 'category' => 'a', + 'total' => '40.00', + ], + ], $query->get()); + $this->assertSame(2, $query->count()); + $this->assertSame('60.00', $query->sum('total')); + } + + /** + * Ordered writes bind SET and WHERE values before ordering parameters. + */ + public function test_raw_ordering_selects_rows_for_limited_writes(): void { + $this->seedEntries(); + $this->assertSame(1, $this->entries->query()->where('id', '>', 0) + ->orderByRaw('id = ? DESC', [ + 2, + ])->limit(1)->update([ + 'category' => 'chosen', + ])); + $this->assertSame(2, $this->entries->query()->where('category', 'chosen')->first()['id'] ?? null); + $this->assertSame(1, $this->entries->query()->where('active', true) + ->orderByRaw('id = ? DESC', [ + 3, + ])->limit(1)->delete()); + $this->assertSame([ + 1, + 2, + ], $this->entries->query()->orderBy('id')->pluck('id')); + } + + /** + * Reject blank ordering without retaining its unused bindings or changing the query. + */ + public function test_blank_raw_ordering_leaves_existing_order_unchanged(): void { + $this->seedEntries(); + $query = $this->entries->query()->orderBy('id', 'desc'); + $sql = $query->toSql(); + + try { + $query->orderByRaw(" \t\n", [ + 'unused', + ]); + $this->fail('Blank raw ordering must be rejected.'); + } catch (InvalidArgumentException $failure) { + $this->assertSame('A raw ordering expression must contain SQL.', $failure->getMessage()); + } + + $this->assertSame($sql, $query->toSql()); + $this->assertSame([ + 3, + 2, + 1, + ], $query->pluck('id')); + } + /** * Column reads preserve filters, ordering and paging without changing the original projection. */ From 69fe122d5fcfdf1bea150422a39e45b7106806cf Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 28 Sep 2026 16:17:47 -0600 Subject: [PATCH 41/53] Add atomic query increments and decrements --- src/Database/Query/Compiler.php | 47 +++++- src/Database/Query/Query.php | 24 +++ .../components/database/query-builder.mdx | 18 +++ tests/integration/Database/QueryTest.php | 151 ++++++++++++++++++ 4 files changed, 236 insertions(+), 4 deletions(-) diff --git a/src/Database/Query/Compiler.php b/src/Database/Query/Compiler.php index 4b2eb07..8d244a0 100644 --- a/src/Database/Query/Compiler.php +++ b/src/Database/Query/Compiler.php @@ -141,8 +141,6 @@ public function exists(QueryState $state): Fragment { * @throws InvalidArgumentException When values are empty or the query cannot be used for an update. */ public function update(QueryState $state, array $values): Fragment { - $this->writable($state); - if ($values === []) { throw new InvalidArgumentException('An update requires at least one column value.'); } @@ -153,14 +151,55 @@ public function update(QueryState $state, array $values): Fragment { $columns[] = $this->quoter->quote($column) . ' = ?'; } + return $this->updateStatement($state, new Fragment(implode(', ', $columns), array_values($values))); + } + + /** + * Compile an atomic addition to a column. + * + * @throws InvalidArgumentException When the column, amount, or update options are invalid. + */ + public function increment(QueryState $state, string $column, int|float $amount): Fragment { + return $this->arithmetic($state, $column, '+', $amount); + } + + /** + * Compile an atomic subtraction from a column. + * + * @throws InvalidArgumentException When the column, amount, or update options are invalid. + */ + public function decrement(QueryState $state, string $column, int|float $amount): Fragment { + return $this->arithmetic($state, $column, '-', $amount); + } + + /** + * Bind the amount without performing arithmetic or decimal conversion in PHP. + */ + private function arithmetic(QueryState $state, string $column, string $operator, int|float $amount): Fragment { + if (is_float($amount) && ! is_finite($amount)) { + throw new InvalidArgumentException('An increment or decrement amount must be a finite number.'); + } + + $quoted = $this->quoter->quote($column); + + return $this->updateStatement($state, new Fragment($quoted . ' = ' . $quoted . ' ' . $operator . ' ?', [ + $amount, + ])); + } + + /** + * Apply shared update restrictions and keep assignment, filter and ordering bindings in SQL order. + */ + private function updateStatement(QueryState $state, Fragment $assignments): Fragment { + $this->writable($state); $tail = $this->tail($state->orders, $state->limit, null); return new Fragment( 'UPDATE ' . $this->resolver->sql($state->table) - . "\nSET " . implode(', ', $columns) + . "\nSET " . $assignments->sql . "\nWHERE " . $state->where->sql . $tail->sql, - array_merge(array_values($values), $state->where->bindings, $tail->bindings), + array_merge($assignments->bindings, $state->where->bindings, $tail->bindings), ); } diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php index 9c9e487..2fd14e7 100644 --- a/src/Database/Query/Query.php +++ b/src/Database/Query/Query.php @@ -354,6 +354,30 @@ public function update(array $values): int|string { return $this->executor->statement($this->compiler->update($this->state(), $values)); } + /** + * Add an amount to a column in one update, returning the affected-row count. + * + * @param int|float $amount Integer amounts retain integer binding; floats use approximate arithmetic. + * + * @throws InvalidArgumentException When the column, amount, filtering, or query options are invalid. + * @throws Exception When execution fails. + */ + public function increment(string $column, int|float $amount = 1): int|string { + return $this->executor->statement($this->compiler->increment($this->state(), $column, $amount)); + } + + /** + * Subtract an amount from a column in one update, returning the affected-row count. + * + * @param int|float $amount Integer amounts retain integer binding; floats use approximate arithmetic. + * + * @throws InvalidArgumentException When the column, amount, filtering, or query options are invalid. + * @throws Exception When execution fails. + */ + public function decrement(string $column, int|float $amount = 1): int|string { + return $this->executor->statement($this->compiler->decrement($this->state(), $column, $amount)); + } + /** * Delete rows matching this query's conditions. * diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index 32752b0..2ea653a 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -303,6 +303,24 @@ Large writes split into multiple statements within the parameter limit. Each sta `update()` and `delete()` require conditions, retain supported ordering and limits, and reject joins, projections, distinct, grouping, having, or offsets. `insert()`, `insertGetId()`, and `upsert()` reject previously accumulated query conditions, projections, ordering, limits, and other shaping state, even for empty input. Start from a fresh query for those operations. +### Increment or decrement a column + +Use `increment()` and `decrement()` to change a numeric column in one database statement: + +```php +$affected = $this->reports->query() + ->where( 'id', $id ) + ->increment( 'views' ); + +$affected = $this->credits->query() + ->where( 'id', $id ) + ->decrement( 'credits_remaining', 5 ); +``` + +The amount defaults to `1`. Both methods return affected-row counts as `int|string`, require conditions, and support the same ordering and limits as `update()`. The database applies the arithmetic to the current column value, avoiding a separate read and write in PHP. They participate in an existing transaction without starting one themselves. + +Amounts accept integers and finite floats. Negative amounts reverse the direction; zero leaves values unchanged. SQL arithmetic leaves a `NULL` column as `NULL`. The column's type and database SQL mode govern rounding and overflow. Integer amounts retain integer binding; floating-point amounts use approximate arithmetic. For exact decimal arithmetic, use the native connection with an explicit SQL decimal cast appropriate to your column instead of converting a decimal string to a PHP float. + ### Upsert using existing keys `upsert( $rows, $update )` uses the table's existing primary and unique keys. The required update list names the columns to change on conflict. There is no conflict-target argument: MySQL and MariaDB choose conflicts from all existing primary and unique keys. diff --git a/tests/integration/Database/QueryTest.php b/tests/integration/Database/QueryTest.php index 0610642..9212508 100644 --- a/tests/integration/Database/QueryTest.php +++ b/tests/integration/Database/QueryTest.php @@ -5,6 +5,8 @@ use DateTimeImmutable; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; use StellarWP\Foundation\Database\Exceptions\TransactionFailed; use StellarWP\Foundation\Database\Query\Database; use StellarWP\Foundation\Database\Query\Executor; @@ -807,6 +809,155 @@ public function test_shaped_writes_reject_unhonored_state(): void { } } + /** + * Arithmetic updates support defaults, signed amounts and fractions without mutating the query. + */ + public function test_increment_and_decrement_change_only_matching_rows(): void { + $this->seedEntries(); + $query = $this->entries->query()->where('id', 1); + $sql = $query->toSql(); + $this->assertSame(1, $query->increment('amount')); + $this->assertSame('11.00', $query->first()['amount'] ?? null); + $this->assertSame(1, $query->decrement('amount')); + $this->assertSame('10.00', $query->first()['amount']); + $this->assertSame(1, $query->increment('amount', 2.5)); + $this->assertSame(1, $query->decrement('amount', 0.25)); + $this->assertSame('12.25', $query->first()['amount']); + $this->assertSame(1, $query->increment('amount', -2)); + $this->assertSame(1, $query->decrement('amount', -3)); + $query->increment('amount', 0); + $this->assertSame('13.25', $query->first()['amount']); + $this->assertSame($sql, $query->toSql()); + $this->assertSame('30.00', $this->entries->query()->where('id', 2)->first()['amount'] ?? null); + $this->assertSame(0, $this->entries->query()->where('id', 999)->decrement('amount')); + } + + /** + * The arithmetic operand precedes WHERE and ORDER BY bindings in limited updates. + */ + public function test_arithmetic_updates_honor_ordering_and_limits(): void { + $this->seedEntries(); + $query = $this->entries->query()->where('active', true) + ->orderByRaw('id = ? DESC', [ + 3, + ])->limit(1); + $this->assertSame(1, $query->increment('amount', 5)); + $this->assertSame('25.00', $this->entries->query()->where('id', 3)->first()['amount'] ?? null); + $this->assertSame(1, $query->decrement('amount', 2)); + $this->assertSame('23.00', $this->entries->query()->where('id', 3)->first()['amount'] ?? null); + $this->assertSame('10.00', $this->entries->query()->where('id', 1)->first()['amount'] ?? null); + } + + /** + * Integer parameters preserve large integer values and SQL leaves null operands null. + */ + public function test_arithmetic_preserves_large_integers_and_nulls(): void { + $this->observer->executeStatement('ALTER TABLE ' . $this->entries->quotedName() . ' ADD counter BIGINT NULL'); + $this->seedEntries(); + $query = $this->entries->query()->where('id', 1); + $query->update([ + 'counter' => '9007199254740992', + ]); + $query->increment('counter'); + $this->assertSame('9007199254740993', (string) ($query->first()['counter'] ?? '')); + $query->decrement('counter'); + $this->assertSame('9007199254740992', (string) ($query->first()['counter'] ?? '')); + $this->entries->query()->where('id', 2)->increment('counter'); + $this->assertSame([ + null, + ], $this->entries->query()->where('id', 2)->pluck('counter')); + } + + /** + * Arithmetic uses the caller's transaction and preserves its escaping business failure. + */ + public function test_arithmetic_updates_participate_in_managed_transactions(): void { + $this->seedEntries(); + $failure = new RuntimeException('Cancel the operation.'); + + try { + $this->db->transactional(function () use ($failure): void { + $this->entries->query()->where('id', 1)->increment('amount', 5); + $this->entries->query()->where('id', 2)->decrement('amount', 3); + + throw $failure; + }); + } catch (RuntimeException $caught) { + $this->assertSame($failure, $caught); + } + + $this->assertSame('10.00', $this->entries->query()->where('id', 1)->first()['amount'] ?? null); + $this->assertSame('30.00', $this->entries->query()->where('id', 2)->first()['amount'] ?? null); + $this->db->transactional(fn () => $this->entries->query()->where('id', 1)->increment('amount', 5)); + $this->assertSame('15.00', $this->observer->fetchOne('SELECT amount FROM ' . $this->entries->quotedName() . ' WHERE id = 1')); + } + + /** + * Arithmetic shares ordinary update restrictions and rejects invalid operands before execution. + * + * @dataProvider invalidArithmetic + */ + #[DataProvider('invalidArithmetic')] + public function test_arithmetic_rejects_invalid_requests(string $method, string $option, string $column, int|float $amount): void { + $query = $this->entries->query(); + + if ($option !== 'unfiltered') { + $query->where('id', 1); + } + + if ($option === 'projection') { + $query->select('amount'); + } + + $this->expectException(InvalidArgumentException::class); + $query->{$method}($column, $amount); + } + + /** + * Invalid requests must fail equally for both arithmetic operations. + * + * @return iterable + */ + public static function invalidArithmetic(): iterable { + foreach ([ + 'increment', + 'decrement', + ] as $method) { + foreach ([ + 'unfiltered' => [ + 'unfiltered', + 'amount', + 1, + ], + 'projection' => [ + 'projection', + 'amount', + 1, + ], + 'column' => [ + 'filtered', + 'amount + 1', + 1, + ], + 'infinity' => [ + 'filtered', + 'amount', + INF, + ], + 'not-a-number' => [ + 'filtered', + 'amount', + NAN, + ], + ] as $name => $arguments) { + yield $method . '-' . $name => [ + $method, + ...$arguments, + ]; + } + } + } + private function smallChunkWriter(): InsertWriter { return new InsertWriter( $this->container->get(Executor::class), From 10e1e90d9886bfd9ece4b7ee2b5d974a1ec9a8a1 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 28 Sep 2026 16:26:43 -0600 Subject: [PATCH 42/53] Add query locking reads with lockForUpdate --- src/Database/Query/Compiler.php | 14 +- src/Database/Query/Query.php | 22 ++- .../Query/ValueObjects/QueryState.php | 1 + .../components/database/query-builder.mdx | 45 +++-- tests/integration/Database/QueryTest.php | 154 ++++++++++++++++++ 5 files changed, 219 insertions(+), 17 deletions(-) diff --git a/src/Database/Query/Compiler.php b/src/Database/Query/Compiler.php index 8d244a0..cd8f666 100644 --- a/src/Database/Query/Compiler.php +++ b/src/Database/Query/Compiler.php @@ -65,7 +65,13 @@ public function select(QueryState $state, ?array $selection = null, ?int $maximu $limit = $maximum === null ? $state->limit : min($state->limit ?? $maximum, $maximum); $tail = $this->tail($ordered ? $state->orders : [], $limit, $state->offset); - return new Fragment($sql . $tail->sql, array_merge($bindings, $tail->bindings)); + $sql .= $tail->sql; + + if ($state->forUpdate) { + $sql .= "\nFOR UPDATE"; + } + + return new Fragment($sql, array_merge($bindings, $tail->bindings)); } /** @@ -231,7 +237,7 @@ public function delete(QueryState $state): Fragment { * @throws InvalidArgumentException When query options would be ignored by an insert. */ public function insertable(QueryState $state): void { - if ($state->selection !== [] || $state->joins !== [] || $state->where->sql !== '' || $this->shaped($state) || $state->orders !== []) { + if ($state->selection !== [] || $state->joins !== [] || $state->where->sql !== '' || $this->shaped($state) || $state->orders !== [] || $state->forUpdate) { throw new InvalidArgumentException('Start a fresh table query before inserting or upserting rows.'); } } @@ -247,6 +253,10 @@ private function shaped(QueryState $state): bool { * Reject writes whose filters or query options cannot be honored. */ private function writable(QueryState $state): void { + if ($state->forUpdate) { + throw new InvalidArgumentException('lockForUpdate() applies to reads; start a fresh query for writes.'); + } + if ($state->where->sql === '') { throw new InvalidArgumentException('Add a condition before updating or deleting; use Table::deleteAll() for an intentional full-table delete.'); } diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php index 2fd14e7..acb4741 100644 --- a/src/Database/Query/Query.php +++ b/src/Database/Query/Query.php @@ -37,10 +37,11 @@ final class Query extends WhereGroup /** * @var list */ - private array $orders = []; - private bool $distinct = false; - private ?int $limit = null; - private ?int $offset = null; + private array $orders = []; + private bool $distinct = false; + private bool $forUpdate = false; + private ?int $limit = null; + private ?int $offset = null; private WhereGroup $having; /** @@ -211,6 +212,18 @@ public function offset(int $rows): self { return $this; } + /** + * Request a FOR UPDATE read on the shared connection. + * + * Execute inside transactional() to retain InnoDB locks until commit or rollback. + * This option does not start a transaction; the database determines which records are locked. + */ + public function lockForUpdate(): self { + $this->forUpdate = true; + + return $this; + } + /** * Read all selected rows as associative arrays. * @@ -426,6 +439,7 @@ private function state(): QueryState { $this->orders, $this->limit, $this->offset, + $this->forUpdate, ); } diff --git a/src/Database/Query/ValueObjects/QueryState.php b/src/Database/Query/ValueObjects/QueryState.php index 659934e..2482a1f 100644 --- a/src/Database/Query/ValueObjects/QueryState.php +++ b/src/Database/Query/ValueObjects/QueryState.php @@ -28,6 +28,7 @@ public function __construct( public array $orders, public ?int $limit, public ?int $offset, + public bool $forUpdate, ) { } } diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index 2ea653a..d1f2a5d 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -410,6 +410,33 @@ final readonly class Create_Report { Nested transactions use savepoints. An inner success is provisional until the outer transaction commits. An inner business exception can be caught after its savepoint is rolled back. A database statement failure prevents further work until Foundation confirms rollback to the original nested savepoint. The application can then catch the failure and decide whether to continue. Foundation does not select recoverable error codes or retry the work. +### Lock rows while making a decision + +Use `lockForUpdate()` inside a transaction when you need to read a row before deciding how to change it: + +```php +$this->connection->transactional( function () use ( $id, $amount ): void { + $credit = $this->credits->query() + ->where( 'id', $id ) + ->lockForUpdate() + ->first(); + + if ( $credit === null || $credit['credits_remaining'] < $amount ) { + throw new \RuntimeException( 'Insufficient credits.' ); + } + + $this->credits->query() + ->where( 'id', $id ) + ->decrement( 'credits_remaining', $amount ); +} ); +``` + +`lockForUpdate()` adds `FOR UPDATE` to the SELECT and works with `first()`, `get()`, and `pluck()`. For InnoDB tables, conflicting writes and locking reads wait until the owning transaction commits or rolls back. Ordinary nonlocking reads can still read a committed snapshot. Lock scope depends on the query, indexes, and isolation level and may include scanned records or gaps beyond the returned rows. + +The method does not begin a transaction. With autocommit enabled and no active transaction, it does not protect a later update. Keep the read and related writes inside the same `transactional()` callback, and perform decisions there before it returns. A nested transaction's successful return does not release the outer transaction's locks. + +Queries retain the locking option for subsequent reads and clones. Start a fresh query for writes; insert, update, and delete operations reject this read-only option. Aggregates and `exists()` pass the locking clause to their underlying SELECT; use a row read when the application needs to inspect and lock particular records. Database lock timeouts and deadlocks propagate through the existing transaction failure handling. + ### Recover from an expected duplicate Wrap an insert that may conflict in a nested `transactional()` call and catch `UniqueConstraintViolationException` outside that call. Foundation attempts to roll back the nested writes before returning control to the catch block. After successful rollback, earlier outer work remains provisional and the outer transaction can continue. If cleanup fails, the original exception still reaches the catch block, but further work and commit are rejected. Savepoint rollback does not necessarily release every lock acquired during the nested work. @@ -428,21 +455,17 @@ return $this->connection->transactional( function () use ( $slug, $title ): int| ] ), ); } catch ( UniqueConstraintViolationException $failure ) { - $id = $this->connection->fetchOne( - "SELECT id - FROM {$this->reports->quotedName()} - WHERE slug = ? - FOR UPDATE", - [ - $slug, - ], - ); + $report = $this->reports->query() + ->select( 'id' ) + ->where( 'slug', $slug ) + ->lockForUpdate() + ->first(); - if ( $id === false ) { + if ( $report === null ) { throw $failure; } - return $id; + return $report['id']; } } ); ``` diff --git a/tests/integration/Database/QueryTest.php b/tests/integration/Database/QueryTest.php index 9212508..50dafbb 100644 --- a/tests/integration/Database/QueryTest.php +++ b/tests/integration/Database/QueryTest.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Tests\Integration\Database; use DateTimeImmutable; +use Doctrine\DBAL\Exception\LockWaitTimeoutException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; @@ -958,6 +959,159 @@ public static function invalidArithmetic(): iterable { } } + /** + * A locking read holds the row after nested success and releases it with the outer transaction. + * + * @dataProvider lockingReads + */ + #[DataProvider('lockingReads')] + public function test_locking_reads_block_another_writer_until_commit_or_rollback(string $method, bool $rollback): void { + $this->seedEntries(); + $this->observer->executeStatement('SET SESSION innodb_lock_wait_timeout = 1'); + $failure = new RuntimeException('Cancel the locked operation.'); + + try { + $this->db->transactional(function () use ($method, $rollback, $failure): void { + $this->db->transactional(function () use ($method): void { + $query = $this->entries->query()->where('id', 1)->lockForUpdate(); + $rows = $method === 'pluck' ? $query->pluck('name') : $query->{$method}(); + $this->assertNotEmpty($rows); + }); + + $this->assertSame('First', $this->observer->fetchOne('SELECT name + FROM ' . $this->entries->quotedName() . ' + WHERE id = 1')); + + try { + $this->observer->executeStatement('UPDATE ' . $this->entries->quotedName() . " + SET name = 'Competing write' + WHERE id = 1"); + $this->fail('The locking read must block another writer.'); + } catch (LockWaitTimeoutException) { + $this->assertTrue($this->db->isTransactionActive()); + } + + if ($rollback) { + throw $failure; + } + }); + } catch (RuntimeException $caught) { + $this->assertSame($failure, $caught); + } + + $this->assertSame(1, $this->observer->executeStatement('UPDATE ' . $this->entries->quotedName() . " + SET name = 'Released' + WHERE id = 1")); + $this->assertSame('Released', $this->entries->query()->where('id', 1)->first()['name'] ?? null); + } + + /** + * Each row-reading terminal must carry the lock through both outer completion paths. + * + * @return iterable + */ + public static function lockingReads(): iterable { + foreach ([ + 'first', + 'get', + 'pluck', + ] as $method) { + yield $method . '-commit' => [ + $method, + false, + ]; + + yield $method . '-rollback' => [ + $method, + true, + ]; + } + } + + /** + * Locking SELECTs keep pagination, parameters, aggregate wrappers and independent clone state. + */ + public function test_locking_reads_preserve_query_options_and_bindings(): void { + $this->seedEntries(); + $original = $this->entries->query()->where('id', '>', 0)->orderByRaw('id + ?', [ + 0, + ])->limit(2)->offset(1); + $query = clone $original; + $query->lockForUpdate()->lockForUpdate(); + $sql = $query->toSql(); + $this->assertStringEndsWith("LIMIT 2 OFFSET 1\nFOR UPDATE", $sql); + $this->assertStringNotContainsString('FOR UPDATE', $original->toSql()); + $this->assertSame([ + 0, + 0, + ], $query->getBindings()); + $this->assertFalse($this->db->isTransactionActive()); + $this->db->transactional(function () use ($query): void { + $this->assertSame(2, $query->first()['id'] ?? null); + $this->assertSame([ + 2, + 3, + ], $query->pluck('id')); + $this->assertCount(2, $query->get()); + $this->assertSame(2, $query->count()); + $this->assertSame('30.00', $query->max('amount')); + $this->assertSame('50.00', $query->sum('amount')); + $this->assertTrue($query->exists()); + $this->assertSame(3, $this->entries->query()->lockForUpdate()->count()); + $this->assertTrue($this->entries->query()->where('id', 1)->lockForUpdate()->exists()); + $this->assertFalse($this->entries->query()->lockForUpdate()->limit(0)->exists()); + }); + $this->assertSame($sql, $query->toSql()); + } + + /** + * Write terminals must not silently ignore a requested locking read. + * + * @param 'insert'|'insertGetId'|'upsert'|'update'|'increment'|'decrement'|'delete' $method + * + * @dataProvider lockedWrites + */ + #[DataProvider('lockedWrites')] + public function test_writes_reject_the_read_lock_option(string $method): void { + $query = $this->entries->query()->where('id', 1)->lockForUpdate(); + $this->expectException(InvalidArgumentException::class); + + match ($method) { + 'insert' => $this->entries->query()->lockForUpdate()->insert([]), + 'insertGetId' => $this->entries->query()->lockForUpdate()->insertGetId([]), + 'upsert' => $this->entries->query()->lockForUpdate()->upsert([], [ + 'name', + ]), + 'update' => $query->update([ + 'name' => 'Changed', + ]), + 'increment' => $query->increment('amount'), + 'decrement' => $query->decrement('amount'), + 'delete' => $query->delete(), + }; + } + + /** + * Every write terminal must honor the read-only option boundary. + * + * @return iterable + */ + public static function lockedWrites(): iterable { + foreach ([ + 'insert', + 'insertGetId', + 'upsert', + 'update', + 'increment', + 'decrement', + 'delete', + ] as $method) { + yield $method => [ + $method, + ]; + } + } + private function smallChunkWriter(): InsertWriter { return new InsertWriter( $this->container->get(Executor::class), From 0f77da2f958707b6021d280dc8d49751d1577cf3 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 29 Sep 2026 08:52:05 -0600 Subject: [PATCH 43/53] Support DBAL 4.5 and aliases for joined tables --- AGENTS.md | 4 +- composer.json | 2 +- src/Database/Connection/AdvisoryLock.php | 14 +- .../Connection/BorrowedConnection.php | 9 +- src/Database/Connection/ErrorReporter.php | 38 +++++ src/Database/Connection/WordPressSession.php | 13 +- src/Database/DatabaseProvider.php | 6 +- src/Database/Query/Query.php | 43 ++++-- src/Database/README.md | 4 +- src/Database/composer.json | 2 +- .../src/content/docs/components/database.mdx | 4 +- .../components/database/query-builder.mdx | 38 ++++- .../content/docs/components/migrations.mdx | 4 +- src/Migrations/README.md | 2 +- src/Migrations/Schema/Blueprint.php | 2 +- .../Schema/Renames/ChangeColumnRename.php | 5 +- src/Migrations/Schema/SchemaPlanner.php | 2 +- .../Database/Connection/ErrorReporterTest.php | 73 ++++++++++ .../Unit/Migrations/ColumnDefinitionTest.php | 5 +- .../Database/ErrorReporterTest.php | 115 +++++++++++++++ .../Database/NativeBehaviorTest.php | 17 +++ tests/integration/Database/QueryTest.php | 136 +++++++++++++++++- .../Migrations/CommonColumnTest.php | 44 +++--- .../Migrations/ForeignKeyMigrationTest.php | 21 ++- 24 files changed, 538 insertions(+), 65 deletions(-) create mode 100644 src/Database/Connection/ErrorReporter.php create mode 100644 tests/Unit/Database/Connection/ErrorReporterTest.php create mode 100644 tests/integration/Database/ErrorReporterTest.php diff --git a/AGENTS.md b/AGENTS.md index 85d95d4..165674d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,7 +169,7 @@ Register infrastructure providers and top-level feature providers from the appli ## Database -`stellarwp/foundation-database` provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress transaction behavior. `stellarwp/foundation-migrations` owns migration discovery, schema declarations and planning, history, failure handling, and migration commands. Developers use Doctrine directly for database expressions and advanced operations. Foundation owns the bounded fluent query API and its SQL compilation. Doctrine owns connection execution, parameter binding, native result handling, schema inspection, and platform schema SQL generation. Foundation owns provider defaults, WordPress connection integration, resource naming, and operational guarantees. Foundation 2.0 targets Doctrine DBAL `^4.4` on PHP 8.3, with its MySQL 5.7.9+ and MariaDB 10.4.3+ server minimums. Keep these requirements visible in the Database and Migrations guides. DBAL 3 support is deferred until the Rector downgrade feature is implemented; it is not a Foundation 2.0 release requirement. Keep dependency-major differences confined to internal connection and schema boundaries, and validate dependency behavior separately from Rector syntax conversion before widening constraints. Future DBAL major lines require a deliberate compatibility decision. +`stellarwp/foundation-database` provides a configured Doctrine DBAL connection, convenient application tables, and reliable WordPress transaction behavior. `stellarwp/foundation-migrations` owns migration discovery, schema declarations and planning, history, failure handling, and migration commands. Developers use Doctrine directly for database expressions and advanced operations. Foundation owns the bounded fluent query API and its SQL compilation. Doctrine owns connection execution, parameter binding, native result handling, schema inspection, and platform schema SQL generation. Foundation owns provider defaults, WordPress connection integration, resource naming, and operational guarantees. Foundation 2.0 targets Doctrine DBAL `~4.5.0` on PHP 8.3, with its MySQL 5.7.9+ and MariaDB 10.4.3+ server minimums. Keep these requirements visible in the Database and Migrations guides. DBAL 3 support is deferred until the Rector downgrade feature is implemented; it is not a Foundation 2.0 release requirement. Keep dependency-major differences confined to internal connection and schema boundaries, and validate dependency behavior separately from Rector syntax conversion before widening constraints. Scope mysqli exception reporting to synchronous driver calls and restore the prior mode before returning to application code. Keep DBAL-specific schema editor and type APIs internal; a future DBAL 3 build requires behavioral adaptation as well as PHP syntax conversion. Allow DBAL patch updates within the supported minor line; validate new minor and major lines explicitly before widening the constraint. Use Foundation's fluent query builder for ordinary application queries, including escaped substring searches and multi-row writes. Keep its initial surface bounded to demonstrated consumer requirements; Laravel-inspired names do not imply full Laravel parity. Use Doctrine's native `Connection`, query builder, results, exceptions, and schema objects for advanced operations and escape hatches. Do not introduce execution facades or gateway contracts that merely forward native APIs. Keep contracts for meaningful Foundation-owned policies and supported variations. Exposed Doctrine types and behavior are intentional compatibility commitments; do not imply that replacing Doctrine will be transparent to consumers. Preserve Foundation-specific exceptions for scope, migration, and managed-transaction failures. @@ -193,7 +193,7 @@ Split packages live in `src//` and are split to read-only repositories When adding a new split package, set its package `composer.json` PHP constraint to `>=8.3` unless the user explicitly says otherwise. PHP 7.4 release compatibility will be handled later by an automated Rector downgrade workflow, not by lowering the package PHP constraint during development. -Doctrine DBAL is an explicit exception to the legacy dependency floor: Foundation 2.0 uses `^4.4`, as described in Database above. +Doctrine DBAL is an explicit exception to the legacy dependency floor: Foundation 2.0 uses `~4.5.0`, as described in Database above. When adding other external dependencies for split packages, choose version constraints whose package line supports PHP 7.4. Use `>=` constraints for those dependencies instead of caret constraints when preserving the PHP 7.4-compatible floor matters. For example, use a Symfony component version such as `>=5.4` rather than a newer line that requires PHP 8+. diff --git a/composer.json b/composer.json index c6fb5b6..3acdc9d 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "ext-mysqli": "*", "adbario/php-dot-notation": ">=2.5", "arokettu/random-polyfill": ">=1.0.6 <1.99", - "doctrine/dbal": "^4.4", + "doctrine/dbal": "~4.5.0", "lucatume/di52": ">=4.1", "monolog/monolog": "^2.11", "nikic/php-parser": ">=5.0 <6.0", diff --git a/src/Database/Connection/AdvisoryLock.php b/src/Database/Connection/AdvisoryLock.php index dd0041c..6587724 100644 --- a/src/Database/Connection/AdvisoryLock.php +++ b/src/Database/Connection/AdvisoryLock.php @@ -31,13 +31,14 @@ final class AdvisoryLock public function __construct( private readonly mysqli $native, private readonly DatabaseScope $scope, + private readonly ErrorReporter $reporter, string $resource, ) { $this->driver = new Connection($native); $this->connectionId = $native->thread_id; $this->site = $scope->capture(); $this->prefix = $scope->resolveTableName(''); - $this->name = hash('sha256', $this->driver->query('SELECT DATABASE()')->fetchOne() . "\0" . $resource); + $this->name = hash('sha256', $this->reporter->run(fn () => $this->driver->query('SELECT DATABASE()')->fetchOne()) . "\0" . $resource); } /** @@ -47,14 +48,15 @@ public function __construct( * @throws Throwable When ambient transaction state or the database prevents acquisition. */ public function acquire(): void { - if ((int) $this->driver->query('SELECT @@session.autocommit')->fetchOne() !== 1) { + if ((int) $this->reporter->run(fn () => $this->driver->query('SELECT @@session.autocommit')->fetchOne()) !== 1) { throw new AdvisoryLockInterrupted('Locked database operations require autocommit to be enabled.'); } + // Reject an ambient transaction without implicitly committing it. - $this->driver->exec('SET TRANSACTION READ WRITE'); + $this->reporter->run(fn () => $this->driver->exec('SET TRANSACTION READ WRITE')); try { - $result = $this->driver->query("SELECT GET_LOCK('{$this->name}', 0)")->fetchOne(); + $result = $this->reporter->run(fn () => $this->driver->query("SELECT GET_LOCK('{$this->name}', 0)")->fetchOne()); } catch (Throwable $failure) { // The server may have acquired the lock before its acknowledgement was lost. try { @@ -92,7 +94,7 @@ public function check(mixed $current): void { throw new AdvisoryLockInterrupted('The locked connection or table prefix changed.'); } - if ((int) $this->driver->query("SELECT IS_USED_LOCK('{$this->name}')")->fetchOne() !== $this->connectionId) { + if ((int) $this->reporter->run(fn () => $this->driver->query("SELECT IS_USED_LOCK('{$this->name}')")->fetchOne()) !== $this->connectionId) { throw new AdvisoryLockInterrupted('The session no longer owns its advisory lock.'); } } catch (Throwable $failure) { @@ -120,7 +122,7 @@ public function release(): void { throw new AdvisoryLockInterrupted('The original locked connection was lost.'); } - if ((int) $this->driver->query("SELECT RELEASE_LOCK('{$this->name}')")->fetchOne() !== 1) { + if ((int) $this->reporter->run(fn () => $this->driver->query("SELECT RELEASE_LOCK('{$this->name}')")->fetchOne()) !== 1) { throw new AdvisoryLockInterrupted('The database did not confirm advisory lock release.'); } } catch (Throwable $failure) { diff --git a/src/Database/Connection/BorrowedConnection.php b/src/Database/Connection/BorrowedConnection.php index 447fd96..24c3278 100644 --- a/src/Database/Connection/BorrowedConnection.php +++ b/src/Database/Connection/BorrowedConnection.php @@ -7,6 +7,7 @@ use Doctrine\DBAL\Driver\Result; use Doctrine\DBAL\Driver\Statement; use mysqli; +use mysqli_sql_exception; use StellarWP\Foundation\Database\Exceptions\DatabaseException; /** @@ -64,8 +65,12 @@ public function exec(string $sql): int|string { */ public function beginTransaction(): void { $this->session->execute($this->native, function (): void { - if (! $this->native->begin_transaction()) { - throw new DatabaseException('Unable to start the transaction: ' . $this->native->error); + try { + if (! $this->native->begin_transaction()) { + throw new DatabaseException('Unable to start the transaction: ' . $this->native->error); + } + } catch (mysqli_sql_exception $failure) { + throw new DatabaseException('Unable to start the transaction: ' . $failure->getMessage(), 0, $failure); } }); } diff --git a/src/Database/Connection/ErrorReporter.php b/src/Database/Connection/ErrorReporter.php new file mode 100644 index 0000000..ff983d8 --- /dev/null +++ b/src/Database/Connection/ErrorReporter.php @@ -0,0 +1,38 @@ +report_mode; + $driver->report_mode = MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT; + + try { + return $operation(); + } finally { + $driver->report_mode = $mode; + } + } +} diff --git a/src/Database/Connection/WordPressSession.php b/src/Database/Connection/WordPressSession.php index 34c5dfa..e928839 100644 --- a/src/Database/Connection/WordPressSession.php +++ b/src/Database/Connection/WordPressSession.php @@ -32,6 +32,7 @@ final class WordPressSession implements AdvisorySession public function __construct( private readonly wpdb $wpdb, private readonly DatabaseScope $scope, + private readonly ErrorReporter $reporter, ) { } @@ -74,7 +75,8 @@ public function withAdvisoryLock(string $resource, Closure $operation): mixed { if ($this->advisoryLock !== null || $this->isActive()) { throw new DatabaseException('Start advisory-locked work outside an existing transaction or locked operation.'); } - $lock = new AdvisoryLock($this->native(), $this->scope, $resource); + + $lock = new AdvisoryLock($this->native(), $this->scope, $this->reporter, $resource); $lock->acquire(); $this->advisoryLock = $lock; @@ -106,12 +108,13 @@ public function start(): void { $native = $this->native(); $driver = new Connection($native); - if ((int) $driver->query('SELECT @@session.autocommit')->fetchOne() !== 1) { + if ((int) $this->reporter->run(static fn () => $driver->query('SELECT @@session.autocommit')->fetchOne()) !== 1) { throw new DatabaseException('Managed transactions require autocommit to be enabled.'); } + // Both MySQL and MariaDB reject this inside an existing transaction. // Unlike START TRANSACTION, it cannot implicitly commit the caller's work. - $driver->exec('SET TRANSACTION READ WRITE'); + $this->reporter->run(static fn () => $driver->exec('SET TRANSACTION READ WRITE')); $this->site = $this->scope->capture(); $this->prefix = $this->scope->resolveTableName(''); $this->owned = $native; @@ -136,7 +139,7 @@ public function execute(mysqli $native, Closure $operation): mixed { throw new DatabaseException('The WordPress connection changed; start a fresh operation.'); } - return $operation(); + return $this->reporter->run($operation); } catch (Throwable $failure) { $this->advisoryLock?->fail($failure); @@ -193,7 +196,7 @@ public function rollback(): bool { } try { - if ($this->owned->rollback()) { + if ($this->reporter->run(fn (): bool => $this->owned->rollback())) { return true; } } catch (Throwable) { diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index 7ba98e0..57eb94a 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\Schema\DefaultSchemaManagerFactory; use StellarWP\Foundation\Container\Contracts\Provider; use StellarWP\Foundation\Container\Contracts\Resolver as C; +use StellarWP\Foundation\Database\Connection\ErrorReporter; use StellarWP\Foundation\Database\Connection\WordPressConnection; use StellarWP\Foundation\Database\Connection\WordPressMiddleware; use StellarWP\Foundation\Database\Connection\WordPressSession; @@ -43,11 +44,14 @@ public function register(): void { $this->container->singleton(Builder::class, ServerBuilder::class); $this->container->singleton(Query\Database::class); $this->container->singleton(WordPressSession::class); + $this->container->singleton(ErrorReporter::class); $this->container->singleton(AdvisorySession::class, static fn (C $c): WordPressSession => $c->get(WordPressSession::class)); $this->container->singleton(Connection::class, static function (C $c): Connection { $config = new Configuration(); $config->setSchemaManagerFactory(new DefaultSchemaManagerFactory()); - $config->setMiddlewares([new WordPressMiddleware($c->get(WordPressSession::class))]); + $config->setMiddlewares([ + new WordPressMiddleware($c->get(WordPressSession::class)), + ]); return DriverManager::getConnection([ 'driver' => 'mysqli', diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php index acb4741..87afd83 100644 --- a/src/Database/Query/Query.php +++ b/src/Database/Query/Query.php @@ -99,24 +99,38 @@ public function distinct(): self { * Require a matching row using column relationships or a join closure. * * @param string|Closure(JoinClause): mixed $first + * @param string|null $alias Replaces any alias already supplied with the table. * - * @throws InvalidArgumentException When a table, column, or join condition is invalid. + * @throws InvalidArgumentException When a table, alias, column, or join condition is invalid. * @throws DatabaseException When the application table name is invalid. */ - public function join(Table|string|TableReference $table, string|Closure $first, ?string $operator = null, ?string $second = null): self { - return $this->addJoin('INNER', $table, $first, $operator, $second); + public function join( + Table|string|TableReference $table, + string|Closure $first, + ?string $operator = null, + ?string $second = null, + ?string $alias = null, + ): self { + return $this->addJoin('INNER', $table, $first, $operator, $second, $alias); } /** * Include the source row even when no joined row matches. * * @param string|Closure(JoinClause): mixed $first + * @param string|null $alias Replaces any alias already supplied with the table. * - * @throws InvalidArgumentException When a table, column, or join condition is invalid. + * @throws InvalidArgumentException When a table, alias, column, or join condition is invalid. * @throws DatabaseException When the application table name is invalid. */ - public function leftJoin(Table|string|TableReference $table, string|Closure $first, ?string $operator = null, ?string $second = null): self { - return $this->addJoin('LEFT', $table, $first, $operator, $second); + public function leftJoin( + Table|string|TableReference $table, + string|Closure $first, + ?string $operator = null, + ?string $second = null, + ?string $alias = null, + ): self { + return $this->addJoin('LEFT', $table, $first, $operator, $second, $alias); } /** @@ -448,7 +462,14 @@ private function state(): QueryState { * * @param string|Closure(JoinClause): mixed $first */ - private function addJoin(string $type, Table|string|TableReference $table, string|Closure $first, ?string $operator, ?string $second): self { + private function addJoin( + string $type, + Table|string|TableReference $table, + string|Closure $first, + ?string $operator, + ?string $second, + ?string $alias, + ): self { $clause = new JoinClause($this->quoter); if ($first instanceof Closure) { @@ -467,8 +488,14 @@ private function addJoin(string $type, Table|string|TableReference $table, strin throw new InvalidArgumentException('A join requires at least one ON condition.'); } + $reference = $this->resolver->resolve($table); + + if ($alias !== null) { + $reference = $reference->as($alias); + } + $this->joins[] = new Fragment( - $type . ' JOIN ' . $this->resolver->sql($this->resolver->resolve($table)) . ' ON ' . $condition->sql, + $type . ' JOIN ' . $this->resolver->sql($reference) . ' ON ' . $condition->sql, $condition->bindings, ); diff --git a/src/Database/README.md b/src/Database/README.md index 7b1b84d..fbcbe4d 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -8,8 +8,10 @@ and managed transactions for WordPress applications. ## Installation +Requires PHP 8.3, mysqli, and Doctrine DBAL **4.5.x** (`~4.5.0`). + Requires MySQL **5.7.9+** or MariaDB **10.4.3+**, matching -[Doctrine DBAL 4.4 platform support](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.4/reference/platforms.html). +[Doctrine DBAL 4.5 platform support](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.5/reference/platforms.html). ```shell composer require stellarwp/foundation-database diff --git a/src/Database/composer.json b/src/Database/composer.json index ae7e655..d2d7e7d 100644 --- a/src/Database/composer.json +++ b/src/Database/composer.json @@ -10,7 +10,7 @@ "require": { "php": ">=8.3", "stellarwp/foundation-container": "^2.0", - "doctrine/dbal": "^4.4", + "doctrine/dbal": "~4.5.0", "ext-mysqli": "*" }, "autoload": { diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index e1dfcd3..54139c0 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -17,10 +17,10 @@ Install the runtime package: composer require stellarwp/foundation-database ``` -Foundation requires PHP 8.3, mysqli, and Doctrine DBAL 4.4. +Foundation requires PHP 8.3, mysqli, and Doctrine DBAL **4.5.x** (`~4.5.0`). :::caution[Database requirements] -Use MySQL **5.7.9+** or MariaDB **10.4.3+**, matching [Doctrine DBAL 4.4’s supported platforms](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.4/reference/platforms.html). Check the database version on every deployment target before adopting Foundation. +Use MySQL **5.7.9+** or MariaDB **10.4.3+**, matching [Doctrine DBAL 4.5’s supported platforms](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.5/reference/platforms.html). Check the database version on every deployment target before adopting Foundation. ::: diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index d1f2a5d..a4aff47 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -209,20 +209,52 @@ A shaped column aggregate must name an output column or alias of the inner query ## Join application and WordPress tables +Pass injected application tables to `join()` or `leftJoin()`. Use `alias:` to give the joined table a query-local name: + +```php +$rows = $this->reports->query( 'r' ) + ->join( + $this->categories, + 'c.id', + '=', + 'r.category_id', + alias: 'c', + ) + ->select( 'r.id', 'r.title', 'c.name as category' ) + ->get(); +``` + +For grouped ON conditions or bound values, pass a callback receiving `JoinClause`. An unmatched `leftJoin()` preserves the source row: + +```php +use StellarWP\Foundation\Database\Query\JoinClause; + +$rows = $this->reports->query( 'r' ) + ->leftJoin( + $this->categories, + static fn ( JoinClause $join ) => $join + ->on( 'c.id', '=', 'r.category_id' ) + ->where( 'c.active', true ), + alias: 'c', + ) + ->select( 'r.id', 'r.title', 'c.name as category' ) + ->get(); +``` + +`join()` and `leftJoin()` also accept unprefixed application names and resolved table references. The optional `alias:` argument replaces any alias already supplied with the table; omitting it preserves that alias. Pass application table objects directly rather than their physical `name()`, which would be treated as an unprefixed string and prefixed again. + Inject `StellarWP\Foundation\Database\Query\Database` when a query needs a table entry point independent of a concrete application table. `DatabaseProvider` supplies its dependencies automatically. `table()` accepts a `Table`, an unprefixed application name, or a `Query\ValueObjects\TableReference`, with an optional alias argument. `wordpress()` returns a resolved reference to a known WordPress core table. It honors WordPress's site and network table properties, including custom users tables. References are resolved when created; build a fresh query after switching sites between operations. ```php $rows = $this->db->table( $this->reports, 'r' ) - ->join( $this->db->wordpress( 'users' )->as( 'u' ), 'u.ID', '=', 'r.owner_id' ) + ->join( $this->db->wordpress( 'users' ), 'u.ID', '=', 'r.owner_id', alias: 'u' ) ->select( 'r.id', 'r.title', 'u.user_email' ) ->where( 'r.status', 'ready' ) ->get(); ``` -`join()` and `leftJoin()` accept the same table kinds. A callback receives `JoinClause` for grouped ON conditions and bound value predicates. Pass application table objects directly; passing their physical `name()` as an unprefixed string would prefix the name again. - ## Write rows Insert one row and retrieve its generated identifier: diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index cee1f9d..244e4b1 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -14,7 +14,9 @@ Unlike WordPress’s [`dbDelta()`](https://developer.wordpress.org/reference/fun ### Set up migrations :::caution[Database requirements] -Use MySQL **5.7.9+** or MariaDB **10.4.3+**, matching [Doctrine DBAL 4.4’s supported platforms](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.4/reference/platforms.html). Check the database version on every deployment target before adopting Foundation. +Foundation Database requires Doctrine DBAL **4.5.x** (`~4.5.0`). + +Use MySQL **5.7.9+** or MariaDB **10.4.3+**, matching [Doctrine DBAL 4.5’s supported platforms](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.5/reference/platforms.html). Check the database version on every deployment target before adopting Foundation. ::: Install the migrations runtime and development CLI. The runtime includes Foundation Database and the WP-CLI integration: diff --git a/src/Migrations/README.md b/src/Migrations/README.md index d321a3e..f1ae2f1 100644 --- a/src/Migrations/README.md +++ b/src/Migrations/README.md @@ -9,7 +9,7 @@ version history, and database advisory locking. ## Installation Requires PHP 8.3 and MySQL **5.7.9+** or MariaDB **10.4.3+**, matching -[Doctrine DBAL 4.4 platform support](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.4/reference/platforms.html). +[Doctrine DBAL 4.5 platform support](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.5/reference/platforms.html). ```shell composer require stellarwp/foundation-migrations diff --git a/src/Migrations/Schema/Blueprint.php b/src/Migrations/Schema/Blueprint.php index 5af39f5..3567ae6 100644 --- a/src/Migrations/Schema/Blueprint.php +++ b/src/Migrations/Schema/Blueprint.php @@ -110,7 +110,7 @@ public function rename(string $from, string $to): void { public function drop(Table|string $table): void { $name = $this->resolve($table); $this->operations[] = function () use ($name): void { - $this->state->schema->dropTable($name); + $this->state->schema = $this->state->schema->edit()->dropTableByUnquotedName($name)->create(); foreach (array_keys($this->state->timestamps) as $key) { if (str_starts_with($key, strtolower($name) . '.')) { diff --git a/src/Migrations/Schema/Renames/ChangeColumnRename.php b/src/Migrations/Schema/Renames/ChangeColumnRename.php index 5db3feb..9cc9a39 100644 --- a/src/Migrations/Schema/Renames/ChangeColumnRename.php +++ b/src/Migrations/Schema/Renames/ChangeColumnRename.php @@ -10,6 +10,7 @@ use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; use Doctrine\DBAL\Types\JsonType; +use Doctrine\DBAL\Types\Type; use StellarWP\Foundation\Migrations\Schema\Renames\Contracts\ColumnRename; /** @@ -87,9 +88,9 @@ public function sql(Table $table, string $from, string $to): array { */ private function definition(array $row, Column $column): string { $platform = $this->db->getDatabasePlatform(); - $json = $column->getType() instanceof JsonType; + $json = Type::getType($column->getTypeName()) instanceof JsonType; // MariaDB reports JSON as LONGTEXT; restating JSON preserves its validation constraint. - $sql = $json ? $platform->getJsonTypeDeclarationSQL($column->toArray()) : (string) $row['COLUMN_TYPE']; + $sql = $json ? $platform->getJsonTypeDeclarationSQL($column->toArray(true)) : (string) $row['COLUMN_TYPE']; if (! $json && $row['CHARACTER_SET_NAME'] !== null) { $sql .= ' CHARACTER SET ' . $platform->quoteSingleIdentifier((string) $row['CHARACTER_SET_NAME']); diff --git a/src/Migrations/Schema/SchemaPlanner.php b/src/Migrations/Schema/SchemaPlanner.php index 0a2752c..be777fd 100644 --- a/src/Migrations/Schema/SchemaPlanner.php +++ b/src/Migrations/Schema/SchemaPlanner.php @@ -117,7 +117,7 @@ private function timestampSql(SchemaState $actual, SchemaState $desired, SchemaD } $quotedTable = $platform->quoteSingleIdentifier($table->getObjectName()->toString()); - $declaration = $platform->getColumnDeclarationSQL($platform->quoteSingleIdentifier($column->getObjectName()->toString()), $column->toArray()); + $declaration = $platform->getColumnDeclarationSQL($platform->quoteSingleIdentifier($column->getObjectName()->toString()), $column->toArray(true)); $sql[] = "ALTER TABLE {$quotedTable} MODIFY {$declaration}"; } diff --git a/tests/Unit/Database/Connection/ErrorReporterTest.php b/tests/Unit/Database/Connection/ErrorReporterTest.php new file mode 100644 index 0000000..7057412 --- /dev/null +++ b/tests/Unit/Database/Connection/ErrorReporterTest.php @@ -0,0 +1,73 @@ + + */ + public static function modes(): iterable { + yield 'WordPress reporting disabled' => [ + MYSQLI_REPORT_OFF, + ]; + + yield 'caller reporting errors without exceptions' => [ + MYSQLI_REPORT_ERROR, + ]; + } + + /** + * @dataProvider modes + */ + #[DataProvider('modes')] + public function test_nested_calls_restore_reporting(int $mode): void { + $driver = new mysqli_driver(); + $original = $driver->report_mode; + $reporter = new ErrorReporter(); + $driver->report_mode = $mode; + + try { + $reporter->run(function () use ($reporter): void { + $this->assertSame(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT, (new mysqli_driver())->report_mode); + $reporter->run(function (): void { + $this->assertSame(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT, (new mysqli_driver())->report_mode); + }); + $this->assertSame(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT, (new mysqli_driver())->report_mode); + }); + + $this->assertSame($mode, (new mysqli_driver())->report_mode); + } finally { + $driver->report_mode = $original; + } + } + + /** + * @dataProvider modes + */ + #[DataProvider('modes')] + public function test_failure_restores_reporting_and_preserves_the_exception(int $mode): void { + $driver = new mysqli_driver(); + $original = $driver->report_mode; + $failure = new RuntimeException('Driver failure'); + $this->expectExceptionObject($failure); + $driver->report_mode = $mode; + + try { + (new ErrorReporter())->run(static fn () => throw $failure); + } catch (RuntimeException $caught) { + $this->assertSame($failure, $caught); + $this->assertSame($mode, (new mysqli_driver())->report_mode); + + throw $caught; + } finally { + $driver->report_mode = $original; + } + } +} diff --git a/tests/Unit/Migrations/ColumnDefinitionTest.php b/tests/Unit/Migrations/ColumnDefinitionTest.php index 0d131ed..7fc0a85 100644 --- a/tests/Unit/Migrations/ColumnDefinitionTest.php +++ b/tests/Unit/Migrations/ColumnDefinitionTest.php @@ -4,7 +4,6 @@ use Doctrine\DBAL\Schema\Exception\InvalidTableModification; use Doctrine\DBAL\Schema\Table; -use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -50,7 +49,7 @@ public function test_change_replaces_type_default_and_comment(): void { (new ColumnDefinition('value', Types::INTEGER))->change()->applyTo($table); $column = $table->create()->getColumn('value'); - $this->assertSame(Types::INTEGER, Type::lookupName($column->getType())); + $this->assertSame(Types::INTEGER, $column->getTypeName()); $this->assertNull($column->getDefault()); $this->assertSame('', $column->getComment()); $this->assertTrue($column->getNotnull()); @@ -71,6 +70,6 @@ public function test_explicit_zero_precision_matches_omitted_precision(): void { (new ColumnDefinition('created_at', Types::DATETIME_MUTABLE))->useCurrent()->applyTo($omitted); $this->assertSame('CURRENT_TIMESTAMP', $explicit->create()->getColumn('created_at')->getDefault()); - $this->assertSame($omitted->create()->getColumn('created_at')->toArray(), $explicit->create()->getColumn('created_at')->toArray()); + $this->assertSame($omitted->create()->getColumn('created_at')->toArray(true), $explicit->create()->getColumn('created_at')->toArray(true)); } } diff --git a/tests/integration/Database/ErrorReporterTest.php b/tests/integration/Database/ErrorReporterTest.php new file mode 100644 index 0000000..2703096 --- /dev/null +++ b/tests/integration/Database/ErrorReporterTest.php @@ -0,0 +1,115 @@ + + */ + public static function modes(): iterable { + yield 'WordPress reporting disabled' => [ + MYSQLI_REPORT_OFF, + ]; + + yield 'caller reporting errors without exceptions' => [ + MYSQLI_REPORT_ERROR, + ]; + } + + /** + * @dataProvider modes + */ + #[DataProvider('modes')] + public function test_driver_failures_keep_doctrine_exceptions_and_restore_reporting(int $mode): void { + $statement = $this->db->prepare('INSERT INTO ' . $this->table . ' (id, name) VALUES (?, ?)'); + $statement->bindValue(1, 1); + $statement->bindValue(2, 'Duplicate'); + $driver = new mysqli_driver(); + $original = $driver->report_mode; + $driver->report_mode = $mode; + + try { + $this->assertSame('Original', $this->db->fetchOne('SELECT name FROM ' . $this->table)); + $this->assertThat((new mysqli_driver())->report_mode, $this->identicalTo($mode)); + + foreach ([ + fn () => $this->db->prepare('SELECT missing_column FROM ' . $this->table . ' WHERE id = ?'), + fn () => $this->db->executeQuery('SELECT missing_column FROM ' . $this->table), + fn () => $this->db->executeStatement('UPDATE ' . $this->table . ' SET missing_column = 1'), + ] as $operation) { + try { + $operation(); + $this->fail('Invalid SQL must raise a Doctrine exception.'); + } catch (InvalidFieldNameException) { + // The failed operation must restore the caller's reporting mode. + } + + $this->assertThat((new mysqli_driver())->report_mode, $this->identicalTo($mode)); + } + + try { + $statement->executeStatement(); + $this->fail('Prepared execution must raise a Doctrine constraint exception.'); + } catch (UniqueConstraintViolationException) { + // Prepared execution must restore the caller's reporting mode too. + } + + $this->assertThat((new mysqli_driver())->report_mode, $this->identicalTo($mode)); + } finally { + $driver->report_mode = $original; + } + } + + public function test_transaction_and_advisory_callbacks_keep_wordpress_reporting(): void { + $driver = new mysqli_driver(); + $original = $driver->report_mode; + $driver->report_mode = MYSQLI_REPORT_OFF; + + try { + $this->db->transactional(function (): void { + $this->assertSame(MYSQLI_REPORT_OFF, (new mysqli_driver())->report_mode); + $this->assertSame('Original', $this->db->fetchOne('SELECT name FROM ' . $this->table)); + $this->assertSame(MYSQLI_REPORT_OFF, (new mysqli_driver())->report_mode); + }); + + $this->assertSame(MYSQLI_REPORT_OFF, (new mysqli_driver())->report_mode); + $session = $this->container->get(AdvisorySession::class); + $session->withAdvisoryLock($this->suffix, function (): void { + $this->assertSame(MYSQLI_REPORT_OFF, (new mysqli_driver())->report_mode); + $this->assertSame('Original', $this->db->fetchOne('SELECT name FROM ' . $this->table)); + $this->assertSame(MYSQLI_REPORT_OFF, (new mysqli_driver())->report_mode); + }); + + $this->assertSame(MYSQLI_REPORT_OFF, (new mysqli_driver())->report_mode); + $failure = new RuntimeException('Application failure'); + $caught = null; + + try { + $session->withAdvisoryLock($this->suffix, static fn () => throw $failure); + } catch (RuntimeException $caught) { + // Check identity below so a swallowed failure cannot satisfy the test. + } + + $this->assertSame($failure, $caught); + $this->assertSame(MYSQLI_REPORT_OFF, (new mysqli_driver())->report_mode); + $suppressed = $this->source->suppress_errors(true); + + try { + $this->assertFalse($this->source->query('SELECT missing_column FROM ' . $this->table)); + } finally { + $this->source->suppress_errors($suppressed); + } + } finally { + $driver->report_mode = $original; + } + } +} diff --git a/tests/integration/Database/NativeBehaviorTest.php b/tests/integration/Database/NativeBehaviorTest.php index ed40f37..be1607a 100644 --- a/tests/integration/Database/NativeBehaviorTest.php +++ b/tests/integration/Database/NativeBehaviorTest.php @@ -4,6 +4,7 @@ use Doctrine\DBAL\Exception\ConnectionLost; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use mysqli_driver; use RuntimeException; use StellarWP\Foundation\Tests\Support\Fixtures\Database\DatabaseTestCase; use StellarWP\Foundation\Tests\Support\Fixtures\Database\NativeConnectionFactory; @@ -11,6 +12,22 @@ final class NativeBehaviorTest extends DatabaseTestCase { + private int $reportMode; + + protected function setUp(): void { + parent::setUp(); + $driver = new mysqli_driver(); + $this->reportMode = $driver->report_mode; + // These probes exercise native DBAL with its required PHP-default error reporting. + $driver->report_mode = MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT; + } + + protected function tearDown(): void { + $driver = new mysqli_driver(); + $driver->report_mode = $this->reportMode; + parent::tearDown(); + } + public function test_native_dbal_commits_after_a_caught_statement_failure(): void { $db = (new NativeConnectionFactory())->create($this->native($this->source), constant('DB_NAME')); $db->transactional(function () use ($db): void { diff --git a/tests/integration/Database/QueryTest.php b/tests/integration/Database/QueryTest.php index 50dafbb..7469220 100644 --- a/tests/integration/Database/QueryTest.php +++ b/tests/integration/Database/QueryTest.php @@ -8,6 +8,7 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; +use StellarWP\Foundation\Database\Connection\ErrorReporter; use StellarWP\Foundation\Database\Exceptions\TransactionFailed; use StellarWP\Foundation\Database\Query\Database; use StellarWP\Foundation\Database\Query\Executor; @@ -519,6 +520,136 @@ public function test_join_callbacks_keep_value_conditions_inside_the_on_clause() $this->assertSame(2, $query->limit(2)->count()); } + /** + * A concrete table can be joined repeatedly under independent aliases. + */ + public function test_table_objects_accept_aliases_in_column_joins(): void { + $this->seedEntries(); + $name = $this->entries->name(); + $query = $this->entries->query('e') + ->join($this->entries, 'matching.id', '=', 'e.id', alias: 'matching') + ->leftJoin($this->entries, 'missing.id', '=', 'e.amount', alias: 'missing') + ->select('e.id', 'matching.name', 'missing.name as absent') + ->orderBy('e.id'); + $this->assertSame([ + [ + 'id' => 1, + 'name' => 'First', + 'absent' => null, + ], + [ + 'id' => 2, + 'name' => 'Second', + 'absent' => null, + ], + [ + 'id' => 3, + 'name' => 'Third', + 'absent' => null, + ], + ], $query->get()); + $this->assertSame($name, $this->entries->name()); + $this->assertSame(3, $this->entries->query()->count()); + } + + /** + * Callback joins accept aliases without changing nested ON conditions or binding order. + */ + public function test_table_aliases_work_in_inner_and_left_join_callbacks(): void { + $this->seedEntries(); + $query = $this->entries->query('e') + ->join( + $this->entries, + static fn (JoinClause $join) => $join->on('matching.id', '=', 'e.id'), + alias: 'matching', + ) + ->leftJoin( + $this->entries, + static fn (JoinClause $join) => $join->where(static fn (JoinClause $group) => $group + ->on('active.id', '=', 'matching.id')->where('active.active', true)), + alias: 'active', + ) + ->select('e.id', 'active.name as matched') + ->selectRaw('? AS marker', [ + 'bound marker', + ]) + ->where('e.id', '>', 0) + ->orderBy('e.id'); + $this->assertSame([ + 'bound marker', + true, + 0, + ], $query->getBindings()); + $rows = $query->get(); + $this->assertCount(3, $rows); + $this->assertSame('bound marker', $rows[0]['marker']); + $this->assertSame('First', $rows[0]['matched']); + $this->assertNull($rows[1]['matched']); + $this->assertSame('Third', $rows[2]['matched']); + } + + /** + * An explicit alias replaces an existing name without changing a resolved reference. + */ + public function test_join_aliases_override_string_and_reference_aliases(): void { + $this->seedEntries(); + $reference = new TableReference($this->entries->name(), 'original'); + $query = $this->entries->query('e') + ->join($this->entries->unprefixedName() . ' as original', 'matching.id', '=', 'e.id', alias: 'matching') + ->leftJoin($reference, 'other.id', '=', 'e.id', alias: 'other') + ->select('e.id')->orderBy('e.id'); + $this->assertSame([ + 1, + 2, + 3, + ], $query->pluck('e.id')); + $this->assertSame('original', $reference->alias); + $this->assertSame(3, $this->entries->query('e')->join($reference, 'original.id', '=', 'e.id')->count()); + } + + /** + * Invalid aliases fail before a join is appended to the original query. + * + * @dataProvider invalidJoinAliases + */ + #[DataProvider('invalidJoinAliases')] + public function test_invalid_join_aliases_preserve_query_state(string $method, string $alias): void { + $query = $this->entries->query('e'); + $sql = $query->toSql(); + + try { + $query->{$method}($this->entries, 'matching.id', '=', 'e.id', alias: $alias); + $this->fail('An invalid alias must be rejected.'); + } catch (InvalidArgumentException $failure) { + $this->assertStringContainsString('Invalid table alias:', $failure->getMessage()); + } + + $this->assertSame($sql, $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * Both join methods use the existing table-reference alias validation. + * + * @return iterable + */ + public static function invalidJoinAliases(): iterable { + foreach ([ + 'join', + 'leftJoin', + ] as $method) { + yield $method . '-empty' => [ + $method, + '', + ]; + + yield $method . '-sql' => [ + $method, + 'matching ON 1 = 1', + ]; + } + } + /** * Supported scalar conversions work under strict SQL mode. */ @@ -983,9 +1114,10 @@ public function test_locking_reads_block_another_writer_until_commit_or_rollback WHERE id = 1')); try { - $this->observer->executeStatement('UPDATE ' . $this->entries->quotedName() . " + // The independent native DBAL observer requires strict mysqli reporting too. + (new ErrorReporter())->run(fn () => $this->observer->executeStatement('UPDATE ' . $this->entries->quotedName() . " SET name = 'Competing write' - WHERE id = 1"); + WHERE id = 1")); $this->fail('The locking read must block another writer.'); } catch (LockWaitTimeoutException) { $this->assertTrue($this->db->isTransactionActive()); diff --git a/tests/integration/Migrations/CommonColumnTest.php b/tests/integration/Migrations/CommonColumnTest.php index ad5c764..3167c87 100644 --- a/tests/integration/Migrations/CommonColumnTest.php +++ b/tests/integration/Migrations/CommonColumnTest.php @@ -3,8 +3,8 @@ namespace StellarWP\Foundation\Tests\Integration\Migrations; use Doctrine\DBAL\Schema\Table; -use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; +use Doctrine\Deprecations\Deprecation; use PHPUnit\Framework\Attributes\DataProvider; use StellarWP\Foundation\Migrations\MigrationsProvider; use StellarWP\Foundation\Migrations\Migrator; @@ -162,16 +162,26 @@ public static function renameStrategies(): iterable { public function test_column_types_survive_rename(string $strategy): void { $this->container->singleton(ColumnRename::class, $strategy); $migrator = $this->container->get(Migrator::class); - $migrator->migrate('2'); - $this->insertEntry(); - $migrator->migrate('3'); - $this->assertSame([], $migrator->migrate('3')); - $this->assertSame(Types::JSON, Type::lookupName($this->introspect()->getColumn('metadata_renamed')->getType())); - $this->assertFalse($this->introspect()->getColumn('token_renamed')->getFixed()); - $this->assertTrue($this->introspect()->getColumn('payload_renamed')->getFixed()); - $migrator->rollbackTo('1'); - $this->assertInitialColumns(); - $this->assertEntry(); + // Select platforms before tracking so older supported servers do not obscure API deprecations. + $this->db->getDatabasePlatform(); + $this->observer->getDatabasePlatform(); + Deprecation::enableTrackingDeprecations(); + + try { + $migrator->migrate('2'); + $this->insertEntry(); + $migrator->migrate('3'); + $this->assertSame([], $migrator->migrate('3')); + $this->assertSame(Types::JSON, $this->introspect()->getColumn('metadata_renamed')->getTypeName()); + $this->assertFalse($this->introspect()->getColumn('token_renamed')->getFixed()); + $this->assertTrue($this->introspect()->getColumn('payload_renamed')->getFixed()); + $migrator->rollbackTo('1'); + $this->assertInitialColumns(); + $this->assertEntry(); + $this->assertSame([], Deprecation::getTriggeredDeprecations()); + } finally { + Deprecation::disable(); + } } private function insertEntry(): void { @@ -219,10 +229,10 @@ private function assertEntry(): void { private function assertInitialColumns(): void { $table = $this->introspect(); - $this->assertSame(Types::JSON, Type::lookupName($table->getColumn('metadata')->getType())); - $this->assertSame(Types::DATE_MUTABLE, Type::lookupName($table->getColumn('starts_on')->getType())); - $this->assertSame(Types::TIME_MUTABLE, Type::lookupName($table->getColumn('opens_at')->getType())); - $this->assertSame(Types::SMALLINT, Type::lookupName($table->getColumn('attempts')->getType())); + $this->assertSame(Types::JSON, $table->getColumn('metadata')->getTypeName()); + $this->assertSame(Types::DATE_MUTABLE, $table->getColumn('starts_on')->getTypeName()); + $this->assertSame(Types::TIME_MUTABLE, $table->getColumn('opens_at')->getTypeName()); + $this->assertSame(Types::SMALLINT, $table->getColumn('attempts')->getTypeName()); $this->assertFalse($table->getColumn('metadata')->getNotnull()); $this->assertSame('External metadata', $table->getColumn('metadata')->getComment()); $this->assertTrue($table->getColumn('currency')->getFixed()); @@ -232,8 +242,8 @@ private function assertInitialColumns(): void { $this->assertSame(0, (int) $table->getColumn('attempts')->getDefault()); $this->assertSame(16777215, $table->getColumn('content')->getLength()); $this->assertFalse($table->getColumn('content')->getNotnull()); - $this->assertSame(Types::BINARY, Type::lookupName($table->getColumn('token')->getType())); - $this->assertSame(Types::BINARY, Type::lookupName($table->getColumn('payload')->getType())); + $this->assertSame(Types::BINARY, $table->getColumn('token')->getTypeName()); + $this->assertSame(Types::BINARY, $table->getColumn('payload')->getTypeName()); $this->assertTrue($table->getColumn('token')->getFixed()); $this->assertFalse($table->getColumn('payload')->getFixed()); $this->assertSame(16, $table->getColumn('token')->getLength()); diff --git a/tests/integration/Migrations/ForeignKeyMigrationTest.php b/tests/integration/Migrations/ForeignKeyMigrationTest.php index d91ce0c..684bcb2 100644 --- a/tests/integration/Migrations/ForeignKeyMigrationTest.php +++ b/tests/integration/Migrations/ForeignKeyMigrationTest.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException; use Doctrine\DBAL\Schema\ForeignKeyConstraint; use PHPUnit\Framework\Attributes\DataProvider; +use StellarWP\Foundation\Database\Connection\ErrorReporter; use StellarWP\Foundation\Migrations\Contracts\Migration; use StellarWP\Foundation\Migrations\Exceptions\MigrationInterrupted; use StellarWP\Foundation\Migrations\MigrationsProvider; @@ -75,7 +76,9 @@ public function test_create_enforces_cascade_and_rolls_back_child_before_parent( $migrator = $this->migrations(new CreateOrders($this->suffix), new CreateReferencedItems($this->suffix)); $migrator->migrate(); $this->rows(); - $this->observer->delete($this->orders, ['id' => 1]); + (new ErrorReporter())->run(fn () => $this->observer->delete($this->orders, [ + 'id' => 1, + ])); $this->assertSame([], $this->observer->fetchAllAssociative('SELECT * FROM ' . $this->items)); $this->assertCount(1, $this->constraints()); $this->assertSame([], $migrator->preview()); @@ -89,7 +92,9 @@ public function test_alter_defaults_to_restrict_and_drop_keeps_columns_indexes_a $this->rows(); try { - $this->observer->delete($this->orders, ['id' => 1]); + (new ErrorReporter())->run(fn () => $this->observer->delete($this->orders, [ + 'id' => 1, + ])); $this->fail('Referenced rows must be protected by default.'); } catch (ForeignKeyConstraintViolationException) { $this->assertCount(1, $this->constraints()); @@ -98,7 +103,9 @@ public function test_alter_defaults_to_restrict_and_drop_keeps_columns_indexes_a $migrator->rollback(); $constraints = $this->constraints(); $this->assertSame([], $constraints); - $this->observer->delete($this->orders, ['id' => 1]); + (new ErrorReporter())->run(fn () => $this->observer->delete($this->orders, [ + 'id' => 1, + ])); $this->assertSame(1, (int) $this->observer->fetchOne('SELECT order_id FROM ' . $this->items)); $this->assertTrue($this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_items')->hasIndex('order_lookup')); } @@ -115,7 +122,9 @@ public function test_replacement_changes_actions_and_rollback_restores_them(): v $migrator->rollback(); $this->rows(); $this->expectException(ForeignKeyConstraintViolationException::class); - $this->observer->delete($this->orders, ['id' => 1]); + (new ErrorReporter())->run(fn () => $this->observer->delete($this->orders, [ + 'id' => 1, + ])); } public function test_composite_reference_clears_all_columns_on_update_and_delete(): void { @@ -126,7 +135,9 @@ public function test_composite_reference_clears_all_columns_on_update_and_delete $this->observer->update($this->orders, ['account_id' => 20], ['id' => 1]); $this->assertSame(['order_id' => null, 'account_id' => null], $this->observer->fetchAssociative('SELECT order_id, account_id FROM ' . $this->items)); $this->observer->update($this->items, ['account_id' => 20, 'order_id' => 1], ['id' => 2]); - $this->observer->delete($this->orders, ['id' => 1]); + (new ErrorReporter())->run(fn () => $this->observer->delete($this->orders, [ + 'id' => 1, + ])); $row = $this->observer->fetchAssociative('SELECT order_id, account_id FROM ' . $this->items); $this->assertSame(['order_id' => null, 'account_id' => null], $row); } From 1a0f34b62ccaa0e7f0ab75c3de55fb0ee5ee1ea0 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 29 Sep 2026 09:02:45 -0600 Subject: [PATCH 44/53] Add query pagination with immutable page results --- src/Database/Query/Query.php | 40 ++++ src/Database/Query/ValueObjects/Page.php | 44 +++++ .../components/database/query-builder.mdx | 26 +++ tests/integration/Database/QueryTest.php | 183 ++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 src/Database/Query/ValueObjects/Page.php diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php index 87afd83..8f03ee9 100644 --- a/src/Database/Query/Query.php +++ b/src/Database/Query/Query.php @@ -8,6 +8,7 @@ use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Query\ValueObjects\Fragment; +use StellarWP\Foundation\Database\Query\ValueObjects\Page; use StellarWP\Foundation\Database\Query\ValueObjects\QueryState; use StellarWP\Foundation\Database\Query\ValueObjects\Selection; use StellarWP\Foundation\Database\Query\ValueObjects\TableReference; @@ -250,6 +251,45 @@ public function get(): array { return $this->executor->rows($this->compiler->select($this->state())); } + /** + * Read one page and count all matching results without changing this builder. + * + * Existing limit and offset settings are replaced for pagination. + * Grouping, distinctness, and HAVING remain part of the total. + * The count does not request row locks. + * + * @throws InvalidArgumentException When pagination arguments or query options are invalid. + * @throws Exception When database execution fails. + */ + public function paginate(int $perPage = 25, int $page = 1): Page { + if ($perPage < 1 || $page < 1) { + throw new InvalidArgumentException('Page size and page number must be greater than zero.'); + } + + if ($page - 1 > intdiv(PHP_INT_MAX, $perPage)) { + throw new InvalidArgumentException('The requested page exceeds the supported row offset.'); + } + + $count = clone $this; + $count->limit = null; + $count->offset = null; + $count->orders = []; + $count->forUpdate = false; + + $total = $count->count(); + + $query = clone $this; + $query->limit($perPage); + $query->offset(($page - 1) * $perPage); + + return new Page( + items: $total === 0 ? [] : $query->get(), + total: $total, + perPage: $perPage, + currentPage: $page, + ); + } + /** * Read a column's values using a temporary single-column projection. * diff --git a/src/Database/Query/ValueObjects/Page.php b/src/Database/Query/ValueObjects/Page.php new file mode 100644 index 0000000..bbf3b58 --- /dev/null +++ b/src/Database/Query/ValueObjects/Page.php @@ -0,0 +1,44 @@ +> $items + * @param positive-int $perPage + * @param positive-int $currentPage + */ + public function __construct( + public array $items, + public int $total, + public int $perPage, + public int $currentPage, + ) { + } + + /** + * Return the final page number, using page one for an empty result. + */ + public function lastPage(): int { + if ($this->total === 0) { + return 1; + } + + return intdiv($this->total - 1, $this->perPage) + 1; + } + + /** + * Report whether another page follows the requested page. + */ + public function hasMorePages(): bool { + return $this->currentPage < $this->lastPage(); + } +} diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index a4aff47..2c6b792 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -163,6 +163,32 @@ $ids = $this->reports->query() The call selects only the requested column, replacing any existing `select()` or `selectRaw()` projection for that execution. Other clauses remain in place, and the original builder is unchanged. Use a column name or qualified column such as `r.id`. Any ordering, grouping, or `having()` must remain valid with that single-column selection; aliases from replaced projections are unavailable. +### Paginate results + +Use `paginate()` to retrieve a page of rows together with the total number of matching results: + +```php +$page = $this->reports->query() + ->where( 'status', 'ready' ) + ->orderBy( 'id' ) + ->paginate( perPage: 25, page: 3 ); + +$rows = $page->items; +$total = $page->total; +$currentPage = $page->currentPage; +$perPage = $page->perPage; +$lastPage = $page->lastPage(); +$hasMore = $page->hasMorePages(); +``` + +The default is 25 rows on page one. Supply positive integers for `perPage` and `page`; the application reads and validates its request parameters and builds any navigation links. Order by a unique column, or add a unique column as the final ordering criterion, so page boundaries have a consistent order. + +The returned `Page` contains readonly metadata and rows as associative arrays. An empty result has `total` zero, empty `items`, and `lastPage()` one. A page beyond the last page returns empty `items` and preserves the requested `currentPage`. + +Pagination replaces any existing `limit()` and `offset()` for this operation and leaves the original builder unchanged. The total excludes paging and ordering but preserves filters, joins, distinctness, grouping, and `having()`. For grouped queries, it counts matching groups. The aggregate projection restrictions described below also apply to pagination totals. + +Foundation runs a count query, then fetches the requested rows when the total is nonzero. These are separate reads: concurrent writes can change the data between them, and pagination does not start a transaction. With `lockForUpdate()`, only the page query requests locks; run it inside a managed transaction when those locks must remain held. + ### Grouping and aggregates `count()` returns an integer, `exists()` returns a boolean, and `max( 'column' )` returns the driver's scalar value or null when no value exists. Terminal reads do not alter the builder, so you can count and then fetch the same query. diff --git a/tests/integration/Database/QueryTest.php b/tests/integration/Database/QueryTest.php index 7469220..f87d358 100644 --- a/tests/integration/Database/QueryTest.php +++ b/tests/integration/Database/QueryTest.php @@ -73,6 +73,189 @@ public function test_conditions_compose_and_terminal_reads_leave_the_query_uncha $this->assertNull($this->entries->query()->where('id', 999)->first()); } + /** + * Pagination counts the full filter and leaves an existing page selection unchanged. + */ + public function test_pagination_returns_metadata_and_preserves_the_original_query(): void { + $this->seedEntries(); + $query = $this->entries->query()->where('id', '>', 0)->orderBy('id')->limit(1)->offset(1); + $sql = $query->toSql(); + $bindings = $query->getBindings(); + $page = $query->paginate(perPage: 1, page: 3); + $this->assertSame(3, $page->total); + $this->assertSame(1, $page->perPage); + $this->assertSame(3, $page->currentPage); + $this->assertSame(3, $page->lastPage()); + $this->assertFalse($page->hasMorePages()); + $this->assertSame([ + 3, + ], array_column($page->items, 'id')); + $this->assertSame($sql, $query->toSql()); + $this->assertSame($bindings, $query->getBindings()); + $this->assertSame([ + 2, + ], $query->pluck('id')); + + $first = $query->paginate(perPage: 2); + $this->assertSame([ + 1, + 2, + ], array_column($first->items, 'id')); + $this->assertSame(1, $first->currentPage); + $this->assertSame(2, $first->lastPage()); + $this->assertTrue($first->hasMorePages()); + $this->assertSame(25, $query->paginate()->perPage); + } + + /** + * Grouped joins retain projection and HAVING bindings while counting without ordering bindings. + */ + public function test_pagination_counts_grouped_results_and_preserves_bound_row_ordering(): void { + $this->seedEntries(); + $query = $this->entries->query('e') + ->selectRaw('? AS marker, e.category, SUM(e.amount) AS total', [ + 'bound projection', + ]) + ->join('foundation_consumer_entries as other', static fn (JoinClause $join) => $join + ->on('e.id', '=', 'other.id')->where('other.id', '>', 0)) + ->where('e.amount', '>=', 10) + ->groupBy('e.category') + ->having('total', '>', 15) + ->orderByRaw('e.category <=> ? DESC', [ + null, + ]); + $page = $query->paginate(perPage: 1); + $this->assertSame(2, $page->total); + $this->assertSame([ + [ + 'marker' => 'bound projection', + 'category' => null, + 'total' => '20.00', + ], + ], $page->items); + $this->assertSame('a', $query->paginate(perPage: 1, page: 2)->items[0]['category']); + } + + /** + * Distinct pagination counts projected values rather than underlying rows. + */ + public function test_pagination_counts_distinct_values(): void { + $this->seedEntries(); + $page = $this->entries->query()->select('category')->distinct()->orderBy('category')->paginate(perPage: 1, page: 2); + $this->assertSame(2, $page->total); + $this->assertSame([ + [ + 'category' => 'a', + ], + ], $page->items); + } + + /** + * Explicit joined projections remain available on each page. + */ + public function test_pagination_supports_explicit_joined_projections(): void { + $this->seedEntries(); + $page = $this->entries->query('e')->select('e.id', 'other.name as related_name') + ->join('foundation_consumer_entries as other', 'e.id', '=', 'other.id') + ->orderBy('e.id')->paginate(perPage: 2, page: 2); + $this->assertSame(3, $page->total); + $this->assertSame([ + [ + 'id' => 3, + 'related_name' => 'Third', + ], + ], $page->items); + } + + /** + * Empty filters and pages past the end retain the requested page number. + */ + public function test_pagination_handles_empty_and_out_of_range_pages(): void { + $this->seedEntries(); + $empty = $this->entries->query()->where('id', 999)->paginate(perPage: 2, page: 5); + $this->assertSame([], $empty->items); + $this->assertSame(0, $empty->total); + $this->assertSame(5, $empty->currentPage); + $this->assertSame(1, $empty->lastPage()); + $this->assertFalse($empty->hasMorePages()); + + $beyond = $this->entries->query()->orderBy('id')->paginate(perPage: 2, page: 5); + $this->assertSame([], $beyond->items); + $this->assertSame(3, $beyond->total); + $this->assertSame(5, $beyond->currentPage); + $this->assertSame(2, $beyond->lastPage()); + $this->assertFalse($beyond->hasMorePages()); + } + + /** + * Invalid page settings fail before querying an otherwise missing table. + * + * @dataProvider invalidPagination + */ + #[DataProvider('invalidPagination')] + public function test_pagination_rejects_invalid_arguments(int $perPage, int $page): void { + $query = $this->queries->table('missing_pagination_table'); + $this->expectException(InvalidArgumentException::class); + $query->paginate($perPage, $page); + } + + /** + * Page sizes and page numbers must be positive and yield a representable offset. + * + * @return iterable + */ + public static function invalidPagination(): iterable { + yield 'zero size' => [ + 0, + 1, + ]; + + yield 'negative size' => [ + -1, + 1, + ]; + + yield 'zero page' => [ + 25, + 0, + ]; + + yield 'negative page' => [ + 25, + -1, + ]; + + yield 'overflow' => [ + 2, + PHP_INT_MAX, + ]; + } + + /** + * Pagination locks the selected page without locking rows merely counted for the total. + */ + public function test_pagination_locks_only_the_page_read(): void { + $this->seedEntries(); + $this->observer->executeStatement('SET SESSION innodb_lock_wait_timeout = 1'); + $this->db->transactional(function (): void { + $page = $this->entries->query()->orderBy('id')->lockForUpdate()->paginate(perPage: 1); + $this->assertSame(3, $page->total); + $this->assertSame(1, $page->items[0]['id']); + $this->assertSame(1, (new ErrorReporter())->run(fn () => $this->observer->executeStatement('UPDATE ' . $this->entries->quotedName() . " + SET name = 'Outside the page' + WHERE id = 3"))); + + try { + (new ErrorReporter())->run(fn () => $this->observer->executeStatement('UPDATE ' . $this->entries->quotedName() . " + SET name = 'Inside the page' + WHERE id = 1")); + $this->fail('The page read must lock its selected row.'); + } catch (LockWaitTimeoutException) { + $this->assertTrue($this->db->isTransactionActive()); + } + }); + } + /** * Raw conditions compose with normal groups and retain bound numeric comparisons. */ From 3b0319bfccdb04a799ee31d3b116d6519b70f95a Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 2 Oct 2026 09:45:37 -0600 Subject: [PATCH 45/53] Support marking migration history through a target version --- .../content/docs/components/migrations.mdx | 22 ++- .../src/content/docs/components/wp-cli.mdx | 12 +- src/Migrations/Cli/MarkAppliedCommand.php | 60 +++++--- src/Migrations/Cli/MarkPendingCommand.php | 2 +- src/Migrations/Cli/RollbackCommand.php | 8 +- src/Migrations/Cli/RunCommand.php | 6 +- src/Migrations/Migrator.php | 21 +++ src/WPCli/stubs/command.stub | 8 +- .../register-wpcli-migrate-command.php | 8 ++ .../Migrations/MigrationHistoryTest.php | 134 +++++++++++++++++- .../Database/Cli/DatabaseMigrateCest.php | 84 +++++++++++ 11 files changed, 319 insertions(+), 46 deletions(-) diff --git a/src/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx index 244e4b1..2d11802 100644 --- a/src/Docs/src/content/docs/components/migrations.mdx +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -699,6 +699,26 @@ wp your-plugin migrate:mark-applied 20260923000001_create_reports_table Marking changes history under the same database advisory lock used for migration execution. It may create the ledger, but does not invoke `up()`, `down()`, or data callbacks, plan schema changes, or verify the live database. You own verification of the recorded effects. Each marking command prompts for confirmation; use `--yes` for an already reviewed operation. +#### Mark migrations through an existing version + +When an installation already contains the work of the older migrations but still needs newer ones, mark through the last migration whose complete schema and data effects are present: + +```shell +wp your-plugin migrate:mark-applied --to=20260923000001_create_reports_table +``` + +The command lists registered migrations through that ID, inclusively, before asking for confirmation. It records pending migrations in ascending ID order and preserves existing history timestamps, including records above the target. Later pending migrations remain available for `migrate:run`. + +The target must exactly match a registered migration ID. A history record whose declaration is missing cannot be used as the target. Choose exactly one selection: a positional ID, `--to=`, or `--all`. + +Check the result and preview the remaining work before running newer migrations: + +```shell +wp your-plugin migrate:status +wp your-plugin migrate:run --dry-run +wp your-plugin migrate:run +``` + #### Mark all pending migrations as applied When adopting a complete existing schema, verify that **every currently pending migration's schema and data effects** are fulfilled, then record them together: @@ -716,7 +736,7 @@ wp your-plugin migrate:run --dry-run Later migrations continue through the normal `migrate:run` workflow. -Programmatic callers inject `Migrator` and use `markApplied(string $id): void`, `markAllApplied(): void`, or `markPending(string $id): void`. `markApplied()` requires a registered ID. Repeating it preserves the existing history entry and its timestamp. `markAllApplied()` records all currently registered pending IDs atomically in one transaction, preserving existing entries. A marked migration participates in normal rollback: reversing it invokes its `down()` declaration, with the same potential for data loss as an executed migration. +Programmatic callers inject `Migrator` and use `markApplied(string $id): void`, `markAppliedThrough(string $target): void`, `markAllApplied(): void`, or `markPending(string $id): void`. The single-ID and through-target methods require a registered ID. Repeating a marking operation preserves existing entries and timestamps. The through-target and all-pending methods record their selected pending IDs atomically in one transaction under the migration lock. A marked migration participates in normal rollback: reversing it invokes its `down()` declaration, with the same potential for data loss as an executed migration. ### Interrupted schema changes diff --git a/src/Docs/src/content/docs/components/wp-cli.mdx b/src/Docs/src/content/docs/components/wp-cli.mdx index 34791d5..e4d1e93 100644 --- a/src/Docs/src/content/docs/components/wp-cli.mdx +++ b/src/Docs/src/content/docs/components/wp-cli.mdx @@ -129,8 +129,6 @@ use StellarWP\Foundation\WPCli\Command; use WP_CLI; use Plugin\Catalog\Catalog_Synchronizer; -use function WP_CLI\Utils\get_flag_value; - /** * Synchronizes the product catalog from a configured source. * @@ -156,13 +154,9 @@ final class Sync_Catalog_Command extends Command { * @throws \WP_CLI\ExitException When command input is invalid. */ public function runCommand( array $args = [], array $assocArgs = [] ): int { - $source = (string) ( $args[0] ?? '' ); - $batchSize = absint( get_flag_value( - $assocArgs, - self::OPTION_BATCH_SIZE, - self::DEFAULT_BATCH_SIZE - ) ); - $dryRun = (bool) get_flag_value( $assocArgs, self::FLAG_DRY_RUN, false ); + $source = $args[0] ?? ''; + $batchSize = absint( $assocArgs[self::OPTION_BATCH_SIZE] ?? self::DEFAULT_BATCH_SIZE ); + $dryRun = (bool) ( $assocArgs[self::FLAG_DRY_RUN] ?? false ); if ( $batchSize < 1 ) { WP_CLI::error( __( 'The batch size must be greater than zero.', 'your-plugin' ) ); diff --git a/src/Migrations/Cli/MarkAppliedCommand.php b/src/Migrations/Cli/MarkAppliedCommand.php index d12d9b7..d380ab5 100644 --- a/src/Migrations/Cli/MarkAppliedCommand.php +++ b/src/Migrations/Cli/MarkAppliedCommand.php @@ -3,11 +3,10 @@ namespace StellarWP\Foundation\Migrations\Cli; use StellarWP\Foundation\Migrations\Migrator; +use StellarWP\Foundation\Migrations\ValueObjects\MigrationStatus; use StellarWP\Foundation\WPCli\Command; use WP_CLI; -use function WP_CLI\Utils\get_flag_value; - /** * Record completed work without executing migration declarations. * @@ -29,24 +28,43 @@ public function __construct( * @throws \Throwable When identity validation, history storage, or lock ownership fails. */ public function runCommand(array $args = [], array $assocArgs = []): int { - $id = isset($args[0]) ? (string) $args[0] : null; - $all = (bool) get_flag_value($assocArgs, 'all', false); + $id = $args[0] ?? null; + $all = (bool) ($assocArgs['all'] ?? false); + $target = $assocArgs['to'] ?? null; + + if ((int) ($id !== null) + (int) $all + (int) ($target !== null) !== 1) { + WP_CLI::error('Specify exactly one migration ID, --all, or --to=.'); + } - if (($id === null && ! $all) || ($id !== null && $all)) { - WP_CLI::error('Specify one migration ID or --all.'); + if ($id !== null) { + WP_CLI::confirm('Record ' . $id . ' as applied? Confirm that its complete schema and data work already exists.', $assocArgs); + $this->migrator->markApplied($id); + WP_CLI::success('Recorded ' . $id . ' as applied. No migration work was executed.'); + + return self::SUCCESS; } - if ($all) { - WP_CLI::line('Registered migrations covered by --all (existing records keep their timestamps):'); + $registered = array_filter( + $this->migrator->status(), + static fn (MigrationStatus $status): bool => $status->migration !== null, + ); + + if ($target !== null && ! in_array($target, array_column($registered, 'id'), true)) { + WP_CLI::error('Unknown registered migration target: ' . $target); + } - foreach ($this->migrator->status() as $status) { - if ($status->migration === null) { - continue; - } + $selection = $target === null ? '--all' : '--to=' . $target; + WP_CLI::line('Registered migrations covered by ' . $selection . ' (existing records keep their timestamps):'); - WP_CLI::line($status->id . ' (' . $status->state() . ')'); + foreach ($registered as $status) { + if ($target !== null && strcmp($status->id, $target) > 0) { + continue; } + WP_CLI::line($status->id . ' (' . $status->state() . ')'); + } + + if ($target === null) { WP_CLI::confirm('Record all pending migrations as applied? Confirm that their complete schema and data work already exists.', $assocArgs); $this->migrator->markAllApplied(); WP_CLI::success('All registered migrations are recorded as applied. No migration work was executed.'); @@ -54,9 +72,9 @@ public function runCommand(array $args = [], array $assocArgs = []): int { return self::SUCCESS; } - WP_CLI::confirm('Record ' . $id . ' as applied? Confirm that its complete schema and data work already exists.', $assocArgs); - $this->migrator->markApplied((string) $id); - WP_CLI::success('Recorded ' . $id . ' as applied. No migration work was executed.'); + WP_CLI::confirm('Record pending migrations through ' . $target . ' as applied? Confirm that their complete schema and data work already exists.', $assocArgs); + $this->migrator->markAppliedThrough($target); + WP_CLI::success('Registered migrations through ' . $target . ' are recorded as applied. No migration work was executed.'); return self::SUCCESS; } @@ -72,7 +90,7 @@ protected function subcommand(): string { * {@inheritDoc} */ protected function description(): string { - return 'Record one migration or all pending migrations as already applied.'; + return 'Record one migration, all pending migrations, or pending migrations through a target as already applied.'; } /** @@ -83,7 +101,7 @@ protected function arguments(): array { [ 'type' => self::POSITIONAL, 'name' => 'id', - 'description' => 'Exact migration ID to mark; omit only with --all.', + 'description' => 'Exact migration ID to mark; omit with --all or --to.', 'optional' => true, ], [ @@ -92,6 +110,12 @@ protected function arguments(): array { 'description' => 'Mark every registered pending migration applied without executing its work.', 'optional' => true, ], + [ + 'type' => self::ASSOCIATIVE, + 'name' => 'to', + 'description' => 'Mark pending migrations through this registered ID, inclusively, without executing their work.', + 'optional' => true, + ], [ 'type' => self::FLAG, 'name' => 'yes', diff --git a/src/Migrations/Cli/MarkPendingCommand.php b/src/Migrations/Cli/MarkPendingCommand.php index ee73fb8..5bb3e88 100644 --- a/src/Migrations/Cli/MarkPendingCommand.php +++ b/src/Migrations/Cli/MarkPendingCommand.php @@ -27,7 +27,7 @@ public function __construct( * @throws \Throwable When identity validation, history storage, or lock ownership fails. */ public function runCommand(array $args = [], array $assocArgs = []): int { - $id = (string) $args[0]; + $id = $args[0]; WP_CLI::confirm('Remove the applied record for ' . $id . '? Its work must already be undone; a registered migration can run again.', $assocArgs); $this->migrator->markPending($id); WP_CLI::success('Removed the applied record for ' . $id . '. No migration work was executed.'); diff --git a/src/Migrations/Cli/RollbackCommand.php b/src/Migrations/Cli/RollbackCommand.php index f465b95..a6ee69d 100644 --- a/src/Migrations/Cli/RollbackCommand.php +++ b/src/Migrations/Cli/RollbackCommand.php @@ -28,7 +28,9 @@ public function __construct( * @throws \Throwable When migration validation, execution, or history storage fails. */ public function runCommand(array $args = [], array $assocArgs = []): int { - if (isset($assocArgs['step'], $assocArgs['to'])) { + $target = $assocArgs['to'] ?? null; + + if (isset($assocArgs['step']) && $target !== null) { WP_CLI::error('--step cannot be combined with --to.'); } @@ -44,13 +46,11 @@ public function runCommand(array $args = [], array $assocArgs = []): int { return self::ERROR; } - $target = (string) ($assocArgs['to'] ?? Migrator::LATEST); - if ($target === Migrator::NONE) { WP_CLI::confirm('Roll back all migrations? This can permanently delete application data.', $assocArgs); } - $result = isset($assocArgs['to']) ? $this->migrator->rollbackTo($target) : $this->migrator->rollback($steps); + $result = $target !== null ? $this->migrator->rollbackTo($target) : $this->migrator->rollback($steps); $this->output->showSteps($result); return self::SUCCESS; diff --git a/src/Migrations/Cli/RunCommand.php b/src/Migrations/Cli/RunCommand.php index 16a0c12..93bfccf 100644 --- a/src/Migrations/Cli/RunCommand.php +++ b/src/Migrations/Cli/RunCommand.php @@ -6,8 +6,6 @@ use StellarWP\Foundation\WPCli\Command; use WP_CLI; -use function WP_CLI\Utils\get_flag_value; - /** * Apply pending changes or preview their SQL. * @@ -30,8 +28,8 @@ public function __construct( * @throws \Throwable When migration validation, execution, or history storage fails. */ public function runCommand(array $args = [], array $assocArgs = []): int { - $target = (string) ($assocArgs['to'] ?? Migrator::LATEST); - $dryRun = (bool) get_flag_value($assocArgs, 'dry-run', false); + $target = $assocArgs['to'] ?? Migrator::LATEST; + $dryRun = (bool) ($assocArgs['dry-run'] ?? false); if (! $dryRun && $target === Migrator::NONE) { WP_CLI::confirm('Roll back all migrations? This can permanently delete application data.', $assocArgs); diff --git a/src/Migrations/Migrator.php b/src/Migrations/Migrator.php index 5a6c890..b1a9719 100644 --- a/src/Migrations/Migrator.php +++ b/src/Migrations/Migrator.php @@ -137,6 +137,27 @@ public function markAllApplied(): void { $this->locked(fn () => $this->recordApplied($this->migrations->ids())); } + /** + * Record pending migrations through a registered target in inclusive ID order. + * + * Does not execute or verify migration work. Records the selection in one transaction + * under the migration lock, preserving existing timestamps and all later history. + * Marked migrations remain eligible for ordinary rollback through their down() declarations. + * + * @throws InvalidArgumentException When the target is not registered. + * @throws Throwable When history storage, transaction completion, or lock ownership fails. + */ + public function markAppliedThrough(string $target): void { + $this->migrations->get($target); + $this->locked(function () use ($target): void { + $ids = array_filter( + $this->migrations->ids(), + static fn (string $id): bool => strcmp($id, $target) <= 0, + ); + $this->recordApplied(array_values($ids)); + }); + } + /** * Remove an applied record after its work has been deliberately undone outside the runner. * diff --git a/src/WPCli/stubs/command.stub b/src/WPCli/stubs/command.stub index 02fbeea..6161879 100644 --- a/src/WPCli/stubs/command.stub +++ b/src/WPCli/stubs/command.stub @@ -5,8 +5,6 @@ namespace {{ namespace }}; use {{ foundation_wpcli_command }}; use WP_CLI; -use function WP_CLI\Utils\get_flag_value; - /** * {{ description_doc }} * @@ -21,9 +19,9 @@ final class {{ class }} extends Command { public const string FLAG_DRY_RUN = 'dry-run'; public function runCommand( array $args = [], array $assocArgs = [] ): int { - $item = (string) ( $args[0] ?? '' ); - $format = (string) get_flag_value( $assocArgs, self::OPTION_FORMAT, self::DEFAULT_FORMAT ); - $dryRun = (bool) get_flag_value( $assocArgs, self::FLAG_DRY_RUN, false ); + $item = $args[0] ?? ''; + $format = $assocArgs[self::OPTION_FORMAT] ?? self::DEFAULT_FORMAT; + $dryRun = (bool) ( $assocArgs[self::FLAG_DRY_RUN] ?? false ); WP_CLI::line( sprintf( 'Processing %s using %s output%s.', diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index 19de26c..0f16eb2 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -60,6 +60,14 @@ public function down(Blueprint $schema): void { $container->mergeArrayVar(MigrationsProvider::MIGRATIONS, [ new MigrationRegistration('20260623000001', $migration), ]); + + // Targeted adoption needs a later declaration that remains outside its selection. + if (getenv('FOUNDATION_TEST_LATER_MIGRATION') === '1') { + $container->mergeArrayVar(MigrationsProvider::MIGRATIONS, [ + new MigrationRegistration('20260623000002', $migration), + ]); + } + $context = new CommandContext(new CommandPrefix('foundation')); // WP-CLI is already loaded; consume the real provider contributions at this hook. diff --git a/tests/integration/Migrations/MigrationHistoryTest.php b/tests/integration/Migrations/MigrationHistoryTest.php index 0fedb06..ea28151 100644 --- a/tests/integration/Migrations/MigrationHistoryTest.php +++ b/tests/integration/Migrations/MigrationHistoryTest.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Tests\Integration\Migrations; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use StellarWP\Foundation\Migrations\Contracts\DescribesMigration; use StellarWP\Foundation\Migrations\Contracts\MigratesData; @@ -59,7 +60,7 @@ public function test_adoption_preserves_rows_and_allows_later_real_alteration(): new MigrationRegistration(AddEntryNote::ID, new AddEntryNote($table)), ); - $runner->markApplied(CreateEntries::ID); + $runner->markAppliedThrough(CreateEntries::ID); $this->assertOriginal(); $steps = $runner->migrate(); $this->assertCount(1, $steps); @@ -140,10 +141,75 @@ public function test_mark_all_preserves_existing_and_unregistered_history(): voi $this->assertSame($applied, $this->history()); } + /** + * Inclusive selection uses bytewise ordering and leaves existing history untouched. + */ + public function test_mark_through_selects_pending_ids_inclusively_and_preserves_history(): void { + $migration = $this->unexecutableMigration(); + $runner = $this->runner( + new MigrationRegistration('case', $migration), + new MigrationRegistration('cAse', $migration), + new MigrationRegistration('11', $migration), + new MigrationRegistration('2', $migration), + new MigrationRegistration('Case', $migration), + new MigrationRegistration('10', $migration), + new MigrationRegistration('3', $migration), + ); + $runner->markApplied('10'); + $runner->markApplied('case'); + + foreach ([ + '10', + 'case', + ] as $id) { + $this->observer->update($this->quotedHistory, [ + 'applied_at' => '2001-02-03 04:05:06.123456', + ], [ + 'version' => $id, + ]); + } + + $this->observer->insert($this->quotedHistory, [ + 'version' => 'missing', + 'applied_at' => '2001-02-03 04:05:06.123456', + ]); + $existing = $this->history(); + $runner->markAppliedThrough('2'); + $applied = $this->history(); + $this->assertSame([ + '10', + '11', + '2', + 'case', + 'missing', + ], array_map('strval', array_keys($applied))); + + foreach ($existing as $id => $timestamp) { + $this->assertSame($timestamp, $applied[$id]); + } + + $runner->markAppliedThrough('2'); + $this->assertSame($applied, $this->history()); + $runner->markAppliedThrough('Case'); + $this->assertSame([ + '10', + '11', + '2', + '3', + 'Case', + 'case', + 'missing', + ], array_map('strval', array_keys($this->history()))); + $this->assertOriginal(); + } + /** * A failure after the first batch insert cannot leave a partially adopted history. + * + * @dataProvider markingSelections */ - public function test_mark_all_rolls_back_earlier_inserts_when_a_later_insert_fails(): void { + #[DataProvider('markingSelections')] + public function test_batch_marking_rolls_back_earlier_inserts_when_a_later_insert_fails(bool $through): void { $migration = $this->unexecutableMigration(); $runner = $this->runner( new MigrationRegistration('first', $migration), @@ -163,7 +229,7 @@ public function test_mark_all_rolls_back_earlier_inserts_when_a_later_insert_fai END"); try { - $runner->markAllApplied(); + $through ? $runner->markAppliedThrough('second') : $runner->markAllApplied(); $this->fail('The second insert must fail.'); } catch (Throwable $failure) { $this->assertStringContainsString('Second history insert rejected', $failure->getMessage()); @@ -173,10 +239,26 @@ public function test_mark_all_rolls_back_earlier_inserts_when_a_later_insert_fai $this->assertSame($existing, $this->history()); $this->assertFalse($this->db->isTransactionActive()); - $runner->markAllApplied(); + $through ? $runner->markAppliedThrough('second') : $runner->markAllApplied(); $this->assertCount(3, $this->history()); } + /** + * Exercise both supported bulk history selections. + * + * @return array + */ + public static function markingSelections(): array { + return [ + 'all' => [ + false, + ], + 'through target' => [ + true, + ], + ]; + } + /** * Exact identifiers preserve case distinctions and PHP numeric key handling. */ @@ -276,6 +358,49 @@ public function test_mark_applied_rejects_unknown_ids_without_creating_history() } } + /** + * Unknown targets and execution aliases fail before creating history storage. + */ + public function test_mark_through_rejects_unknown_targets_without_creating_history(): void { + $runner = $this->runner(new MigrationRegistration('Case', $this->unexecutableMigration())); + + foreach ([ + 'missing', + 'case', + '0', + 'latest', + ] as $target) { + try { + $runner->markAppliedThrough($target); + $this->fail('A target must exactly identify a registered migration.'); + } catch (InvalidArgumentException) { + $this->assertHistoryAbsent(); + } + } + } + + /** + * A recorded target without a declaration cannot authorize adopting other work. + */ + public function test_mark_through_rejects_history_only_targets_without_changing_history(): void { + $runner = $this->runner( + new MigrationRegistration('first', $this->unexecutableMigration()), + new MigrationRegistration('last', $this->unexecutableMigration()), + ); + $runner->markApplied('last'); + $this->observer->insert($this->quotedHistory, [ + 'version' => 'missing', + ]); + $existing = $this->history(); + + try { + $runner->markAppliedThrough('missing'); + $this->fail('Ledger-only targets must be rejected.'); + } catch (InvalidArgumentException) { + $this->assertSame($existing, $this->history()); + } + } + /** * The removal boundary rejects invalid ledger identities and target aliases. */ @@ -329,6 +454,7 @@ public function test_another_session_holding_the_lock_blocks_each_marking_operat foreach ([ static fn () => $runner->markApplied('one'), static fn () => $runner->markAllApplied(), + static fn () => $runner->markAppliedThrough('one'), static fn () => $runner->markPending('one'), ] as $operation) { try { diff --git a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php index 9998c3d..2db0318 100644 --- a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -435,6 +435,61 @@ public function test_mark_all_requires_confirmation_and_skips_application_schema $I->seeInShellOutput('No application table created'); } + /** + * Target adoption previews its selection, requires confirmation, and executes no schema work. + */ + public function test_mark_through_requires_confirmation_and_skips_application_schema(WPCLITester $I): void { + $later = [ + 'FOUNDATION_TEST_LATER_MIGRATION' => '1', + ]; + $I->cli([ + 'foundation', + 'migrate:mark-applied', + '--to=20260623000001', + ], $later, "n\n"); + $I->seeInShellOutput('20260623000001 (pending)'); + $I->dontSeeInShellOutput('20260623000002'); + $I->seeInShellOutput('Record pending migrations through 20260623000001 as applied?'); + $I->cli([ + 'foundation', + 'migrate:status', + ], $later); + $I->seeInShellOutput('pending'); + + for ($attempt = 0; $attempt < 2; $attempt++) { + $I->cli([ + 'foundation', + 'migrate:mark-applied', + '--to=20260623000001', + '--yes', + ], $later); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Registered migrations through 20260623000001 are recorded as applied.'); + $I->seeInShellOutput('No migration work was executed.'); + $I->dontSeeInShellOutput('20260623000002'); + } + + $I->cli([ + 'foundation', + 'migrate:status', + ], $later); + $I->seeInShellOutput('applied'); + $I->seeInShellOutput("20260623000002\t20260623000002\tpending"); + $I->cli([ + 'eval', + <<<'PHP' + global $wpdb; + $table = $wpdb->get_var($wpdb->prepare( + 'SHOW TABLES LIKE %s', + $wpdb->esc_like($wpdb->prefix . 'foundation_cli_example'), + )); + echo $table === null ? 'No application table created' : 'Unexpected table'; + PHP, + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('No application table created'); + } + /** * Invalid history requests must leave the migration pending. */ @@ -451,6 +506,34 @@ public function test_history_marking_rejects_ambiguous_or_unknown_requests(WPCLI 'migrate:mark-applied', 'unknown', ], + [ + 'migrate:mark-applied', + '--to=unknown', + ], + [ + 'migrate:mark-applied', + '--to=0', + ], + [ + 'migrate:mark-applied', + '--to=latest', + ], + [ + 'migrate:mark-applied', + '20260623000001', + '--to=20260623000001', + ], + [ + 'migrate:mark-applied', + '--all', + '--to=20260623000001', + ], + [ + 'migrate:mark-applied', + '20260623000001', + '--all', + '--to=20260623000001', + ], [ 'migrate:mark-applied', '20260623000001', @@ -603,6 +686,7 @@ public function test_each_command_has_its_own_help_synopsis(WPCLITester $I): voi 'migrate:mark-applied' => [ '', '--all', + '--to', ], 'migrate:mark-pending' => [ '', From 14c5215892e6e7a2c7117e197e59994d26d3131c Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 2 Oct 2026 10:29:28 -0600 Subject: [PATCH 46/53] Support Monolog 2 and 3 with shared logging integration --- .github/workflows/tests.yml | 35 +++++++++++ composer.json | 6 +- src/Docs/src/content/docs/components/log.mdx | 4 ++ src/Log/Formatters/ColoredLineFormatter.php | 6 +- src/Log/Handlers/NullHandler.php | 9 ++- src/Log/README.md | 3 + src/Log/composer.json | 2 +- .../Feature/Log/Handlers/NullHandlerTest.php | 50 ++++++---------- tests/Feature/Log/StackLoggerTest.php | 2 +- tests/Support/Fixtures/Log/CallbackLogger.php | 32 ++++++++++ .../Formatters/ColoredLineFormatterTest.php | 60 ++++++++++++++----- .../integration/Database/TransactionTest.php | 25 +++----- 12 files changed, 159 insertions(+), 75 deletions(-) create mode 100644 tests/Support/Fixtures/Log/CallbackLogger.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a3a9d2f..654926d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,6 +37,41 @@ jobs: - name: Run Feature Tests run: vendor/bin/phpunit --configuration ./phpunit.xml.dist --colors=always --testsuite Feature + monolog: + runs-on: ubuntu-latest + name: Monolog ${{ matrix.monolog }} compatibility + strategy: + fail-fast: false + matrix: + monolog: [ '2.11.0', '3.10.0' ] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + extensions: redis, mysqli + + - name: Install Composer dependencies + uses: ramsey/composer-install@v4 + with: + dependency-versions: highest + + - name: Install supported Monolog floor + run: composer update monolog/monolog:${{ matrix.monolog }} --with-all-dependencies --no-interaction --prefer-dist + + - name: Analyze supported Monolog APIs + run: composer analyze + + - name: Test logging behavior + run: | + vendor/bin/phpunit --configuration phpunit.xml.dist tests/Unit/Log + vendor/bin/phpunit --configuration phpunit.xml.dist tests/Feature/Log + slic: runs-on: ubuntu-latest name: SLIC Tests (${{ matrix.database.name }}) diff --git a/composer.json b/composer.json index 3acdc9d..10da720 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "arokettu/random-polyfill": ">=1.0.6 <1.99", "doctrine/dbal": "~4.5.0", "lucatume/di52": ">=4.1", - "monolog/monolog": "^2.11", + "monolog/monolog": "^2.11 || ^3.10", "nikic/php-parser": ">=5.0 <6.0", "psr/container": ">=1.1", "psr/log": ">=1.0", @@ -48,11 +48,11 @@ "stellarwp/foundation-lock-database": "self.version", "stellarwp/foundation-lock-redis": "self.version", "stellarwp/foundation-log": "self.version", + "stellarwp/foundation-migrations": "self.version", "stellarwp/foundation-pipeline": "self.version", "stellarwp/foundation-shutdown": "self.version", "stellarwp/foundation-view": "self.version", - "stellarwp/foundation-wpcli": "self.version", - "stellarwp/foundation-migrations": "self.version" + "stellarwp/foundation-wpcli": "self.version" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/src/Docs/src/content/docs/components/log.mdx b/src/Docs/src/content/docs/components/log.mdx index 7350807..c240d0e 100644 --- a/src/Docs/src/content/docs/components/log.mdx +++ b/src/Docs/src/content/docs/components/log.mdx @@ -17,6 +17,8 @@ Install the split package: composer require stellarwp/foundation-log ``` +Foundation supports Monolog `^2.11` and `^3.10`. Composer selects the version compatible with your application's other dependencies. Provider configuration and ordinary PSR-3 logging work with either version. + ### Prepare the application Foundation Log uses the shared application configuration and provider architecture established in these guides: @@ -236,6 +238,8 @@ Foundation supplies the built-in channel definitions; configure only the setting Custom handlers implement Monolog's `HandlerInterface`. Foundation resolves them through the container, so their dependencies use ordinary application provider bindings. Use transient bindings when the same custom handler class serves independently configured channels. A singleton handler can serve one channel and remain shared with other application services. An optional `formatter` class selects a formatter for handlers implementing `FormattableHandlerInterface`; setting it to `null` keeps the handler's own formatter. Handlers extending Monolog's `AbstractHandler` receive `log.level`; implementations of the minimal `HandlerInterface` own their filtering behavior. +Custom handlers, processors, and formatters must support the installed Monolog major. Monolog 2 supplies array records; Monolog 3 supplies `Monolog\LogRecord` objects. Foundation does not convert records for application extensions. See Monolog's [upgrade guidance](https://github.com/Seldaek/monolog/blob/main/UPGRADE.md) when supporting both. + List handler-backed channels as stack members. Handlers run in reverse list order, preserving Monolog's `pushHandler()` behavior; a handler with bubbling disabled stops delivery to earlier entries. An empty stack, or one whose only handler is an unavailable PHP error log, discards records. ### Write to separate stream destinations diff --git a/src/Log/Formatters/ColoredLineFormatter.php b/src/Log/Formatters/ColoredLineFormatter.php index d66811a..b7f8a89 100644 --- a/src/Log/Formatters/ColoredLineFormatter.php +++ b/src/Log/Formatters/ColoredLineFormatter.php @@ -68,11 +68,13 @@ public function __construct( /** * Formats a log record, with color. * - * @param mixed[] $record A log record to format. + * Accept the installed Monolog version's record and preserve it for the parent formatter. + * + * @param mixed $record Monolog 2's record array or Monolog 3's LogRecord, supplied by the installed logger. * * @return string The formatted and colored record */ - public function format(array $record): string { + public function format($record): string { $formatted = parent::format($record); $formatted = str_replace('%color_start%', $this->colorScheme[\StellarWP\Foundation\Log\LogLevel::toPsrLogLevel($record['level'])], $formatted); diff --git a/src/Log/Handlers/NullHandler.php b/src/Log/Handlers/NullHandler.php index 57c9ef7..df135b8 100644 --- a/src/Log/Handlers/NullHandler.php +++ b/src/Log/Handlers/NullHandler.php @@ -11,7 +11,12 @@ */ final class NullHandler extends AbstractHandler { - public function handle(array $record): bool { - return $record['level'] >= $this->level; + /** + * Discard matching records and stop their delivery to subsequent handlers. + * + * Accept the installed Monolog version's record; the parent owns level comparison. + */ + public function handle($record): bool { + return $this->isHandling($record); } } diff --git a/src/Log/README.md b/src/Log/README.md index a03fdd1..b2c36cb 100644 --- a/src/Log/README.md +++ b/src/Log/README.md @@ -13,6 +13,9 @@ stacked, and null channels. composer require stellarwp/foundation-log ``` +Supports Monolog `^2.11 || ^3.10`, using the version compatible with the +application's other dependencies. + ## Documentation See the [Foundation Log documentation](https://foundation.nexcess.dev/components/log/) diff --git a/src/Log/composer.json b/src/Log/composer.json index 8ac9d8d..b42af7f 100644 --- a/src/Log/composer.json +++ b/src/Log/composer.json @@ -9,7 +9,7 @@ }, "require": { "php": ">=8.3", - "monolog/monolog": "^2.11", + "monolog/monolog": "^2.11 || ^3.10", "stellarwp/foundation-container": "^2.0", "psr/log": ">=1.0" }, diff --git a/tests/Feature/Log/Handlers/NullHandlerTest.php b/tests/Feature/Log/Handlers/NullHandlerTest.php index 251450e..2f5f4be 100644 --- a/tests/Feature/Log/Handlers/NullHandlerTest.php +++ b/tests/Feature/Log/Handlers/NullHandlerTest.php @@ -2,6 +2,8 @@ namespace StellarWP\Foundation\Tests\Feature\Log\Handlers; +use Monolog\Handler\TestHandler; +use Monolog\Logger; use PHPUnit\Framework\Attributes\DataProvider; use StellarWP\Foundation\Log\Handlers\NullHandler; use StellarWP\Foundation\Log\LogLevel; @@ -9,63 +11,45 @@ final class NullHandlerTest extends TestCase { - private NullHandler $handler; - - protected function setUp(): void { - parent::setUp(); - - $this->handler = $this->container->get(NullHandler::class); - } - /** - * @param array{level: int, result: bool} $input + * @param array{level: LogLevel::*, result: bool} $input * * @dataProvider logProvider */ #[DataProvider('logProvider')] - public function test_it_null_logs_valid_levels(array $input): void { - $this->assertSame( - $input['result'], - // @phpstan-ignore-next-line - $this->handler->handle([ - 'level' => $input['level'], - ]) - ); + public function test_it_discards_matching_records_and_leaves_lower_levels_for_other_handlers(array $input): void { + $captured = new TestHandler(); + $logger = new Logger('tests', [ + new NullHandler(LogLevel::WARNING), + $captured, + ]); + + $logger->log(LogLevel::toPsrLogLevel($input['level']), 'A record subject to the null threshold.'); + + $this->assertCount($input['result'] ? 0 : 1, $captured->getRecords()); } /** - * @return array> + * @return array> */ public static function logProvider(): array { return [ - [ - [ - 'level' => 1, - 'result' => false, - ], - ], - [ - [ - 'level' => 50, - 'result' => false, - ], - ], [ [ 'level' => LogLevel::DEBUG, - 'result' => true, + 'result' => false, ], ], [ [ 'level' => LogLevel::INFO, - 'result' => true, + 'result' => false, ], ], [ [ 'level' => LogLevel::NOTICE, - 'result' => true, + 'result' => false, ], ], [ diff --git a/tests/Feature/Log/StackLoggerTest.php b/tests/Feature/Log/StackLoggerTest.php index 7ae55e6..2757b0d 100644 --- a/tests/Feature/Log/StackLoggerTest.php +++ b/tests/Feature/Log/StackLoggerTest.php @@ -30,7 +30,7 @@ public function test_it_has_the_correct_logger_handlers_when_running_tests(): vo /** @var AbstractProcessingHandler $handler */ foreach ($logger->getHandlers() as $handler) { // https://github.com/Seldaek/monolog/blob/main/doc/01-usage.md#log-levels - $this->assertSame(100, $handler->getLevel()); + $this->assertSame(Logger::toMonologLevel('debug'), $handler->getLevel()); $this->assertThat($handler, $this->logicalOr( $this->isInstanceOf(ErrorLogHandler::class), diff --git a/tests/Support/Fixtures/Log/CallbackLogger.php b/tests/Support/Fixtures/Log/CallbackLogger.php new file mode 100644 index 0000000..6fbbdad --- /dev/null +++ b/tests/Support/Fixtures/Log/CallbackLogger.php @@ -0,0 +1,32 @@ +): void $callback + */ + public function __construct( + private readonly Closure $callback, + ) { + } + + /** + * {@inheritDoc} + * + * @param string|Stringable $message + */ + public function log($level, $message, array $context = []): void { + ($this->callback)((string) $message, $context); + } +} diff --git a/tests/Unit/Log/Formatters/ColoredLineFormatterTest.php b/tests/Unit/Log/Formatters/ColoredLineFormatterTest.php index 6aab934..1f77e54 100644 --- a/tests/Unit/Log/Formatters/ColoredLineFormatterTest.php +++ b/tests/Unit/Log/Formatters/ColoredLineFormatterTest.php @@ -2,33 +2,63 @@ namespace StellarWP\Foundation\Tests\Unit\Log\Formatters; -use DateTimeImmutable; +use Monolog\Handler\StreamHandler; +use Monolog\Logger; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Log\LogLevel as PsrLogLevel; use StellarWP\Foundation\Log\Formatters\ColoredLineFormatter; -use StellarWP\Foundation\Log\LogLevel; use StellarWP\Foundation\Tests\TestCase; final class ColoredLineFormatterTest extends TestCase { - public function test_it_formats_records_with_a_custom_color_scheme(): void { + /** + * @dataProvider coloredFormats + */ + #[DataProvider('coloredFormats')] + public function test_it_formats_records_with_a_custom_color_scheme(string $format, int $mode, string $expected): void { $formatter = new ColoredLineFormatter( - '%color_start%%level_name%%color_end% %message%', + $format, colorScheme: [ PsrLogLevel::ERROR => '[error]', - ] + ], + colorMode: $mode, ); + $handler = new StreamHandler('php://memory'); + $handler->setFormatter($formatter); + $logger = new Logger('tests', [ + $handler, + ]); + $logger->error('Something happened'); + $stream = $handler->getStream(); + $this->assertIsResource($stream); + rewind($stream); $this->assertSame( - '[error]ERROR' . "\033[0m" . ' Something happened', - $formatter->format([ - 'message' => 'Something happened', - 'context' => [], - 'level' => LogLevel::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'tests', - 'datetime' => new DateTimeImmutable(), - 'extra' => [], - ]) + $expected, + stream_get_contents($stream), ); } + + /** + * @return iterable + */ + public static function coloredFormats(): iterable { + yield 'explicit color markers' => [ + '%color_start%%level_name%%color_end% %message%', + ColoredLineFormatter::MODE_COLOR_LEVEL_ALL, + "[error]ERROR\033[0m Something happened", + ]; + + yield 'color every level marker' => [ + '%level_name% %level_name% %message%', + ColoredLineFormatter::MODE_COLOR_LEVEL_ALL, + "[error]ERROR\033[0m [error]ERROR\033[0m Something happened", + ]; + + yield 'color only the first level marker' => [ + '%level_name% %level_name% %message%', + ColoredLineFormatter::MODE_COLOR_LEVEL_FIRST, + "[error]ERROR\033[0m ERROR Something happened", + ]; + } } diff --git a/tests/integration/Database/TransactionTest.php b/tests/integration/Database/TransactionTest.php index a3ba640..0211560 100644 --- a/tests/integration/Database/TransactionTest.php +++ b/tests/integration/Database/TransactionTest.php @@ -12,6 +12,7 @@ use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Exceptions\TransactionFailed; use StellarWP\Foundation\Tests\Support\Fixtures\Database\DatabaseTestCase; +use StellarWP\Foundation\Tests\Support\Fixtures\Log\CallbackLogger; use Throwable; final class TransactionTest extends DatabaseTestCase @@ -564,14 +565,10 @@ public function test_savepoint_recovery_does_not_refresh_a_repeatable_read_snaps } public function test_failed_savepoint_recovery_preserves_duplicate_and_prevents_outer_commit(): void { - $logger = new \Monolog\Logger('doctrine-evaluation'); - $logger->pushHandler(new \Monolog\Handler\TestHandler()); - $logger->pushProcessor(function (array $record): array { - if (str_starts_with($record['context']['sql'] ?? '', 'ROLLBACK TO SAVEPOINT')) { + $logger = new CallbackLogger(function (string $message, array $context): void { + if (str_starts_with($context['sql'] ?? '', 'ROLLBACK TO SAVEPOINT')) { $this->killConnection(); } - - return $record; }); $db = $this->withLogging($logger); @@ -880,14 +877,10 @@ public function test_native_logging_middleware_can_wrap_the_wordpress_driver(): } public function test_connection_loss_before_begin_rejects_the_callback(): void { - $logger = new \Monolog\Logger('doctrine-evaluation'); - $logger->pushHandler(new \Monolog\Handler\TestHandler()); - $logger->pushProcessor(function (array $record): array { - if ($record['message'] === 'Beginning transaction') { + $logger = new CallbackLogger(function (string $message, array $context): void { + if ($message === 'Beginning transaction') { $this->killConnection(); } - - return $record; }); $db = $this->withLogging($logger); $called = false; @@ -905,14 +898,10 @@ public function test_connection_loss_before_begin_rejects_the_callback(): void { } public function test_caught_savepoint_release_failure_remains_terminal(): void { - $logger = new \Monolog\Logger('doctrine-evaluation'); - $logger->pushHandler(new \Monolog\Handler\TestHandler()); - $logger->pushProcessor(function (array $record): array { - if (str_starts_with($record['context']['sql'] ?? '', 'RELEASE SAVEPOINT')) { + $logger = new CallbackLogger(function (string $message, array $context): void { + if (str_starts_with($context['sql'] ?? '', 'RELEASE SAVEPOINT')) { $this->killConnection(); } - - return $record; }); $db = $this->withLogging($logger); From b2828d4406c348cfad876eb50c02cea5181ac377 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 2 Oct 2026 10:56:21 -0600 Subject: [PATCH 47/53] Update to blacksmith runners --- .github/workflows/check-for-required-files.yml | 2 +- .github/workflows/docs-preview.yml | 2 +- .github/workflows/monorepo-split.yml | 6 +++--- .github/workflows/quality.yml | 2 +- .github/workflows/tests.yml | 12 ++++++------ src/Docs/.github/workflows/deploy-docs.yml | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/check-for-required-files.yml b/.github/workflows/check-for-required-files.yml index 04fa512..5036c58 100644 --- a/.github/workflows/check-for-required-files.yml +++ b/.github/workflows/check-for-required-files.yml @@ -8,7 +8,7 @@ on: jobs: check-workflows: - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Check out repository diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index d39d3d0..99509dd 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -13,7 +13,7 @@ concurrency: jobs: preview: - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 permissions: contents: read diff --git a/.github/workflows/monorepo-split.yml b/.github/workflows/monorepo-split.yml index 8dccb37..cc3cdbe 100644 --- a/.github/workflows/monorepo-split.yml +++ b/.github/workflows/monorepo-split.yml @@ -13,7 +13,7 @@ env: jobs: list-repos: - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} @@ -30,7 +30,7 @@ jobs: split: needs: list-repos - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 strategy: fail-fast: false @@ -72,7 +72,7 @@ jobs: bump-next-development-line: needs: split if: ${{ startsWith(github.ref, 'refs/tags/') }} - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Determine next development line diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 696d538..651e625 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -7,7 +7,7 @@ on: jobs: ci: - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 name: Code Quality steps: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 654926d..9ec5dd8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,13 +7,13 @@ on: jobs: ci: - runs-on: ${{ matrix.os }} + runs-on: blacksmith-2vcpu-ubuntu-2404 strategy: fail-fast: true matrix: - os: [ ubuntu-latest ] php: [ '8.3', '8.4', '8.5' ] - name: Tests PHP${{ matrix.php }} - ${{ matrix.os }} + # Preserve existing required-check names when changing runner providers. + name: Tests PHP${{ matrix.php }} - ubuntu-latest steps: - name: Checkout code @@ -38,7 +38,7 @@ jobs: run: vendor/bin/phpunit --configuration ./phpunit.xml.dist --colors=always --testsuite Feature monolog: - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 name: Monolog ${{ matrix.monolog }} compatibility strategy: fail-fast: false @@ -73,7 +73,7 @@ jobs: vendor/bin/phpunit --configuration phpunit.xml.dist tests/Feature/Log slic: - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 name: SLIC Tests (${{ matrix.database.name }}) strategy: fail-fast: false @@ -211,7 +211,7 @@ jobs: coverage: if: github.event_name == 'pull_request' needs: slic - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 name: Monitor coverage permissions: diff --git a/src/Docs/.github/workflows/deploy-docs.yml b/src/Docs/.github/workflows/deploy-docs.yml index 899c675..80528c6 100644 --- a/src/Docs/.github/workflows/deploy-docs.yml +++ b/src/Docs/.github/workflows/deploy-docs.yml @@ -11,7 +11,7 @@ concurrency: jobs: deploy: - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 permissions: contents: read From 22079ac22cbf2c19423c08cbf967a5fce590c50f Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 2 Oct 2026 11:00:56 -0600 Subject: [PATCH 48/53] Add slic composer caching now that 2.5.1 is out --- .github/workflows/tests.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9ec5dd8..035e492 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -109,6 +109,18 @@ jobs: path: slic fetch-depth: 1 + - name: Get Composer cache directory + id: composer-cache-directory + run: echo "path=$(composer config cache-dir --absolute)" >> "$GITHUB_OUTPUT" + + - name: Restore Composer cache + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache-directory.outputs.path }} + key: ${{ runner.os }}-composer-slic-php8.3-v1-${{ hashFiles('composer.lock', 'composer.json', 'src/*/composer.json') }} + restore-keys: | + ${{ runner.os }}-composer-slic-php8.3-v1- + - name: Set SLIC environment variables run: | echo "SLIC_BIN=${GITHUB_WORKSPACE}/slic/slic" >> $GITHUB_ENV @@ -124,6 +136,8 @@ jobs: echo "SSH_AUTH_SOCK=/tmp/ssh_agent.sock" >> $GITHUB_ENV - name: Configure SLIC + env: + COMPOSER_CACHE_PATH: ${{ steps.composer-cache-directory.outputs.path }} run: | cd "${GITHUB_WORKSPACE}/.." "${SLIC_BIN}" here @@ -133,13 +147,13 @@ jobs: "${SLIC_BIN}" xdebug off "${SLIC_BIN}" debug on "${SLIC_BIN}" php-version set 8.3 --skip-rebuild + "${SLIC_BIN}" composer-cache set "$COMPOSER_CACHE_PATH" "${SLIC_BIN}" info "${SLIC_BIN}" config - name: Install dependencies run: | "${SLIC_BIN}" use foundation - "${SLIC_BIN}" composer set-version 2 "${SLIC_BIN}" composer install - name: Update WordPress From 402102afc4f7a907d368123878f28b0bb518da34 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 2 Oct 2026 11:13:21 -0600 Subject: [PATCH 49/53] Test composer version on runner and install v2 --- .github/workflows/tests.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 035e492..d424db0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -109,9 +109,25 @@ jobs: path: slic fetch-depth: 1 + - name: Show preinstalled Composer version + env: + COMPOSER_AUTH: '{}' + run: | + command -v composer + composer --no-ansi --version + + - name: Set up host PHP and Composer + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: composer:v2 + - name: Get Composer cache directory id: composer-cache-directory - run: echo "path=$(composer config cache-dir --absolute)" >> "$GITHUB_OUTPUT" + run: | + cache_directory=$(composer config cache-dir --absolute) + echo "path=$cache_directory" >> "$GITHUB_OUTPUT" - name: Restore Composer cache uses: actions/cache@v6 From 9945d09f54baeab42621f1a2d4ad1ff7aa16b4ed Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 2 Oct 2026 12:01:25 -0600 Subject: [PATCH 50/53] Select Composer 2 explicitly in host workflow setup --- .github/workflows/monorepo-split.yml | 1 + .github/workflows/quality.yml | 1 + .github/workflows/tests.yml | 9 ++------- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/monorepo-split.yml b/.github/workflows/monorepo-split.yml index cc3cdbe..c2bb111 100644 --- a/.github/workflows/monorepo-split.yml +++ b/.github/workflows/monorepo-split.yml @@ -109,6 +109,7 @@ jobs: with: php-version: '8.3' coverage: none + tools: composer:v2 - name: Install Composer dependencies if: ${{ steps.version.outputs.should_bump == 'true' }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 651e625..2111e0f 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -20,6 +20,7 @@ jobs: php-version: '8.3' coverage: pcov extensions: redis, mysqli + tools: composer:v2 - name: Detect File Changes uses: dorny/paths-filter@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d424db0..b3bf21e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,6 +25,7 @@ jobs: php-version: ${{ matrix.php }} coverage: none extensions: redis, mysqli + tools: composer:v2 - name: Install Composer dependencies uses: ramsey/composer-install@v4 @@ -55,6 +56,7 @@ jobs: php-version: '8.3' coverage: none extensions: redis, mysqli + tools: composer:v2 - name: Install Composer dependencies uses: ramsey/composer-install@v4 @@ -109,13 +111,6 @@ jobs: path: slic fetch-depth: 1 - - name: Show preinstalled Composer version - env: - COMPOSER_AUTH: '{}' - run: | - command -v composer - composer --no-ansi --version - - name: Set up host PHP and Composer uses: shivammathur/setup-php@v2 with: From d2fed718ecb6298b5c68d21921e3a7220d1a60bb Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 2 Oct 2026 13:36:11 -0600 Subject: [PATCH 51/53] Add insertOrIgnore queries with documented IGNORE semantics --- src/Database/Query/InsertWriter.php | 80 ++++++++----- src/Database/Query/Query.php | 17 +++ .../components/database/query-builder.mdx | 31 ++++- .../Unit/Database/Query/InsertWriterTest.php | 91 ++++++++++++++- tests/integration/Database/QueryTest.php | 106 ++++++++++++++++-- .../Database/QueryValidationTest.php | 4 + 6 files changed, 286 insertions(+), 43 deletions(-) diff --git a/src/Database/Query/InsertWriter.php b/src/Database/Query/InsertWriter.php index 920dadb..462f8ee 100644 --- a/src/Database/Query/InsertWriter.php +++ b/src/Database/Query/InsertWriter.php @@ -46,6 +46,58 @@ public function __construct( * @throws Exception When execution fails, including in a later chunk. */ public function insert(TableReference $table, array $rows, array $update = []): int { + return $this->write($table, $rows, 'INSERT', $update); + } + + /** + * Insert rows using the server's IGNORE behavior and return the inserted-row count. + * + * Duplicate keys skip rows; other ignorable errors may coerce values with warnings. + * Validation and chunking match insert(), without opening a transaction. + * + * @param array|list> $rows + * + * @throws InvalidArgumentException For invalid columns, values, or mismatched rows. + * @throws Exception When an unignored execution failure occurs. + */ + public function insertOrIgnore(TableReference $table, array $rows): int { + return $this->write($table, $rows, 'INSERT IGNORE'); + } + + /** + * Insert one row, including a defaults-only row, before reading its generated ID. + * + * @param array $data Unvalidated input that must describe exactly one row. + * + * @throws InvalidArgumentException For aliased targets, row lists, or invalid columns and values. + * @throws Exception When insertion fails or no generated identifier is available. + */ + public function insertGetId(TableReference $table, array $data): int|string { + if ($table->alias !== null) { + throw new InvalidArgumentException('Insert targets cannot have an alias.'); + } + + if ($data !== [] && array_is_list($data)) { + throw new InvalidArgumentException('insertGetId() requires one column-to-value row, not a list of rows.'); + } + + if ($data === []) { + $this->executor->statement(new Fragment('INSERT INTO ' . $this->quoter->quote($table->name) . ' () VALUES ()')); + } else { + $this->insert($table, $data); + } + + return $this->executor->lastInsertId(); + } + + /** + * Validate the complete input and execute each parameter-bounded statement. + * + * @param array|list> $rows + * @param 'INSERT'|'INSERT IGNORE' $command + * @param list $update + */ + private function write(TableReference $table, array $rows, string $command, array $update = []): int { if ($table->alias !== null) { throw new InvalidArgumentException('Insert targets cannot have an alias.'); } @@ -71,7 +123,7 @@ public function insert(TableReference $table, array $rows, array $update = []): } $suffix = $update === [] ? '' : $this->builder->build($update, $table->name); - $prefix = 'INSERT INTO ' . $this->quoter->quote($table->name) . ' (' . implode(', ', array_map($this->quoter->quote(...), $columns)) . ') VALUES '; + $prefix = $command . ' INTO ' . $this->quoter->quote($table->name) . ' (' . implode(', ', array_map($this->quoter->quote(...), $columns)) . ') VALUES '; $placeholder = '(' . implode(', ', array_fill(0, count($columns), '?')) . ')'; $chunkSize = intdiv($this->parameterLimit, count($columns)); $total = 0; @@ -91,32 +143,6 @@ public function insert(TableReference $table, array $rows, array $update = []): return $total; } - /** - * Insert one row, including a defaults-only row, before reading its generated ID. - * - * @param array $data Unvalidated input that must describe exactly one row. - * - * @throws InvalidArgumentException For aliased targets, row lists, or invalid columns and values. - * @throws Exception When insertion fails or no generated identifier is available. - */ - public function insertGetId(TableReference $table, array $data): int|string { - if ($table->alias !== null) { - throw new InvalidArgumentException('Insert targets cannot have an alias.'); - } - - if ($data !== [] && array_is_list($data)) { - throw new InvalidArgumentException('insertGetId() requires one column-to-value row, not a list of rows.'); - } - - if ($data === []) { - $this->executor->statement(new Fragment('INSERT INTO ' . $this->quoter->quote($table->name) . ' () VALUES ()')); - } else { - $this->insert($table, $data); - } - - return $this->executor->lastInsertId(); - } - /** * Validate all rows and normalize their values into a consistent column order. * diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php index 8f03ee9..cae76a7 100644 --- a/src/Database/Query/Query.php +++ b/src/Database/Query/Query.php @@ -374,6 +374,23 @@ public function insert(array $rows): int { return $this->writer->insert($this->table, $rows); } + /** + * Insert one row or a list using INSERT IGNORE, returning the number actually inserted. + * + * Duplicate keys skip rows. Other ignorable database errors may adjust invalid + * values with warnings, even in strict mode. Empty input returns zero. + * + * @param array|list> $rows + * + * @throws InvalidArgumentException When rows or accumulated query options are invalid. + * @throws Exception When an unignored statement fails; use transactional() for atomic multi-chunk writes. + */ + public function insertOrIgnore(array $rows): int { + $this->compiler->insertable($this->state()); + + return $this->writer->insertOrIgnore($this->table, $rows); + } + /** * Insert exactly one row and return its generated identifier without truncation. * diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index 2c6b792..774aa03 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -351,7 +351,7 @@ $this->connection->insert( $this->reports->quotedName(), [ ] ); ``` -Insert, upsert, and delete queries use an unaliased target table. Start them with `$table->query()` without an alias. +Insert, insert-or-ignore, upsert, and delete queries use an unaliased target table. Start them with `$table->query()` without an alias. Query `insert()` and `upsert()` return total affected rows as `int`; `update()` and `delete()` preserve Doctrine’s `int|string` affected-row counts. Rows in a multi-row write must have identical column sets, although their key order may differ. Empty insert and upsert inputs return zero. Boolean values are bound as integers; use decimal strings for exact amounts. Date/time values are formatted with microseconds, and the target column and database determine retained precision. @@ -359,7 +359,34 @@ MySQL normally rounds fractional seconds when writing to a lower-precision colum Large writes split into multiple statements within the parameter limit. Each statement is atomic, but the complete write needs a caller-owned transaction to roll back earlier chunks if a later chunk fails. -`update()` and `delete()` require conditions, retain supported ordering and limits, and reject joins, projections, distinct, grouping, having, or offsets. `insert()`, `insertGetId()`, and `upsert()` reject previously accumulated query conditions, projections, ordering, limits, and other shaping state, even for empty input. Start from a fresh query for those operations. +`update()` and `delete()` require conditions, retain supported ordering and limits, and reject joins, projections, distinct, grouping, having, or offsets. `insert()`, `insertOrIgnore()`, `insertGetId()`, and `upsert()` reject previously accumulated query conditions, projections, ordering, limits, and other shaping state, even for empty input. Start from a fresh query for those operations. + +### Insert rows and skip duplicates + +Use `insertOrIgnore()` when existing rows should remain unchanged on a primary or unique-key conflict: + +```php +$inserted = $this->reports->query()->insertOrIgnore( [ + [ + 'id' => 10, + 'title' => 'Weekly', + 'status' => 'draft', + ], + [ + 'id' => 11, + 'title' => 'Monthly', + 'status' => 'draft', + ], +] ); +``` + +The method accepts one associative row or a list of rows and returns the number actually inserted as `int`. If report `10` already exists and `11` is new, `$inserted` is `1`. Empty input or a write in which every row is skipped returns `0`. + +:::caution[IGNORE also changes data-error handling] +This uses MySQL/MariaDB `INSERT IGNORE`, not a duplicate-only exception handler. The server can truncate oversized strings, adjust invalid values, or skip rows with other ignorable constraint errors, even in strict mode. The returned count does not tell you whether inserted values were adjusted. Use ordinary `insert()` to retain the server's normal error handling, or `upsert()` when conflicting rows should be updated. See the [MySQL IGNORE rules](https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#ignore-effect-on-execution) and [MariaDB INSERT IGNORE documentation](https://mariadb.com/docs/server/reference/sql-statements/data-manipulation/inserting-loading-data/insert-ignore). +::: + +Foundation still rejects invalid row shapes, unsupported PHP values, and accumulated query options. Unignored database failures throw. The method shares `insert()`'s binding and chunking behavior and participates in the caller's transaction; wrap multi-chunk writes in `transactional()` when they must roll back together. ### Increment or decrement a column diff --git a/tests/Unit/Database/Query/InsertWriterTest.php b/tests/Unit/Database/Query/InsertWriterTest.php index 6929bd1..1a16a98 100644 --- a/tests/Unit/Database/Query/InsertWriterTest.php +++ b/tests/Unit/Database/Query/InsertWriterTest.php @@ -16,6 +16,73 @@ final class InsertWriterTest extends TestCase { + public function test_ignored_inserts_bind_and_reorder_rows_across_chunks(): void { + $connection = $this->createMock(Connection::class); + $calls = []; + $connection->expects($this->exactly(2))->method('executeStatement')->willReturnCallback( + static function (string $sql, array $bindings) use (&$calls): string { + $calls[] = [ + $sql, + $bindings, + ]; + + return count($calls) === 1 ? '1' : '0'; + }, + ); + $count = $this->writer($connection, 4)->insertOrIgnore(new TableReference('wp_entries'), [ + [ + 'id' => 1, + 'name' => "O'Reilly", + ], + [ + 'name' => 'duplicate', + 'id' => 1, + ], + [ + 'id' => 1, + 'name' => 'another duplicate', + ], + ]); + $this->assertSame(1, $count); + $this->assertSame([ + [ + 'INSERT IGNORE INTO `wp_entries` (`id`, `name`) VALUES (?, ?), (?, ?)', + [ + 1, + "O'Reilly", + 1, + 'duplicate', + ], + ], + [ + 'INSERT IGNORE INTO `wp_entries` (`id`, `name`) VALUES (?, ?)', + [ + 1, + 'another duplicate', + ], + ], + ], $calls); + } + + public function test_empty_ignored_insert_performs_no_database_work(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->never())->method('executeStatement'); + $this->assertSame(0, $this->writer($connection)->insertOrIgnore(new TableReference('wp_entries'), [])); + } + + /** + * @return iterable + */ + public static function insertMethods(): iterable { + yield 'insert' => [ + 'insert', + ]; + + yield 'insertOrIgnore' => [ + 'insertOrIgnore', + ]; + } + public function test_rows_reorder_columns_and_split_at_parameter_limit(): void { $connection = $this->createMock(Connection::class); $calls = []; @@ -59,12 +126,16 @@ static function (string $sql, array $bindings) use (&$calls): int { ], $calls[1][1]); } - public function test_later_invalid_row_is_rejected_before_any_write(): void { + /** + * @dataProvider insertMethods + */ + #[DataProvider('insertMethods')] + public function test_later_invalid_row_is_rejected_before_any_write(string $method): void { $connection = $this->createMock(Connection::class); $connection->expects($this->never())->method('executeStatement'); $writer = $this->writer($connection, 1); $this->expectException(InvalidArgumentException::class); - $writer->insert(new TableReference('wp_entries'), [ + $writer->$method(new TableReference('wp_entries'), [ [ 'id' => 1, ], @@ -74,12 +145,16 @@ public function test_later_invalid_row_is_rejected_before_any_write(): void { ]); } - public function test_mismatched_columns_are_rejected_before_any_write(): void { + /** + * @dataProvider insertMethods + */ + #[DataProvider('insertMethods')] + public function test_mismatched_columns_are_rejected_before_any_write(string $method): void { $connection = $this->createMock(Connection::class); $connection->expects($this->never())->method('executeStatement'); $writer = $this->writer($connection, 1); $this->expectException(InvalidArgumentException::class); - $writer->insert(new TableReference('wp_entries'), [ + $writer->$method(new TableReference('wp_entries'), [ [ 'id' => 1, ], @@ -89,7 +164,11 @@ public function test_mismatched_columns_are_rejected_before_any_write(): void { ]); } - public function test_a_later_chunk_failure_escapes_without_owning_transactions(): void { + /** + * @dataProvider insertMethods + */ + #[DataProvider('insertMethods')] + public function test_a_later_chunk_failure_escapes_without_owning_transactions(string $method): void { $connection = $this->createMock(Connection::class); $connection->expects($this->never())->method('beginTransaction'); $connection->expects($this->never())->method('commit'); @@ -105,7 +184,7 @@ static function () use (&$calls): int { ); $writer = $this->writer($connection, 1); $this->expectExceptionMessage('second chunk failed'); - $writer->insert(new TableReference('wp_entries'), [ + $writer->$method(new TableReference('wp_entries'), [ [ 'id' => 1, ], diff --git a/tests/integration/Database/QueryTest.php b/tests/integration/Database/QueryTest.php index f87d358..c91d97d 100644 --- a/tests/integration/Database/QueryTest.php +++ b/tests/integration/Database/QueryTest.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Tests\Integration\Database; use DateTimeImmutable; +use Doctrine\DBAL\Exception\InvalidFieldNameException; use Doctrine\DBAL\Exception\LockWaitTimeoutException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use InvalidArgumentException; @@ -45,6 +46,93 @@ protected function setUp(): void { ) ENGINE=InnoDB'); } + /** + * Duplicate keys skip rows without changing existing values or poisoning the transaction. + */ + public function test_insert_or_ignore_counts_new_rows_and_preserves_duplicates(): void { + $this->entries->insert([ + 'id' => 1, + 'name' => 'Original', + 'email' => 'original@example.test', + ]); + $count = $this->db->transactional(fn (): int => $this->entries->query()->insertOrIgnore([ + [ + 'id' => 1, + 'name' => 'Primary key conflict', + 'email' => 'other@example.test', + ], + [ + 'id' => 2, + 'name' => 'Unique key conflict', + 'email' => 'original@example.test', + ], + [ + 'id' => 3, + 'name' => "O'Reilly", + 'email' => 'new@example.test', + ], + ])); + $this->assertSame(1, $count); + $this->assertSame([ + 'Original', + "O'Reilly", + ], $this->observer->fetchFirstColumn('SELECT name FROM ' . $this->entries->quotedName() . ' ORDER BY id')); + $this->assertSame(0, $this->entries->query()->insertOrIgnore([ + 'id' => 1, + 'name' => 'Still original', + ])); + $this->assertSame(1, $this->entries->query()->insertOrIgnore([ + 'id' => 4, + 'name' => 'Single row', + ])); + $this->assertSame(0, $this->entries->query()->insertOrIgnore([])); + } + + /** + * IGNORE also permits database coercion even when strict SQL mode is enabled. + */ + public function test_insert_or_ignore_can_truncate_invalid_values_in_strict_mode(): void { + $this->db->executeStatement("SET SESSION sql_mode = 'STRICT_ALL_TABLES'"); + $this->assertSame(1, $this->entries->query()->insertOrIgnore([ + 'id' => 1, + 'name' => str_repeat('x', 192), + ])); + $this->assertSame(str_repeat('x', 191), $this->entries->query()->where('id', 1)->first()['name'] ?? null); + } + + /** + * Ignored inserts remain inside the caller's transaction and roll back with its work. + */ + public function test_insert_or_ignore_rolls_back_with_the_callers_transaction(): void { + $failure = new RuntimeException('Cancel import'); + + try { + $this->db->transactional(function () use ($failure): void { + $this->assertSame(1, $this->entries->query()->insertOrIgnore([ + 'id' => 1, + 'name' => 'Provisional', + ])); + + throw $failure; + }); + } catch (RuntimeException $caught) { + $this->assertSame($failure, $caught); + } + + $this->assertSame(0, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->entries->quotedName())); + } + + /** + * IGNORE does not swallow SQL errors such as a nonexistent column. + */ + public function test_insert_or_ignore_propagates_unignored_database_errors(): void { + $this->expectException(InvalidFieldNameException::class); + $this->entries->query()->insertOrIgnore([ + 'id' => 1, + 'missing_column' => 'Invalid', + ]); + } + /** * Bound values, nested predicates, arrays and nulls compose without raw SQL. */ @@ -1382,7 +1470,7 @@ public function test_locking_reads_preserve_query_options_and_bindings(): void { /** * Write terminals must not silently ignore a requested locking read. * - * @param 'insert'|'insertGetId'|'upsert'|'update'|'increment'|'decrement'|'delete' $method + * @param 'insert'|'insertOrIgnore'|'insertGetId'|'upsert'|'update'|'increment'|'decrement'|'delete' $method * * @dataProvider lockedWrites */ @@ -1392,17 +1480,18 @@ public function test_writes_reject_the_read_lock_option(string $method): void { $this->expectException(InvalidArgumentException::class); match ($method) { - 'insert' => $this->entries->query()->lockForUpdate()->insert([]), - 'insertGetId' => $this->entries->query()->lockForUpdate()->insertGetId([]), - 'upsert' => $this->entries->query()->lockForUpdate()->upsert([], [ + 'insert' => $this->entries->query()->lockForUpdate()->insert([]), + 'insertOrIgnore' => $this->entries->query()->lockForUpdate()->insertOrIgnore([]), + 'insertGetId' => $this->entries->query()->lockForUpdate()->insertGetId([]), + 'upsert' => $this->entries->query()->lockForUpdate()->upsert([], [ 'name', ]), - 'update' => $query->update([ + 'update' => $query->update([ 'name' => 'Changed', ]), - 'increment' => $query->increment('amount'), - 'decrement' => $query->decrement('amount'), - 'delete' => $query->delete(), + 'increment' => $query->increment('amount'), + 'decrement' => $query->decrement('amount'), + 'delete' => $query->delete(), }; } @@ -1414,6 +1503,7 @@ public function test_writes_reject_the_read_lock_option(string $method): void { public static function lockedWrites(): iterable { foreach ([ 'insert', + 'insertOrIgnore', 'insertGetId', 'upsert', 'update', diff --git a/tests/integration/Database/QueryValidationTest.php b/tests/integration/Database/QueryValidationTest.php index bb41d1c..ce79b55 100644 --- a/tests/integration/Database/QueryValidationTest.php +++ b/tests/integration/Database/QueryValidationTest.php @@ -67,6 +67,10 @@ public function test_invalid_writes_leave_existing_rows_unchanged(): void { 'filtered empty insert' => fn () => $queries->table($this->suffix)->where('id', 1)->insert([]), 'ordered empty insert' => fn () => $queries->table($this->suffix)->orderBy('id')->insert([]), 'aliased empty insert' => fn () => $queries->table($this->suffix, 'e')->insert([]), + 'filtered ignored insert' => fn () => $queries->table($this->suffix)->where('id', 1)->insertOrIgnore([]), + 'ordered ignored insert' => fn () => $queries->table($this->suffix)->orderBy('id')->insertOrIgnore([]), + 'projected ignored insert' => fn () => $queries->table($this->suffix)->select('name')->insertOrIgnore([]), + 'aliased ignored insert' => fn () => $queries->table($this->suffix, 'e')->insertOrIgnore([]), 'aliased empty upsert' => fn () => $queries->table($this->suffix, 'e')->upsert([], [ 'name', ]), From efc534945463975ccb3f500432359c2ad7726bf8 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 2 Oct 2026 13:55:03 -0600 Subject: [PATCH 52/53] Allow application contributions to database middleware --- src/Database/DatabaseProvider.php | 8 ++ .../src/content/docs/components/database.mdx | 55 ++++++++ tests/integration/Database/MiddlewareTest.php | 130 ++++++++++++++++++ .../integration/Database/TransactionTest.php | 17 ++- 4 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 tests/integration/Database/MiddlewareTest.php diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index 57eb94a..e7709e0 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -26,10 +26,17 @@ */ final class DatabaseProvider extends Provider { + /** + * Contribute Doctrine Driver\Middleware objects before resolving the shared connection. + * Later contributions wrap earlier ones, outside Foundation's WordPress middleware. + */ + public const string MIDDLEWARE = self::class . '.middleware'; + /** * Register lazy database services without executing queries or creating storage. */ public function register(): void { + $this->container->mergeArrayVar(self::MIDDLEWARE, []); $this->container->singleton(wpdb::class, static function (): wpdb { $source = $GLOBALS['wpdb'] ?? null; @@ -51,6 +58,7 @@ public function register(): void { $config->setSchemaManagerFactory(new DefaultSchemaManagerFactory()); $config->setMiddlewares([ new WordPressMiddleware($c->get(WordPressSession::class)), + ...$c->get(self::MIDDLEWARE), ]); return DriverManager::getConnection([ diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index 54139c0..dfa7c32 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -116,3 +116,58 @@ Applications can replace `DatabaseScope` and `TableNameResolver` when they own a Implementations acquire once without waiting, retain the original site and connection across DDL and nested transactions, check ownership before execution and completion, and preserve a caught database failure until the locked operation ends. An escaping callback exception takes precedence over release failure. Connection changes must never reconnect or replay protected work. Contention raises `AdvisoryLockContended`; failed or uncertain ownership raises `AdvisoryLockInterrupted`. Database owns the supported DBAL constraint. When upgrading it, verify the migration schema APIs and failure-handling tests as well as queries and transactions. Foundation Migrations requires a compatible Database release; applications should update their Composer lock file and run their migration tests before deployment. + +### Add database middleware + +Contribute [Doctrine driver middleware](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.5/reference/architecture.html#middlewares) through `DatabaseProvider::MIDDLEWARE` to add logging or instrumentation to the shared connection. Foundation keeps its WordPress connection integration installed. + +For example, Doctrine's supplied logging middleware sends query and transaction events to a PSR logger. In `src/Database/Query_Logging_Provider.php`: + +```php title="Query_Logging_Provider.php" +container->mergeArrayVar( + DatabaseProvider::MIDDLEWARE, + static fn ( C $container ): array => [ + new Middleware( $container->get( LoggerInterface::class ) ), + ], + ); + } +} +``` + +Register the providers in dependency order in `src/App.php`: + +```php title="App.php" +use Plugin\Database\Query_Logging_Provider; +use StellarWP\Foundation\Container\Contracts\Provider; +use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Log\LogProvider; + +/** @var list> */ +private const array PROVIDERS = [ + DatabaseProvider::class, + LogProvider::class, + Query_Logging_Provider::class, +]; +``` + +Configure your [logging handler](/components/log/) to accept debug records for SQL execution events. The supplied middleware includes SQL and bound parameter values, which may contain sensitive application data. Enable it only where that logging is appropriate. It does not update `$wpdb->num_queries` or integrate with Query Monitor. + +Contribute middleware objects in an array, or a lazy factory returning that array as shown above. Register all contributions before first resolving `Doctrine\DBAL\Connection`, including through tables or repositories. Foundation resolves the factories when constructing that shared connection; registering them alone does not open the database. Adding contributions afterward does not reconfigure an existing connection. + +Foundation's WordPress middleware is applied first. Application middleware is applied in contribution order, with each wrapping the previous driver. For contributions `[A, B]`, calls enter `B`, then `A`, then Foundation's WordPress driver. Your middleware must delegate to the wrapped driver and preserve exceptions, results, and transaction behavior; opening a separate connection or retrying failed work would violate the shared connection's guarantees. diff --git a/tests/integration/Database/MiddlewareTest.php b/tests/integration/Database/MiddlewareTest.php new file mode 100644 index 0000000..b153c69 --- /dev/null +++ b/tests/integration/Database/MiddlewareTest.php @@ -0,0 +1,130 @@ +container = (new ContainerFactory())->create(new ArrayConfiguration([])); + $this->container->register(DatabaseProvider::class); + $this->container->singleton(wpdb::class, $this->source); + } + + /** + * Resolve middleware once with the shared connection, without opening it eagerly. + */ + public function test_contributions_are_lazy_and_share_the_application_connection(): void { + $resolutions = 0; + $observed = []; + $logger = new CallbackLogger(static function (string $message, array $context) use (&$observed): void { + if (isset($context['sql'])) { + $observed[] = $context['sql']; + } + }); + $this->container->mergeArrayVar(DatabaseProvider::MIDDLEWARE, static function () use (&$resolutions, $logger): array { + $resolutions++; + + return [ + new Middleware($logger), + ]; + }); + $this->assertSame(0, $resolutions); + $db = $this->container->get(Connection::class); + $this->assertSame(1, $resolutions); + $this->assertFalse($db->isConnected()); + $this->assertSame($db, $this->container->get(Connection::class)); + $this->assertSame(1, $resolutions); + + $this->assertSame(1, $this->container->get(Database::class)->table($this->suffix)->count()); + $this->assertNotEmpty($observed); + $this->assertSame($this->native($this->source)->thread_id, (int) $db->fetchOne('SELECT CONNECTION_ID()')); + } + + /** + * Later contributions wrap earlier ones for direct and repeatedly executed prepared queries. + */ + public function test_multiple_contributions_observe_queries_in_decorator_order(): void { + $events = []; + + foreach ([ + 'first', + 'second', + ] as $name) { + $logger = new CallbackLogger(static function (string $message, array $context) use (&$events, $name): void { + if (isset($context['sql'])) { + $events[] = $name . ': ' . $context['sql']; + } + }); + $this->container->mergeArrayVar(DatabaseProvider::MIDDLEWARE, static fn (): array => [ + new Middleware($logger), + ]); + } + + $db = $this->container->get(Connection::class); + $this->assertSame(1, (int) $db->fetchOne('SELECT 1')); + $statement = $db->prepare('SELECT ?'); + $statement->bindValue(1, 'first execution'); + $this->assertSame('first execution', $statement->executeQuery()->fetchOne()); + $statement->bindValue(1, 'second execution'); + $this->assertSame('second execution', $statement->executeQuery()->fetchOne()); + $update = "UPDATE {$this->table} + SET name = 'Changed'"; + $db->executeStatement($update); + $this->assertSame([ + 'second: SELECT 1', + 'first: SELECT 1', + 'second: SELECT ?', + 'first: SELECT ?', + 'second: SELECT ?', + 'first: SELECT ?', + 'second: ' . $update, + 'first: ' . $update, + ], $events); + $this->assertSame('Changed', $this->observer->fetchOne("SELECT name + FROM {$this->table}")); + } + + /** + * Decorating execution does not permit a caught database failure to commit earlier work. + */ + public function test_caught_database_errors_remain_terminal_with_contributed_middleware(): void { + $this->container->mergeArrayVar(DatabaseProvider::MIDDLEWARE, static fn (): array => [ + new Middleware(new \Psr\Log\NullLogger()), + ]); + $db = $this->container->get(Connection::class); + + try { + $db->transactional(function () use ($db): void { + $db->executeStatement("UPDATE {$this->table} + SET name = 'Provisional'"); + + try { + $db->insert($this->table, [ + 'id' => 1, + 'name' => 'Duplicate', + ]); + } catch (UniqueConstraintViolationException) { + } + }); + $this->fail('A caught database failure must prevent commit.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } +} diff --git a/tests/integration/Database/TransactionTest.php b/tests/integration/Database/TransactionTest.php index 0211560..7bbb0ae 100644 --- a/tests/integration/Database/TransactionTest.php +++ b/tests/integration/Database/TransactionTest.php @@ -8,6 +8,9 @@ use Doctrine\DBAL\Exception\NotNullConstraintViolationException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use RuntimeException; +use StellarWP\Foundation\Container\Configuration\ArrayConfiguration; +use StellarWP\Foundation\Container\ContainerFactory; +use StellarWP\Foundation\Database\DatabaseProvider; use StellarWP\Foundation\Database\Exceptions\CommitOutcomeUnknown; use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Exceptions\TransactionFailed; @@ -972,17 +975,13 @@ public function test_native_manual_rollback_remains_available(): void { } private function withLogging(\Psr\Log\LoggerInterface $logger): Connection { - $configuration = new \Doctrine\DBAL\Configuration(); - $configuration->setSchemaManagerFactory(new \Doctrine\DBAL\Schema\DefaultSchemaManagerFactory()); - $configuration->setMiddlewares([ - new \StellarWP\Foundation\Database\Connection\WordPressMiddleware($this->container->get(\StellarWP\Foundation\Database\Connection\WordPressSession::class)), + $container = (new ContainerFactory())->create(new ArrayConfiguration([])); + $container->register(DatabaseProvider::class); + $container->singleton(\wpdb::class, $this->source); + $container->mergeArrayVar(DatabaseProvider::MIDDLEWARE, static fn (): array => [ new \Doctrine\DBAL\Logging\Middleware($logger), ]); - $db = \Doctrine\DBAL\DriverManager::getConnection([ - 'driver' => 'mysqli', - 'wrapperClass' => \StellarWP\Foundation\Database\Connection\WordPressConnection::class, - ], $configuration); - return $db; + return $container->get(Connection::class); } } From f38c9ccafe66dc4c3b08d54d95927d5cd6c9da12 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 2 Oct 2026 15:28:29 -0600 Subject: [PATCH 53/53] Add raw SQL operations with inferred parameter types --- src/Database/Query/Database.php | 76 +++- src/Database/Query/Executor.php | 68 +++- .../Query/ValueObjects/ParameterSet.php | 10 +- .../components/database/query-builder.mdx | 123 ++++++- tests/Unit/Database/Query/ExecutorTest.php | 56 +++ tests/integration/Database/RawQueryTest.php | 347 ++++++++++++++++++ 6 files changed, 652 insertions(+), 28 deletions(-) create mode 100644 tests/integration/Database/RawQueryTest.php diff --git a/src/Database/Query/Database.php b/src/Database/Query/Database.php index 98c5d9f..1c610d8 100644 --- a/src/Database/Query/Database.php +++ b/src/Database/Query/Database.php @@ -2,13 +2,17 @@ namespace StellarWP\Foundation\Database\Query; +use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\Exception; +use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Types\Type; use InvalidArgumentException; use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Query\ValueObjects\TableReference; /** - * Start fresh application queries and resolve WordPress-owned tables. + * Query application tables and execute SQL with inferred parameter types. */ final readonly class Database { @@ -57,4 +61,74 @@ public function table(Table|string|TableReference $table, ?string $alias = null) public function wordpress(string $name): TableReference { return $this->resolver->wordpress($name); } + + /** + * Execute application-owned SQL and return associative rows, or an empty list. + * + * Omitted types use the fluent builder's value normalization and type inference. + * Explicit types pass the original values to Doctrine for conversion. + * + * @param list|array $bindings A zero-based positional list or named values, without mixing styles. + * @param array|string, string|Type|ParameterType|ArrayParameterType> $types Types keyed by zero-based position or parameter name. + * + * @throws InvalidArgumentException For unsupported values without an explicit type. + * @throws DatabaseException When the managed session or transaction is invalid. + * @throws Exception When execution or type conversion fails. + * + * @return list> + */ + public function select(string $sql, array $bindings = [], array $types = []): array { + return $this->executor->fetchAllAssociative($sql, $bindings, $types); + } + + /** + * Execute an application-owned SQL insert and return its affected-row count. + * + * Omitted types use the fluent builder's value normalization and type inference. + * Explicit types pass the original values to Doctrine for conversion. + * + * @param list|array $bindings A zero-based positional list or named values, without mixing styles. + * @param array|string, string|Type|ParameterType|ArrayParameterType> $types Types keyed by zero-based position or parameter name. + * + * @throws InvalidArgumentException For unsupported values without an explicit type. + * @throws DatabaseException When the managed session or transaction is invalid. + * @throws Exception When execution or type conversion fails. + */ + public function insert(string $sql, array $bindings = [], array $types = []): int|string { + return $this->executor->executeStatement($sql, $bindings, $types); + } + + /** + * Execute an application-owned SQL update and return its affected-row count. + * + * Omitted types use the fluent builder's value normalization and type inference. + * Explicit types pass the original values to Doctrine for conversion. + * + * @param list|array $bindings A zero-based positional list or named values, without mixing styles. + * @param array|string, string|Type|ParameterType|ArrayParameterType> $types Types keyed by zero-based position or parameter name. + * + * @throws InvalidArgumentException For unsupported values without an explicit type. + * @throws DatabaseException When the managed session or transaction is invalid. + * @throws Exception When execution or type conversion fails. + */ + public function update(string $sql, array $bindings = [], array $types = []): int|string { + return $this->executor->executeStatement($sql, $bindings, $types); + } + + /** + * Execute an application-owned SQL delete and return its affected-row count. + * + * Omitted types use the fluent builder's value normalization and type inference. + * Explicit types pass the original values to Doctrine for conversion. + * + * @param list|array $bindings A zero-based positional list or named values, without mixing styles. + * @param array|string, string|Type|ParameterType|ArrayParameterType> $types Types keyed by zero-based position or parameter name. + * + * @throws InvalidArgumentException For unsupported values without an explicit type. + * @throws DatabaseException When the managed session or transaction is invalid. + * @throws Exception When execution or type conversion fails. + */ + public function delete(string $sql, array $bindings = [], array $types = []): int|string { + return $this->executor->executeStatement($sql, $bindings, $types); + } } diff --git a/src/Database/Query/Executor.php b/src/Database/Query/Executor.php index cb84934..0f1f376 100644 --- a/src/Database/Query/Executor.php +++ b/src/Database/Query/Executor.php @@ -3,15 +3,17 @@ namespace StellarWP\Foundation\Database\Query; use DateTimeInterface; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception; use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Types\Type; use InvalidArgumentException; use StellarWP\Foundation\Database\Query\ValueObjects\Fragment; use StellarWP\Foundation\Database\Query\ValueObjects\ParameterSet; /** - * Executes compiled statements through the application's shared connection. + * Executes compiled and application-owned SQL through the shared connection. * * @internal */ @@ -34,9 +36,7 @@ public function __construct( * @return list> */ public function rows(Fragment $statement): array { - $parameters = $this->parameters($statement); - - return $this->connection->fetchAllAssociative($statement->sql, $parameters->values, $parameters->types); + return $this->fetchAllAssociative($statement->sql, $statement->bindings); } /** @@ -48,7 +48,7 @@ public function rows(Fragment $statement): array { * @return list */ public function column(Fragment $statement): array { - $parameters = $this->parameters($statement); + $parameters = $this->parameters($statement->bindings); return $this->connection->fetchFirstColumn($statement->sql, $parameters->values, $parameters->types); } @@ -62,7 +62,7 @@ public function column(Fragment $statement): array { * @return array|false */ public function first(Fragment $statement): array|false { - $parameters = $this->parameters($statement); + $parameters = $this->parameters($statement->bindings); return $this->connection->fetchAssociative($statement->sql, $parameters->values, $parameters->types); } @@ -74,7 +74,7 @@ public function first(Fragment $statement): array|false { * @throws Exception When execution fails. */ public function value(Fragment $statement): mixed { - $parameters = $this->parameters($statement); + $parameters = $this->parameters($statement->bindings); return $this->connection->fetchOne($statement->sql, $parameters->values, $parameters->types); } @@ -86,9 +86,39 @@ public function value(Fragment $statement): mixed { * @throws Exception When execution fails. */ public function statement(Fragment $statement): int|string { - $parameters = $this->parameters($statement); + return $this->executeStatement($statement->sql, $statement->bindings); + } + + /** + * Fetch associative rows with inferred types and optional explicit conversions. + * + * @param list|array $bindings + * @param array|string, string|Type|ParameterType|ArrayParameterType> $types + * + * @throws InvalidArgumentException For unsupported values without an explicit type. + * @throws Exception When execution, type conversion, or fetching fails. + * + * @return list> + */ + public function fetchAllAssociative(string $sql, array $bindings = [], array $types = []): array { + $parameters = $this->parameters($bindings, $types); - return $this->connection->executeStatement($statement->sql, $parameters->values, $parameters->types); + return $this->connection->fetchAllAssociative($sql, $parameters->values, $parameters->types); + } + + /** + * Execute SQL with inferred types unless the caller supplies an explicit type. + * + * @param list|array $bindings + * @param array|string, string|Type|ParameterType|ArrayParameterType> $types + * + * @throws InvalidArgumentException For unsupported values without an explicit type. + * @throws Exception When execution or type conversion fails. + */ + public function executeStatement(string $sql, array $bindings = [], array $types = []): int|string { + $parameters = $this->parameters($bindings, $types); + + return $this->connection->executeStatement($sql, $parameters->values, $parameters->types); } /** @@ -130,15 +160,19 @@ public function normalize(mixed $value): int|string|null { /** * Prepare values and explicit binding types for one execution. + * + * @param list|array $bindings + * @param array|string, string|Type|ParameterType|ArrayParameterType> $types */ - private function parameters(Fragment $statement): ParameterSet { - $bindings = []; - $types = []; - - foreach ($statement->bindings as $value) { - $value = $this->normalize($value); - $bindings[] = $value; - $types[] = match (true) { + private function parameters(array $bindings, array $types = []): ParameterSet { + foreach ($bindings as $key => $value) { + if (array_key_exists($key, $types)) { + continue; + } + + $value = $this->normalize($value); + $bindings[$key] = $value; + $types[$key] = match (true) { $value === null => ParameterType::NULL, is_int($value) => ParameterType::INTEGER, default => ParameterType::STRING, diff --git a/src/Database/Query/ValueObjects/ParameterSet.php b/src/Database/Query/ValueObjects/ParameterSet.php index 9d8d1b3..831846a 100644 --- a/src/Database/Query/ValueObjects/ParameterSet.php +++ b/src/Database/Query/ValueObjects/ParameterSet.php @@ -2,20 +2,22 @@ namespace StellarWP\Foundation\Database\Query\ValueObjects; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Types\Type; /** - * Normalized values and their matching DBAL parameter types. + * Values and their matching DBAL parameter types, including explicit conversions. * * @internal */ final readonly class ParameterSet { /** - * Keep values and types in the same placeholder order. + * Associate values and types by placeholder position or name. * - * @param list $values - * @param list $types + * @param array|string, mixed> $values + * @param array|string, string|Type|ParameterType|ArrayParameterType> $types */ public function __construct( public array $values, diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx index 774aa03..91d9718 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -448,20 +448,131 @@ An insertion failure rolls back the deletion and preserves the previous rows. `toSql()` and `getBindings()` inspect the compiled read query without executing it. Bindings remain separate from SQL. -Inject `Doctrine\DBAL\Connection` for specialized SQL or native Doctrine builders and results. Resolve application table names through `quotedName()` and bind external values: +### Run SQL with bound values + +Inject `StellarWP\Foundation\Database\Query\Database` when an operation needs application-written SQL. Each method accepts `( $sql, $bindings = [], $types = [] )`, uses the shared managed connection, and infers binding types from PHP values using the same rules as the fluent builder. + +| Method | Result | +| --- | --- | +| `insert()` | Affected-row count (`int|string`), not the generated ID | +| `update()` | Affected-row count (`int|string`) | +| `delete()` | Affected-row count (`int|string`), including zero when no rows match | +| `select()` | List of associative rows, or `[]` when no rows match | + +Failures throw exceptions. `select()` fetches all rows and preserves Doctrine's returned values; it does not hydrate objects or convert columns through Doctrine types. Use a native Doctrine result when you need to stream a large result set. + +The following examples assume an injected `Database $db` and your application's `Reports_Table $reports`: ```php -$connection->executeStatement( +$table = $this->reports->quotedName(); + +$inserted = $this->db->insert( + "INSERT INTO {$table} ( + title, + status + ) + VALUES (?, ?)", + [ + 'Quarterly report', + 'draft', + ], +); + +$rows = $this->db->select( + "SELECT id, + title + FROM {$table} + WHERE status = :status + ORDER BY id", + [ + 'status' => 'draft', + ], +); + +$deleted = $this->db->delete( + "DELETE FROM {$table} + WHERE status = ?", + [ + 'archived', + ], +); +``` + +### Apply a conditional SQL update + +For example, this repository increments credits within the current period, resets them for a new period, and enforces the quota in one update. MySQL's `<=>` compares periods with null-safe equality. Place the repository in `src/Usage/Usage_Repository.php`. + +```php title="Usage_Repository.php" +use Plugin\Database\Tables\Usage_Table; +use StellarWP\Foundation\Database\Query\Database; + +final readonly class Usage_Repository { + + public function __construct( + private Usage_Table $usage, + private Database $db, + ) { + } + + /** + * Apply a credit claim when the resulting usage fits within the quota. + */ + public function claim( int $id, int $amount, ?string $period, int $quota ): bool { + $table = $this->usage->quotedName(); + + return $this->db->update( + "UPDATE {$table} + SET credits_used = IF(period_start <=> ?, credits_used + ?, ?), + period_start = ? + WHERE id = ? + AND IF(period_start <=> ?, credits_used + ?, ?) <= ?", + [ + $period, + $amount, + $amount, + $period, + $id, + $period, + $amount, + $amount, + $quota, + ], + ) > 0; + } +} +``` + +Integers bind as integers, booleans become `0` or `1`, and null binds as SQL `NULL`. Strings remain strings, including numeric strings such as `'00500'`. Dates retain the fluent builder's microsecond formatting, and floats use its string conversion. All four SQL methods infer types from the values, not the table schema. + +### Override parameter types + +Use either a zero-based list for positional `?` bindings or an associative array for named `:name` bindings in one statement. Supply optional Doctrine types by zero-based position or parameter name when you need explicit conversion. Explicitly typed values reach Doctrine unchanged; other values still use inference: + +```php +use Doctrine\DBAL\Types\Types; + +$db->update( 'UPDATE ' . $reports->quotedName() . ' - SET status = ? - WHERE id = ?', + SET metadata = :metadata + WHERE id = :id', [ - 'ready', - $id, + 'metadata' => [ + 'source' => 'portal', + ], + 'id' => $id, + ], + [ + 'metadata' => Types::JSON, ], ); ``` +`$db->update()` accepts complete SQL, while `$table->query()->update()` accepts column values and builds the SQL. Resolve table names through `quotedName()` and bind external values. With complete SQL, you own the `WHERE` clause and any intended whole-table update or deletion. Raw SQL insert statements execute as supplied; use the fluent builder for automatic bulk-insert chunking. + +### Use native Doctrine operations + +Inject `Doctrine\DBAL\Connection` for transactions, native builders, results, and other advanced operations. Native execution keeps Doctrine's parameter behavior: supply explicit types when the default string binding would change the query's meaning. + Native Doctrine execution exceptions remain unchanged. Foundation validation failures reject invalid query construction before execution. Keep raw SQL and expressions application-owned; raw query methods do not make untrusted SQL safe. ## Choose a transaction boundary diff --git a/tests/Unit/Database/Query/ExecutorTest.php b/tests/Unit/Database/Query/ExecutorTest.php index 1a903c3..231afa2 100644 --- a/tests/Unit/Database/Query/ExecutorTest.php +++ b/tests/Unit/Database/Query/ExecutorTest.php @@ -3,8 +3,11 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Query; use DateTimeImmutable; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Types\JsonType; +use Doctrine\DBAL\Types\Types; use InvalidArgumentException; use PHPUnit\Framework\TestCase; use stdClass; @@ -53,6 +56,59 @@ public function test_unsupported_values_fail_before_execution(): void { ])); } + public function test_named_bindings_preserve_explicit_values_and_infer_remaining_types(): void { + $date = new DateTimeImmutable('2026-10-02 12:34:56.123456'); + $json = new JsonType(); + $bindings = [ + 'metadata' => [ + 'source' => 'portal', + ], + 'created' => $date, + 'ids' => [ + 1, + 2, + ], + 'explicit' => 500, + 'amount' => 500, + 'code' => '00500', + ]; + $types = [ + 'metadata' => $json, + 'created' => Types::DATETIME_IMMUTABLE, + 'ids' => ArrayParameterType::INTEGER, + 'explicit' => ParameterType::STRING, + ]; + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('executeStatement')->with('UPDATE', $bindings, [ + ...$types, + 'amount' => ParameterType::INTEGER, + 'code' => ParameterType::STRING, + ])->willReturn(2); + + $this->assertSame(2, (new Executor($connection))->executeStatement('UPDATE', $bindings, $types)); + } + + public function test_positional_type_overrides_keep_their_placeholder_positions(): void { + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('executeStatement')->with('UPDATE', [ + 1, + 500, + null, + ], [ + 1 => ParameterType::STRING, + 0 => ParameterType::INTEGER, + 2 => ParameterType::NULL, + ])->willReturn(1); + + $this->assertSame(1, (new Executor($connection))->executeStatement('UPDATE', [ + true, + 500, + null, + ], [ + 1 => ParameterType::STRING, + ])); + } + public function test_reads_preserve_dbal_results(): void { $connection = $this->createMock(Connection::class); $connection->expects($this->once())->method('fetchAllAssociative')->with('SELECT', [ diff --git a/tests/integration/Database/RawQueryTest.php b/tests/integration/Database/RawQueryTest.php new file mode 100644 index 0000000..6180bbb --- /dev/null +++ b/tests/integration/Database/RawQueryTest.php @@ -0,0 +1,347 @@ +queries = $this->container->get(Database::class); + $this->observer->executeStatement("ALTER TABLE {$this->table} + ADD credits_used INT NOT NULL DEFAULT 0, + ADD period_start DATETIME NULL, + ADD metadata JSON NULL"); + } + + /** + * Raw operations share inference and explicit conversions while retaining their result shapes. + */ + public function test_insert_select_and_delete_use_bound_values_and_preserve_results(): void { + $this->assertSame(1, $this->queries->insert( + "INSERT INTO {$this->table} ( + id, + name, + credits_used, + period_start, + metadata + ) + VALUES (:id, :name, :credits, :period, :metadata)", + [ + 'id' => 2, + 'name' => "O'Reilly", + 'credits' => false, + 'period' => new DateTimeImmutable('2026-10-02 12:34:56'), + 'metadata' => [ + 'source' => 'portal', + ], + ], + [ + 'period' => Types::DATETIME_IMMUTABLE, + 'metadata' => Types::JSON, + ], + )); + $rows = $this->queries->select( + "SELECT name, + credits_used, + period_start, + metadata + FROM {$this->table} + WHERE id IN (?) + AND credits_used = ?", + [ + [ + 2, + 3, + ], + 0, + ], + [ + ArrayParameterType::INTEGER, + ], + ); + $this->assertCount(1, $rows); + $this->assertSame("O'Reilly", $rows[0]['name']); + $this->assertSame(0, (int) $rows[0]['credits_used']); + $this->assertSame('2026-10-02 12:34:56', $rows[0]['period_start']); + $this->assertSame([ + 'source' => 'portal', + ], json_decode((string) $rows[0]['metadata'], true, flags: JSON_THROW_ON_ERROR)); + $this->assertSame(1, $this->queries->delete( + "DELETE FROM {$this->table} + WHERE id IN (:ids) + AND name = :name", + [ + 'ids' => [ + 2, + ], + 'name' => "O'Reilly", + ], + [ + 'ids' => ArrayParameterType::INTEGER, + ], + )); + $this->assertSame([], $this->queries->select("SELECT name + FROM {$this->table} + WHERE id = :id", [ + 'id' => 2, + ])); + $this->assertSame(0, $this->queries->delete("DELETE FROM {$this->table} + WHERE id = ?", [ + 2, + ])); + $this->assertOriginal(); + } + + /** + * Select shares numeric inference, supports binding-free SQL, and returns all rows. + */ + public function test_select_infers_numeric_expression_types_and_returns_associative_rows(): void { + $rows = $this->queries->select('SELECT IF(1, 0 + ?, ?) <= ? AS allowed', [ + 500, + 500, + 2000, + ]); + $this->assertSame(1, (int) $rows[0]['allowed']); + $this->assertSame([ + [ + 'name' => 'Original', + ], + [ + 'name' => 'Second', + ], + ], $this->queries->select("SELECT name + FROM {$this->table} + UNION ALL + SELECT 'Second' AS name + ORDER BY name")); + } + + /** + * The Licensing expression must compare numeric credits, including its reset branch. + */ + public function test_conditional_update_enforces_quota_and_resets_the_period(): void { + $sql = "UPDATE {$this->table} + SET credits_used = IF(period_start <=> ?, credits_used + ?, ?), + period_start = ? + WHERE id = ? + AND IF(period_start <=> ?, credits_used + ?, ?) <= ?"; + $claims = [ + [ + 'period' => null, + 'amount' => 500, + 'affected' => 1, + 'used' => 500, + ], + [ + 'period' => null, + 'amount' => 1800, + 'affected' => 0, + 'used' => 500, + ], + [ + 'period' => '2026-10-01 00:00:00', + 'amount' => 1800, + 'affected' => 1, + 'used' => 1800, + ], + [ + 'period' => '2026-10-01 00:00:00', + 'amount' => 300, + 'affected' => 0, + 'used' => 1800, + ], + [ + 'period' => '2026-10-01 00:00:00', + 'amount' => 200, + 'affected' => 1, + 'used' => 2000, + ], + ]; + + foreach ($claims as $claim) { + $this->assertSame($claim['affected'], $this->queries->update($sql, [ + $claim['period'], + $claim['amount'], + $claim['amount'], + $claim['period'], + 1, + $claim['period'], + $claim['amount'], + $claim['amount'], + 2000, + ])); + $this->assertSame($claim['used'], (int) $this->observer->fetchOne("SELECT credits_used + FROM {$this->table} + WHERE id = 1")); + $this->assertSame($claim['period'], $this->observer->fetchOne("SELECT period_start + FROM {$this->table} + WHERE id = 1")); + } + } + + /** + * Explicit conversions coexist with inferred named values and numeric strings. + */ + public function test_named_update_supports_json_and_array_parameter_types(): void { + $this->assertSame(1, $this->queries->update( + "UPDATE {$this->table} + SET metadata = :metadata, + name = :name, + credits_used = :credits, + period_start = :period + WHERE id IN (:ids)", + [ + 'metadata' => [ + 'source' => 'portal', + ], + 'name' => '00500', + 'credits' => true, + 'period' => null, + 'ids' => [ + 1, + 2, + ], + ], + [ + 'metadata' => Types::JSON, + 'ids' => ArrayParameterType::INTEGER, + ], + )); + $row = $this->observer->fetchAssociative("SELECT name, + credits_used, + period_start, + metadata + FROM {$this->table} + WHERE id = 1"); + $this->assertIsArray($row); + $this->assertSame('00500', $row['name']); + $this->assertSame(1, (int) $row['credits_used']); + $this->assertNull($row['period_start']); + $this->assertSame([ + 'source' => 'portal', + ], json_decode((string) $row['metadata'], true, flags: JSON_THROW_ON_ERROR)); + } + + /** + * SQL without placeholders still returns the affected-row count. + */ + public function test_update_accepts_sql_without_bindings(): void { + $this->assertSame(1, $this->queries->update("UPDATE {$this->table} + SET credits_used = 5 + WHERE id = 1")); + $this->assertSame(5, (int) $this->observer->fetchOne("SELECT credits_used + FROM {$this->table} + WHERE id = 1")); + } + + /** + * Raw writes remain provisional and raw reads see them on the shared transaction. + */ + public function test_raw_operations_share_the_callers_transaction(): void { + $failure = new RuntimeException('Cancel claim'); + + try { + $this->db->transactional(function () use ($failure): void { + $this->assertSame(1, $this->queries->update("UPDATE {$this->table} + SET name = ? + WHERE id = ?", [ + 'Provisional', + 1, + ])); + $this->assertSame(1, $this->queries->insert("INSERT INTO {$this->table} ( + id, + name + ) + VALUES (?, ?)", [ + 2, + 'Inserted', + ])); + $this->assertSame([ + [ + 'name' => 'Provisional', + ], + [ + 'name' => 'Inserted', + ], + ], $this->queries->select("SELECT name + FROM {$this->table} + ORDER BY id")); + $this->assertSame(1, $this->queries->delete("DELETE FROM {$this->table} + WHERE id = ?", [ + 1, + ])); + + throw $failure; + }); + } catch (RuntimeException $caught) { + $this->assertSame($failure, $caught); + } + + $this->assertOriginal(); + } + + /** + * Native read failures propagate and poison the same managed transaction as writes. + */ + public function test_caught_select_failure_keeps_the_transaction_failed(): void { + try { + $this->db->transactional(function (): void { + $this->queries->delete("DELETE FROM {$this->table} + WHERE id = 1"); + + try { + $this->queries->select("SELECT missing_column + FROM {$this->table}"); + $this->fail('The native database exception must reach the caller.'); + } catch (InvalidFieldNameException) { + // A caught read failure still prevents a successful commit. + } + }); + $this->fail('The transaction must not commit after a caught read failure.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + /** + * Catching a database error does not allow earlier work to commit. + */ + public function test_caught_update_failure_keeps_the_transaction_failed(): void { + try { + $this->db->transactional(function (): void { + $this->queries->update("UPDATE {$this->table} + SET name = 'Provisional' + WHERE id = 1"); + + try { + $this->queries->update("UPDATE {$this->table} + SET missing_column = ? + WHERE id = ?", [ + 'Invalid', + 1, + ]); + $this->fail('The native database exception must reach the caller.'); + } catch (InvalidFieldNameException) { + // A caught statement failure still prevents a successful commit. + } + }); + $this->fail('The transaction must not commit after a caught statement failure.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } +}