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..c2bb111 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 @@ -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 8459de8..2111e0f 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: @@ -19,7 +19,8 @@ jobs: with: php-version: '8.3' coverage: pcov - extensions: redis + 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 7dd87c2..b3bf21e 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 @@ -24,7 +24,8 @@ jobs: with: php-version: ${{ matrix.php }} coverage: none - extensions: redis + extensions: redis, mysqli + tools: composer:v2 - name: Install Composer dependencies uses: ramsey/composer-install@v4 @@ -37,11 +38,65 @@ jobs: - name: Run Feature Tests run: vendor/bin/phpunit --configuration ./phpunit.xml.dist --colors=always --testsuite Feature + monolog: + runs-on: blacksmith-2vcpu-ubuntu-2404 + 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 + tools: composer:v2 + + - 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 + runs-on: blacksmith-2vcpu-ubuntu-2404 + 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: 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 env: + SLIC_DB_IMAGE: ${{ matrix.database.image }} + SLIC_DB_HEALTHCHECK: ${{ matrix.database.healthcheck }} COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"}}' steps: @@ -56,6 +111,27 @@ jobs: path: slic fetch-depth: 1 + - 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: | + cache_directory=$(composer config cache-dir --absolute) + echo "path=$cache_directory" >> "$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 @@ -71,6 +147,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 @@ -80,13 +158,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 @@ -150,7 +228,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 @@ -158,7 +236,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: @@ -170,7 +248,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/AGENTS.md b/AGENTS.md index f111791..165674d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,10 @@ Split packages: - `stellarwp/foundation-container` - `stellarwp/foundation-log` - `stellarwp/foundation-lock` +- `stellarwp/foundation-lock-database` - `stellarwp/foundation-lock-redis` - `stellarwp/foundation-database` +- `stellarwp/foundation-migrations` - `stellarwp/foundation-identifier` - `stellarwp/foundation-pipeline` - `stellarwp/foundation-shutdown` @@ -45,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. +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 @@ -73,6 +75,14 @@ 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 `[]`. + +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. @@ -89,10 +99,14 @@ 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 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 `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 +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. @@ -125,9 +139,17 @@ 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. 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 +169,23 @@ 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 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. -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 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. -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 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. -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. 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. -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. 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. -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. 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. 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. 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. -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. + +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 @@ -169,7 +193,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.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+. 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. @@ -260,12 +286,14 @@ 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. ## 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. @@ -278,7 +306,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/README.md b/README.md index 3fa38f9..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. @@ -16,8 +16,10 @@ 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 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 | @@ -26,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 @@ -190,12 +195,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/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/composer.json b/composer.json index c339257..10da720 100644 --- a/composer.json +++ b/composer.json @@ -12,10 +12,12 @@ "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.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", @@ -43,8 +45,10 @@ "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-migrations": "self.version", "stellarwp/foundation-pipeline": "self.version", "stellarwp/foundation-shutdown": "self.version", "stellarwp/foundation-view": "self.version", @@ -58,9 +62,11 @@ "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/", + "StellarWP\\Foundation\\Migrations\\": "src/Migrations/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", "StellarWP\\Foundation\\Shutdown\\": "src/Shutdown/", "StellarWP\\Foundation\\View\\": "src/View/", 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/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/* 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/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php b/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php index 5263983..308a0d9 100644 --- a/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php +++ b/src/Cli/Commands/Make/Database/Factories/MigrationFileFactory.php @@ -2,182 +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\Database\DatabaseStubPath; -use StellarWP\Foundation\Database\Migration\ValueObjects\Id; +use StellarWP\Foundation\Container\Contracts\Configuration; +use StellarWP\Foundation\Migrations\MigrationStubPath; +use StellarWP\Foundation\Migrations\ValueObjects\Id; +use StellarWP\Foundation\Migrations\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\\Contracts\\Migration'), - 'foundation_database_schema' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), - 'foundation_database_irreversible_migration' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Migration\\Exceptions\\IrreversibleMigration'), - ])); + public function generic(string $name): GeneratedFile { + return $this->render($name, 'migration', MigrationStubPath::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\\Contracts\\Migration'), - 'foundation_database_schema' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), - 'foundation_database_blueprint' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Table\\Blueprint'), - ])); + public function createTable(string $name, string $table): GeneratedFile { + return $this->render($name, 'create-table-migration', MigrationStubPath::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\\Contracts\\Migration'), - 'foundation_database_schema' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), - 'foundation_database_blueprint' => $context['project']->foundationClass('StellarWP\\Foundation\\Database\\Table\\Blueprint'), - ])); + public function alterTable(string $name, string $table): GeneratedFile { + return $this->render($name, 'alter-table-migration', MigrationStubPath::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('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\\Migrations\\Migration', + 'foundation_database_schema' => $prefix . 'StellarWP\\Foundation\\Migrations\\Schema\\Blueprint', + 'foundation_database_irreversible_migration' => $prefix . 'StellarWP\\Foundation\\Migrations\\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 79b16bb..da11958 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 Migrations. * * 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:' . self::CONFIG_KEY; - 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. */ @@ -289,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 4400f75..6e0122f 100644 --- a/src/Cli/Commands/Make/Database/ProviderCommand.php +++ b/src/Cli/Commands/Make/Database/ProviderCommand.php @@ -21,13 +21,13 @@ /** * 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 { 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( @@ -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(); @@ -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/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 35d5f7a..6f9ccef 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,15 +22,15 @@ 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 { 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'; /** @@ -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,18 +92,17 @@ 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(''); 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.', + 'Register %s with your database provider when it needs shared or custom wiring.', $this->classNameResolver->tableClass((string) $input->getArgument('name')), - $migration->class )); } @@ -119,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(''); @@ -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) { @@ -376,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)) { @@ -392,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."; } /** @@ -408,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/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 @@ - [...])`. + * `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/Cli/Migrate.php b/src/Database/Cli/Migrate.php deleted file mode 100644 index 21a1cb3..0000000 --- a/src/Database/Cli/Migrate.php +++ /dev/null @@ -1,207 +0,0 @@ - $args - * @param array $assocArgs - */ - public function runCommand(array $args = [], array $assocArgs = []): int { - $operation = $this->selectedOperation($assocArgs); - - if ($operation !== null && $operation !== self::FLAG_INITIALIZE && ! $this->migrator->isInitialized()) { - WP_CLI::error($this->uninitializedMessage()); - } - - 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; - } - - protected function subcommand(): string { - return 'migrate'; - } - - protected function description(): string { - return 'List and manage database migrations.'; - } - - 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, - ], - ]; - } - - private function showStatus(): void { - if (! $this->migrator->isInitialized()) { - WP_CLI::warning($this->uninitializedMessage()); - } - - 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', - ]); - } - - /** - * 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 $selected[0] ?? null; - } - - private function initializeStore(): void { - $this->migrator->initialize(); - WP_CLI::success('Foundation migration storage is initialized.'); - } - - /** - * Confirm and remove only the migration ledger. - * - * @param array $assocArgs - */ - 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.'); - } - - /** - * Confirm, roll back, and rerun all configured migrations. - * - * @param array $assocArgs - */ - 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))); - } - - private function rollbackMigrations(): void { - $result = $this->migrator->rollback(); - - WP_CLI::success(sprintf('Rolled back %d migrations.', count($result->rolledBack))); - } - - private function runMigrations(): void { - $result = $this->migrator->run(); - - WP_CLI::success(sprintf('Ran %d migrations.', count($result->ran))); - } - - private function uninitializedMessage(): string { - $command = $this->registeredCommandName(); - - if ($command === null) { - return 'Migration storage is not initialized. Run this command with --initialize first.'; - } - - return sprintf('Migration storage is not initialized. Run `wp %s --initialize` first.', $command); - } -} diff --git a/src/Database/Connection/AdvisoryLock.php b/src/Database/Connection/AdvisoryLock.php new file mode 100644 index 0000000..6587724 --- /dev/null +++ b/src/Database/Connection/AdvisoryLock.php @@ -0,0 +1,138 @@ +driver = new Connection($native); + $this->connectionId = $native->thread_id; + $this->site = $scope->capture(); + $this->prefix = $scope->resolveTableName(''); + $this->name = hash('sha256', $this->reporter->run(fn () => $this->driver->query('SELECT DATABASE()')->fetchOne()) . "\0" . $resource); + } + + /** + * Acquire without waiting, before invoking protected work. + * + * @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->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->reporter->run(fn () => $this->driver->exec('SET TRANSACTION READ WRITE')); + + try { + $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 { + $this->native->close(); + } catch (Throwable) { + // A terminated session cannot retain an advisory lock. + } + + throw $failure; + } + + if ($result === null || $result === false) { + throw new AdvisoryLockInterrupted('The database could not acquire the advisory lock.'); + } + + if ((int) $result !== 1) { + throw new AdvisoryLockContended('Another session owns this advisory lock; retry after it completes.'); + } + } + + /** + * Verify ownership before SQL, including after a callback catches an earlier exception. + * + * @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 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 AdvisoryLockInterrupted('The locked connection or table prefix changed.'); + } + + 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) { + $this->fail($failure); + + throw new AdvisoryLockInterrupted('Advisory lock 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 AdvisoryLockInterrupted('The original locked connection was lost.'); + } + + 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) { + 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..24c3278 --- /dev/null +++ b/src/Database/Connection/BorrowedConnection.php @@ -0,0 +1,104 @@ +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 DatabaseException When mysqli cannot start the transaction. + */ + public function beginTransaction(): void { + $this->session->execute($this->native, function (): void { + 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); + } + }); + } + + /** + * Commit the native transaction and require a positive acknowledgement. + * + * @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 DatabaseException('The database did not acknowledge the commit.'); + } + }); + } + + /** + * Roll back the captured native session when DBAL invokes driver cleanup. + * + * 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 DatabaseException('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/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/WordPressConnection.php b/src/Database/Connection/WordPressConnection.php new file mode 100644 index 0000000..5490bfc --- /dev/null +++ b/src/Database/Connection/WordPressConnection.php @@ -0,0 +1,198 @@ +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) { + $session->fail($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) { + $session->fail($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 savepoint cleanup fails or explicit rollback cannot be confirmed. + */ + public function rollBack(): void { + $session = $this->session(); + + if ($this->getTransactionNestingLevel() > 1 && $session->hasFailed()) { + 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(); + + return; + } + + if ($this->getTransactionNestingLevel() > 1 || ! $session->isActive()) { + try { + parent::rollBack(); + } catch (Throwable $failure) { + $session->fail($failure); + + throw $failure; + } + + return; + } + + $failed = $session->hasFailed(); + $rolledBack = $session->rollback(); + parent::close(); + $session->finish(); + + if (! $rolledBack && ! $failed) { + throw new DatabaseException('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..e928839 --- /dev/null +++ b/src/Database/Connection/WordPressSession.php @@ -0,0 +1,274 @@ +check(); + $native = $this->wpdb->__get('dbh'); + + if (! $native instanceof mysqli) { + throw new DatabaseException('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 locked 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 DatabaseException('Start advisory-locked work outside an existing transaction or locked operation.'); + } + + $lock = new AdvisoryLock($this->native(), $this->scope, $this->reporter, $resource); + $lock->acquire(); + $this->advisoryLock = $lock; + + try { + $result = $operation(); + $this->check(); + } catch (Throwable $failure) { + try { + $lock->release(); + } catch (Throwable) { + // Preserve the operation 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) $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. + $this->reporter->run(static fn () => $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 DatabaseException('The WordPress connection changed; start a fresh operation.'); + } + + return $this->reporter->run($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 DatabaseException('The WordPress table prefix changed during a transaction.'); + } + + if ($this->wpdb->__get('dbh') !== $this->owned) { + throw new DatabaseException('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->reporter->run(fn (): bool => $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 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. + */ + public function finish(): void { + $this->owned = null; + $this->failure = null; + } +} 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 @@ +|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..e7709e0 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -2,217 +2,69 @@ 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\ErrorReporter; +use StellarWP\Foundation\Database\Connection\WordPressConnection; +use StellarWP\Foundation\Database\Connection\WordPressMiddleware; +use StellarWP\Foundation\Database\Connection\WordPressSession; +use StellarWP\Foundation\Database\Contracts\AdvisorySession; 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\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\Query\Upsert\Contracts\Builder; +use StellarWP\Foundation\Database\Query\Upsert\ServerBuilder; 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 transactions. */ final class DatabaseProvider extends Provider { - 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. - * - * @throws InvalidArgumentException When the configured Foundation prefix is invalid. + * Contribute Doctrine Driver\Middleware objects before resolving the shared connection. + * Later contributions wrap earlier ones, outside Foundation's WordPress middleware. */ - public function register(): void { - if ($this->registered) { - return; - } - - $this->registerDatabase(); - $this->registerDatabaseScope(); - $this->registerSchema(); - $this->registerConfiguration(); - $this->registerTables(); - $this->registerMigrations(); - $this->registerLocks(); - $this->registerCliCommands(); - - $this->registered = true; - } + public const string MIDDLEWARE = self::class . '.middleware'; /** - * Register application-scoped table names, lock name, and lock lifetime. + * Register lazy database services without executing queries or creating storage. */ - 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)); - } - - /** - * 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; + public function register(): void { + $this->container->mergeArrayVar(self::MIDDLEWARE, []); + $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(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)), + ...$c->get(self::MIDDLEWARE), + ]); + + 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)); - - $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') - ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); - - $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)); - - $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(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/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 @@ + $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..c671d62 --- /dev/null +++ b/src/Database/Exceptions/TransactionFailed.php @@ -0,0 +1,10 @@ + - */ -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/Repository.php b/src/Database/Migration/Contracts/Repository.php deleted file mode 100644 index fc997cd..0000000 --- a/src/Database/Migration/Contracts/Repository.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ - 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/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/Migrator.php b/src/Database/Migration/Migrator.php deleted file mode 100644 index f5d404f..0000000 --- a/src/Database/Migration/Migrator.php +++ /dev/null @@ -1,253 +0,0 @@ -store->initialize(); - } - - /** - * Drop the migration ledger while preserving shared lock storage. - * - * @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. - */ - public function dropStore(): void { - $this->store->drop(); - } - - /** - * Determine whether the complete migration store has been initialized. - * - * @throws DatabaseException When migration storage cannot be inspected. - */ - public function isInitialized(): bool { - return $this->store->isInitialized(); - } - - /** - * Run all pending configured migrations. - * - * @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. - */ - public function run(): Result { - $migrations = $this->migrations->all(); - - return $this->store->withMigrationLock( - fn (Session $session): Result => $this->runPending($migrations, $session) - ); - } - - /** - * Roll back the latest recorded migration batch. - * - * @param int|null $expectedLatestBatch The expected latest batch, available as Status::$batch from status(). Pass null to roll back whichever batch is latest. - * - * @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. - */ - 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(); - } - - $batch = $latestBatch; - - return $this->rollbackRecords( - $configured, - $this->repository->recordsForBatch($batch), - $session - ); - }); - } - - /** - * Roll back and rerun all configured migrations. - * - * @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. - */ - 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 - ); - }); - } - - /** - * Return the status of every configured and recorded migration. - * - * @throws DatabaseException When migration storage cannot be inspected. - * - * @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) - ); - } - - $records = $this->repository->all(); - $statuses = []; - - foreach ($configured as $migration) { - $statuses[] = isset($records[$migration->id()]) - ? Status::applied($records[$migration->id()]) - : Status::pending($migration->id()); - - unset($records[$migration->id()]); - } - - foreach ($records as $record) { - $statuses[] = Status::unavailable($record); - } - - return $statuses; - } - - /** - * Roll back recorded migrations in reverse order after confirming every implementation is available. - * - * @param array $migrations - * @param list $records Ledger records in ascending execution order. - * @param Session $session The active migration session that maintains lock ownership. - * - * @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. - */ - 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])) - )); - - if ($unavailable !== []) { - throw new UnavailableMigration($unavailable); - } - - $rolledBack = []; - - foreach ($records as $record) { - $migration = $migrations[$record->migration]; - $session->revert($migration); - - if (! $this->repository->deleteRun($migration->id())) { - throw LedgerFailure::notDeletedAfterRollback($migration->id()); - } - - $rolledBack[] = $migration->id(); - } - - return new Result(rolledBack: $rolledBack); - } - - /** - * 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. - */ - 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; - } - - $session->apply($migration); - - $this->repository->recordRun($migration->id(), $batch); - $ran[] = $migration->id(); - } - - return new Result(ran: $ran, skipped: $skipped); - } -} 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/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/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/Query/Compiler.php b/src/Database/Query/Compiler.php new file mode 100644 index 0000000..cd8f666 --- /dev/null +++ b/src/Database/Query/Compiler.php @@ -0,0 +1,295 @@ +|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); + $tail = $this->tail($ordered ? $state->orders : [], $limit, $state->offset); + + $sql .= $tail->sql; + + if ($state->forUpdate) { + $sql .= "\nFOR UPDATE"; + } + + return new Fragment($sql, array_merge($bindings, $tail->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 { + // 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)); + + 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 { + 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 $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 " . $assignments->sql + . "\nWHERE " . $state->where->sql + . $tail->sql, + array_merge($assignments->bindings, $state->where->bindings, $tail->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.'); + } + + $tail = $this->tail($state->orders, $state->limit, null); + + return new Fragment( + 'DELETE FROM ' . $this->resolver->sql($state->table) + . "\nWHERE " . $state->where->sql + . $tail->sql, + array_merge($state->where->bindings, $tail->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 !== [] || $state->forUpdate) { + 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->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.'); + } + + 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): 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'); + } + + if ($offset !== null) { + $sql .= ' OFFSET ' . $offset; + } + + return new Fragment($sql, $bindings); + } +} diff --git a/src/Database/Query/Database.php b/src/Database/Query/Database.php new file mode 100644 index 0000000..1c610d8 --- /dev/null +++ b/src/Database/Query/Database.php @@ -0,0 +1,134 @@ +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); + } + + /** + * 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 new file mode 100644 index 0000000..0f1f376 --- /dev/null +++ b/src/Database/Query/Executor.php @@ -0,0 +1,184 @@ +> + */ + public function rows(Fragment $statement): array { + return $this->fetchAllAssociative($statement->sql, $statement->bindings); + } + + /** + * 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->bindings); + + return $this->connection->fetchFirstColumn($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->bindings); + + 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->bindings); + + 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 { + 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->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); + } + + /** + * 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. + * + * @param list|array $bindings + * @param array|string, string|Type|ParameterType|ArrayParameterType> $types + */ + 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, + }; + } + + 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..462f8ee --- /dev/null +++ b/src/Database/Query/InsertWriter.php @@ -0,0 +1,189 @@ + 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 { + 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.'); + } + + 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 = $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; + + 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; + } + + /** + * 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 index d1f354e..cae76a7 100644 --- a/src/Database/Query/Query.php +++ b/src/Database/Query/Query.php @@ -2,85 +2,560 @@ namespace StellarWP\Foundation\Database\Query; -use StellarWP\Foundation\Database\Contracts\QueryReader; +use Closure; +use Doctrine\DBAL\Exception; +use InvalidArgumentException; +use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Exceptions\DatabaseException; -use StellarWP\Foundation\Database\Exceptions\QueryException; +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; /** - * Represents a prepared-query template and bindings that can be inspected before execution. + * Describe a table query fluently and execute it on the shared managed connection. * - * The SQL is materialized for the active database scope when this object is created. - * Rebuild the query after changing the active WordPress site. + * Terminal operations preserve this builder for subsequent reads. */ -final readonly class Query +final class Query extends WhereGroup { /** - * Capture a SQL template and its bindings for later inspection or execution. + * @var list + */ + private array $selection = []; + + /** + * @var list + */ + private array $joins = []; + + /** + * @var list + */ + private array $groups = []; + + /** + * @var list + */ + private array $orders = []; + private bool $distinct = false; + private bool $forUpdate = false; + private ?int $limit = null; + private ?int $offset = null; + private WhereGroup $having; + + /** + * Receive shared collaborators and this query's resolved source. * - * @param list $bindings + * @internal Obtain fresh queries from a Table or Database. */ public function __construct( - private QueryReader $database, - private string $sql, - private array $bindings = [] + 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); } /** - * Return the SQL template before bindings are prepared. + * Select named columns, qualified columns, wildcards, or columns with aliases. + * + * @throws InvalidArgumentException When a column or output alias is invalid. */ - public function toSql(): string { - return $this->sql; + public function select(string ...$columns): self { + $this->selection = array_map( + $this->quoter->selection(...), + array_values($columns), + ); + + return $this; } /** - * Return the bindings in placeholder order. + * Add a trusted SQL projection with positional values supplied separately. * - * @return list + * @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 + * @param string|null $alias Replaces any alias already supplied with the table. + * + * @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, + ?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, 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, + ?string $alias = null, + ): self { + return $this->addJoin('LEFT', $table, $first, $operator, $second, $alias); + } + + /** + * 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[] = 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; + } + + /** + * 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 bindings(): array { - return $this->bindings; + public function offset(int $rows): self { + if ($rows < 0) { + throw new InvalidArgumentException('The row offset cannot be negative.'); + } + + $this->offset = $rows; + + return $this; } /** - * Prepare the SQL template with its captured bindings. + * Request a FOR UPDATE read on the shared connection. * - * @throws QueryException When WordPress cannot prepare the statement. + * 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 toPreparedSql(): string { - return $this->database->prepare($this->sql, ...$this->bindings); + public function lockForUpdate(): self { + $this->forUpdate = true; + + return $this; } /** - * Execute the query and return every matching row. + * Read all selected rows as associative arrays. * - * @throws DatabaseException When the database returns a row in an invalid shape. - * @throws QueryException When the query fails. + * @throws Exception When execution fails. + * @throws InvalidArgumentException For unsupported bound values. * * @return list> */ public function get(): array { - return $this->database->rows($this->sql, ...$this->bindings); + return $this->executor->rows($this->compiler->select($this->state())); } /** - * Execute the query and return its first row when present. + * Read one page and count all matching results without changing this builder. * - * @throws DatabaseException When the database returns a row in an invalid shape. - * @throws QueryException When the query fails. + * 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. + * + * @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. + * + * @throws Exception When execution fails. + * @throws InvalidArgumentException For unsupported bound values. * * @return array|null */ public function first(): ?array { - return $this->database->row($this->sql, ...$this->bindings); + $result = $this->executor->first($this->compiler->select($this->state(), maximum: 1)); + + return $result === false ? null : $result; } /** - * Execute the query and return the first column from its first row. + * Count result rows, including the effects of grouping, distinctness, and paging. * - * @throws QueryException When the query fails. + * @throws Exception When execution fails. + * @throws InvalidArgumentException For unsupported bound values or ambiguous shaped join projections. */ - public function value(): mixed { - return $this->database->value($this->sql, ...$this->bindings); + 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)); + } + + /** + * 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. + * + * @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 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. + * + * 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)); + } + + /** + * 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. + * + * @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, + $this->forUpdate, + ); + } + + /** + * 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, + ?string $alias, + ): 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.'); + } + + $reference = $this->resolver->resolve($table); + + if ($alias !== null) { + $reference = $reference->as($alias); + } + + $this->joins[] = new Fragment( + $type . ' JOIN ' . $this->resolver->sql($reference) . ' ON ' . $condition->sql, + $condition->bindings, + ); + + return $this; } } 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/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/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/Database/Query/ValueObjects/ParameterSet.php b/src/Database/Query/ValueObjects/ParameterSet.php new file mode 100644 index 0000000..831846a --- /dev/null +++ b/src/Database/Query/ValueObjects/ParameterSet.php @@ -0,0 +1,27 @@ +|string, mixed> $values + * @param array|string, string|Type|ParameterType|ArrayParameterType> $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, + public bool $forUpdate, + ) { + } +} 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..292d825 --- /dev/null +++ b/src/Database/Query/WhereGroup.php @@ -0,0 +1,286 @@ + + */ + 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'); + } + + /** + * 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. + * + * @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 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. + * + * @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/README.md b/src/Database/README.md index 412a2a4..fbcbe4d 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -3,17 +3,23 @@ > [!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, +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.5 platform support](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.5/reference/platforms.html). + ```shell composer require stellarwp/foundation-database ``` +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, database locks, and testing. +for configuration, query building, transactions, and testing. 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; - } + abstract public function unprefixedName(): string; /** - * Return the stable table name before WordPress scope is applied. + * Resolve this table's physical name at the current site. + * + * @throws DatabaseException When the physical name is invalid. + * + * @return non-empty-string */ - abstract public function unprefixedName(): string; + final public function name(): string { + return $this->resolver->tableName($this); + } /** - * Resolve a validated physical table name for the active WordPress database scope. + * Quote the resolved name for native Doctrine expressions and SQL. * - * 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 Exception When the platform cannot be determined. + */ + final public function quotedName(): string { + return $this->connection->getDatabasePlatform()->quoteSingleIdentifier($this->name()); + } + + /** + * Start a fresh fluent query with this table as its source. * - * @throws DatabaseException When the resolved physical table name is invalid. + * @throws DatabaseException When the physical name is invalid. + * @throws Exception When the platform cannot be determined. */ - final public function name(): string { - return $this->database->tableName($this); + final public function query(?string $alias = null): Query { + return $this->db->table($this, $alias); } /** - * Begin a query against this table. + * 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 query(?string $alias = null): QueryBuilder { - return new QueryBuilder($this->database, $this, $alias); + final public function count(): int { + return $this->query()->count(); } /** - * Insert one row into this table. + * Insert one row or a list of rows and return the total affected-row count. * - * @param array $data + * Empty input performs no write. Use a caller-owned transaction when a bulk + * insert must be atomic across multiple statements. * - * @throws DatabaseException When the resolved physical table name is invalid. - * @throws QueryException When the insert 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): int { - return $this->database->insert($this, $data); + final public function insert(array $rows): int { + return $this->query()->insert($rows); } /** - * Insert one row and return the connection's auto-increment identifier. + * Insert exactly one row and return its generated identifier without truncation. + * + * Empty data inserts one row using the database defaults. * * @param array $data * - * @throws DatabaseException When the resolved physical table name is invalid. - * @throws QueryException When the insert fails. + * @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): int { - return $this->database->insertGetId($this, $data); + final public function insertGetId(array $data): int|string { + return $this->query()->insertGetId($data); } /** - * 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 * - * @throws DatabaseException When the resolved physical table name is invalid. - * @throws QueryException When the update fails. + * @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): int { - return $this->database->update($this, $data, $where); + 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->query()->where($where)->update($data); } /** - * Delete rows from this table matching equality-based column values. + * Delete rows matching equality criteria, returning the affected-row count. * * @param array $where * - * @throws DatabaseException When the resolved physical table name is invalid. - * @throws QueryException When the delete fails. + * @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): int|string { + if ($where === []) { + throw new InvalidArgumentException('Table deletions require criteria; use deleteAll() for an intentional whole-table delete.'); + } + + return $this->query()->where($where)->delete(); + } + + /** + * 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 delete(array $where): int { - return $this->database->delete($this, $where); + final public function deleteAll(): int|string { + return $this->connection->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->connection->isTransactionActive()) { + throw new DatabaseException('Table truncation cannot run inside a transaction; use deleteAll() instead.'); + } + + $sql = $this->connection->getDatabasePlatform()->getTruncateTableSQL($this->quotedName()); + $this->connection->executeStatement($sql); } } diff --git a/src/Database/Table/TableNameResolver.php b/src/Database/Table/TableNameResolver.php new file mode 100644 index 0000000..2055530 --- /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/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" + "doctrine/dbal": "~4.5.0", + "ext-mysqli": "*" }, "autoload": { "psr-4": { diff --git a/src/Database/stubs/alter-table-migration.stub b/src/Database/stubs/alter-table-migration.stub deleted file mode 100644 index e114f8f..0000000 --- a/src/Database/stubs/alter-table-migration.stub +++ /dev/null @@ -1,39 +0,0 @@ -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 { - throw IrreversibleMigration::forMigration( self::ID ); - } - -} diff --git a/src/Database/stubs/create-table-migration.stub b/src/Database/stubs/create-table-migration.stub deleted file mode 100644 index ddbc8e1..0000000 --- a/src/Database/stubs/create-table-migration.stub +++ /dev/null @@ -1,37 +0,0 @@ -table ); - - $blueprint->bigIncrements( 'id' ); - - // Define the complete initial schema before running this migration. - - $schema->create( $blueprint ); - } - - public function down( Schema $schema ): void { - $schema->drop( $this->table ); - } - -} diff --git a/src/Database/stubs/migration.stub b/src/Database/stubs/migration.stub deleted file mode 100644 index 7d4de6c..0000000 --- a/src/Database/stubs/migration.stub +++ /dev/null @@ -1,28 +0,0 @@ -registered ) { - return; - } - $this->register_tables(); - $this->register_migrations(); - - $this->registered = true; } 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/.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 diff --git a/src/Docs/astro.config.mjs b/src/Docs/astro.config.mjs index d4226c1..5775c2d 100644 --- a/src/Docs/astro.config.mjs +++ b/src/Docs/astro.config.mjs @@ -37,12 +37,18 @@ export default defineConfig({ collapsed: false, items: [ { 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/lock/redis' }, + ], + }, + { slug: 'components/migrations' }, { slug: 'components/log' }, { slug: 'components/identifier' }, { slug: 'components/pipeline' }, 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/database.mdx b/src/Docs/src/content/docs/components/database.mdx index 5efd373..dfa7c32 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -1,182 +1,173 @@ --- title: Database -description: WordPress-backed migrations, queries, and distributed locks built on wpdb and dbDelta. +description: A shared Doctrine connection, application tables, and managed WordPress transactions. 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 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. -## 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.5.x** (`~4.5.0`). -:::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. +:::caution[Database requirements] +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. ::: -### Prepare the application -Database services use the application's existing container, provider graph, Foundation prefix, and WP-CLI command prefix: +Database services borrow the active WordPress connection. Use the primary MySQL or MariaDB database and InnoDB tables for transactional application data. - - - - - - - -## 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" [ + 'root' => __DIR__, 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', ], ]; ``` -With this prefix, Foundation uses these resources by default: - -| Resource | Default | -| --- | --- | -| Migration table | `your_plugin_foundation_migrations` | -| Lock table | `your_plugin_foundation_locks` | -| Migration lock | `your-plugin-foundation-database-migrations` | -| WP-CLI command | `wp your-plugin migrate` | - -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. - -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. +:::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/). +::: -### Override individual resources +After [creating the application container](/start/configure-the-container/), register the database provider before application providers in `src/App.php`: -Package-specific settings in the root `config.php` override values derived from `foundation.prefix`: +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Provider; +use StellarWP\Foundation\Database\DatabaseProvider; -```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 ), - ], +/** @var list> */ +private const array PROVIDERS = [ + DatabaseProvider::class, ]; ``` -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. Register an application provider afterward when you need custom service bindings. + +### Query an application table + +Application tables declare their stable name and inherit their infrastructure constructor: -The migration lock settings coordinate migration execution only. Applications selecting `DatabaseLock` for their own work choose each lock name and TTL when calling `acquire()`. +In `src/Database/Tables/Reports_Table.php`: -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. +```php title="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: +use StellarWP\Foundation\Database\Table\Table; -```php title="Migrate_Network.php" -foreach ( $site_ids as $site_id ) { - switch_to_blog( $site_id ); +final readonly class Reports_Table extends Table { - try { - $migrator->initialize(); - $migrator->run(); - } finally { - restore_current_blog(); + 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 ) + ->first(); ``` -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. +[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. -:::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. -::: +## 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. 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`. + +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 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`. -### Register the providers +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. -In `src/App.php`, register `WPCliProvider` before `DatabaseProvider`, then register application providers that contribute migrations or select the database lock: +### 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; -use StellarWP\Foundation\WPCli; -use Plugin\Database as ApplicationDatabase; +use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Log\LogProvider; /** @var list> */ private const array PROVIDERS = [ - WPCli\WPCliProvider::class, - Database\DatabaseProvider::class, - ApplicationDatabase\Provider::class, + DatabaseProvider::class, + LogProvider::class, + Query_Logging_Provider::class, ]; ``` -`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. +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. -## Choose a feature +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/src/Docs/src/content/docs/components/database/lock.mdx b/src/Docs/src/content/docs/components/database/lock.mdx deleted file mode 100644 index 29f5dcd..0000000 --- a/src/Docs/src/content/docs/components/database/lock.mdx +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: Database Lock -description: Use the WordPress database as the shared backend for Foundation lock ownership. -sidebar: - order: 3 ---- - -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. - - - - - - -## Configuration - -### 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`: - -```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 ) - ); - } -} -``` - -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. - -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 Plugin\Lock; - -/** @var list> */ -private const array PROVIDERS = [ - WPCliProvider::class, - DatabaseProvider::class, - Lock\Provider::class, -]; -``` - -### Initialize lock storage - -Create or reconcile the database lock table during deployment: - -```shell -wp your-plugin migrate --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. - -## 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/). - -Database-backed locks have these operational constraints: - -- Lock names must fit within 191 bytes. -- Expiration uses the database's UTC clock, keeping ownership consistent between PHP processes. -- Every contender must read and write through the same primary database. Replica reads can report stale ownership. -- The TTL must exceed the protected operation, or the owner must refresh the token before it expires. - -:::caution[Keep each lock token in one site context] -On multisite, acquire, refresh, and release a database lock without changing the active blog. `DatabaseLock` resolves its table from the current `$wpdb->prefix`; attempting to refresh or release a token after `switch_to_blog()` fails closed and leaves the original site's lease to expire. -::: - -:::caution[Keep database and remote guarantees separate] -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. -::: - -## 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/Docs/src/content/docs/components/database/migrations.mdx b/src/Docs/src/content/docs/components/database/migrations.mdx deleted file mode 100644 index 1e578f1..0000000 --- a/src/Docs/src/content/docs/components/database/migrations.mdx +++ /dev/null @@ -1,733 +0,0 @@ ---- -title: Migrations -description: Define WordPress tables, register versioned schema changes, and run them safely during deployment. -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. - - - -## 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: - -```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 -``` - -### Understand migration classes - -Every migration has one permanent identifier and two operations: - -| 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" -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 down( Schema $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: - -```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 ); - } -} -``` - -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. - -## Register migrations - -The generators update an existing database provider automatically. When registering classes by hand, bind table services and contribute them from `src/Database/Provider.php`: - -```php title="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 ), - ] - ); - } -} -``` - -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. - -## Run migrations - -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: - -```shell -wp your-plugin migrate --rollback -``` - -Roll back every configured migration and run them again: - -```shell -wp your-plugin migrate --refresh --yes -``` - -:::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: - -```shell -wp your-plugin migrate --drop-store --yes -``` - -:::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. -::: - -## 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: - -```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. - -### Inspect migration status from PHP - -`Migrator::status()` returns one `Status` object for each configured or recorded migration. Each status is in exactly one state: - -- `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. - -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: - -```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. - } -} -``` - -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. - -## Testing - -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. - -Create and remove application tables within the test lifecycle so tests exercise the real `wpdb` and `dbDelta()` behavior rather than a PHP fake. - -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`: - -```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 ); - - try { - $migration->up( $schema ); - - $this->assertTrue( $schema->hasTable( $table ) ); - $this->assertTrue( $schema->hasIndex( $table, 'status' ) ); - } finally { - $schema->drop( $table ); - } - } -} -``` - -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. 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..91d9718 100644 --- a/src/Docs/src/content/docs/components/database/query-builder.mdx +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -1,42 +1,15 @@ --- -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 Foundation's fluent builder and choose reliable transaction boundaries. sidebar: order: 2 --- -import { LinkCard } from '@astrojs/starlight/components'; - -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. - - - -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" -> + * Find one report, or return null when it does not exist. + * + * @return array|null */ - 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(); + public function find( int $id ): ?array { + return $this->reports->query() + ->select( 'id', 'title' ) + ->where( 'id', $id ) + ->first(); } } ``` -`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. +`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', '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(); +``` + +`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(); +``` + +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. + +### 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. + +### 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: + +```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. + +### 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. + +```php +$query = $this->reports->query()->where( 'status', 'ready' ); +$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. -`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. +`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. -Use `null` with equality operators for SQL null checks: +```php +$largest = $this->reports->query() + ->select( 'score as report_score' ) + ->orderBy( 'report_score', 'desc' ) + ->limit( 10 ) + ->max( 'report_score' ); +``` + +For grouped reports, use application-owned SQL expressions through `selectRaw()` and pass external values in its bindings array: ```php -$unpublished = $this->table - ->query() - ->where( 'published_at', '=', null ) +$groups = $this->reports->query() + ->selectRaw( 'status, COUNT(*) AS total' ) + ->groupBy( 'status' ) + ->having( 'total', '>', 1 ) ->get(); +``` + +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. + +## Join application and WordPress tables + +Pass injected application tables to `join()` or `leftJoin()`. Use `alias:` to give the joined table a query-local name: -$published = $this->table - ->query() - ->where( 'published_at', '!=', null ) +```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(); ``` -These comparisons compile to `IS NULL` and `IS NOT NULL`. Other operators with `null` are rejected because they do not have useful SQL semantics. +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' ), 'u.ID', '=', 'r.owner_id', alias: 'u' ) + ->select( 'r.id', 'r.title', 'u.user_email' ) + ->where( 'r.status', 'ready' ) + ->get(); +``` ## Write rows -Use the table object for inserts, updates, and deletes so physical table naming stays in one place: +Insert one row and retrieve its generated identifier: ```php -$reportId = $this->table->insertGetId( [ - 'status' => 'draft', - 'payload' => wp_json_encode( $payload ), - 'created_at' => current_time( 'mysql', true ), +$id = $this->reports->insertGetId( [ + 'title' => 'Weekly', + 'status' => 'draft', +] ); + +$this->reports->update( [ + 'status' => 'ready', +], [ + 'id' => $id, ] ); +``` + +`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: -$updated = $this->table->update( +```php +$this->reports->insert( [ [ - 'status' => 'published', - 'updated_at' => current_time( 'mysql', true ), + 'title' => 'Weekly', + 'status' => 'draft', ], - [ 'id' => $reportId ] -); + [ + 'title' => 'Monthly', + 'status' => 'draft', + ], +] ); -$deleted = $this->table->delete( [ 'id' => $reportId ] ); +$this->reports->query() + ->where( 'id', $id ) + ->update( [ + 'status' => 'ready', + ] ); + +$this->reports->query()->where( 'id', $id )->delete(); ``` -`insert()` returns the affected row count, while `insertGetId()` returns the generated integer ID. `update()` and `delete()` return affected row counts. +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. -### Add table-specific operations +`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. -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. +`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 example, add `archive_status()` to `src/Database/Tables/Reports_Table.php`: +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 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 - ); -} +```php +$this->reports->insert( [ + 'payload' => json_encode( [ + 'enabled' => true, + ], JSON_THROW_ON_ERROR ), +] ); ``` -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. +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: -Keep these methods scoped to one table. Cross-table queries, multi-step workflows, and business rules belong in a repository or feature service. +```php +use Doctrine\DBAL\Types\Types; -## Inspect and execute SQL +$this->connection->insert( $this->reports->quotedName(), [ + 'payload' => [ + 'enabled' => true, + ], +], [ + 'payload' => Types::JSON, +] ); +``` -Build a query before executing it when logging or diagnostics need the SQL shape and separate bindings: +Insert, insert-or-ignore, upsert, and delete queries use an unaliased target table. Start them with `$table->query()` without an alias. -```php -$query = $this->table - ->query( 'r' ) - ->select( 'r.id', 'r.status' ) - ->where( 'r.status', '=', 'failed' ) - ->limit( 25 ) - ->toQuery(); +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()`, `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 -$sql = $query->toSql(); -$bindings = $query->bindings(); -$preparedSql = $query->toPreparedSql(); -$rows = $query->get(); +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', + ], +] ); ``` -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. +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[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()`. +:::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). ::: -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: +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 + +Use `increment()` and `decrement()` to change a numeric column in one database statement: ```php -use StellarWP\Foundation\Database\Contracts\QueryReader; +$affected = $this->reports->query() + ->where( 'id', $id ) + ->increment( 'views' ); -public function __construct( - private Reports_Table $table, - private QueryReader $database -) { -} +$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. + +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', +] ); ``` -The contract exposes prepared low-level read operations: +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 -$row = $this->database->row( - 'SELECT * FROM %i WHERE id = %d', - $this->table->name(), - $reportId +$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()` 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 + +`toSql()` and `getBindings()` inspect the compiled read query without executing it. Bindings remain separate from SQL. + +### 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 +$table = $this->reports->quotedName(); + +$inserted = $this->db->insert( + "INSERT INTO {$table} ( + title, + status + ) + VALUES (?, ?)", + [ + 'Quarterly report', + 'draft', + ], ); -$count = $this->database->value( - 'SELECT COUNT(*) FROM %i WHERE status = %s', - $this->table->name(), - 'published' +$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', + ], ); ``` -Use `QueryExecutor` instead when the same repository must also execute raw writes: +### Apply a conditional SQL update -```php -use StellarWP\Foundation\Database\Contracts\QueryExecutor; +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; -public function __construct( - private Reports_Table $table, - private QueryExecutor $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 -$affected = $this->database->execute( - 'UPDATE %i SET status = %s WHERE status = %s', - $this->table->name(), - 'archived', - 'published' +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 metadata = :metadata + WHERE id = :id', + [ + 'metadata' => [ + 'source' => 'portal', + ], + 'id' => $id, + ], + [ + 'metadata' => Types::JSON, + ], ); ``` -Use `prepare()` when another WordPress API requires the prepared SQL string. Keep values in placeholders instead of concatenating untrusted input. +`$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. -For normal application code, follow one of these paths: +### Use native Doctrine operations -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. +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. -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. +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. -## Handle query failures +## Choose a transaction boundary -Database operations throw `QueryException` when `wpdb` reports an error. The exception retains the SQL template, bindings, and database error separately: +Inject the shared connection into the service that owns the complete unit of work. Collaborating repositories can continue to use their injected tables. ```php -use Psr\Log\LoggerInterface; -use StellarWP\Foundation\Database\Exceptions\QueryException; +use Doctrine\DBAL\Connection; +use Plugin\Database\Tables\Reports_Table; -try { - $rows = $query->get(); -} catch ( QueryException $exception ) { - $this->logger->error( 'Report query failed.', [ - 'sql' => $exception->sql(), - 'bindings' => $exception->bindings(), - 'database_error' => $exception->databaseError(), - ] ); +final readonly class Create_Report { - throw $exception; + 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', + ] ); + } ); + } } ``` -Avoid exposing database errors or bindings to end users. They may contain schema details or sensitive values. +`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. 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. + +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 ) { + $report = $this->reports->query() + ->select( 'id' ) + ->where( 'slug', $slug ) + ->lockForUpdate() + ->first(); + + if ( $report === null ) { + throw $failure; + } + + return $report['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 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 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. -## 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 without the supported savepoint recovery. diff --git a/src/Docs/src/content/docs/components/lock.mdx b/src/Docs/src/content/docs/components/lock.mdx index d8e1c1e..e7dffcd 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 @@ -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/database.mdx b/src/Docs/src/content/docs/components/lock/database.mdx new file mode 100644 index 0000000..67938c5 --- /dev/null +++ b/src/Docs/src/content/docs/components/lock/database.mdx @@ -0,0 +1,149 @@ +--- +title: Database Lock +description: Use the WordPress database as the shared backend for Foundation lock ownership. +sidebar: + order: 1 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +`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. + + + + + + +## 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', + ], +]; +``` + +:::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 + +`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" +container->singleton( + Lock::class, + static fn ( C $c ): DatabaseLock => $c->get( DatabaseLock::class ) + ); + } +} +``` + +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\LockDatabase\LockDatabaseProvider; +use Plugin\Lock; + +/** @var list> */ +private const array PROVIDERS = [ + DatabaseProvider::class, + LockDatabaseProvider::class, + Lock\Provider::class, +]; +``` + +### Initialize lock storage + +Create the configured lock table during activation or deployment by resolving `DatabaseLock` and calling `initialize()` after registering the providers: + +```php +$container->get( DatabaseLock::class )->initialize(); +``` + +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 + +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: + +- Lock names must fit within 191 bytes. +- Expiration uses the database's UTC clock, keeping ownership consistent between PHP processes. +- Every contender must read and write through the same primary database. Replica reads can report stale ownership. +- The TTL must exceed the protected operation, or the owner must refresh the token before it expires. + +:::caution[Keep each lock token in one site context] +On multisite, acquire, refresh, and release a database lock without changing the active blog. `DatabaseLock` resolves its table from the current `$wpdb->prefix`; attempting to refresh or release a token after `switch_to_blog()` fails closed and leaves the original site's lease to expire. +::: + +:::caution[Keep database and remote guarantees separate] +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/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/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/Docs/src/content/docs/components/migrations.mdx b/src/Docs/src/content/docs/components/migrations.mdx new file mode 100644 index 0000000..2d11802 --- /dev/null +++ b/src/Docs/src/content/docs/components/migrations.mdx @@ -0,0 +1,872 @@ +--- +title: Migrations +description: Create tables, write later schema changes, preview SQL, and roll back safely. +sidebar: + order: 1 +--- + +`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 + +:::caution[Database requirements] +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: + +```shell +composer require stellarwp/foundation-migrations +composer require --dev stellarwp/foundation-cli +``` + +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: + +```php title="config.php" + [ + 'root' => __DIR__, + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], +]; +``` + +:::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 in `src/App.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; + +/** @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 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. + +### Generate a new table + +Create the table class and its first migration together: + +```shell +vendor/bin/foundation make:database:table Reports \ + --table-name=your_plugin_reports \ + --migration +``` + +The command writes two files: + +```text +db/migrations/ +└── 20260923000001_create_reports_table.php +src/Database/Tables/ +└── Reports_Table.php +``` + +Your timestamp will differ. `Reports_Table` supplies the stable table name for application queries; Foundation adds the current WordPress site prefix. The migration supplies the schema. Generation writes PHP files; applying the migration creates the database table. + +### Define the initial schema + +The generated `up()` already declares an auto-incrementing `id`. Add the columns your application needs. For this example, the completed file is `db/migrations/20260923000001_create_reports_table.php`: + +```php title="20260923000001_create_reports_table.php" +create( 'your_plugin_reports' ); + + $table->bigIncrements( 'id' ); + $table->string( 'title' ); + $table->string( 'status', 20 )->default( 'draft' ); + $table->index( 'status_lookup', 'status' ); + } + + /** + * Remove report storage and its data. + */ + public function down( Blueprint $schema ): void { + $schema->drop( 'your_plugin_reports' ); + } +}; +``` + +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 + +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 +``` + +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 + +Generate a new migration for the existing table: + +```shell +vendor/bin/foundation make:database:migration add_published_at --table=your_plugin_reports +``` + +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. + +Complete `db/migrations/20260924000001_add_published_at.php` like this: + +```php title="20260924000001_add_published_at.php" +table( 'your_plugin_reports' ); + + $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' ); + } +}; +``` + +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. + +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: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. 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 + +To reverse the highest applied migration, inspect its `down()` and run: + +```shell +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. [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:*`. + +### 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. + +### 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. + +### 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`. + +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). + +### 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 )` | 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. + +[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). + +### 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()`: + +```php +$table = $schema->table( 'your_plugin_reports' ); + +$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. + +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. + +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 `migrate: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. + +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 + +```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' ); +``` + +### 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. + +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. + +`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. 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. + +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. 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 + +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. + +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 + +| 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)` | 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()`. + +`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 + +An anonymous migration can implement `MigratesData`. Foundation supplies a `DataMigrationContext` to its data callback. Use `quotedTable()` to resolve a historical unprefixed name for the active site, and `$context->db` to run native Doctrine queries: + +```php +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 { + + /** + * 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( DataMigrationContext $context ): void { + $table = $context->quotedTable( 'your_plugin_reports' ); + + $context->db->executeStatement( + 'UPDATE ' . $table . ' SET status = ? WHERE status IS NULL', + [ 'draft' ], + ); + } +}; +``` + +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`. + +## Deploy and recover + +### Run migrations from application code + +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 +$migrator->migrate(); +``` + +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. + +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 + +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 +``` + +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: + +```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`, `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 + +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. + +:::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. +::: + +`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 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. + +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 +``` + +`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. + +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` 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. + +## 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: + +```php title="config.php" +return [ + 'foundation' => [ + 'root' => __DIR__, + ], + '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. + +### 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: + +```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( MigrationsProvider::MIGRATIONS, static fn ( C $c ): array => [ + new MigrationRegistration( '20260923000001_package_setup', $c->get( Package_Migration::class ) ), +] ); +``` + +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. + +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`. + +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/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/components/wp-cli.mdx b/src/Docs/src/content/docs/components/wp-cli.mdx index ebc2e18..e4d1e93 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." @@ -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' ) ); @@ -317,6 +311,35 @@ 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: + +```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/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/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/Docs/src/content/docs/tooling/foundation-cli.mdx b/src/Docs/src/content/docs/tooling/foundation-cli.mdx index 801f955..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. @@ -54,7 +60,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: @@ -71,22 +77,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-migrations +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. - -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. +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. -Generated database classes require the runtime package: +`--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`. -```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,16 +96,16 @@ 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. ### Inspect command options @@ -111,13 +113,13 @@ 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. +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 +141,6 @@ return [ 'database-table' => [ 'namespace' => 'Plugin\\Persistence\\Tables', ], - 'database-migration' => [ - 'namespace' => 'Plugin\\Persistence\\Migrations', - ], ], ]; ``` @@ -152,14 +151,15 @@ 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/` | + +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. -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. +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 `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/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 54% rename from src/Database/Lock/DatabaseLock.php rename to src/LockDatabase/DatabaseLock.php index 8044f24..e08556e 100644 --- a/src/Database/Lock/DatabaseLock.php +++ b/src/LockDatabase/DatabaseLock.php @@ -1,19 +1,25 @@ db->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 (TableExistsException) { + // Another activation created the same configured storage concurrently. + } + } + /** * Acquire an unexpired lock or return null when another owner holds it. * @@ -45,40 +80,36 @@ 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->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 - 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->db->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 +127,16 @@ 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 + $table = $this->table->quotedName(); + + return $this->db->executeStatement( + "DELETE FROM {$table} + 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 +151,32 @@ 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->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->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->db->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 +193,17 @@ 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) { + $table = $this->table->quotedName(); + + return $this->db->fetchAssociative( + "SELECT name + FROM {$table} + 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/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 50% rename from src/Database/Table/Tables/LockTable.php rename to src/LockDatabase/Tables/LockTable.php index a48611a..617353e 100644 --- a/src/Database/Table/Tables/LockTable.php +++ b/src/LockDatabase/Tables/LockTable.php @@ -1,12 +1,16 @@ =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/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. 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/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/Migrations/Cli/MarkAppliedCommand.php b/src/Migrations/Cli/MarkAppliedCommand.php new file mode 100644 index 0000000..d380ab5 --- /dev/null +++ b/src/Migrations/Cli/MarkAppliedCommand.php @@ -0,0 +1,127 @@ +.'); + } + + 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; + } + + $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); + } + + $selection = $target === null ? '--all' : '--to=' . $target; + WP_CLI::line('Registered migrations covered by ' . $selection . ' (existing records keep their timestamps):'); + + 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.'); + + return self::SUCCESS; + } + + 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; + } + + /** + * {@inheritDoc} + */ + protected function subcommand(): string { + return 'migrate:mark-applied'; + } + + /** + * {@inheritDoc} + */ + protected function description(): string { + return 'Record one migration, all pending migrations, or pending migrations through a target as already applied.'; + } + + /** + * {@inheritDoc} + */ + protected function arguments(): array { + return [ + [ + 'type' => self::POSITIONAL, + 'name' => 'id', + 'description' => 'Exact migration ID to mark; omit with --all or --to.', + 'optional' => true, + ], + [ + 'type' => self::FLAG, + 'name' => 'all', + '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', + '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..5bb3e88 --- /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/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/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/Cli/RollbackCommand.php b/src/Migrations/Cli/RollbackCommand.php new file mode 100644 index 0000000..a6ee69d --- /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; + } + + if ($target === Migrator::NONE) { + WP_CLI::confirm('Roll back all migrations? This can permanently delete application data.', $assocArgs); + } + + $result = $target !== null ? $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..93bfccf --- /dev/null +++ b/src/Migrations/Cli/RunCommand.php @@ -0,0 +1,83 @@ +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/Contracts/DescribesMigration.php b/src/Migrations/Contracts/DescribesMigration.php new file mode 100644 index 0000000..cbaffac --- /dev/null +++ b/src/Migrations/Contracts/DescribesMigration.php @@ -0,0 +1,14 @@ +db->getDatabasePlatform()->quoteSingleIdentifier($this->names->tableName($name)); + } +} diff --git a/src/Migrations/Exceptions/InvalidMigrationId.php b/src/Migrations/Exceptions/InvalidMigrationId.php new file mode 100644 index 0000000..9418701 --- /dev/null +++ b/src/Migrations/Exceptions/InvalidMigrationId.php @@ -0,0 +1,10 @@ +table->name(); + } + + /** + * Create the history table when first running migrations. + * + * @throws Exception When inspection or creation fails. + */ + public function initialize(): void { + $manager = $this->db->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->db->createSchemaManager()->tablesExist([$this->table()])) { + return []; + } + $table = $this->quoted(); + $rows = $this->db->fetchAllKeyValue( + "SELECT version, applied_at + FROM {$table} + 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->db->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->db->delete($this->quoted(), ['version' => $id]); + } + + private function quoted(): string { + return $this->table->quotedName(); + } +} diff --git a/src/Migrations/Migration.php b/src/Migrations/Migration.php new file mode 100644 index 0000000..c010231 --- /dev/null +++ b/src/Migrations/Migration.php @@ -0,0 +1,23 @@ + + */ + 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 $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. + * + * @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/Migrations/MigrationDiscovery.php b/src/Migrations/MigrationDiscovery.php new file mode 100644 index 0000000..5a7ad9e --- /dev/null +++ b/src/Migrations/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/Migrations/MigrationStubPath.php b/src/Migrations/MigrationStubPath.php new file mode 100644 index 0000000..f9f2dfa --- /dev/null +++ b/src/Migrations/MigrationStubPath.php @@ -0,0 +1,30 @@ +registerTables(); + $this->registerMigrations(); + + $this->container->singleton(MigrationOutput::class); + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $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), + ]); + } + + 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->singleton( + self::MIGRATION_COMPARATOR_CONFIG, + static fn (): ComparatorConfig => (new ComparatorConfig()) + ->withReportModifiedIndexes(false) + ->withDetectRenamedColumns(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, + ]); + }); + + $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(SchemaInspector::class); + $this->container->singleton(SchemaPlanner::class); + $this->container->singleton(Migrator::class); + } +} diff --git a/src/Migrations/Migrator.php b/src/Migrations/Migrator.php new file mode 100644 index 0000000..b1a9719 --- /dev/null +++ b/src/Migrations/Migrator.php @@ -0,0 +1,376 @@ + + */ + public function migrate(string $target = self::LATEST): array { + $this->assertTarget($target); + + return $this->locked(fn (): array => $this->run($target, true)); + } + + /** + * Reverse the highest applied migration IDs, one by default. + * + * 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 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); + }); + } + + /** + * Reverse applied migrations above the target without applying pending migrations. + * + * @throws Throwable When the target is unknown, an inverse fails, or ownership is lost. + * + * @return list + */ + 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. + * + * @throws Throwable When an inverse, execution, or ownership check fails. + * + * @return list + */ + 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())); + } + + /** + * 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. + * + * 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. + * + * @template T + * + * @param Closure(): T $operation + * + * @return T + */ + private function locked(Closure $operation): mixed { + try { + return $this->session->withAdvisoryLock($this->history->table(), function () use ($operation): mixed { + try { + return $operation(); + } catch (Throwable $failure) { + try { + while ($this->db->isTransactionActive()) { + $this->db->rollBack(); + } + } catch (Throwable) { + // Preserve the migration failure when transaction cleanup also fails. + } + + 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); + } + } + + /** + * Compute the SQL each step would execute, without executing or recording anything. + * + * @return list + */ + public function preview(string $target = self::LATEST): array { + $this->assertTarget($target); + + return $this->run($target, false); + } + + /** + * List registered and recorded migrations without mutating storage. + * + * @return list + */ + public function status(): array { + $applied = $this->history->applied(); + $rows = []; + + foreach ($this->migrations->all() as $id => $migration) { + $id = (string) $id; + $rows[$id] = new MigrationStatus( + $id, + self::displayName($migration, $id), + $migration instanceof DescribesMigration ? $migration->describe() : '', + $applied[$id] ?? null, + ); + } + + foreach ($applied as $id => $appliedAt) { + $rows[$id] ??= new MigrationStatus((string) $id, null, '', $appliedAt); + } + ksort($rows, SORT_STRING); + + return array_values($rows); + } + + /** + * @return list + */ + private function run(string $target, bool $execute, bool $applyPending = true): array { + 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); + $change = $this->planner->plan($id, $reverse, $simulated, $simulatedNames); + $sql = $change->sql; + + if ($execute) { + foreach ($sql as $statement) { + $this->db->executeStatement($statement); + } + $this->session->check(); + + if (! $reverse && $migration instanceof MigratesData) { + $migration->migrate(new DataMigrationContext($this->db, $this->names)); + $this->session->check(); + + if ($this->db->isTransactionActive()) { + throw new MigrationInterrupted('A migration data callback left a transaction open; complete its transaction before returning.'); + } + } + + try { + $reverse ? $this->history->remove($id) : $this->history->record($id); + } catch (Throwable $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)))); + } + + $steps[] = new Step($id, self::displayName($migration, $id), $reverse, $sql, ! $reverse && $migration instanceof MigratesData); + } + + return $steps; + } + + /** + * Select forward or reverse work. An explicit target names the version that must remain applied. + * + * @param list $applied + * + * @return list + */ + 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($reverse); + $plan = array_map(static fn (string $id): array => [$id, true], $reverse); + + if ($target === self::NONE || ! $applyPending) { + return $plan; + } + foreach ($this->migrations->ids() as $id) { + if (in_array($id, $applied, true) || ($target !== self::LATEST && strcmp($id, $target) > 0)) { + continue; + } + $plan[] = [$id, false]; + } + + return $plan; + } + + /** + * @param list $ids + */ + 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) && ! array_key_exists($target, $this->history->applied())) { + throw new InvalidArgumentException('Unknown migration target: ' . $target); + } + } + + private static function displayName(Migration $migration, string $id): string { + if (str_contains($migration::class, "\0")) { + return $id; + } + $parts = explode('\\', $migration::class); + + return (string) end($parts); + } +} diff --git a/src/Migrations/README.md b/src/Migrations/README.md new file mode 100644 index 0000000..f1ae2f1 --- /dev/null +++ b/src/Migrations/README.md @@ -0,0 +1,25 @@ +# 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, SQL preview, +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.5 platform support](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.5/reference/platforms.html). + +```shell +composer require stellarwp/foundation-migrations +``` + +Register `DatabaseProvider` followed by `MigrationsProvider`. Register `WPCliProvider` +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, adopting existing installations, +rollback, and repairing history after interrupted upgrades. diff --git a/src/Migrations/Schema/Blueprint.php b/src/Migrations/Schema/Blueprint.php new file mode 100644 index 0000000..3567ae6 --- /dev/null +++ b/src/Migrations/Schema/Blueprint.php @@ -0,0 +1,173 @@ + 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. + * + * @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|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); + } + + $table = DoctrineTable::editor()->setUnquotedName($name)->setOptions($this->tableOptions); + $this->replaceTable($blueprint->applyTo($table, $this->state, $this->names)); + }; + + return $blueprint; + } + + /** + * Declare additions, changes, and removals on an existing table. + */ + public function table(Table|string $table): TableBlueprint { + $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)); + + $this->replaceTable($blueprint->applyTo($editor, $this->state, $this->names)); + }; + + 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. + */ + public function drop(Table|string $table): void { + $name = $this->resolve($table); + $this->operations[] = function () use ($name): void { + $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) . '.')) { + unset($this->state->timestamps[$key]); + } + } + }; + } + + /** + * Resolve the physical table name for the current site. + * + * @return non-empty-string + */ + public function resolve(Table|string $table): string { + $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(): 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 { + $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 Schema(array_values($tables)); + } +} diff --git a/src/Migrations/Schema/Definitions/ColumnDefinition.php b/src/Migrations/Schema/Definitions/ColumnDefinition.php new file mode 100644 index 0000000..7010e32 --- /dev/null +++ b/src/Migrations/Schema/Definitions/ColumnDefinition.php @@ -0,0 +1,237 @@ +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) + ->setFixed($this->options['fixed'] ?? false) + ->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/Migrations/Schema/Definitions/ForeignKeyDefinition.php b/src/Migrations/Schema/Definitions/ForeignKeyDefinition.php new file mode 100644 index 0000000..839129f --- /dev/null +++ b/src/Migrations/Schema/Definitions/ForeignKeyDefinition.php @@ -0,0 +1,150 @@ +|null + */ + private ?array $referencedColumns = null; + private ReferentialAction $onDelete = ReferentialAction::RESTRICT; + private ReferentialAction $onUpdate = ReferentialAction::RESTRICT; + + /** + * 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; + } + + /** + * 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(). + * + * @param non-empty-string $constraintName + * + * @throws InvalidArgumentException When references() has not completed the declaration. + */ + 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($constraintName) + ->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/RenamePlanner.php b/src/Migrations/Schema/RenamePlanner.php new file mode 100644 index 0000000..2bd3721 --- /dev/null +++ b/src/Migrations/Schema/RenamePlanner.php @@ -0,0 +1,59 @@ + + */ + public function plan(SchemaState $before, SchemaState $after): array { + $sql = []; + + foreach ($after->renames as $rename) { + $sql = array_merge($sql, $this->sql($rename, $before)); + $before->rename($rename); + } + + return $sql; + } + + /** + * 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..9cc9a39 --- /dev/null +++ b/src/Migrations/Schema/Renames/ChangeColumnRename.php @@ -0,0 +1,153 @@ +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 = Type::getType($column->getTypeName()) instanceof JsonType; + // MariaDB reports JSON as LONGTEXT; restating JSON preserves its validation constraint. + $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']); + $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/SchemaInspector.php b/src/Migrations/Schema/SchemaInspector.php new file mode 100644 index 0000000..2fdb43a --- /dev/null +++ b/src/Migrations/Schema/SchemaInspector.php @@ -0,0 +1,144 @@ + $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 { + foreach ($overlay?->schema->getTables() ?? [] as $table) { + $names[] = $table->getObjectName()->toString(); + } + + $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 new file mode 100644 index 0000000..be777fd --- /dev/null +++ b/src/Migrations/Schema/SchemaPlanner.php @@ -0,0 +1,182 @@ + $tableOptions Defaults such as charset/collation applied to created tables. + */ + public function __construct( + private Connection $db, + private TableNameResolver $names, + private MigrationCollection $migrations, + private MigrationComparatorFactory $comparators, + private RenamePlanner $renames, + private SchemaInspector $inspector, + private array $tableOptions = [], + ) { + } + + /** + * Plan the current declaration against live tables or earlier preview results. + * + * @param list $simulatedNames Tables already decided by an earlier preview step. + * + * @throws \Throwable When a declaration is invalid or schema inspection fails. + */ + 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); + + 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); + } + + $names = $blueprint->tableNames(); + $before = $this->inspector->inspect($names, $simulated, $simulatedNames); + $state->schema = clone $before->schema; + $state->timestamps = $before->timestamps; + $comparator = $this->comparators->create(); + $sql = []; + + try { + 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 apply its declaration to the current schema (%s). Inspect and repair any partially applied work before retrying.', + $id, + $failure->getMessage(), + ), 0, $failure); + } + + return new SchemaPlan($state, $sql, $names); + } + + /** + * 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 { + $platform = $this->db->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; + } + + $quotedTable = $platform->quoteSingleIdentifier($table->getObjectName()->toString()); + $declaration = $platform->getColumnDeclarationSQL($platform->quoteSingleIdentifier($column->getObjectName()->toString()), $column->toArray(true)); + $sql[] = "ALTER TABLE {$quotedTable} + MODIFY {$declaration}"; + } + } + + return $sql; + } + + /** + * Compare table comments, which DBAL does not include in its table differences. + * + * @return list + */ + private function commentSql(SchemaState $actual, SchemaState $after): array { + $sql = []; + $platform = $this->db->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; + } + + $quotedTable = $platform->quoteSingleIdentifier($name); + $quotedComment = $platform->quoteStringLiteral($comment); + $sql[] = "ALTER TABLE {$quotedTable} + COMMENT = {$quotedComment}"; + } + + 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 = $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()); + } + } + + return $keys; + } +} diff --git a/src/Migrations/Schema/SchemaState.php b/src/Migrations/Schema/SchemaState.php new file mode 100644 index 0000000..a9d7998 --- /dev/null +++ b/src/Migrations/Schema/SchemaState.php @@ -0,0 +1,126 @@ + Name changes declared by the migration being planned. + */ + public array $renames = []; + + /** + * Hold a schema snapshot and its supplemental timestamp facts. + * + * @param array $timestamps Physical table.column keys. + */ + public function __construct( + public Schema $schema, + public array $timestamps = [], + ) { + } + + /** + * Rename an object and carry its references 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; + } + } + + $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. + */ + public function __clone(): void { + $this->schema = clone $this->schema; + } +} diff --git a/src/Migrations/Schema/TableBlueprint.php b/src/Migrations/Schema/TableBlueprint.php new file mode 100644 index 0000000..b7441b0 --- /dev/null +++ b/src/Migrations/Schema/TableBlueprint.php @@ -0,0 +1,503 @@ + + */ + private array $columns = []; + /** + * @var list + */ + private array $renamedColumns = []; + /** + * @var list, unique: bool}> + */ + private array $indexes = []; + /** + * @var list + */ + private array $primary = []; + /** + * @var list + */ + private array $droppedColumns = []; + /** + * @var list + */ + private array $droppedIndexes = []; + /** + * @var list + */ + private array $foreignKeys = []; + /** + * @var list + */ + private array $droppedForeignKeys = []; + 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 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. + * + * @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 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. + * + * @param non-empty-string $name + */ + 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. + * + * @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]); + } + + /** + * 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). + * + * @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; + } + + /** + * 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, + '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, + ]); + } + + /** + * 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; + } + + /** + * 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($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 { + 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. + * + * @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, TableNameResolver $names): Table { + $existing = $state->schema->hasTable($this->name) ? $state->schema->getTable($this->name)->getForeignKeys() : []; + + foreach ($this->droppedForeignKeys as $name) { + $constraint = $this->foreignKeyName($existing, $name); + $editor->dropForeignKeyConstraintByUnquotedName($constraint); + } + + 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()); + } + + foreach ($this->foreignKeys as $foreignKey) { + $constraint = $this->foreignKeyName($existing, $foreignKey->name()); + + $foreignKey->applyTo($editor, $names, $constraint); + } + + if ($this->comment !== null) { + $editor->setComment($this->comment); + } + + return $editor->create(); + } + + /** + * 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(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; + } + + /** + * @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); + $this->columns[] = $definition; + + return $definition; + } +} diff --git a/src/Migrations/Schema/ValueObjects/Rename.php b/src/Migrations/Schema/ValueObjects/Rename.php new file mode 100644 index 0000000..1a4a0d5 --- /dev/null +++ b/src/Migrations/Schema/ValueObjects/Rename.php @@ -0,0 +1,32 @@ + $sql + * @param list $tableNames + */ + public function __construct( + public SchemaState $schema, + public array $sql, + public array $tableNames, + ) { + } +} diff --git a/src/Database/Table/Tables/MigrationTable.php b/src/Migrations/Tables/MigrationTable.php similarity index 58% rename from src/Database/Table/Tables/MigrationTable.php rename to src/Migrations/Tables/MigrationTable.php index 7a4f0d3..7a5e0a2 100644 --- a/src/Database/Table/Tables/MigrationTable.php +++ b/src/Migrations/Tables/MigrationTable.php @@ -1,12 +1,16 @@ 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/Migrations/ValueObjects/MigrationDirectory.php b/src/Migrations/ValueObjects/MigrationDirectory.php new file mode 100644 index 0000000..a5b5c1a --- /dev/null +++ b/src/Migrations/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/Migrations/ValueObjects/MigrationRegistration.php b/src/Migrations/ValueObjects/MigrationRegistration.php new file mode 100644 index 0000000..362d2b9 --- /dev/null +++ b/src/Migrations/ValueObjects/MigrationRegistration.php @@ -0,0 +1,20 @@ +migration === null) { + return 'missing'; + } + + return $this->appliedAt === null ? 'pending' : 'applied'; + } +} diff --git a/src/Migrations/ValueObjects/Step.php b/src/Migrations/ValueObjects/Step.php new file mode 100644 index 0000000..229ee47 --- /dev/null +++ b/src/Migrations/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/Migrations/composer.json b/src/Migrations/composer.json new file mode 100644 index 0000000..f061003 --- /dev/null +++ b/src/Migrations/composer.json @@ -0,0 +1,26 @@ +{ + "name": "stellarwp/foundation-migrations", + "type": "library", + "description": "Declarative WordPress migrations with SQL preview and database session locking.", + "license": "GPL-2.0-or-later", + "config": { + "vendor-dir": "vendor", + "preferred-install": "dist" + }, + "require": { + "php": ">=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/Migrations/stubs/alter-table-migration.stub b/src/Migrations/stubs/alter-table-migration.stub new file mode 100644 index 0000000..516790b --- /dev/null +++ b/src/Migrations/stubs/alter-table-migration.stub @@ -0,0 +1,27 @@ +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 new IrreversibleMigration( 'Implement a safe down() declaration before rolling back this migration.' ); + } + +}; diff --git a/src/Migrations/stubs/create-table-migration.stub b/src/Migrations/stubs/create-table-migration.stub new file mode 100644 index 0000000..8172681 --- /dev/null +++ b/src/Migrations/stubs/create-table-migration.stub @@ -0,0 +1,26 @@ +create( {{ table_php }} ); + + $table->bigIncrements( 'id' ); + + // Add the initial columns and indexes before running this migration. + } + + /** + * Drop the table and its data. + */ + public function down( Blueprint $schema ): void { + $schema->drop( {{ table_php }} ); + } + +}; diff --git a/src/Migrations/stubs/migration.stub b/src/Migrations/stubs/migration.stub new file mode 100644 index 0000000..5eff2e3 --- /dev/null +++ b/src/Migrations/stubs/migration.stub @@ -0,0 +1,25 @@ +registered) { - return; - } - - $this->registered = true; $this->container->mergeArrayVar(self::TASKS, []); $this->container->when(Runner::class) diff --git a/src/WPCli/Command.php b/src/WPCli/Command.php index 953f4bf..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); @@ -101,6 +112,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/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/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/CodeceptionSupport/WPCLITester.php b/tests/CodeceptionSupport/WPCLITester.php index 258e8d2..0ddea22 100644 --- a/tests/CodeceptionSupport/WPCLITester.php +++ b/tests/CodeceptionSupport/WPCLITester.php @@ -15,11 +15,8 @@ * @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() + * + * @mixin \lucatume\WPBrowser\Module\WPCLI * * @SuppressWarnings(PHPMD) */ 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/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/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/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/DataDiscovery/20260923000003_publish_reports.php b/tests/Support/Fixtures/Database/DataDiscovery/20260923000003_publish_reports.php new file mode 100644 index 0000000..9a4dd07 --- /dev/null +++ b/tests/Support/Fixtures/Database/DataDiscovery/20260923000003_publish_reports.php @@ -0,0 +1,21 @@ +quotedTable('discovery_reports'); + $context->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/DatabaseTestCase.php b/tests/Support/Fixtures/Database/DatabaseTestCase.php new file mode 100644 index 0000000..0287c9c --- /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->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']); + } + + 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'); + $this->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 { + $this->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..d864bf6 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/AddEntryArchivedFlag.php @@ -0,0 +1,28 @@ +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..d02a24b --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/AddEntryNote.php @@ -0,0 +1,32 @@ +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..e1f77c8 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/BackfillEntryStatus.php @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..2ad6c4d --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/CreateEntries.php @@ -0,0 +1,37 @@ +create($this->table)->comment('Application entries'); + + $entries->bigIncrements('id'); + $entries->string('name', 50); + $entries->string('status', 20)->default('active')->comment('Current processing state'); + $entries->decimal('amount', 12, 4)->default('0'); + $entries->varBinary('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..6baae65 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/CreateEntryTags.php @@ -0,0 +1,29 @@ +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..e2cac16 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/DropEntryTags.php @@ -0,0 +1,28 @@ +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..77bc851 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/IndexEntryNote.php @@ -0,0 +1,27 @@ +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..11ba4e6 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/RecreateEntryTags.php @@ -0,0 +1,30 @@ +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..f717508 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/ReduceCreatedAtPrecision.php @@ -0,0 +1,27 @@ +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..1a57275 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/ReplaceNoteLookup.php @@ -0,0 +1,27 @@ +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..90b3841 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/TrackEntryUpdates.php @@ -0,0 +1,27 @@ +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..c497882 --- /dev/null +++ b/tests/Support/Fixtures/Database/Declarative/WidenEntryName.php @@ -0,0 +1,27 @@ +table($this->table)->comment('Imported entries')->string('name', 120)->nullable()->change(); + } + + public function down(Blueprint $schema): void { + $schema->table($this->table)->comment('Application entries')->string('name', 50)->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..907ef9d --- /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..6188725 --- /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/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->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 new file mode 100644 index 0000000..a4b0834 --- /dev/null +++ b/tests/Support/Fixtures/Database/Import/EntryRepository.php @@ -0,0 +1,80 @@ +db->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->db->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->db->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->db->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..12b3467 --- /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); + } + }; + } + }]); + $db = DriverManager::getConnection(['driver' => 'mysqli', 'dbname' => $database], $configuration); + + return $db; + } +} 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..0f16eb2 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -2,29 +2,15 @@ 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\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; @@ -33,61 +19,59 @@ 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([ + '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 TestTable $table + private readonly Table $table, ) { } - public function id(): string { - return '2026_06_23_000001_create_foundation_cli_example'; - } - - public function up(SchemaContract $schema): void { - $blueprint = Blueprint::for($this->table); - $blueprint->bigIncrements('id'); - - $schema->create($blueprint); + /** + * Declare the initial application table. + */ + public function up(Blueprint $schema): void { + $schema->create($this->table)->bigIncrements(); } - public function down(SchemaContract $schema): void { + /** + * Remove the fixture's application table. + */ + public function down(Blueprint $schema): void { $schema->drop($this->table); } }; + $container->mergeArrayVar(MigrationsProvider::MIGRATIONS, [ + new MigrationRegistration('20260623000001', $migration), + ]); - $command = new Migrate( - new Migrator( - new MigrationCollection([$migration]), - $repository, - $store - ) - ); + // 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')); - $command->register(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/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/Support/Fixtures/Migrations/Columns/ChangeCommonColumns.php b/tests/Support/Fixtures/Migrations/Columns/ChangeCommonColumns.php new file mode 100644 index 0000000..2156402 --- /dev/null +++ b/tests/Support/Fixtures/Migrations/Columns/ChangeCommonColumns.php @@ -0,0 +1,50 @@ +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(); + $table->varBinary('token', 16)->nullable()->comment('Variable token')->change(); + $table->binary('payload', 16)->nullable()->comment('Fixed payload')->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(); + $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 new file mode 100644 index 0000000..deb937e --- /dev/null +++ b/tests/Support/Fixtures/Migrations/Columns/CreateCommonColumns.php @@ -0,0 +1,43 @@ +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'); + $table->binary('token', 16)->nullable()->comment('Fixed bytes'); + $table->varBinary('payload', 16)->nullable()->comment('Variable bytes'); + } + + /** + * {@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..d190edf --- /dev/null +++ b/tests/Support/Fixtures/Migrations/Columns/RenameCommonColumns.php @@ -0,0 +1,53 @@ +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/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/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/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/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/Support/Fixtures/WPCli/TestCommand.php b/tests/Support/Fixtures/WPCli/TestCommand.php index e69260c..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 { @@ -61,6 +70,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/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/Cli/CliProviderTest.php b/tests/Unit/Cli/CliProviderTest.php index 4870f1a..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)); @@ -57,9 +55,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/Commands/Make/DatabaseCommandTest.php b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php index 0662b05..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; @@ -70,7 +68,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); @@ -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('$blueprint = Blueprint::for( $this->table );', $migration); - $this->assertStringContainsString("\$blueprint->bigIncrements( 'id' );", $migration); - $this->assertStringContainsString('Define the complete initial schema before running this migration.', $migration); - $this->assertStringContainsString('$schema->create( $blueprint );', $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\\Contracts\\Migration;', $contents); - $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Schema;', $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('$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('$blueprint = Blueprint::for( $this->table );', $contents); - $this->assertStringContainsString('$schema->alter( $blueprint );', $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('$blueprint = Blueprint::for( $this->table );', $contents); - $this->assertStringContainsString('$schema->alter( $blueprint );', $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)); @@ -683,8 +185,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 { @@ -713,719 +215,12 @@ 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->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_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\\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\\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\\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->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); - } - 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,166 +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 'integer-like' => ['123', 'integer-like']; - - 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 { @@ -2357,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..5fb9aec --- /dev/null +++ b/tests/Unit/Cli/Commands/Make/MigrationGenerationTest.php @@ -0,0 +1,216 @@ +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-migrations' => '^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(); + $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); + $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(); + $this->assertSame(0, $command->execute(['name' => 'Add_Published_At', '--table' => 'reports']), $command->getDisplay()); + $this->assertStringNotContainsString('Runtime dependency missing', $command->getDisplay()); + $files = $this->files(); + $this->assertCount(2, $files); + $this->assertSame($createFile, $files[0]); + $alter = (string) file_get_contents($files[1]); + $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 { + $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(); + + $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(); + $this->assertSame(0, $command->execute(['name' => 'Reports/Add_Status']), $command->getDisplay()); + $file = $this->files('migrations/reports')[0]; + $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(); + $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 { + 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(); + $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(); + $exitCode = $command->execute(['name' => 'Change_Status']); + $this->assertSame(0, $exitCode); + $file = $this->files()[0]; + $contents = file_get_contents($file); + $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(); + $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 { + $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]); + $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(); + $this->assertSame(0, $command->execute(['name' => 'Create_Reports_Table'])); + $contents = (string) file_get_contents($this->files()[0]); + $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 { + 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) { + $this->assertSame(1, $this->migration()->execute($input)); + $this->assertSame([], $this->files()); + } + + $this->configure(['migrations' => ['path' => '']]); + $command = $this->migration(); + $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' => '']]); + $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)); + $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); + $this->assertStringContainsString('use Scoped\\StellarWP\\Foundation\\Migrations\\Migration;', $contents); + $this->assertStringContainsString('use Scoped\\StellarWP\\Foundation\\Migrations\\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', '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(); + $this->assertCount(3, $files); + + foreach (['create-table-migration', 'alter-table-migration', 'migration'] as $i => $stub) { + $this->assertStringContainsString('// custom ' . $stub, (string) file_get_contents($files[$i])); + } + } +} 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']); 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/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/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/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/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/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/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/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/ExecutorTest.php b/tests/Unit/Database/Query/ExecutorTest.php new file mode 100644 index 0000000..231afa2 --- /dev/null +++ b/tests/Unit/Database/Query/ExecutorTest.php @@ -0,0 +1,136 @@ +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_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', [ + 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..1a16a98 --- /dev/null +++ b/tests/Unit/Database/Query/InsertWriterTest.php @@ -0,0 +1,377 @@ +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 = []; + $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]); + } + + /** + * @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->$method(new TableReference('wp_entries'), [ + [ + 'id' => 1, + ], + [ + 'id' => new stdClass(), + ], + ]); + } + + /** + * @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->$method(new TableReference('wp_entries'), [ + [ + 'id' => 1, + ], + [ + 'name' => 'two', + ], + ]); + } + + /** + * @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'); + $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->$method(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/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/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..0180f5b --- /dev/null +++ b/tests/Unit/Database/Query/WhereGroupTest.php @@ -0,0 +1,283 @@ +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_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', []); + $this->assertSame('0 = 1 AND 1 = 1 AND 0 = 1', $group->condition()->sql); + $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); + $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/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..982f5f0 --- /dev/null +++ b/tests/Unit/Database/Table/TableNameResolverTest.php @@ -0,0 +1,59 @@ + + */ + 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); + } + /** + * @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($this->exactly(2))->method('resolveTableName')->with('reports')->willReturnOnConsecutiveCalls('wp_reports', 'wp_2_reports'); + $names = new TableNameResolver($scope); + $this->assertSame('wp_reports', $names->tableName('reports')); + $this->assertSame('wp_2_reports', $names->tableName('reports')); + } +} 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/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/Unit/Migrations/ColumnDefinitionTest.php b/tests/Unit/Migrations/ColumnDefinitionTest.php new file mode 100644 index 0000000..7fc0a85 --- /dev/null +++ b/tests/Unit/Migrations/ColumnDefinitionTest.php @@ -0,0 +1,75 @@ + + */ + 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); + + $this->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'); + $this->assertSame(Types::INTEGER, $column->getTypeName()); + $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 { + $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); + + $this->assertSame('CURRENT_TIMESTAMP', $explicit->create()->getColumn('created_at')->getDefault()); + $this->assertSame($omitted->create()->getColumn('created_at')->toArray(true), $explicit->create()->getColumn('created_at')->toArray(true)); + } +} diff --git a/tests/Unit/Migrations/ColumnRenameTest.php b/tests/Unit/Migrations/ColumnRenameTest.php new file mode 100644 index 0000000..4f131e4 --- /dev/null +++ b/tests/Unit/Migrations/ColumnRenameTest.php @@ -0,0 +1,108 @@ + + */ + 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, + ])); + $after = clone $before; + $after->rename(new Rename('old', 'new', 'reports')); + + $this->assertSame($statements, $planner->plan($before, $after)); + } + + /** + * 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..74cb8a0 --- /dev/null +++ b/tests/Unit/Migrations/ForeignKeyComparatorTest.php @@ -0,0 +1,60 @@ +schema('original'); + $after = $this->schema('replacement'); + $diff = $comparator->compareTables($before->schema->getTable('items'), $after->schema->getTable('items')); + + $this->assertCount(1, $diff->getAddedForeignKeys()); + $this->assertCount(1, $diff->getDroppedForeignKeyConstraintNames()); + } + + /** + * 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 new file mode 100644 index 0000000..457b115 --- /dev/null +++ b/tests/Unit/Migrations/ForeignKeyDefinitionTest.php @@ -0,0 +1,181 @@ +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]); + } + + /** + * Logical identities remain discoverable from live constraint names after renames. + */ + 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()); + $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); + $this->assertSame(64, strlen($constraint)); + + $blueprint->table('items')->renameColumn('order_id', 'purchase_id'); + $blueprint->rename('items', 'archive'); + $blueprint->apply(); + $this->assertTrue($state->schema->getTable('wp_archive')->hasForeignKey($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->apply(); + $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(); + } + + /** + * 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/Unit/Migrations/MigrationCollectionTest.php b/tests/Unit/Migrations/MigrationCollectionTest.php new file mode 100644 index 0000000..fe8a6fc --- /dev/null +++ b/tests/Unit/Migrations/MigrationCollectionTest.php @@ -0,0 +1,61 @@ +migration(...), $ids); + $collection = new MigrationCollection($registrations); + $this->assertSame(['01', '10', '2', '20260922000100', 'A', 'a'], $collection->ids()); + foreach ($registrations as $registration) { + $this->assertSame($registration->migration, $collection->get($registration->id)); + } + $this->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/Migrations/MigrationDiscoveryTest.php b/tests/Unit/Migrations/MigrationDiscoveryTest.php new file mode 100644 index 0000000..429ed63 --- /dev/null +++ b/tests/Unit/Migrations/MigrationDiscoveryTest.php @@ -0,0 +1,89 @@ +fixtures = dirname(__DIR__, 2) . '/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()); + $this->assertSame(['20260923000001_create_reports_table', '20260923000002_add_published_at'], $collection->ids()); + $again = new MigrationCollection($discovery->migrations()); + $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 { + $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()); + $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 { + $this->assertSame([], iterator_to_array((new MigrationDiscovery(null, null))->migrations())); + $root = $this->prepare_temp_dir('missing-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 { + $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, 'assertSame([], iterator_to_array((new MigrationDiscovery(null, $root))->migrations())); + } + + public function test_default_and_custom_paths_are_normalized_without_namespace_mappings(): void { + $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 { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot be blank'); + new MigrationDirectory('/project', ''); + } +} diff --git a/tests/Unit/Migrations/TableBlueprintTest.php b/tests/Unit/Migrations/TableBlueprintTest.php new file mode 100644 index 0000000..b298fef --- /dev/null +++ b/tests/Unit/Migrations/TableBlueprintTest.php @@ -0,0 +1,79 @@ + + */ + 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'); + } + /** + * 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/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/_data/cli/generation/php-source-editor/arrow-callback-without-registration-list.stub b/tests/_data/cli/generation/php-source-editor/arrow-callback-without-registration-list.stub index 814501f..755d194 100644 --- a/tests/_data/cli/generation/php-source-editor/arrow-callback-without-registration-list.stub +++ b/tests/_data/cli/generation/php-source-editor/arrow-callback-without-registration-list.stub @@ -3,11 +3,11 @@ 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 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/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/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/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/ImporterTest.php b/tests/integration/Database/ImporterTest.php new file mode 100644 index 0000000..d62c234 --- /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)); + $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 { + $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); + $this->fail('Expected a second-batch constraint violation.'); + } catch (UniqueConstraintViolationException) { + $this->assertOriginal(); + } + } +} 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/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..be1607a --- /dev/null +++ b/tests/integration/Database/NativeBehaviorTest.php @@ -0,0 +1,75 @@ +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 { + $db->update($this->table, ['name' => 'Committed despite failure'], ['id' => 1]); + + try { + $db->insert($this->table, ['id' => 1, 'name' => 'Duplicate']); + } catch (UniqueConstraintViolationException) { + } + }); + $this->assertSame('Committed despite failure', $this->observer->fetchOne('SELECT name FROM ' . $this->table)); + } + + public function test_native_dbal_rejects_unacknowledged_commit(): void { + $db = (new NativeConnectionFactory())->create($this->native($this->source), constant('DB_NAME')); + + try { + @$db->transactional(function () use ($db): string { + $db->update($this->table, ['name' => 'Lost'], ['id' => 1]); + $this->killConnection(); + + return 'success'; + }); + $this->fail('Expected DBAL to reject the unacknowledged commit.'); + } catch (ConnectionLost) { + $this->assertOriginal(); + } + } + + public function test_native_cleanup_can_replace_the_business_exception(): void { + $db = (new NativeConnectionFactory())->create($this->native($this->source), constant('DB_NAME')); + $business = new RuntimeException('Business failure'); + + try { + $db->transactional(function () use ($business): void { + $this->native($this->source)->close(); + + throw $business; + }); + } catch (Throwable $caught) { + $this->assertNotSame($business, $caught); + $this->assertStringContainsString('closed', $caught->getMessage()); + } + } +} diff --git a/tests/integration/Database/QueryTest.php b/tests/integration/Database/QueryTest.php new file mode 100644 index 0000000..c91d97d --- /dev/null +++ b/tests/integration/Database/QueryTest.php @@ -0,0 +1,1554 @@ +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'); + } + + /** + * 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. + */ + 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()); + } + + /** + * 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. + */ + 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')); + } + + /** + * 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. + */ + 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. + */ + 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()); + } + + /** + * 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. + */ + 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')); + } + + /** + * 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. + */ + 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()); + } + + /** + * 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. + */ + 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()); + } + } + + /** + * 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, + ]; + } + } + } + + /** + * 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 { + // 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")); + $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'|'insertOrIgnore'|'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([]), + 'insertOrIgnore' => $this->entries->query()->lockForUpdate()->insertOrIgnore([]), + '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', + 'insertOrIgnore', + 'insertGetId', + 'upsert', + 'update', + 'increment', + 'decrement', + 'delete', + ] as $method) { + yield $method => [ + $method, + ]; + } + } + + 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..ce79b55 --- /dev/null +++ b/tests/integration/Database/QueryValidationTest.php @@ -0,0 +1,212 @@ +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([]), + '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', + ]), + ]; + + 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/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(); + } + } +} diff --git a/tests/integration/Database/TableTest.php b/tests/integration/Database/TableTest.php new file mode 100644 index 0000000..a716c18 --- /dev/null +++ b/tests/integration/Database/TableTest.php @@ -0,0 +1,433 @@ +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]); + $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')->first()['active'] ?? null)); + + try { + $this->db->transactional(function (): void { + $this->entries->insert(['name' => 'Rolled back']); + + throw new \RuntimeException('Cancel'); + }); + } catch (\RuntimeException $failure) { + $this->assertSame('Cancel', $failure->getMessage()); + } + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT COUNT(*) FROM ' . $this->entries->quotedName())); + $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->toSql()); + $this->assertNotSame($oldName, $this->entries->name()); + $this->assertStringContainsString($this->entries->name(), $this->entries->query()->select('id')->toSql()); + } + + 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', + ], array_column($this->entries->query()->select('name')->get(), 'name')); + } + + 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', + ], array_column($this->entries->query()->select('name')->get(), 'name')); + } + + 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 { + $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()->count()); + } + } + } + + public function test_idle_connection_replacement_supports_queries_and_transactions(): void { + $old = $this->native($this->source); + $oldStatement = $this->db->prepare('SELECT CONNECTION_ID()'); + $this->source->__set('dbh', $this->native($this->observerSource)); + + try { + $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'; + })); + $this->assertSame('Reconnected', ($this->entries->query()->select('name')->first()['name'] ?? null)); + + try { + $oldStatement->executeQuery(); + $this->fail('An old statement must not run on a replaced session.'); + } catch (\RuntimeException $failure) { + $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 new file mode 100644 index 0000000..7bbb0ae --- /dev/null +++ b/tests/integration/Database/TransactionTest.php @@ -0,0 +1,987 @@ +source->prefix; + + try { + $this->db->transactional(function () use ($prefix): void { + $this->db->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); + $this->source->set_prefix($prefix . 'changed_'); + + try { + $this->db->fetchOne('SELECT 1'); + } catch (\RuntimeException) { + $this->source->set_prefix($prefix); + } + }); + $this->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 { + $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 { + $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->db->insert($this->table, ['id' => 3, 'name' => 'Second']); + $this->assertOriginal(); + + return 2; + }); + $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) { + $this->assertSame('void', (string) (new \ReflectionMethod($this->db, $method))->getReturnType()); + } + + $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->db->rollBack(); + $this->assertOriginal(); + + $this->db->beginTransaction(); + $this->db->update($this->table, ['name' => 'Committed'], ['id' => 1]); + $this->db->commit(); + $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 { + foreach ([false, null] as $expected) { + $name = $expected === false ? 'False result' : 'Null result'; + $result = $this->db->transactional(function () use ($expected, $name): ?bool { + $this->db->update($this->table, ['name' => $name], ['id' => 1]); + + return $expected; + }); + + $this->assertSame($expected, $result); + $this->assertSame($name, $this->observer->fetchOne('SELECT name FROM ' . $this->table)); + $this->assertFalse($this->db->isTransactionActive()); + } + } + + public function test_late_failure_restores_existing_data(): void { + $failure = new RuntimeException('Invalid later batch'); + + try { + $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; + }); + } catch (Throwable $caught) { + $this->assertSame($failure, $caught); + } + $this->assertOriginal(); + $this->assertSame(42, $this->db->transactional(static fn (): int => 42)); + } + + public function test_caught_prepared_statement_failure_is_terminal(): void { + $statement = $this->db->prepare('INSERT INTO ' . $this->table . ' (id, name) VALUES (?, ?)'); + + try { + $this->db->transactional(function () use ($statement): void { + $this->db->update($this->table, ['name' => 'Provisional'], ['id' => 1]); + + try { + $statement->bindValue(1, 1); + $statement->bindValue(2, 'Duplicate'); + $statement->executeStatement(); + } catch (UniqueConstraintViolationException) { + } + }); + $this->fail('A caught database failure must prevent commit.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + public function test_caught_query_failure_blocks_further_queries(): void { + try { + $this->db->transactional(function (): void { + try { + $this->db->executeQuery('SELECT missing_column FROM ' . $this->table); + } catch (Throwable) { + } + + try { + $this->db->insert($this->table, ['id' => 2, 'name' => 'Must not execute']); + $this->fail('A failed transaction must reject further SQL.'); + } catch (Throwable $failure) { + $this->assertInstanceOf(TransactionFailed::class, $failure); + } + }); + $this->fail('Expected terminal failure.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + public function test_nested_application_failure_uses_savepoint_and_outer_can_continue(): void { + $this->db->transactional(function (): void { + $this->db->insert($this->table, ['id' => 2, 'name' => 'Outer']); + + try { + $this->db->transactional(function (): void { + $this->db->insert($this->table, ['id' => 3, 'name' => 'Inner']); + + throw new RuntimeException('Skip inner work'); + }); + } catch (RuntimeException) { + } + $this->db->transactional(function (): void { + $this->db->insert($this->table, ['id' => 4, 'name' => 'Nested success']); + }); + }); + $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 { + try { + $this->db->transactional(function (): void { + $this->db->transactional(function (): void { + $this->db->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->db->transactional(static function (): void { + self::fail('Callback must not run.'); + }); + } catch (Throwable $failure) { + $this->assertStringContainsString('Transaction characteristics', $failure->getMessage()); + } + $this->assertOriginal(); + $result = $native->query('SELECT name FROM ' . $this->table); + $this->assertInstanceOf(\mysqli_result::class, $result); + $this->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->db->transactional(static function (): void { + self::fail('Callback must not run.'); + }); + } catch (RuntimeException $failure) { + $this->assertStringContainsString('autocommit', $failure->getMessage()); + } finally { + $native->autocommit(true); + } + } + + public function test_lost_connection_cannot_replay_a_write_even_if_wordpress_reconnects(): void { + try { + @$this->db->transactional(function (): void { + $this->db->executeStatement('DELETE FROM ' . $this->table); + $this->killConnection(); + + try { + $this->db->insert($this->table, ['id' => 2, 'name' => 'Must not replay']); + } catch (Throwable) { + $this->source->db_connect(false); + } + + try { + $this->db->insert($this->table, ['id' => 3, 'name' => 'Must not adopt']); + } catch (Throwable) { + } + }); + $this->fail('Expected terminal connection loss.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + $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 { + try { + @$this->db->transactional(function (): void { + $this->db->update($this->table, ['name' => 'Lost'], ['id' => 1]); + $this->killConnection(); + }); + $this->fail('Expected uncertain commit.'); + } catch (DatabaseException $failure) { + $this->assertInstanceOf(CommitOutcomeUnknown::class, $failure); + $this->assertOriginal(); + } + } + + public function test_cleanup_failure_preserves_the_original_business_exception(): void { + $failure = new RuntimeException('Original business failure'); + + try { + $this->db->transactional(function () use ($failure): void { + $this->native($this->source)->close(); + + throw $failure; + }); + } catch (Throwable $caught) { + $this->assertSame($failure, $caught); + } + } + + public function test_site_change_prevents_commit_and_restoration_does_not_erase_failure(): void { + $otherSite = $this->factory()->blog->create(); + $this->assertIsInt($otherSite); + + try { + $this->db->transactional(function () use ($otherSite): void { + $this->db->update($this->table, ['name' => 'Wrong scope'], ['id' => 1]); + switch_to_blog($otherSite); + + try { + $this->db->executeQuery('SELECT 1'); + } catch (Throwable) { + } finally { + restore_current_blog(); + } + }); + $this->fail('Expected terminal scope failure.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + 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->insert($this->table, [ + 'id' => 2, + 'name' => 'Parent', + ]); + + try { + $this->db->transactional(function (): void { + $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->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 CallbackLogger(function (string $message, array $context): void { + if (str_starts_with($context['sql'] ?? '', 'ROLLBACK TO SAVEPOINT')) { + $this->killConnection(); + } + }); + $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'); + + try { + $this->db->transactional(function () use ($business): void { + try { + $this->db->executeQuery('SELECT missing_column FROM ' . $this->table); + } catch (Throwable) { + } + + throw $business; + }); + } catch (Throwable $failure) { + $this->assertSame($business, $failure); + } + $this->assertOriginal(); + } + + public function test_replacing_a_live_wordpress_session_prevents_commit(): void { + try { + $this->db->transactional(function (): void { + $this->db->executeStatement('DELETE FROM ' . $this->table); + $this->source->db_connect(false); + }); + $this->fail('Expected connection replacement to abort the operation.'); + } catch (RuntimeException $failure) { + $this->assertStringContainsString('connection changed', $failure->getMessage()); + $this->assertOriginal(); + } + $this->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); + $db = $this->withLogging($logger); + $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 { + $logger = new CallbackLogger(function (string $message, array $context): void { + if ($message === 'Beginning transaction') { + $this->killConnection(); + } + }); + $db = $this->withLogging($logger); + $called = false; + + try { + @$db->transactional(static function () use (&$called): void { + $called = true; + }); + $this->fail('Expected begin failure.'); + } catch (RuntimeException $failure) { + $this->assertStringContainsString('Unable to start', $failure->getMessage()); + } + $this->assertFalse($called); + $this->assertOriginal(); + } + + public function test_caught_savepoint_release_failure_remains_terminal(): void { + $logger = new CallbackLogger(function (string $message, array $context): void { + if (str_starts_with($context['sql'] ?? '', 'RELEASE SAVEPOINT')) { + $this->killConnection(); + } + }); + $db = $this->withLogging($logger); + + try { + @$db->transactional(function () use ($db): void { + try { + $db->transactional(function () use ($db): void { + $db->update($this->table, ['name' => 'Nested'], ['id' => 1]); + }); + } catch (Throwable) { + } + }); + $this->fail('Expected terminal savepoint failure.'); + } catch (TransactionFailed) { + $this->assertOriginal(); + } + } + + public function test_stale_prepared_statement_cannot_write_to_an_old_session(): void { + $statement = $this->db->prepare('UPDATE ' . $this->table . ' SET name = ?'); + $this->source->db_connect(false); + + try { + $this->db->transactional(static function () use ($statement): void { + try { + $statement->bindValue(1, 'Stale'); + $statement->executeStatement(); + } catch (Throwable) { + } + }); + $this->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->db->transactional(static function (): void { + self::fail('Must not invoke work.'); + }); + } + + 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]); + $this->db->rollBack(); + $this->assertOriginal(); + $this->db->beginTransaction(); + $this->killConnection(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The database did not acknowledge rollback.'); + @$this->db->rollBack(); + } + + private function withLogging(\Psr\Log\LoggerInterface $logger): Connection { + $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), + ]); + + return $container->get(Connection::class); + } +} diff --git a/tests/integration/LockDatabase/DatabaseLockTest.php b/tests/integration/LockDatabase/DatabaseLockTest.php new file mode 100644 index 0000000..ab57ee2 --- /dev/null +++ b/tests/integration/LockDatabase/DatabaseLockTest.php @@ -0,0 +1,104 @@ + ['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'); + } + + public function test_migrations_do_not_create_application_lock_storage(): void { + $this->container->register(MigrationsProvider::class); + $this->container->get(Migrator::class)->migrate(); + $this->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_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'; + }); + + $this->assertSame('imported', $result); + $this->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(); + $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); + $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); + $this->assertFalse($lock->release($otherOwner)); + $this->assertNull($lock->refresh($otherOwner, 60)); + $renewed = $lock->refresh($token, 120); + $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); + $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); + $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/LockDatabase/LockDatabaseProviderTest.php b/tests/integration/LockDatabase/LockDatabaseProviderTest.php new file mode 100644 index 0000000..337a392 --- /dev/null +++ b/tests/integration/LockDatabase/LockDatabaseProviderTest.php @@ -0,0 +1,69 @@ +, 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 { + $this->container->register(DatabaseProvider::class); + $db = $this->createMock(Connection::class); + $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)); + $this->container->singleton(Connection::class, $db); + $this->container->singleton(TableNameResolver::class, new DatabaseTableNameResolver($scope)); + $this->container->register(LockDatabaseProvider::class); + + $this->assertSame($name, $this->container->get(LockTable::class)->name()); + $lock = $this->container->get(DatabaseLock::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 { + $existing = new InMemoryLock(new SystemClock()); + $this->container->singleton(Lock::class, $existing); + $this->container->register(LockDatabaseProvider::class); + + $this->assertSame($existing, $this->container->get(Lock::class)); + $this->assertNotNull($existing->acquire('resource', 60)); + } +} diff --git a/tests/integration/Migrations/AdvisoryMigrationTest.php b/tests/integration/Migrations/AdvisoryMigrationTest.php new file mode 100644 index 0000000..f978971 --- /dev/null +++ b/tests/integration/Migrations/AdvisoryMigrationTest.php @@ -0,0 +1,445 @@ +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'); + } + + /** + * @param Closure(Connection): void $operation + */ + private function runner(Container $container, Closure $operation, ?string $resource = null): Migrator { + $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(''))), $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) { + } + public function id(): string { + return '20260922000001'; + } + public function up(Blueprint $schema): void { + } + public function down(Blueprint $schema): void { + } + 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), + new History($db, $table), + new SchemaPlanner( + $db, + $names, + $migrations, + $container->get(MigrationComparatorFactory::class), + new RenamePlanner($db, $container->get(ColumnRename::class)), + $container->get(SchemaInspector::class), + ), + $migrations, + $names, + ); + } + + private function lockName(?string $resource = null): string { + return hash('sha256', constant('DB_NAME') . "\0" . ($resource ?? $this->resource)); + } + + private function assertLockAvailable(?string $resource = null): void { + $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 { + try { + $this->runner($this->container, function (Connection $db): void { + $db->beginTransaction(); + $db->executeStatement("UPDATE {$this->table} SET name = 'Uncommitted'"); + })->migrate(); + $this->fail('A callback cannot return success with an open transaction.'); + } catch (MigrationInterrupted $failure) { + $this->assertStringContainsString('left a transaction open', $failure->getMessage()); + $this->assertOriginal(); + $this->assertFalse($this->db->isTransactionActive()); + $this->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 $db): void { + $db->executeStatement("UPDATE {$this->table} SET name = CONCAT(name, '|duplicate')"); + }); + $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(); + $this->fail('The second session must not run before history is recorded.'); + } catch (MigrationAlreadyRunning) { + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + } + }); + $first->migrate(); + $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(); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [$name])); + + try { + $this->runner($this->container, static function (): void { + })->migrate(); + $this->fail('Expected contention.'); + } catch (MigrationAlreadyRunning) { + $this->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 $db): void { + $db->transactional(function (Connection $db): void { + $db->executeStatement("UPDATE {$this->table} SET name = 'Committed'"); + }); + $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) { + $isFree = (int) $this->observer->fetchOne('SELECT IS_FREE_LOCK(?)', [$this->lockName()]); + $this->assertSame(0, $isFree); + } + })->migrate(); + $this->assertLockAvailable(); + } + + public function test_killed_session_cannot_record_success_after_a_caught_error(): void { + try { + $this->runner($this->container, function (Connection $db): void { + $this->killConnection(); + + try { + $db->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); + } catch (Throwable) { + // Simulate migration code that catches and ignores a lost connection. + } + })->migrate(); + $this->fail('A caught connection loss must remain terminal.'); + } catch (MigrationInterrupted) { + $this->assertOriginal(); + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertLockAvailable(); + } + $this->runner($this->contender, static function (): void { + })->migrate(); + $this->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(); + $this->fail('Expected the original exception.'); + } catch (Throwable $failure) { + $this->assertSame($expected, $failure); + $this->assertLockAvailable(); + } + } + + public function test_site_change_blocks_the_ledger_and_releases_only_the_original_lock(): void { + $site = $this->factory()->blog->create(); + $this->assertIsInt($site); + + try { + $this->runner($this->container, static function () use ($site): void { + switch_to_blog($site); + })->migrate(); + $this->fail('A migration cannot change WordPress sites.'); + } catch (MigrationInterrupted) { + $this->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 { + $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) { + $this->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 $db) use ($native): void { + $this->source->__set('dbh', $this->native($this->observerSource)); + + try { + $db->executeStatement("UPDATE {$this->table} SET name = 'Wrong'"); + } catch (Throwable $failure) { + $this->assertInstanceOf(\StellarWP\Foundation\Database\Exceptions\AdvisoryLockInterrupted::class, $failure); + $this->source->__set('dbh', $native); + } + })->migrate(); + $this->fail('Restoring the connection must not reset terminal failure.'); + } catch (MigrationInterrupted) { + $this->assertOriginal(); + $this->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 $db): void { + try { + $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. + } + })->migrate(); + $this->fail('A caught SQL failure must prevent the history write.'); + } catch (MigrationInterrupted) { + $this->assertSame(0, (int) $this->observer->fetchOne("SELECT COUNT(*) FROM {$this->ledger}")); + $this->assertOriginal(); + $this->assertLockAvailable(); + } + } + + 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 { + throw new RuntimeException('Migration failed'); + })->migrate(); + $this->fail('Expected failure.'); + } catch (RuntimeException $failure) { + $this->assertSame('Migration failed', $failure->getMessage()); + $this->assertLockAvailable(); + } + $this->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(); + $this->fail('Expected an ambient transaction rejection.'); + } catch (Throwable) { + $this->assertOriginal(); + $this->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(); + $this->fail('Expected disabled autocommit rejection.'); + } catch (RuntimeException $failure) { + $this->assertInstanceOf(MigrationInterrupted::class, $failure); + $this->assertStringContainsString('autocommit', $failure->getPrevious()?->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(); + $this->fail('Expected nested migration rejection.'); + } catch (RuntimeException $failure) { + $this->assertSame('Start advisory-locked work outside an existing transaction or locked operation.', $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'); + $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 { + $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(); + $this->fail('A lost acquisition reply cannot permit execution.'); + } catch (RuntimeException $failure) { + $this->assertSame('Lost advisory lock acquisition reply', $failure->getMessage()); + $this->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(); + $this->fail('Expected the business exception.'); + } catch (Throwable $failure) { + $this->assertSame($expected, $failure); + $this->assertLockAvailable(); + } + } +} 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..3167c87 --- /dev/null +++ b/tests/integration/Migrations/CommonColumnTest.php @@ -0,0 +1,260 @@ + [ + '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)), + ]); + } + + /** + * Completed migrations are not repeated and retain their column definitions and values. + */ + public function test_creation_preserves_types_defaults_and_data(): void { + $migrator = $this->container->get(Migrator::class); + $migrator->migrate(Migrator::NONE); + $migrator->migrate('1'); + $this->insertEntry(); + $steps = $migrator->migrate('1'); + $this->assertSame([], $steps); + $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', + ])); + } + + /** + * 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. + */ + public function test_alteration_and_rollback_preserve_values(): void { + $migrator = $this->container->get(Migrator::class); + $migrator->migrate('1'); + $this->insertEntry(); + $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()); + $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->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(); + $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(string $strategy): void { + $this->container->singleton(ColumnRename::class, $strategy); + $migrator = $this->container->get(Migrator::class); + // 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 { + $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), + 'token' => hex2bin('00112233445566778899aabbccddeeff'), + 'payload' => hex2bin('ffeeddccbbaa99887766554433221100'), + ], [ + 'metadata' => Types::JSON, + ]); + } + + private function assertEntry(): void { + $row = $this->observer->fetchAssociative("SELECT + metadata, + starts_on, + opens_at, + attempts, + currency, + content, + token, + payload + 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']); + $this->assertSame(hex2bin('00112233445566778899aabbccddeeff'), $row['token']); + $this->assertSame(hex2bin('ffeeddccbbaa99887766554433221100'), $row['payload']); + } + + private function assertInitialColumns(): void { + $table = $this->introspect(); + $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()); + $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()); + $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()); + $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 { + return $this->observer->createSchemaManager()->introspectTable($this->source->prefix . $this->suffix . '_entries'); + } +} diff --git a/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php b/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php new file mode 100644 index 0000000..84c09d4 --- /dev/null +++ b/tests/integration/Migrations/DeclarativeMigratorEdgeCaseTest.php @@ -0,0 +1,226 @@ + ['table' => $this->suffix . '_history']]; + } + + protected function setUp(): void { + parent::setUp(); + $this->container->register(MigrationsProvider::class); + $this->quotedEntries = $this->privateTable($this->suffix . '_entries'); + $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(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)), + 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)), + ]); + } + + 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); + $this->assertIsArray($row); + + return strtolower((string) $row['Create Table']); + } + + public function test_rollback_honors_an_irreversible_down(): void { + $this->migrator()->migrate(AddEntryArchivedFlag::ID); + $this->assertStringContainsString('`archived`', $this->createTableSql()); + + try { + $this->migrator()->rollback(); + $this->fail('Expected the author\'s refusal to reverse.'); + } catch (IrreversibleMigration $failure) { + $this->assertStringContainsString(AddEntryArchivedFlag::ID, $failure->getMessage()); + } + $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 { + $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) { + $this->assertStringNotContainsStringIgnoringCase('foreign key', implode("\n", $step->sql)); + } + $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'); + $this->assertSame(120, $column->getLength()); + $this->assertFalse($column->getNotnull()); + $this->assertStringContainsString("comment='imported entries'", $this->createTableSql()); + + $steps = $this->migrator()->rollback(); + + $this->assertSame([WidenEntryName::ID], array_map(static fn (Step $step): string => $step->id, $steps)); + $column = $this->observer->createSchemaManager()->introspectTable(trim($this->quotedEntries, '`'))->getColumn('name'); + $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); + $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); + $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(); + $this->assertStringContainsString('`created_at` datetime(6) not null default current_timestamp(6),', $this->createTableSql()); + } + + 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)"); + $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 { + $preview = $this->migrator()->preview(); + + $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), + ); + $this->assertCount(1, $preview[0]->sql, 'Entries is created'); + $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'); + + $executed = $this->migrator()->migrate(); + $this->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_stops_without_recording_success(): void { + $this->migrator()->migrate(IndexEntryNote::ID); + $this->observer->executeStatement("DROP INDEX note_lookup ON {$this->quotedEntries}"); + + 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()); + } + + $this->assertNotContains(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); + $this->fail('Expected the conflicting index to be rejected.'); + } 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()); + } + + 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); + $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); + $this->assertSame(1, (int) $this->observer->fetchOne('SELECT GET_LOCK(?, 0)', [$name])); + + try { + $this->migrator()->rollback(); + $this->fail('Expected contention before any history read.'); + } catch (MigrationAlreadyRunning) { + $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 new file mode 100644 index 0000000..56cc1da --- /dev/null +++ b/tests/integration/Migrations/DeclarativeMigratorTest.php @@ -0,0 +1,381 @@ + ['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'); + $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(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)), + ]); + } + + 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); + $this->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_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->assertSame([], $this->migrator()->migrate()); + } + + public function test_rollback_on_an_empty_history_has_no_work(): void { + $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); + $this->fail('Invalid rollback count must fail.'); + } catch (\InvalidArgumentException) { + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); + } + } + + public function test_unknown_target_is_rejected_before_creating_history(): void { + try { + $this->migrator()->migrate('unknown'); + $this->fail('Unknown target must fail.'); + } catch (\InvalidArgumentException) { + $this->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(); + $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]); + $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); + $this->assertFalse($steps[1]->reverse); + $this->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); + + $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'); + $this->fail('Unknown rollback target must fail.'); + } catch (\InvalidArgumentException) { + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->historyName])); + } + } + + 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 { + $this->migrator()->preview(); + $this->migrator()->migrate(); + $this->migrator()->status(); + $this->migrator()->preview(Migrator::NONE); + $this->migrator()->refresh(); + $this->migrator()->rollback(3); + $this->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(); + + $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(); + $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(); + $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 { + // 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); + + $this->assertSame('utf8mb4_bin', $this->observer->fetchOne( + 'SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', + [$this->entriesName], + )); + $this->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(); + + $this->assertSame(['active', 'archived'], $this->observer->fetchFirstColumn('SELECT status FROM ' . $this->quotedEntries . ' ORDER BY id')); + } + + public function test_ledger_failure_requires_deliberate_repair_before_retry(): void { + $this->migrator()->migrate(self::CREATE); + $trigger = $this->rejectHistoryWrite('INSERT'); + + try { + $this->migrator()->migrate(self::ALTER); + $this->fail('Expected the ledger insert to fail.'); + } catch (\StellarWP\Foundation\Migrations\Exceptions\LedgerFailure $failure) { + $this->assertStringContainsString('History write failure', $failure->getPrevious()?->getMessage() ?? ''); + } finally { + $this->observer->executeStatement('DROP TRIGGER ' . $trigger); + } + $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']); + + 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::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->migrator()->markApplied(self::ALTER); + $this->assertSame([], $this->migrator()->migrate(self::ALTER)); + $this->assertSame([self::CREATE, self::ALTER], $this->history()); + } + + 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'); + + try { + $this->migrator()->migrate(self::CREATE); + $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); + } + $this->assertSame([], $this->history()); + $this->observer->insert($this->quotedEntries, ['name' => 'Survivor', 'status' => 'active']); + + 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([], $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()); + $this->assertSame('Current processing state', $table->getColumn('status')->getComment()); + } + + 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); + $this->fail('Expected drift to be rejected.'); + } catch (MigrationInterrupted $failure) { + $this->assertStringContainsString(self::ALTER, $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'); + } + + 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); + $this->fail('Expected the existing table to be rejected.'); + } catch (MigrationInterrupted $failure) { + $this->assertStringContainsString('already exists', $failure->getMessage()); + } + $this->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(); + $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(); + + $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), + ); + $this->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(); + $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); + $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); + $this->assertSame([], $this->history()); + $this->assertFalse($this->observer->createSchemaManager()->tablesExist([$this->entriesName])); + } + + 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])); + + try { + $this->migrator()->migrate(); + $this->fail('Expected contention.'); + } catch (DatabaseException $failure) { + $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/ForeignKeyIdentityTest.php b/tests/integration/Migrations/ForeignKeyIdentityTest.php new file mode 100644 index 0000000..7088398 --- /dev/null +++ b/tests/integration/Migrations/ForeignKeyIdentityTest.php @@ -0,0 +1,171 @@ + [ + '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_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()); + $migrator->rollback(); + $this->assertCount(1, $this->constraints()); + $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_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->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 new file mode 100644 index 0000000..684bcb2 --- /dev/null +++ b/tests/integration/Migrations/ForeignKeyMigrationTest.php @@ -0,0 +1,326 @@ + ['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(); + (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()); + $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 { + (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()); + } + + $migrator->rollback(); + $constraints = $this->constraints(); + $this->assertSame([], $constraints); + (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')); + } + + 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(); + $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]); + $this->assertSame([], $this->observer->fetchAllAssociative('SELECT * FROM ' . $this->items)); + $migrator->rollback(); + $this->rows(); + $this->expectException(ForeignKeyConstraintViolationException::class); + (new ErrorReporter())->run(fn () => $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]); + (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); + } + + 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_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(); + } + + 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(MigrationInterrupted::class); + $this->expectExceptionMessage('already exists'); + $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, + ]; + } + + /** + * Pending operations do not repair or reject unrelated relationship changes. + * + * @dataProvider foreignKeyDrift + */ + #[DataProvider('foreignKeyDrift')] + 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'); + $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"); + } + + $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()); + } + } + + 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(MigrationInterrupted::class); + $this->expectExceptionMessage('already exists'); + $migrator->migrate(); + } + + /** + * Resume rollback after the child table was dropped but its ledger entry remains. + */ + 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); + + $this->expectException(MigrationInterrupted::class); + $migrator->rollback(); + } + + 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->assertEqualsCanonicalizing($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')); + } + } +} diff --git a/tests/integration/Migrations/GeneratedMigrationTest.php b/tests/integration/Migrations/GeneratedMigrationTest.php new file mode 100644 index 0000000..f44f4e3 --- /dev/null +++ b/tests/integration/Migrations/GeneratedMigrationTest.php @@ -0,0 +1,63 @@ + */ + 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['migrations']['path'] = $path; + } + + 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); + $tooling->singleton(ProjectDirectory::class, new ProjectDirectory($root)); + $table = new CommandTester($tooling->get(TableCommand::class)); + $this->assertSame(0, $table->execute(['name' => 'Reports', '--migration' => true]), $table->getDisplay()); + $migration = new CommandTester($tooling->get(MigrationCommand::class)); + $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(); + $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 new file mode 100644 index 0000000..5a3dd0a --- /dev/null +++ b/tests/integration/Migrations/MigrationDiscoveryTest.php @@ -0,0 +1,94 @@ +container->register(MigrationsProvider::class); + } + + protected function data_dir(string $appendPath = ''): string { + return codecept_data_dir($appendPath); + } + + protected function tearDown(): void { + try { + $this->cleanup_temp_dirs(); + } finally { + parent::tearDown(); + } + } + + protected function configuration(): array { + return ['migrations' => [ + 'table' => $this->suffix . '_history', + '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(); + $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(); + $this->assertSame([['title' => 'Existing report', 'published_at' => null]], $this->observer->fetchAllAssociative('SELECT title, published_at FROM ' . $table)); + $migrator->rollback(1); + $this->assertSame(['Existing report'], $this->observer->fetchFirstColumn('SELECT title FROM ' . $table)); + $migrator->migrate(); + $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'; + 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(); + $this->assertTrue($preview[0]->hasDataStep); + $this->assertSame(['Existing report'], $this->observer->fetchFirstColumn('SELECT title FROM ' . $table)); + $migrator->migrate(); + $this->assertSame(['Published report'], $this->observer->fetchFirstColumn('SELECT title FROM ' . $table)); + $status = $migrator->status()[2]; + $this->assertSame('20260923000003_publish_reports', $status->migration); + $this->assertSame('Publish existing reports', $status->description); + + try { + $migrator->rollback(); + $this->fail('The inherited down() must stop rollback.'); + } catch (\StellarWP\Foundation\Migrations\Exceptions\MigrationException $failure) { + $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()); + } + $this->assertSame('applied', $migrator->status()[2]->state()); + } +} 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/MigrationHistoryTest.php b/tests/integration/Migrations/MigrationHistoryTest.php new file mode 100644 index 0000000..ea28151 --- /dev/null +++ b/tests/integration/Migrations/MigrationHistoryTest.php @@ -0,0 +1,528 @@ + [ + '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->markAppliedThrough(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()); + } + + /** + * 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 + */ + #[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), + 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 { + $through ? $runner->markAppliedThrough('second') : $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()); + $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. + */ + 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(); + } + } + + /** + * 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. + */ + 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->markAppliedThrough('one'), + 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/integration/Migrations/RenameMigrationTest.php b/tests/integration/Migrations/RenameMigrationTest.php new file mode 100644 index 0000000..6077c08 --- /dev/null +++ b/tests/integration/Migrations/RenameMigrationTest.php @@ -0,0 +1,871 @@ + [ + '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 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(MigrationInterrupted::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(InvalidArgumentException::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(MigrationInterrupted::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(InvalidArgumentException::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(); + $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 + 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, + ]); + $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)); + $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()); + } + + /** + * 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')); + } + + /** + * A rename preserves the live definition without checking historical declarations. + */ + 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 ''"); + $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()); + } + + /** + * 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()); + } + + /** + * Explicit rename sequences may swap column names through a temporary name. + */ + public function test_column_name_cycles_execute_in_declaration_order(): 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(); + + $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)); + } + + /** + * A renamed source may be reused by a later operation in the same migration. + */ + public function test_renaming_and_recreating_the_source_column(): 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(); + + $migrator->migrate(); + $this->assertSame('Original', $this->observer->fetchOne('SELECT headline FROM ' . $this->reports)); + $this->assertSame('', $this->observer->fetchOne('SELECT title FROM ' . $this->reports)); + } + + /** + * Later renames follow an earlier name swap in the declared order. + */ + 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. + */ + 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(); + + $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)); + } + + /** + * 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_execute_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(); + $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)); + } + + /** + * 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 { + /** + * 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 rename and preservation behavior. + * + * @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); + } + } +} 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/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/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)); } 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/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); + } +} diff --git a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php index c1f0250..2db0318 100644 --- a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -2,107 +2,748 @@ use PHPUnit\Framework\Assert; +/** + * Exercise migration commands through their registered WP-CLI synopses. + */ final class DatabaseMigrateCest { + /** + * Start each command scenario with empty storage. + */ public function _before(WPCLITester $I): void { $this->dropTables($I); } + /** + * Remove application and ledger tables after each scenario. + */ public function _after(WPCLITester $I): void { $this->dropTables($I); } - public function test_it_runs_database_migrations_through_wp_cli(WPCLITester $I): void { - $I->cli(['foundation', 'migrate', '--initialize']); + /** + * Inspect, preview, execute, and reverse one migration. + */ + public function test_it_previews_runs_and_rolls_back_migrations(WPCLITester $I): void { + $I->cli([ + 'foundation', + 'migrate:status', + ]); $I->seeResultCodeIs(0); - $I->seeInShellOutput('Foundation migration storage is initialized.'); - - $I->cli(['foundation', 'migrate', '--run']); + $I->seeInShellOutput('pending'); + $I->seeInShellOutput("20260623000001\t20260623000001\tpending"); + $I->cli([ + 'foundation', + 'migrate:run', + '--dry-run', + ]); $I->seeResultCodeIs(0); - $I->seeInShellOutput('Ran 1 migrations.'); - - $I->cli(['foundation', 'migrate']); + $I->seeInShellOutput('CREATE TABLE'); + $I->seeInShellOutput('Previewed 1 migration steps.'); + $I->cli([ + 'foundation', + 'migrate:status', + ]); + $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:status', + ]); + $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:status', + ]); + $I->seeInShellOutput('pending'); } - public function test_it_refreshes_and_drops_database_tables_through_wp_cli(WPCLITester $I): void { - $I->cli(['foundation', 'migrate', '--initialize']); + /** + * 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->seeResultCodeIs(0); + $I->cli([ + 'foundation', + 'migrate:refresh', + '--yes', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Completed 2 migration steps.'); + $I->cli([ + 'foundation', + 'migrate:rollback', + '--to=0', + '--yes', + ]); $I->seeResultCodeIs(0); + $I->cli([ + 'foundation', + 'migrate:status', + ]); + $I->seeInShellOutput('pending'); + } - $I->cli(['foundation', 'migrate', '--run']); + /** + * 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('Ran 1 migrations.'); + $I->seeInShellOutput('Completed 1 migration steps.'); + } - $I->cli(['foundation', 'migrate', '--refresh', '--yes']); + /** + * 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->seeResultCodeIs(0); - $I->seeInShellOutput('Rolled back 1 migrations and ran 1 migrations.'); + $I->seeInShellOutput('Completed 0 migration steps.'); + $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 ([ + 'migrate:run', + 'migrate:rollback', + ] as $operation) { + $I->cli([ + 'foundation', + 'migrate:run', + ]); + $I->seeResultCodeIs(0); + $I->cli([ + 'foundation', + $operation, + '--to=0', + ], null, "n\n"); + $I->seeInShellOutput('Roll back all migrations?'); + $I->cli([ + 'foundation', + 'migrate:status', + ]); + $I->seeInShellOutput('applied'); + $I->cli([ + 'foundation', + 'migrate:run', + '--to=0', + '--dry-run', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Previewed 1 migration steps.'); + $I->cli([ + 'foundation', + $operation, + '--to=0', + '--yes', + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Completed 1 migration steps.'); + $I->cli([ + 'foundation', + 'migrate:status', + ]); + $I->seeInShellOutput('pending'); + } + } - $I->cli(['foundation', 'migrate', '--drop-store', '--yes']); + /** + * 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:reset', + '--step=1', + ], + [ + 'migrate:reset', + '--to=0', + ], + [ + 'migrate:reset', + '--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('The migration ledger was dropped. Application tables were not changed, and shared lock storage remains available.'); + $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: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([ - 'db', - 'query', - sprintf("SHOW TABLES LIKE '%sfoundation_cli_example'", $this->prefix($I)), + 'eval', + <<<'PHP' + global $wpdb; + echo $wpdb->get_var("SELECT id + FROM {$wpdb->prefix}foundation_cli_example"); + PHP, ]); $I->seeResultCodeIs(0); - $I->seeInShellOutput('foundation_cli_example'); + $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([ - 'db', - 'query', - sprintf("SHOW TABLES LIKE '%sfoundation_cli_locks'", $this->prefix($I)), + '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('foundation_cli_locks'); + $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']); + $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); - Assert::assertStringContainsString('Migration storage is not initialized.', $I->grabLastShellErrorOutput()); + $I->seeInShellOutput('No application table created'); } - 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()); + /** + * 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', + '--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', + '--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']); + $I->cli([ + 'foundation', + 'migrate:status', + ]); $I->seeResultCodeIs(0); - Assert::assertStringContainsString('Migration storage is not initialized.', $I->grabLastShellErrorOutput()); - Assert::assertStringContainsString('wp foundation migrate --initialize', $I->grabLastShellErrorOutput()); + $I->seeInShellOutput('pending'); + } - $I->cli(['foundation', 'migrate', '--run']); - $I->seeResultCodeIs(1); - Assert::assertStringContainsString('wp foundation migrate --initialize', $I->grabLastShellErrorOutput()); + /** + * 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'); } - private function dropTables(WPCLITester $I): void { - $prefix = $this->prefix($I); + /** + * Declined reset and refresh commands preserve application rows and history. + */ + public function test_declining_reset_and_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); + + 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([ - 'db', - 'query', - sprintf( - 'DROP TABLE IF EXISTS %sfoundation_cli_migrations, %sfoundation_cli_locks, %sfoundation_cli_example', - $prefix, - $prefix, - $prefix - ), + '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'); } - private function prefix(WPCLITester $I): string { - $I->cli(['db', 'prefix']); - $I->seeResultCodeIs(0); + /** + * 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:reset' => [ + '--yes', + ], + 'migrate:refresh' => [ + '--yes', + ], + 'migrate:mark-applied' => [ + '', + '--all', + '--to', + ], + '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' + global $wpdb; + $prefix = $wpdb->prefix; - return trim($I->grabLastShellOutput()); + 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); } } 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; - } - } -} 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, [ 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'); + } + } +}