diff --git a/.env.testing.slic b/.env.testing.slic index db7a2a7..bb361c6 100644 --- a/.env.testing.slic +++ b/.env.testing.slic @@ -3,6 +3,7 @@ SLIC_PHP_VERSION=8.3 ENVIRONMENT=tests TEST_LOG_CHANNEL=stack TEST_LOG_LEVEL=debug +TEST_COMMAND_PREFIX=nxtest WP_VERSION=latest WP_ROOT_FOLDER=/var/www/html diff --git a/.gitattributes b/.gitattributes index dfb08e8..8fcef21 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,7 @@ /.gitignore export-ignore /.github export-ignore /docs export-ignore +/dev export-ignore /.env.testing.slic export-ignore /.env.slic.local export-ignore /.env.slic.run export-ignore diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2861477..540e0ab 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -36,8 +36,7 @@ jobs: - name: Run code style checks if: ${{ steps.filter.outputs.php == 'true' }} - run: composer lint ${{ steps.filter.outputs.php_files }} + run: composer lint - name: Run static analysis run: composer analyze - diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 85e5e86..139812b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -106,20 +106,33 @@ jobs: run: | "${SLIC_BIN}" run feature --ext DotReporter + - name: Run integration tests + if: github.event_name != 'pull_request' + run: | + "${SLIC_BIN}" run integration --ext DotReporter + - name: Run wpunit tests if: github.event_name != 'pull_request' run: | "${SLIC_BIN}" run wpunit --ext DotReporter - - name: Enable Xdebug for coverage - if: github.event_name == 'pull_request' + - name: Run wpcli tests + if: github.event_name != 'pull_request' run: | - "${SLIC_BIN}" xdebug on + "${SLIC_BIN}" run wpcli --ext DotReporter - name: Run Codeception tests with coverage if: github.event_name == 'pull_request' run: | - "${SLIC_BIN}" run --coverage --coverage-xml clover.xml --disable-coverage-php --ext DotReporter + composer run coverage:phpcov-install + composer run coverage:prepare + "${SLIC_BIN}" pcov on --yes + "${SLIC_BIN}" run unit --coverage coverage/unit.cov --ext DotReporter + "${SLIC_BIN}" run feature --coverage coverage/feature.cov --ext DotReporter + "${SLIC_BIN}" run integration --coverage coverage/integration.cov --ext DotReporter + "${SLIC_BIN}" run wpunit --coverage coverage/wpunit.cov --ext DotReporter + "${SLIC_BIN}" run wpcli --coverage coverage/wpcli.cov --ext DotReporter + "${SLIC_BIN}" composer run coverage:merge - name: Monitor coverage if: github.event_name == 'pull_request' @@ -128,7 +141,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} comment_footer: false coverage_format: clover - coverage_path: tests/_output/clover.xml + coverage_path: clover.xml threshold_alert: 90 threshold_warning: 95 threshold_metric: "lines" diff --git a/.gitignore b/.gitignore index 0229a22..7cb9e19 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ vendor /coverage/ /tests/_output/* !/tests/_output/.gitkeep -/tests/CodeceptionSupport/ +!/tests/_output/coverage/ +!/tests/_output/coverage/.gitignore /tests/_data/temp/* !/tests/_data/temp/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index b07274f..e5c7fc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,9 @@ Initial packages: - `stellarwp/foundation-container` - `stellarwp/foundation-log` +- `stellarwp/foundation-lock` +- `stellarwp/foundation-database` +- `stellarwp/foundation-identifier` - `stellarwp/foundation-pipeline` - `stellarwp/foundation-wpcli` - `stellarwp/foundation-cli` @@ -43,7 +46,13 @@ Feature-local interfaces should live in a `Contracts/` folder inside the feature Shared infrastructure interfaces should live under that shared namespace's `Contracts/` folder, for example `Process/Contracts/ProcessRunner.php`. -Generator commands should be grouped by the `make:*` workflow under `src/Cli/Commands/Make/`, for example `src/Cli/Commands/Make/WPCliCommand.php`. Shared generation infrastructure that is not itself a console command should live under `src/Cli/Generation/`. +Avoid `use ... as ...` import aliases unless they resolve a real class-name collision or ambiguity. Prefer importing the class by its actual short name. The standing exception is `use lucatume\DI52\Container as C;`, which may be used for concise container factory callbacks. + +Exceptions should live in an `Exceptions/` folder. Put shared package exceptions at the package root, for example `src/Database/Exceptions/DatabaseException.php`; put feature-only exceptions under that feature's `Exceptions/` folder only when they are not shared outside that feature. + +Generator commands should be grouped by the `make:*` workflow under `src/Cli/Commands/Make/`, for example `src/Cli/Commands/Make/WPCliCommand.php`. When a make feature grows beyond a single command class or needs private collaborators, group that feature under its own namespace such as `src/Cli/Commands/Make/Database/`. Command-specific collaborators should live inside that feature namespace, not beside unrelated command classes in `Commands/Make/`. + +Shared generation infrastructure that is not itself a console command and is reused across command features should live under `src/Cli/Generation/`. Default stubs should live with the package that owns the generated class shape. For example, WP-CLI command stubs live in `src/WPCli/stubs/` because the WPCli package owns the base `Command` API. The CLI package owns resolving, rendering, and writing generated files. @@ -73,10 +82,16 @@ When writing providers or container registration code, prefer container-driven c Use contextual bindings with `$this->container->when()->needs()->give()` for scalar constructor arguments, command lists, or feature-specific substitutions. Use a factory closure only when the value must be computed or resolved from the container, and keep that closure focused on supplying the constructor dependency rather than constructing the full object. +Classes should take the dependencies they need directly. Do not make constructor dependencies nullable just to instantiate fallback concrete classes internally, for example `?Dependency $dependency = null` with `$this->dependency = $dependency ?? new Dependency()`. Register default implementations and aliases in a provider instead so consumers can replace them through container configuration. + +Organize provider registration by feature or capability, not by container mechanism. The main `register()` method should call focused private methods such as `registerConfiguration()`, `registerMigrations()`, `registerLocks()`, or `registerCliCommands()`. Keep each feature's contextual bindings beside the classes they configure. Avoid generic methods such as `configureContextualBindings()` that group unrelated bindings only because they use the same container API. + ## Split Packages Split packages live in `src//` and are split to read-only repositories named `stellarwp/foundation-`. +`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. + 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+. @@ -151,13 +166,15 @@ Reusable test fixtures, sample classes, and test doubles should live under `test Tests that need writable temporary files or directories should use a test-specific subdirectory under `tests/_data/temp` instead of `sys_get_temp_dir()`. Use `$this->temp_dir('')` when only the path is needed; it mirrors `codecept_data_dir()` and does not create the directory. Use `$this->prepare_temp_dir('')` in `setUp()` to create a unique clean directory under that name and register it for automatic cleanup by the base test case. Only call `$this->remove_temp_dir('')` manually when a test needs to remove the prepared directories before teardown. -Codeception tests run through SLIC. Use `.env.testing.slic` as the SLIC/Codeception environment file. First-time local setup is `slic here` from the directory that contains this repository, `slic use foundation` from the repository, `slic composer install`, and `slic cc build`. If host-installed dependencies conflict with the SLIC PHP version, run `slic composer update --with-all-dependencies` inside the container. Run suites with `slic run unit`, `slic run feature`, and `composer test:wpunit` or `slic run wpunit`. +Codeception tests run through SLIC. Use SLIC 2.3.0 or newer so PCOV-backed coverage commands are available. Use `.env.testing.slic` as the SLIC/Codeception environment file. First-time local setup is `slic here` from the directory that contains this repository, `slic use foundation` from the repository, `slic composer install`, and `slic cc build`. If host-installed dependencies conflict with the SLIC PHP version, run `slic composer update --with-all-dependencies` inside the container. Run suites with `slic run unit`, `slic run feature`, `composer test:integration` or `slic run integration`, `composer test:wpunit` or `slic run wpunit`, and `composer test:wpcli` or `slic run wpcli`. + +Test suite meanings: `Unit` is isolated class/package behavior, `Feature` is Foundation feature behavior without bootstrapping WordPress, `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, and `wpunit` is WordPress-loaded behavior through wp-browser. 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. +Use `integration` for behavior where multiple providers/packages must be registered together to prove the container graph works. Use `wpunit` for a single package/class where the main concern is direct WordPress API behavior. Use `wpcli` for real WP-CLI command execution shared across packages. Keep unit tests focused on portable package behavior and pure collaborators; do not build large fake WordPress runtimes in unit tests when the behavior can be covered with wp-browser. Use `tests/WPUnitSupport/WPTestCase.php` as the base class for wpunit tests instead of extending Codeception's `WPTestCase` directly. Keep Codeception-generated actor files in `tests/CodeceptionSupport/`; that directory is ignored and excluded from lint/static analysis. -After completing a feature, run `composer test:coverage`, review `clover.xml` for missed source coverage, and add meaningful tests for uncovered behavior before considering the feature complete. +After completing a feature, run `composer test:coverage`, review `clover.xml` for missed source coverage, and add meaningful tests for uncovered behavior before considering the feature complete. Coverage uses SLIC 2.3.0+ PCOV support, runs each SLIC suite separately, and merges the serialized `.cov` artifacts with `phpcov`; run the merge through `slic composer run coverage:merge` or `slic composer run coverage:merge-html` because the coverage files contain container paths like `/var/www/html/wp-content/plugins/foundation`. ## Releases diff --git a/README.md b/README.md index 3380a10..d25ec8c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f - [stellarwp/foundation-container-wordpress](https://github.com/stellarwp/foundation-container-wordpress) - [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline) - [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) +- [stellarwp/foundation-lock](https://github.com/stellarwp/foundation-lock) +- [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) +- [stellarwp/foundation-identifier](https://github.com/stellarwp/foundation-identifier) - [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) - [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) @@ -53,7 +56,9 @@ Run the Codeception suites with SLIC: ```bash slic run unit slic run feature +composer test:integration composer test:wpunit +composer test:wpcli ``` The first time you run the WordPress suite locally, point SLIC at the directory that contains this repository and select the `foundation` project: @@ -65,7 +70,9 @@ cd foundation slic use foundation slic composer install slic cc build +composer test:integration composer test:wpunit +composer test:wpcli ``` If dependencies were installed on a different host PHP version and the SLIC container reports Composer platform conflicts, refresh them inside SLIC: @@ -76,14 +83,16 @@ slic composer update --with-all-dependencies Run `slic cc build` again after changing Codeception suite configuration or modules. Generated Codeception actor files are written to `tests/CodeceptionSupport/` and are intentionally ignored. -The `unit` and `feature` SLIC suites run the same tests as `composer test:unit` and `composer test:feature`. The `wpunit` suite runs WordPress-loaded tests through wp-browser. +The `unit` and `feature` SLIC suites run the same tests as `composer test:unit` and `composer test:feature`. The `integration` suite covers multi-provider/container behavior that needs WordPress runtime APIs. The `wpunit` suite runs lower-level WordPress-loaded tests through wp-browser. The `wpcli` suite is shared across the monorepo for WP-CLI command tests and uses wp-browser's WPCLI module without the full wpunit module stack. -Generate the test coverage HTML dashboard (XDEBUG required to be enabled on your machine): +Generate the test coverage HTML dashboard: ```bash composer test:coverage-html ``` +Coverage uses SLIC 2.3.0+ PCOV support for faster collection. It runs each SLIC suite separately, writes serialized `.cov` artifacts, and merges them with `phpcov` so multiple WordPress-loaded suites can contribute to one Clover or HTML report. + ### Code Quality Check your code style: diff --git a/composer.json b/composer.json index 25cd000..80406e7 100644 --- a/composer.json +++ b/composer.json @@ -13,8 +13,10 @@ "ext-curl": "*", "ext-exif": "*", "adbario/php-dot-notation": ">=2.5", + "arokettu/random-polyfill": ">=1.0.6 <1.99", "lucatume/di52": ">=4.1", "monolog/monolog": "^2.11", + "nikic/php-parser": ">=5.0 <6.0", "psr/log": ">=1.0", "stellarwp/container-contract": "^1.1", "symfony/console": ">=5.4", @@ -25,7 +27,7 @@ "monorepo-php/monorepo": "^12.7", "nunomaduro/collision": "^8.9", "php-mock/php-mock-mockery": "^1.5", - "php-stubs/wordpress-stubs": ">=6.0", + "php-stubs/wordpress-stubs": "^7.0", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^11.5", @@ -36,6 +38,9 @@ "stellarwp/foundation-cli": "self.version", "stellarwp/foundation-container": "self.version", "stellarwp/foundation-container-wordpress": "self.version", + "stellarwp/foundation-database": "self.version", + "stellarwp/foundation-identifier": "self.version", + "stellarwp/foundation-lock": "self.version", "stellarwp/foundation-log": "self.version", "stellarwp/foundation-pipeline": "self.version", "stellarwp/foundation-wpcli": "self.version" @@ -47,6 +52,9 @@ "StellarWP\\Foundation\\Cli\\": "src/Cli/", "StellarWP\\Foundation\\ContainerWordPress\\": "src/ContainerWordPress/", "StellarWP\\Foundation\\Container\\": "src/Container/", + "StellarWP\\Foundation\\Database\\": "src/Database/", + "StellarWP\\Foundation\\Identifier\\": "src/Identifier/", + "StellarWP\\Foundation\\Lock\\": "src/Lock/", "StellarWP\\Foundation\\Log\\": "src/Log/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", "StellarWP\\Foundation\\WPCli\\": "src/WPCli/" @@ -58,9 +66,16 @@ }, "autoload-dev": { "psr-4": { - "StellarWP\\Foundation\\Tests\\": "tests/", + "StellarWP\\Foundation\\Tests\\Feature\\": "tests/Feature/", + "StellarWP\\Foundation\\Tests\\Integration\\": "tests/integration/", + "StellarWP\\Foundation\\Tests\\Support\\": "tests/Support/", + "StellarWP\\Foundation\\Tests\\Unit\\": "tests/Unit/", + "StellarWP\\Foundation\\Tests\\WPUnitSupport\\": "tests/WPUnitSupport/", "StellarWP\\Foundation\\Tests\\WPUnit\\": "tests/wpunit/" - } + }, + "classmap": [ + "tests/TestCase.php" + ] }, "bin": [ "src/Cli/bin/foundation" @@ -82,9 +97,31 @@ "test:slic": "slic run", "test:slic:unit": "slic run unit", "test:slic:feature": "slic run feature", + "test:integration": "slic run integration", "test:wpunit": "slic run wpunit", - "test:coverage": "slic xdebug on && slic run --coverage --coverage-xml clover.xml --disable-coverage-php; status=$?; slic xdebug off; exit $status", - "test:coverage-html": "slic xdebug on && slic run --coverage --coverage-html coverage --disable-coverage-php; status=$?; slic xdebug off; exit $status", + "test:wpcli": "slic run wpcli", + "test:coverage": "@test:coverage:split", + "test:coverage-html": "@test:coverage-html:split", + "test:clean": "rm -f clover.xml tests/_output/coverage/*.cov && rm -rf ./coverage", + "coverage:phpcov-install": [ + "mkdir -p dev/bin", + "test -f ./dev/bin/phpcov.phar || curl -L -o ./dev/bin/phpcov.phar https://phar.phpunit.de/phpcov-10.0.1.phar" + ], + "coverage:prepare": "rm -f tests/_output/coverage/*.cov", + "coverage:merge": "@php dev/bin/phpcov.phar merge tests/_output/coverage --clover clover.xml", + "coverage:merge-html": "@php dev/bin/phpcov.phar merge tests/_output/coverage --clover clover.xml --html coverage", + "test:coverage:split": [ + "@coverage:phpcov-install", + "@coverage:prepare", + "slic pcov on --yes && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic pcov off; exit $rc", + "slic composer run coverage:merge" + ], + "test:coverage-html:split": [ + "@coverage:phpcov-install", + "@coverage:prepare", + "slic pcov on --yes && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic pcov off; exit $rc", + "slic composer run coverage:merge-html" + ], "analyze": "@php vendor/bin/phpstan analyse --ansi --memory-limit 2G", "lint": "@php vendor/bin/pinte --test -v", "format": "@php vendor/bin/pinte -v" @@ -98,9 +135,18 @@ "test:slic": "Run all Codeception suites through SLIC.", "test:slic:unit": "Run the Codeception unit suite through SLIC.", "test:slic:feature": "Run the Codeception feature suite through SLIC.", + "test:integration": "Run the WordPress-loaded integration suite through SLIC.", "test:wpunit": "Run the WordPress-loaded wpunit suite through SLIC.", - "test:coverage": "Generate combined SLIC/Codeception Clover coverage, then turn Xdebug off.", - "test:coverage-html": "Generate combined SLIC/Codeception HTML coverage, then turn Xdebug off.", + "test:wpcli": "Run the shared WP-CLI command suite through SLIC.", + "test:coverage": "Generate merged Clover coverage from split SLIC suite artifacts.", + "test:coverage-html": "Generate merged Clover and HTML coverage from split SLIC suite artifacts.", + "test:clean": "Remove generated test coverage reports and serialized coverage artifacts.", + "coverage:phpcov-install": "Download the pinned phpcov PHAR used to merge split coverage artifacts.", + "coverage:prepare": "Create and clear the split coverage artifact directory.", + "coverage:merge": "Merge split coverage artifacts into clover.xml. Run through SLIC so container paths resolve.", + "coverage:merge-html": "Merge split coverage artifacts into clover.xml and HTML coverage. Run through SLIC so container paths resolve.", + "test:coverage:split": "Run SLIC suites separately, merge PHPUnit coverage artifacts, and generate clover.xml.", + "test:coverage-html:split": "Run SLIC suites separately, merge PHPUnit coverage artifacts, and generate clover.xml plus HTML coverage.", "analyze": "Run PHPStan static analysis.", "lint": "Check code style with Pinte.", "format": "Fix code style with Pinte." diff --git a/dev/bin/.gitignore b/dev/bin/.gitignore new file mode 100644 index 0000000..86d8465 --- /dev/null +++ b/dev/bin/.gitignore @@ -0,0 +1 @@ +*.phar diff --git a/phpstan.neon.dist b/phpstan.neon.dist index a1cb3a6..606a19c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,6 +5,7 @@ parameters: - tests scanFiles: - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php + - vendor/wp-cli/wp-cli/php/utils.php excludePaths: analyse: - src/*/vendor/* diff --git a/src/Cli/CliProvider.php b/src/Cli/CliProvider.php index f56297d..0c25edf 100644 --- a/src/Cli/CliProvider.php +++ b/src/Cli/CliProvider.php @@ -3,6 +3,12 @@ namespace StellarWP\Foundation\Cli; use lucatume\DI52\Container; +use PhpParser\Lexer; +use PhpParser\ParserFactory; +use StellarWP\Foundation\Cli\Commands\Make\Database\MigrationCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderRegistrationEditor; +use StellarWP\Foundation\Cli\Commands\Make\Database\TableCommand; use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand; use StellarWP\Foundation\Cli\Commands\Package\Contracts\PackageRepositoryCreator; use StellarWP\Foundation\Cli\Commands\Package\CreateCommand; @@ -13,6 +19,7 @@ use StellarWP\Foundation\Cli\Commands\Package\PackageScaffolder; use StellarWP\Foundation\Cli\Generation\ComposerAutoloadResolver; use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; +use StellarWP\Foundation\Cli\Generation\Php\PhpSourceEditor; use StellarWP\Foundation\Cli\Generation\StubRenderer; use StellarWP\Foundation\Cli\Generation\StubResolver; use StellarWP\Foundation\Cli\Generation\WordPressClassNameResolver; @@ -53,10 +60,25 @@ public function register(): void { ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + $this->container->when(MigrationCommand::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + + $this->container->when(ProviderCommand::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + + $this->container->when(TableCommand::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + $this->container->when(Application::class) ->needs('$commands') ->give(static fn (Container $c): array => [ $c->get(CreateCommand::class), + $c->get(MigrationCommand::class), + $c->get(ProviderCommand::class), + $c->get(TableCommand::class), $c->get(WPCliCommand::class), ]); @@ -71,8 +93,15 @@ public function register(): void { $this->container->singleton(WordPressClassNameResolver::class); $this->container->singleton(ComposerAutoloadResolver::class); $this->container->singleton(GeneratedFileWriter::class); + $this->container->singleton(Lexer::class); + $this->container->singleton(ParserFactory::class); + $this->container->singleton(PhpSourceEditor::class); $this->container->singleton(StubRenderer::class); $this->container->singleton(StubResolver::class); + $this->container->singleton(MigrationCommand::class); + $this->container->singleton(ProviderCommand::class); + $this->container->singleton(ProviderRegistrationEditor::class); + $this->container->singleton(TableCommand::class); $this->container->singleton(WPCliCommand::class); $this->container->singleton(Application::class); } diff --git a/src/Cli/Commands/Make/Database/MigrationCommand.php b/src/Cli/Commands/Make/Database/MigrationCommand.php new file mode 100644 index 0000000..43ef3d0 --- /dev/null +++ b/src/Cli/Commands/Make/Database/MigrationCommand.php @@ -0,0 +1,366 @@ +setDescription('Generate a Foundation database migration class.') + ->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 class.') + ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the migration class should be written.') + ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'Database provider file to update when it exists.') + ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable migration identifier.') + ->addOption('table-class', null, InputOption::VALUE_REQUIRED, 'Table class or base name used by a table-backed migration.') + ->addOption('table-namespace', null, InputOption::VALUE_REQUIRED, 'Namespace containing the table class.') + ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->validateExplicitProviderUpdate($input); + $file = $this->generatedFile($input); + $this->fileWriter->write($file, (bool) $input->getOption('force')); + $providerPath = $this->updateProvider($input); + } catch (RuntimeException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + + return Command::FAILURE; + } + + $output->writeln(sprintf('Created: %s', $file->relativePath)); + $output->writeln(''); + $output->writeln('Register this migration with DatabaseProvider::MIGRATIONS using mergeArrayVar().'); + + if ($providerPath !== null) { + $output->writeln(sprintf('Updated: %s', $this->relativePath($providerPath))); + } + + $runtimeDependencyWarning = $this->runtimeDependencyWarning(); + + if ($runtimeDependencyWarning !== null) { + $output->writeln(''); + $output->writeln('Runtime dependency missing: ' . $runtimeDependencyWarning); + } + + return Command::SUCCESS; + } + + private function generatedFile(InputInterface $input): GeneratedFile { + $className = $this->classNameResolver->className((string) $input->getArgument('name')); + $project = $this->autoloadResolver->project(); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $path = $this->path($input, $namespace, $project); + $relative = $this->relativePath($path . '/' . $className . '.php'); + $id = $this->optionOrDefault($input, 'id', $this->classNameResolver->migrationId($className)); + + if ($this->isTableMigration($input, $className)) { + $stub = $this->stubResolver->resolve('database', 'table-migration', DatabaseStubPath::tableMigration()); + $tableNamespace = $this->tableNamespace($input, $project->defaultPsr4Namespace()); + $tableClass = $this->tableClass($input, $className); + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'id_php' => $this->phpString($id), + 'table_class' => $tableClass, + 'table_namespace' => $tableNamespace, + 'foundation_database_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), + 'foundation_database_schema' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), + 'foundation_database_create_table' => $project->foundationClass('StellarWP\\Foundation\\Database\\Table\\CreateTable'), + ]) + ); + } + + $stub = $this->stubResolver->resolve('database', 'migration', DatabaseStubPath::migration()); + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'id_php' => $this->phpString($id), + 'foundation_database_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), + 'foundation_database_schema' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), + 'foundation_database_irreversible_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Exceptions\\IrreversibleMigration'), + ]) + ); + } + + private function validateExplicitProviderUpdate(InputInterface $input): void { + if (! $this->hasExplicitProvider($input)) { + return; + } + + $project = $this->autoloadResolver->project(); + $className = $this->classNameResolver->className((string) $input->getArgument('name')); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $providerPath = $this->providerPath($input, $project); + + if (! is_file($providerPath)) { + throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->relativePath($providerPath))); + } + + $status = $this->providerUpdater->checkMigration($providerPath, $className, $namespace); + + if ($status === ProviderRegistrationEditor::UPDATED || $status === ProviderRegistrationEditor::ALREADY_REGISTERED) { + return; + } + + throw new RuntimeException(sprintf( + 'Could not update database provider "%s": %s.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status) + )); + } + + private function updateProvider(InputInterface $input): ?string { + $project = $this->autoloadResolver->project(); + $className = $this->classNameResolver->className((string) $input->getArgument('name')); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $providerPath = $this->providerPath($input, $project); + $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->relativePath($providerPath))); + } + + return null; + } + + $status = $this->providerUpdater->addMigration($providerPath, $className, $namespace); + + if ($status === ProviderRegistrationEditor::UPDATED) { + return $providerPath; + } + + if ($status === ProviderRegistrationEditor::ALREADY_REGISTERED) { + return null; + } + + if ($explicit) { + throw new RuntimeException(sprintf( + 'Could not update database provider "%s": %s.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status) + )); + } + + return null; + } + + private function isTableMigration(InputInterface $input, string $className): bool { + $tableClass = $input->getOption('table-class'); + + if (is_string($tableClass) && trim($tableClass) !== '') { + return true; + } + + return preg_match('/^Create_.*_Table$/', $className) === 1; + } + + private function tableClass(InputInterface $input, string $migrationClass): string { + $tableClass = $input->getOption('table-class'); + + if (is_string($tableClass) && trim($tableClass) !== '') { + return $this->classNameResolver->tableClass($tableClass); + } + + $name = (string) preg_replace('/^Create_?/', '', $migrationClass); + + return $this->classNameResolver->tableClass($name); + } + + private function optionOrDefault(InputInterface $input, string $option, string $default): string { + $value = $input->getOption($option); + + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + + return $default; + } + + private function phpString(string $value): string { + return var_export($value, true); + } + + private function namespace(InputInterface $input, Psr4Namespace $autoload): string { + $namespace = $input->getOption('namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $this->validNamespace(trim($namespace, '\\')); + } + + return trim($autoload->namespace, '\\') . '\\Database\\Migrations'; + } + + private function tableNamespace(InputInterface $input, Psr4Namespace $autoload): string { + $namespace = $input->getOption('table-namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $this->validNamespace(trim($namespace, '\\')); + } + + return trim($autoload->namespace, '\\') . '\\Database\\Tables'; + } + + private function path(InputInterface $input, string $namespace, ComposerProject $project): string { + $path = $input->getOption('path'); + + if (is_string($path) && trim($path) !== '') { + return $this->absolutePath($path); + } + + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + throw new RuntimeException(sprintf( + 'Namespace "%s" is outside the Composer PSR-4 namespaces in composer.json. Pass --path to choose an output directory.', + $namespace + )); + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace); + } + + private function providerPath(InputInterface $input, ComposerProject $project): string { + $provider = $input->getOption('provider'); + + if (is_string($provider) && trim($provider) !== '') { + return $this->absolutePath($provider); + } + + $namespace = trim($project->defaultPsr4Namespace()->namespace, '\\') . '\\Database'; + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + return $this->rootPath . '/src/Database/Provider.php'; + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace) . '/Provider.php'; + } + + private function hasExplicitProvider(InputInterface $input): bool { + $provider = $input->getOption('provider'); + + return is_string($provider) && trim($provider) !== ''; + } + + private function providerUpdateFailure(string $status): string { + return match ($status) { + ProviderRegistrationEditor::NOT_FOUND => 'file does not exist or is not readable', + ProviderRegistrationEditor::NOT_WRITABLE => 'file is not writable', + ProviderRegistrationEditor::MISSING_ANCHOR => 'file does not contain a generated database provider registration point', + ProviderRegistrationEditor::MISSING_MARKER => 'file does not contain the generated database provider markers', + ProviderRegistrationEditor::IMPORT_COLLISION => 'a different imported class uses the same short class name', + ProviderRegistrationEditor::PARSE_FAILED => 'file could not be parsed as PHP', + ProviderRegistrationEditor::WRITE_FAILED => 'file could not be written', + default => 'provider could not be updated', + }; + } + + private function absolutePath(string $path): string { + $path = trim($path); + + if (str_starts_with($path, '/')) { + return rtrim($path, '/'); + } + + return $this->rootPath . '/' . trim($path, '/'); + } + + private function relativePath(string $path): string { + $root = rtrim($this->rootPath, '/') . '/'; + + if (str_starts_with($path, $root)) { + return substr($path, strlen($root)); + } + + return $path; + } + + private function validNamespace(string $namespace): string { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$/', $namespace)) { + throw new RuntimeException(sprintf('Namespace "%s" is not a valid PHP namespace.', $namespace)); + } + + return $namespace; + } + + private function runtimeDependencyWarning(): ?string { + $composerPath = $this->rootPath . '/composer.json'; + + if (! is_readable($composerPath)) { + return null; + } + + $composer = json_decode((string) file_get_contents($composerPath), true); + + if (! is_array($composer)) { + return null; + } + + $require = is_array($composer['require'] ?? null) ? $composer['require'] : []; + $requireDev = is_array($composer['require-dev'] ?? null) ? $composer['require-dev'] : []; + + if ($this->hasFoundationRuntimeDependency($require)) { + return null; + } + + 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 Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this migration.'; + } + + /** + * @param array $dependencies + */ + private function hasFoundationRuntimeDependency(array $dependencies): bool { + return array_key_exists('stellarwp/foundation-database', $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 new file mode 100644 index 0000000..7247449 --- /dev/null +++ b/src/Cli/Commands/Make/Database/ProviderCommand.php @@ -0,0 +1,185 @@ +setDescription('Generate a Foundation database provider class.') + ->addArgument('name', InputArgument::OPTIONAL, 'Provider class name, e.g. Provider or Database_Provider.', 'Provider') + ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated provider class.') + ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the provider class should be written.') + ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $file = $this->generatedFile($input); + $this->fileWriter->write($file, (bool) $input->getOption('force')); + } catch (RuntimeException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + + return Command::FAILURE; + } + + $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.'); + + $runtimeDependencyWarning = $this->runtimeDependencyWarning(); + + if ($runtimeDependencyWarning !== null) { + $output->writeln(''); + $output->writeln('Runtime dependency missing: ' . $runtimeDependencyWarning); + } + + return Command::SUCCESS; + } + + private function generatedFile(InputInterface $input): GeneratedFile { + $className = $this->classNameResolver->className((string) $input->getArgument('name')); + $project = $this->autoloadResolver->project(); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $path = $this->path($input, $namespace, $project); + $stub = $this->stubResolver->resolve('database', 'provider', DatabaseStubPath::provider()); + $relative = $this->relativePath($path . '/' . $className . '.php'); + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'foundation_database_provider' => $project->foundationClass('StellarWP\\Foundation\\Database\\DatabaseProvider'), + 'foundation_service_provider' => $project->foundationClass('StellarWP\\Foundation\\Container\\Contracts\\Provider'), + ]) + ); + } + + private function namespace(InputInterface $input, Psr4Namespace $autoload): string { + $namespace = $input->getOption('namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $this->validNamespace(trim($namespace, '\\')); + } + + return trim($autoload->namespace, '\\') . '\\Database'; + } + + private function path(InputInterface $input, string $namespace, ComposerProject $project): string { + $path = $input->getOption('path'); + + if (is_string($path) && trim($path) !== '') { + return $this->absolutePath($path); + } + + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + throw new RuntimeException(sprintf( + 'Namespace "%s" is outside the Composer PSR-4 namespaces in composer.json. Pass --path to choose an output directory.', + $namespace + )); + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace); + } + + private function absolutePath(string $path): string { + $path = trim($path); + + if (str_starts_with($path, '/')) { + return rtrim($path, '/'); + } + + return $this->rootPath . '/' . trim($path, '/'); + } + + private function relativePath(string $path): string { + $root = rtrim($this->rootPath, '/') . '/'; + + if (str_starts_with($path, $root)) { + return substr($path, strlen($root)); + } + + return $path; + } + + private function validNamespace(string $namespace): string { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$/', $namespace)) { + throw new RuntimeException(sprintf('Namespace "%s" is not a valid PHP namespace.', $namespace)); + } + + return $namespace; + } + + private function runtimeDependencyWarning(): ?string { + $composerPath = $this->rootPath . '/composer.json'; + + if (! is_readable($composerPath)) { + return null; + } + + $composer = json_decode((string) file_get_contents($composerPath), true); + + if (! is_array($composer)) { + return null; + } + + $require = is_array($composer['require'] ?? null) ? $composer['require'] : []; + $requireDev = is_array($composer['require-dev'] ?? null) ? $composer['require-dev'] : []; + + if ($this->hasFoundationRuntimeDependency($require)) { + return null; + } + + if ($this->hasFoundationRuntimeDependency($requireDev)) { + return 'this provider 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 provider.'; + } + + return 'this provider uses Foundation Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this provider.'; + } + + /** + * @param array $dependencies + */ + private function hasFoundationRuntimeDependency(array $dependencies): bool { + return array_key_exists('stellarwp/foundation-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 new file mode 100644 index 0000000..e634f11 --- /dev/null +++ b/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php @@ -0,0 +1,187 @@ +addRegistration( + providerPath: $providerPath, + class: $class, + classNamespace: $classNamespace, + marker: self::TABLE_MARKER, + registration: sprintf('$this->container->singleton(%s::class);', $class), + write: true + ); + } + + public function addMigration(string $providerPath, string $class, string $classNamespace): string { + return $this->addMergeArrayVarRegistration( + providerPath: $providerPath, + class: $class, + classNamespace: $classNamespace, + write: true + ); + } + + public function checkTable(string $providerPath, string $class, string $classNamespace): string { + return $this->addRegistration( + providerPath: $providerPath, + class: $class, + classNamespace: $classNamespace, + marker: self::TABLE_MARKER, + registration: sprintf('$this->container->singleton(%s::class);', $class), + write: false + ); + } + + public function checkMigration(string $providerPath, string $class, string $classNamespace): string { + return $this->addMergeArrayVarRegistration( + providerPath: $providerPath, + class: $class, + classNamespace: $classNamespace, + write: false + ); + } + + private function addRegistration(string $providerPath, string $class, string $classNamespace, string $marker, string $registration, bool $write): string { + if (! is_file($providerPath) || ! is_readable($providerPath)) { + return self::NOT_FOUND; + } + + if ($write && ! is_writable($providerPath)) { + return self::NOT_WRITABLE; + } + + $contents = (string) file_get_contents($providerPath); + + if (! $this->sourceEditor->canParse($contents)) { + return self::PARSE_FAILED; + } + + if (! $this->sourceEditor->hasLineComment($contents, $marker)) { + return self::MISSING_MARKER; + } + + $fullyQualifiedClass = $classNamespace . '\\' . $class; + + if ($this->sourceEditor->hasImport($contents, $fullyQualifiedClass) && str_contains($contents, $registration)) { + return self::ALREADY_REGISTERED; + } + + if ($this->sourceEditor->hasImportShortNameCollision($contents, $class, $fullyQualifiedClass)) { + return self::IMPORT_COLLISION; + } + + if (! $write) { + return self::UPDATED; + } + + $contents = $this->sourceEditor->addImport($contents, $fullyQualifiedClass); + + if ($contents === null) { + return self::PARSE_FAILED; + } + + $contents = $this->sourceEditor->insertBeforeLineComment($contents, $marker, $registration); + + if ($contents === null) { + return self::MISSING_MARKER; + } + + if (file_put_contents($providerPath, $contents) === false) { + return self::WRITE_FAILED; + } + + return self::UPDATED; + } + + private function addMergeArrayVarRegistration(string $providerPath, string $class, string $classNamespace, bool $write): string { + if (! is_file($providerPath) || ! is_readable($providerPath)) { + return self::NOT_FOUND; + } + + if ($write && ! is_writable($providerPath)) { + return self::NOT_WRITABLE; + } + + $contents = (string) file_get_contents($providerPath); + + if (! $this->sourceEditor->canParse($contents)) { + return self::PARSE_FAILED; + } + + $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 self::MISSING_ANCHOR; + } + + $fullyQualifiedClass = $classNamespace . '\\' . $class; + $registration = sprintf('%s->get(%s::class),', $containerExpression, $class); + + if ($this->sourceEditor->hasImport($contents, $fullyQualifiedClass) && str_contains($contents, $registration)) { + return self::ALREADY_REGISTERED; + } + + if ($this->sourceEditor->hasImportShortNameCollision($contents, $class, $fullyQualifiedClass)) { + return self::IMPORT_COLLISION; + } + + if (! $write) { + return self::UPDATED; + } + + $contents = $this->sourceEditor->addImport($contents, $fullyQualifiedClass); + + if ($contents === null) { + return self::PARSE_FAILED; + } + + $contents = $this->sourceEditor->insertIntoMergeArrayVar( + contents: $contents, + class: self::MIGRATIONS_CLASS, + constant: self::MIGRATIONS_CONST, + statement: $registration, + beforeComment: self::MIGRATION_MARKER + ); + + if ($contents === null) { + return self::MISSING_ANCHOR; + } + + if (file_put_contents($providerPath, $contents) === false) { + return self::WRITE_FAILED; + } + + return self::UPDATED; + } +} diff --git a/src/Cli/Commands/Make/Database/TableCommand.php b/src/Cli/Commands/Make/Database/TableCommand.php new file mode 100644 index 0000000..4120a4d --- /dev/null +++ b/src/Cli/Commands/Make/Database/TableCommand.php @@ -0,0 +1,313 @@ +setDescription('Generate a Foundation database table class.') + ->addArgument('name', InputArgument::REQUIRED, 'Table class name, e.g. Reports_Table, Reports, or reports.') + ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated table class.') + ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the table class should be written.') + ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'Database provider file to update when it exists.') + ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable table identifier used by migrations.') + ->addOption('table', null, InputOption::VALUE_REQUIRED, 'Unprefixed WordPress table name.') + ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->validateExplicitProviderUpdate($input); + $file = $this->generatedFile($input); + $this->fileWriter->write($file, (bool) $input->getOption('force')); + $providerPath = $this->updateProvider($input); + } catch (RuntimeException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + + return Command::FAILURE; + } + + $output->writeln(sprintf('Created: %s', $file->relativePath)); + $output->writeln(''); + $output->writeln('Add this table to a migration, usually with StellarWP\Foundation\Database\Table\CreateTable.'); + + if ($providerPath !== null) { + $output->writeln(sprintf('Updated: %s', $this->relativePath($providerPath))); + } + + $runtimeDependencyWarning = $this->runtimeDependencyWarning(); + + if ($runtimeDependencyWarning !== null) { + $output->writeln(''); + $output->writeln('Runtime dependency missing: ' . $runtimeDependencyWarning); + } + + return Command::SUCCESS; + } + + private function generatedFile(InputInterface $input): GeneratedFile { + $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); + $project = $this->autoloadResolver->project(); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $path = $this->path($input, $namespace, $project); + $stub = $this->stubResolver->resolve('database', 'table', DatabaseStubPath::table()); + $relative = $this->relativePath($path . '/' . $className . '.php'); + $table = $this->optionOrDefault($input, 'table', $this->classNameResolver->tableName($className)); + $id = $this->optionOrDefault($input, 'id', $table . '_table'); + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'id_php' => $this->phpString($id), + 'table_php' => $this->phpString($table), + 'foundation_database_contract' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Database'), + 'foundation_database_table' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Table'), + 'foundation_database_table_definition' => $project->foundationClass('StellarWP\\Foundation\\Database\\Table\\TableDefinition'), + ]) + ); + } + + private function validateExplicitProviderUpdate(InputInterface $input): void { + if (! $this->hasExplicitProvider($input)) { + return; + } + + $project = $this->autoloadResolver->project(); + $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $providerPath = $this->providerPath($input, $project); + + if (! is_file($providerPath)) { + throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->relativePath($providerPath))); + } + + $status = $this->providerUpdater->checkTable($providerPath, $className, $namespace); + + if ($status === ProviderRegistrationEditor::UPDATED || $status === ProviderRegistrationEditor::ALREADY_REGISTERED) { + return; + } + + throw new RuntimeException(sprintf( + 'Could not update database provider "%s": %s.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status) + )); + } + + private function updateProvider(InputInterface $input): ?string { + $project = $this->autoloadResolver->project(); + $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $providerPath = $this->providerPath($input, $project); + $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->relativePath($providerPath))); + } + + return null; + } + + $status = $this->providerUpdater->addTable($providerPath, $className, $namespace); + + if ($status === ProviderRegistrationEditor::UPDATED) { + return $providerPath; + } + + if ($status === ProviderRegistrationEditor::ALREADY_REGISTERED) { + return null; + } + + if ($explicit) { + throw new RuntimeException(sprintf( + 'Could not update database provider "%s": %s.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status) + )); + } + + return null; + } + + private function optionOrDefault(InputInterface $input, string $option, string $default): string { + $value = $input->getOption($option); + + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + + return $default; + } + + private function phpString(string $value): string { + return var_export($value, true); + } + + private function namespace(InputInterface $input, Psr4Namespace $autoload): string { + $namespace = $input->getOption('namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $this->validNamespace(trim($namespace, '\\')); + } + + return trim($autoload->namespace, '\\') . '\\Database\\Tables'; + } + + private function path(InputInterface $input, string $namespace, ComposerProject $project): string { + $path = $input->getOption('path'); + + if (is_string($path) && trim($path) !== '') { + return $this->absolutePath($path); + } + + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + throw new RuntimeException(sprintf( + 'Namespace "%s" is outside the Composer PSR-4 namespaces in composer.json. Pass --path to choose an output directory.', + $namespace + )); + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace); + } + + private function providerPath(InputInterface $input, ComposerProject $project): string { + $provider = $input->getOption('provider'); + + if (is_string($provider) && trim($provider) !== '') { + return $this->absolutePath($provider); + } + + $namespace = trim($project->defaultPsr4Namespace()->namespace, '\\') . '\\Database'; + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + return $this->rootPath . '/src/Database/Provider.php'; + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace) . '/Provider.php'; + } + + private function hasExplicitProvider(InputInterface $input): bool { + $provider = $input->getOption('provider'); + + return is_string($provider) && trim($provider) !== ''; + } + + private function providerUpdateFailure(string $status): string { + return match ($status) { + ProviderRegistrationEditor::NOT_FOUND => 'file does not exist or is not readable', + ProviderRegistrationEditor::NOT_WRITABLE => 'file is not writable', + ProviderRegistrationEditor::MISSING_ANCHOR => 'file does not contain a generated database provider registration point', + ProviderRegistrationEditor::MISSING_MARKER => 'file does not contain the generated database provider markers', + ProviderRegistrationEditor::IMPORT_COLLISION => 'a different imported class uses the same short class name', + ProviderRegistrationEditor::PARSE_FAILED => 'file could not be parsed as PHP', + ProviderRegistrationEditor::WRITE_FAILED => 'file could not be written', + default => 'provider could not be updated', + }; + } + + private function absolutePath(string $path): string { + $path = trim($path); + + if (str_starts_with($path, '/')) { + return rtrim($path, '/'); + } + + return $this->rootPath . '/' . trim($path, '/'); + } + + private function relativePath(string $path): string { + $root = rtrim($this->rootPath, '/') . '/'; + + if (str_starts_with($path, $root)) { + return substr($path, strlen($root)); + } + + return $path; + } + + private function validNamespace(string $namespace): string { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$/', $namespace)) { + throw new RuntimeException(sprintf('Namespace "%s" is not a valid PHP namespace.', $namespace)); + } + + return $namespace; + } + + private function runtimeDependencyWarning(): ?string { + $composerPath = $this->rootPath . '/composer.json'; + + if (! is_readable($composerPath)) { + return null; + } + + $composer = json_decode((string) file_get_contents($composerPath), true); + + if (! is_array($composer)) { + return null; + } + + $require = is_array($composer['require'] ?? null) ? $composer['require'] : []; + $requireDev = is_array($composer['require-dev'] ?? null) ? $composer['require-dev'] : []; + + if ($this->hasFoundationRuntimeDependency($require)) { + 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.'; + } + + return 'this table uses Foundation Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this table.'; + } + + /** + * @param array $dependencies + */ + private function hasFoundationRuntimeDependency(array $dependencies): bool { + return array_key_exists('stellarwp/foundation-database', $dependencies) + || array_key_exists('stellarwp/foundation', $dependencies); + } +} diff --git a/src/Cli/Generation/Php/PhpSourceEditor.php b/src/Cli/Generation/Php/PhpSourceEditor.php new file mode 100644 index 0000000..71b6a9c --- /dev/null +++ b/src/Cli/Generation/Php/PhpSourceEditor.php @@ -0,0 +1,551 @@ +parse($contents) !== null; + } + + public function hasImport(string $contents, string $fullyQualifiedClass): bool { + $target = trim($fullyQualifiedClass, '\\'); + $alias = basename(str_replace('\\', '/', $target)); + + foreach ($this->imports($contents) as $import) { + if ($import['class'] === $target && $import['alias'] === $alias) { + return true; + } + } + + return false; + } + + public function hasImportShortNameCollision(string $contents, string $class, string $fullyQualifiedClass): bool { + $target = trim($fullyQualifiedClass, '\\'); + + foreach ($this->imports($contents) as $import) { + if ($import['alias'] === $class && $import['class'] !== $target) { + return true; + } + } + + return false; + } + + public function hasLineComment(string $contents, string $comment): bool { + return $this->lineComment($contents, $comment) !== null; + } + + public function addImport(string $contents, string $fullyQualifiedClass): ?string { + if ($this->hasImport($contents, $fullyQualifiedClass)) { + return $contents; + } + + $offset = $this->importInsertionOffset($contents); + + if ($offset === null) { + return null; + } + + $import = 'use ' . trim($fullyQualifiedClass, '\\') . ';'; + + return substr($contents, 0, $offset) . $this->importSeparator($contents, $offset) . $import . substr($contents, $offset); + } + + public function insertBeforeLineComment(string $contents, string $comment, string $statement): ?string { + $lineComment = $this->lineComment($contents, $comment); + + if ($lineComment === null) { + return null; + } + + return substr($contents, 0, $lineComment->lineStartOffset) + . $lineComment->indent . $statement . "\n" + . substr($contents, $lineComment->lineStartOffset); + } + + public function canInsertIntoMergeArrayVar(string $contents, string $class, string $constant, ?string $beforeComment = null): bool { + $target = $this->mergeArrayVarTarget($contents, $class, $constant); + + if ($target === null) { + return false; + } + + $insertion = $beforeComment === null ? null : $this->lineComment($contents, $beforeComment, $target->registrationList); + $insertion ??= $this->arrayInsertion($contents, $target->registrationList); + + return $insertion !== null; + } + + public function mergeArrayVarContainerExpression(string $contents, string $class, string $constant): ?string { + return $this->mergeArrayVarTarget($contents, $class, $constant)?->containerExpression; + } + + public function insertIntoMergeArrayVar(string $contents, string $class, string $constant, string $statement, ?string $beforeComment = null): ?string { + $target = $this->mergeArrayVarTarget($contents, $class, $constant); + + if ($target === null) { + return null; + } + + $insertion = $beforeComment === null ? null : $this->lineComment($contents, $beforeComment, $target->registrationList); + $insertion ??= $this->arrayInsertion($contents, $target->registrationList); + + if ($insertion === null) { + return null; + } + + return substr($contents, 0, $insertion->lineStartOffset) + . $insertion->indent . $statement . "\n" + . substr($contents, $insertion->lineStartOffset); + } + + /** + * @return list + */ + private function imports(string $contents): array { + $statements = $this->parse($contents); + + if ($statements === null) { + return []; + } + + $imports = []; + + foreach ($this->topLevelStatements($statements) as $statement) { + if ($statement instanceof Stmt\Use_) { + $imports = array_merge($imports, $this->useImports($statement->uses, $statement->type)); + } + + if ($statement instanceof Stmt\GroupUse) { + $imports = array_merge($imports, $this->groupUseImports($statement)); + } + } + + return $imports; + } + + /** + * @param array $uses + * + * @return list + */ + private function useImports(array $uses, int $type): array { + if ($type !== Stmt\Use_::TYPE_NORMAL) { + return []; + } + + $imports = []; + + foreach ($uses as $use) { + $imports[] = [ + 'class' => $use->name->toString(), + 'alias' => $use->getAlias()->toString(), + ]; + } + + return $imports; + } + + /** + * @return list + */ + private function groupUseImports(Stmt\GroupUse $groupUse): array { + $imports = []; + $prefix = $groupUse->prefix->toString(); + + foreach ($groupUse->uses as $use) { + $type = $use->type === Stmt\Use_::TYPE_UNKNOWN ? $groupUse->type : $use->type; + + if ($type !== Stmt\Use_::TYPE_NORMAL) { + continue; + } + + $imports[] = [ + 'class' => $prefix . '\\' . $use->name->toString(), + 'alias' => $use->getAlias()->toString(), + ]; + } + + return $imports; + } + + private function importInsertionOffset(string $contents): ?int { + $statements = $this->parse($contents); + + if ($statements === null) { + return null; + } + + $lastUse = null; + + foreach ($this->topLevelStatements($statements) as $statement) { + if ($statement instanceof Stmt\Use_ || $statement instanceof Stmt\GroupUse) { + $lastUse = $statement; + } + } + + if ($lastUse instanceof Node) { + return $this->lineEndOffset($contents, $lastUse->getEndFilePos() + 1); + } + + foreach ($statements as $statement) { + if ($statement instanceof Stmt\Namespace_) { + return $this->namespaceDeclarationEndOffset($contents, $statement); + } + } + + foreach ($statements as $statement) { + if ($statement instanceof Stmt\Declare_) { + return $statement->getEndFilePos() + 1; + } + } + + return $this->openingTagEndOffset($contents); + } + + private function namespaceDeclarationEndOffset(string $contents, Stmt\Namespace_ $namespace): ?int { + foreach ($this->lexer->tokenize($contents) as $token) { + if ($token->pos < $namespace->getStartFilePos()) { + continue; + } + + if ($token->id === ord(';') || $token->id === ord('{')) { + return $token->getEndPos(); + } + } + + return null; + } + + private function openingTagEndOffset(string $contents): int { + foreach ($this->lexer->tokenize($contents) as $token) { + if ($token->id === T_OPEN_TAG) { + return $token->getEndPos(); + } + } + + return 0; + } + + private function importSeparator(string $contents, int $offset): string { + $before = substr($contents, 0, $offset); + + if (preg_match('/^use\s/m', $before) === 1) { + return "\n"; + } + + return "\n\n"; + } + + private function lineComment(string $contents, string $comment, ?Node $within = null): ?LineComment { + foreach ($this->lexer->tokenize($contents) as $token) { + if (! $token->is(T_COMMENT) || trim($token->text) !== $comment) { + continue; + } + + if ($within !== null && ($token->pos < $within->getStartFilePos() || $token->pos > $within->getEndFilePos())) { + continue; + } + + $lineStartOffset = $this->lineStartOffset($contents, $token->pos); + $indent = substr($contents, $lineStartOffset, $token->pos - $lineStartOffset); + + if (trim($indent) !== '') { + continue; + } + + return new LineComment( + indent: $indent, + lineStartOffset: $lineStartOffset, + commentStartOffset: $token->pos + ); + } + + return null; + } + + private function mergeArrayVarTarget(string $contents, string $class, string $constant): ?MergeArrayVarTarget { + $statements = $this->parse($contents); + + if ($statements === null) { + return null; + } + + $aliases = $this->classAliases($contents, $class); + $call = $this->findNode($statements, fn (Node $node): bool => $this->isMergeArrayVarCall($node, $class, $constant, $aliases)); + + if (! $call instanceof Expr\MethodCall) { + return null; + } + + return $this->mergeArrayVarTargetFromCall($call); + } + + /** + * @return list + */ + private function classAliases(string $contents, string $class): array { + $class = trim($class, '\\'); + $aliases = []; + + foreach ($this->imports($contents) as $import) { + if ($import['class'] === $class || str_ends_with($import['class'], '\\' . $class)) { + $aliases[] = $import['alias']; + } + } + + return $aliases; + } + + /** + * @param list $aliases + */ + private function isMergeArrayVarCall(Node $node, string $class, string $constant, array $aliases): bool { + if (! $node instanceof Expr\MethodCall || ! $node->name instanceof Node\Identifier || $node->name->toString() !== 'mergeArrayVar') { + return false; + } + + if (! $this->isThisContainer($node->var)) { + return false; + } + + $firstArgument = $node->args[0]->value ?? null; + + if (! $firstArgument instanceof Expr\ClassConstFetch || ! $firstArgument->class instanceof Node\Name || ! $firstArgument->name instanceof Node\Identifier) { + return false; + } + + if ($firstArgument->name->toString() !== $constant) { + return false; + } + + $referencedClass = $firstArgument->class->toString(); + + if (str_contains($referencedClass, '\\')) { + $referencedClass = trim($referencedClass, '\\'); + + return $referencedClass === $class || str_ends_with($referencedClass, '\\' . $class); + } + + return in_array($referencedClass, $aliases, true); + } + + private function isThisContainer(Node $node): bool { + return $node instanceof Expr\PropertyFetch + && $node->var instanceof Expr\Variable + && $node->var->name === 'this' + && $node->name instanceof Node\Identifier + && $node->name->toString() === 'container'; + } + + private function mergeArrayVarTargetFromCall(Expr\MethodCall $call): ?MergeArrayVarTarget { + $callback = $call->args[1]->value ?? null; + + if ($callback instanceof Expr\Array_) { + return new MergeArrayVarTarget( + registrationList: $callback, + containerExpression: '$this->container' + ); + } + + if ($callback instanceof Expr\ArrowFunction && $callback->expr instanceof Expr\Array_) { + $containerExpression = $this->callbackContainerExpression($callback); + + if ($containerExpression === null) { + return null; + } + + return new MergeArrayVarTarget( + registrationList: $callback->expr, + containerExpression: $containerExpression + ); + } + + if (! $callback instanceof Expr\Closure) { + return null; + } + + foreach ($callback->stmts as $statement) { + if ($statement instanceof Stmt\Return_ && $statement->expr instanceof Expr\Array_) { + $containerExpression = $this->callbackContainerExpression($callback); + + if ($containerExpression === null) { + return null; + } + + return new MergeArrayVarTarget( + registrationList: $statement->expr, + containerExpression: $containerExpression + ); + } + } + + return null; + } + + private function callbackContainerExpression(Expr\Closure|Expr\ArrowFunction $callback): ?string { + $parameter = $callback->params[0] ?? null; + + if ($parameter === null || ! $parameter->var instanceof Expr\Variable || ! is_string($parameter->var->name)) { + return null; + } + + return '$' . $parameter->var->name; + } + + private function arrayInsertion(string $contents, Expr\Array_ $array): ?LineInsertion { + $indent = null; + + if ($array->items !== []) { + $firstItem = $array->items[0]; + $lineStartOffset = $this->lineStartOffset($contents, $firstItem->getStartFilePos()); + $firstItemIndent = substr($contents, $lineStartOffset, $firstItem->getStartFilePos() - $lineStartOffset); + + if (trim($firstItemIndent) === '') { + $indent = $firstItemIndent; + } + } + + $closingOffset = $array->getEndFilePos(); + $lineStartOffset = $this->lineStartOffset($contents, $closingOffset); + $closingIndent = substr($contents, $lineStartOffset, $closingOffset - $lineStartOffset); + + if (trim($closingIndent) !== '') { + return null; + } + + return new LineInsertion( + lineStartOffset: $lineStartOffset, + indent: $indent ?? $this->childIndent($closingIndent) + ); + } + + private function childIndent(string $indent): string { + if ($indent === '' || str_contains($indent, "\t")) { + return $indent . "\t"; + } + + return $indent . ' '; + } + + /** + * @param array $statements + * @param callable(Node): bool $predicate + */ + private function findNode(array $statements, callable $predicate): ?Node { + foreach ($statements as $statement) { + $match = $this->findMatchingNode($statement, $predicate); + + if ($match instanceof Node) { + return $match; + } + } + + return null; + } + + /** + * @param callable(Node): bool $predicate + */ + private function findMatchingNode(Node $node, callable $predicate): ?Node { + if ($predicate($node)) { + return $node; + } + + foreach ($node->getSubNodeNames() as $name) { + $value = $node->{$name}; + + if ($value instanceof Node) { + $match = $this->findMatchingNode($value, $predicate); + + if ($match instanceof Node) { + return $match; + } + } + + if (is_array($value)) { + foreach ($value as $item) { + if (! $item instanceof Node) { + continue; + } + + $match = $this->findMatchingNode($item, $predicate); + + if ($match instanceof Node) { + return $match; + } + } + } + } + + return null; + } + + private function lineStartOffset(string $contents, int $offset): int { + $previousNewline = strrpos(substr($contents, 0, $offset), "\n"); + + if ($previousNewline === false) { + return 0; + } + + return $previousNewline + 1; + } + + private function lineEndOffset(string $contents, int $offset): int { + $nextNewline = strpos($contents, "\n", $offset); + + if ($nextNewline === false) { + return strlen($contents); + } + + return $nextNewline; + } + + /** + * @param array $statements + * + * @return array + */ + private function topLevelStatements(array $statements): array { + foreach ($statements as $statement) { + if ($statement instanceof Stmt\Namespace_) { + return $statement->stmts; + } + } + + return $statements; + } + + /** + * @return array|null + */ + private function parse(string $contents): ?array { + try { + return $this->parserFactory->createForNewestSupportedVersion()->parse($contents); + } catch (Error) { + return null; + } + } +} diff --git a/src/Cli/Generation/Php/ValueObjects/LineComment.php b/src/Cli/Generation/Php/ValueObjects/LineComment.php new file mode 100644 index 0000000..65a9c82 --- /dev/null +++ b/src/Cli/Generation/Php/ValueObjects/LineComment.php @@ -0,0 +1,16 @@ +classNameFromWords($input); + } + public function commandClass(string $input): string { $words = $this->words($input); @@ -33,6 +37,42 @@ public function commandClass(string $input): string { return $className; } + public function tableClass(string $input): string { + $words = $this->words($input); + + if ($words === []) { + throw new RuntimeException(sprintf('Could not create a table class name from "%s".', $input)); + } + + if (strtolower((string) end($words)) !== 'table') { + $words[] = 'table'; + } + + return $this->validClassName(implode('_', array_map($this->pascalWord(...), $words)), $input); + } + + public function tableName(string $className): string { + $words = $this->words((string) preg_replace('/_?Table$/', '', $className)); + + if ($words === []) { + return strtolower($className); + } + + return implode('_', array_map(strtolower(...), $words)); + } + + public function migrationId(string $className, ?\DateTimeImmutable $now = null): string { + $words = $this->words($className); + + if ($words === []) { + throw new RuntimeException(sprintf('Could not create a migration id from "%s".', $className)); + } + + $now ??= new \DateTimeImmutable(); + + return $now->format('Y_m_d_His') . '_' . implode('_', array_map(strtolower(...), $words)); + } + public function subcommand(string $className): string { $words = $this->words((string) preg_replace('/_?Command$/', '', $className)); @@ -49,6 +89,16 @@ public function description(string $className): string { return ucfirst(implode(' ', array_map(strtolower(...), $words))) . '.'; } + private function classNameFromWords(string $input): string { + $words = $this->words($input); + + if ($words === []) { + throw new RuntimeException(sprintf('Could not create a class name from "%s".', $input)); + } + + return $this->validClassName(implode('_', array_map($this->pascalWord(...), $words)), $input); + } + /** * @return list */ @@ -69,4 +119,12 @@ private function pascalWord(string $word): string { return ucfirst($word); } + + private function validClassName(string $className, string $input): string { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $className)) { + throw new RuntimeException(sprintf('Could not create a valid PHP class name from "%s".', $input)); + } + + return $className; + } } diff --git a/src/Cli/README.md b/src/Cli/README.md index 6f331c9..415070c 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -35,9 +35,23 @@ Foundation CLI includes generators for packages that own generated class shapes. vendor/bin/foundation make:wpcli-command Sync_Products_Command ``` +The Database package provides generators for a feature provider, table definitions, and migrations: + +```bash +vendor/bin/foundation make:database-provider +vendor/bin/foundation make:database-table Reports_Table +vendor/bin/foundation make:database-migration Create_Reports_Table +``` + +Generated database providers, tables, and migrations require `stellarwp/foundation-database` as a normal runtime dependency when they ship with the project: + +```bash +composer require stellarwp/foundation-database +``` + Do not add `StellarWP\Foundation\Cli\CliProvider` to the consuming WordPress plugin's provider list. That provider only boots the Foundation Symfony Console application for the `foundation` binary. Register generated WP-CLI commands from the plugin's own WP-CLI provider using `stellarwp/foundation-wpcli`. -See the WPCli package README for WP-CLI generator behavior, options, and stub overrides. +See the WPCli and Database package READMEs for generator behavior, options, and stub overrides. ## Foundation Monorepo Maintenance diff --git a/src/Cli/composer.json b/src/Cli/composer.json index 76a910a..f9e8d2f 100644 --- a/src/Cli/composer.json +++ b/src/Cli/composer.json @@ -9,7 +9,9 @@ }, "require": { "php": ">=8.3", + "nikic/php-parser": ">=5.0 <6.0", "stellarwp/foundation-container": "^1.4", + "stellarwp/foundation-database": "^1.4", "stellarwp/foundation-wpcli": "^1.4", "symfony/console": ">=5.4" }, diff --git a/src/Container/ContainerAdapter.php b/src/Container/ContainerAdapter.php index 96bce13..613a689 100644 --- a/src/Container/ContainerAdapter.php +++ b/src/Container/ContainerAdapter.php @@ -90,11 +90,6 @@ public function give(mixed $implementation): void { $this->container->give($implementation); } - public function instance(mixed $id, array $buildArgs = [], ?array $afterBuildMethods = null): Closure { - // @phpstan-ignore-next-line invalid DocBlock comments in DI52 - return $this->container->instance($id, $buildArgs, $afterBuildMethods); - } - /** * {@inheritDoc} * @@ -104,6 +99,11 @@ public function mergeArrayVar(string $id, mixed $implementation): void { $this->container->mergeArrayVar($id, $implementation); } + public function instance(mixed $id, array $buildArgs = [], ?array $afterBuildMethods = null): Closure { + // @phpstan-ignore-next-line invalid DocBlock comments in DI52 + return $this->container->instance($id, $buildArgs, $afterBuildMethods); + } + /** * @param class-string|string|object $id * diff --git a/src/Database/.gitattributes b/src/Database/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Database/.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/Database/.github/workflows/close-pull-request.yml b/src/Database/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/Database/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Database/.gitignore b/src/Database/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Database/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Database/Cli/Migrate.php b/src/Database/Cli/Migrate.php new file mode 100644 index 0000000..8f54aa2 --- /dev/null +++ b/src/Database/Cli/Migrate.php @@ -0,0 +1,198 @@ +container, $commandPrefix); + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function runCommand(array $args = [], array $assocArgs = []): int { + $run = (bool) get_flag_value($assocArgs, self::FLAG_RUN, false); + $rollback = (bool) get_flag_value($assocArgs, self::FLAG_ROLLBACK, false); + $refresh = (bool) get_flag_value($assocArgs, self::FLAG_REFRESH, false); + $drop = (bool) get_flag_value($assocArgs, self::FLAG_DROP, false); + $prepare = (bool) get_flag_value($assocArgs, self::FLAG_PREPARE, false); + $createTable = (bool) get_flag_value($assocArgs, self::FLAG_CREATE_TABLE, false); + + if (! $this->hasSingleOperation([ + self::FLAG_RUN => $run, + self::FLAG_ROLLBACK => $rollback, + self::FLAG_REFRESH => $refresh, + self::FLAG_DROP => $drop, + self::FLAG_PREPARE => $prepare || $createTable, + ])) { + return self::ERROR; + } + + if ($drop) { + WP_CLI::confirm('Are you sure you want to drop the Foundation database tables? This cannot be undone.', $assocArgs); + $this->migrator->drop(); + WP_CLI::success('Foundation database tables were dropped.'); + + return self::SUCCESS; + } + + if ($prepare || $createTable) { + $this->migrator->prepare(); + WP_CLI::success('Foundation database tables are ready.'); + + return self::SUCCESS; + } + + if ($refresh) { + 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))); + + return self::SUCCESS; + } + + if ($rollback) { + $result = $this->migrator->rollback(); + WP_CLI::success(sprintf('Rolled back %d migrations.', count($result->rolledBack))); + + return self::SUCCESS; + } + + if ($run) { + $result = $this->migrator->run(); + WP_CLI::success(sprintf('Ran %d migrations.', count($result->ran))); + + return self::SUCCESS; + } + + $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, + 'description' => 'Drop Foundation database tables.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_PREPARE, + 'description' => 'Prepare Foundation migration storage without running migrations.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_CREATE_TABLE, + 'description' => 'Alias for --prepare.', + '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->exists()) { + WP_CLI::warning('The Foundation database tables do not exist. Run this command with --prepare or --run.'); + } + + format_items('table', array_map( + static fn ($status): array => [ + 'migration' => $status->migration, + 'status' => $status->ran ? 'ran' : 'pending', + 'batch' => $status->batch ?? '', + 'ran_at' => $status->ranAt?->format('Y-m-d H:i:s') ?? '', + ], + $this->migrator->status() + ), [ + 'migration', + 'status', + 'batch', + 'ran_at', + ]); + } + + /** + * @param array $operations + */ + private function hasSingleOperation(array $operations): bool { + $selected = array_keys(array_filter($operations)); + + if (count($selected) <= 1) { + return true; + } + + WP_CLI::error(sprintf( + 'Only one migration operation can be used at a time. Received: --%s.', + implode(', --', $selected) + ), false); + + return false; + } +} diff --git a/src/Database/Contracts/Database.php b/src/Database/Contracts/Database.php new file mode 100644 index 0000000..8cc0027 --- /dev/null +++ b/src/Database/Contracts/Database.php @@ -0,0 +1,59 @@ +|null + */ + public function row(string $sql, mixed ...$bindings): ?array; + + /** + * @return list> + */ + public function rows(string $sql, mixed ...$bindings): array; + + public function value(string $sql, mixed ...$bindings): mixed; + + public function execute(string $sql, mixed ...$bindings): int; + + /** + * @param array $data + */ + public function insert(Table|string $table, array $data): int; + + /** + * @param array $data + * @param array $where + */ + public function update(Table|string $table, array $data, array $where): int; + + /** + * @param array $where + */ + public function delete(Table|string $table, array $where): int; + + public function quoteIdentifier(string $identifier): string; + + public function escLike(string $value): string; + + public function charsetCollate(): string; +} diff --git a/src/Database/Contracts/Migration.php b/src/Database/Contracts/Migration.php new file mode 100644 index 0000000..488938a --- /dev/null +++ b/src/Database/Contracts/Migration.php @@ -0,0 +1,24 @@ + + */ + public function all(): array; + + public function hasRun(string $migration): bool; + + public function recordRun(string $migration, int $batch): Record; + + public function deleteRun(string $migration): bool; + + public function nextBatch(): int; + + public function latestBatch(): ?int; + + /** + * @return list + */ + public function recordsForBatch(int $batch): array; +} diff --git a/src/Database/Contracts/Schema.php b/src/Database/Contracts/Schema.php new file mode 100644 index 0000000..a755ab6 --- /dev/null +++ b/src/Database/Contracts/Schema.php @@ -0,0 +1,32 @@ +name(); + } + + if (str_starts_with($table, $this->wpdb->prefix)) { + return $table; + } + + return $this->wpdb->prefix . $table; + } + + public function tableExists(Table|string $table): bool { + $tableName = $this->tableName($table); + + return $this->row( + 'SHOW TABLES LIKE %s', + $this->escLike($tableName) + ) !== null; + } + + public function columnExists(Table|string $table, string $column): bool { + return $this->row( + 'SHOW COLUMNS FROM %i LIKE %s', + $this->tableName($table), + $this->escLike($column) + ) !== null; + } + + public function indexExists(Table|string $table, string $index): bool { + return $this->row( + 'SHOW INDEX FROM %i WHERE Key_name = %s', + $this->tableName($table), + $index + ) !== null; + } + + public function prepare(string $sql, mixed ...$bindings): string { + if ($bindings === []) { + return $sql; + } + + $bindings = array_values($bindings); + $prepared = $this->prepareWithWpdb($sql, $bindings); + + if (! is_string($prepared) || $prepared === '') { + throw new QueryException('Unable to prepare SQL statement.', $sql, $bindings, $this->lastError()); + } + + return $prepared; + } + + /** + * @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); + + if ($result === null) { + $this->throwIfLastError('Unable to retrieve database row.', $sql, $bindings); + + return null; + } + + return $this->stringKeyedRow($result, $sql, $bindings); + } + + /** + * @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); + + if ($results === null) { + $this->throwIfLastError('Unable to retrieve database rows.', $sql, $bindings); + + return []; + } + + $rows = []; + + foreach ($results as $result) { + $rows[] = $this->stringKeyedRow($result, $sql, $bindings); + } + + return $rows; + } + + public function value(string $sql, mixed ...$bindings): mixed { + $bindings = array_values($bindings); + $query = $this->prepare($sql, ...$bindings); + $result = $this->wpdb->get_var($query); + + if ($result === null) { + $this->throwIfLastError('Unable to retrieve database value.', $sql, $bindings); + } + + return $result; + } + + 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; + } + + /** + * @param array $data + */ + public function insert(Table|string $table, array $data): int { + $result = $this->wpdb->insert($this->tableName($table), $data); + + if ($result === false) { + throw new QueryException($this->message('Unable to insert database row.'), 'INSERT', [], $this->lastError()); + } + + return (int) $this->wpdb->insert_id; + } + + /** + * @param array $data + * @param array $where + */ + public function update(Table|string $table, array $data, array $where): int { + $result = $this->wpdb->update($this->tableName($table), $data, $where); + + if ($result === false) { + throw new QueryException($this->message('Unable to update database rows.'), 'UPDATE', [], $this->lastError()); + } + + return (int) $result; + } + + /** + * @param array $where + */ + public function delete(Table|string $table, array $where): int { + $result = $this->wpdb->delete($this->tableName($table), $where); + + if ($result === false) { + throw new QueryException($this->message('Unable to delete database rows.'), 'DELETE', [], $this->lastError()); + } + + return (int) $result; + } + + public function quoteIdentifier(string $identifier): string { + return '`' . str_replace('`', '``', $identifier) . '`'; + } + + public function escLike(string $value): string { + return $this->wpdb->esc_like($value); + } + + public function charsetCollate(): string { + return $this->wpdb->get_charset_collate(); + } + + /** + * @param list $bindings + */ + private function throwIfLastError(string $fallback, string $sql, array $bindings): void { + $error = $this->lastError(); + + if ($error !== null) { + throw new QueryException($error, $sql, $bindings, $error); + } + } + + private function message(string $fallback): string { + return $this->lastError() ?? $fallback; + } + + private function lastError(): ?string { + $error = $this->wpdb->last_error; + + return $error !== '' ? $error : null; + } + + /** + * @param list $bindings + */ + private function prepareWithWpdb(string $sql, array $bindings): mixed { + $method = 'prepare'; + + return call_user_func_array([$this->wpdb, $method], array_merge([$sql], $bindings)); + } + + /** + * @param array $result + * @param list $bindings + * + * @return array + */ + private function stringKeyedRow(array $result, string $sql, array $bindings): 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 new file mode 100644 index 0000000..ce4117d --- /dev/null +++ b/src/Database/DatabaseProvider.php @@ -0,0 +1,146 @@ +registerConfiguration(); + $this->registerDatabase(); + $this->registerTables(); + $this->registerMigrations(); + $this->registerLocks(); + $this->registerCliCommands(); + } + + private function registerConfiguration(): void { + $this->container->mergeArrayVar(self::MIGRATIONS, []); + $this->container->singleton(self::MIGRATIONS_TABLE, $this->tableName('migrations_table', 'nexcess_foundation_migrations')); + $this->container->singleton(self::LOCKS_TABLE, $this->tableName('locks_table', 'nexcess_foundation_locks')); + $this->container->singleton(self::LOCK_NAME, $this->config->get('database.lock_name', 'foundation-database-migrations')); + $this->container->singleton(self::LOCK_TTL, (int) $this->config->get('database.lock_ttl', 300)); + } + + private function registerDatabase(): void { + $this->container->singleton(Database::class, static function (): Database { + $wpdb = $GLOBALS['wpdb'] ?? null; + + if (! $wpdb instanceof \wpdb) { + throw new DatabaseException('The global wpdb instance is not available.'); + } + + return new Database($wpdb); + }); + $this->container->singleton(DatabaseContract::class, static fn (C $c): Database => $c->get(Database::class)); + $this->container->singleton(Schema::class, static fn (C $c): Schema => new Schema($c->get(DatabaseContract::class))); + $this->container->singleton(SchemaContract::class, static fn (C $c): Schema => $c->get(Schema::class)); + } + + private function registerTables(): void { + $this->container->when(MigrationTable::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); + + $this->container->when(LockTable::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::LOCKS_TABLE)); + + $this->container->when(Collection::class) + ->needs('$tables') + ->give(static fn (C $c): array => [ + $c->get(MigrationTable::class), + $c->get(LockTable::class), + ]); + + $this->container->singleton(MigrationTable::class); + $this->container->singleton(LockTable::class); + $this->container->singleton(Collection::class); + } + + private function registerMigrations(): void { + $this->container->when(MigrationCollection::class) + ->needs('$migrations') + ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); + + $this->container->when(MigrationRecordRepository::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); + + $this->container->when(Runner::class) + ->needs('$lockName') + ->give(static fn (C $c): string => $c->get(self::LOCK_NAME)); + + $this->container->when(Runner::class) + ->needs('$lockTtl') + ->give(static fn (C $c): int => $c->get(self::LOCK_TTL)); + + $this->container->when(Runner::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(Runner::class); + $this->container->singleton(Store::class); + $this->container->singleton(Migrator::class); + } + + private function registerLocks(): void { + $this->container->when(DatabaseLock::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::LOCKS_TABLE)); + + $this->container->singleton(DatabaseLock::class); + } + + private function registerCliCommands(): void { + $this->container->when(Migrate::class) + ->needs('$commandPrefix') + ->give(static fn (C $c): string => $c->get(WPCliProvider::COMMAND_PREFIX)); + + $this->container->singleton(Migrate::class); + + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $c->get(Migrate::class), + ]); + } + + private function tableName(string $key, string $default): mixed { + $configured = $this->config->get('database.' . $key); + + if (is_string($configured) && $configured !== '') { + return $configured; + } + + return static fn (C $c): string => $c->get(DatabaseContract::class)->tableName($default); + } +} diff --git a/src/Database/DatabaseStubPath.php b/src/Database/DatabaseStubPath.php new file mode 100644 index 0000000..43031cc --- /dev/null +++ b/src/Database/DatabaseStubPath.php @@ -0,0 +1,25 @@ + $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); + } + + public function sql(): string { + return $this->sql; + } + + /** + * @return list + */ + public function bindings(): array { + return $this->bindings; + } + + public function databaseError(): ?string { + return $this->databaseError; + } +} diff --git a/src/Database/Lock/DatabaseLock.php b/src/Database/Lock/DatabaseLock.php new file mode 100644 index 0000000..9216170 --- /dev/null +++ b/src/Database/Lock/DatabaseLock.php @@ -0,0 +1,146 @@ +assertValidName($name); + $this->assertValidTtl($ttl); + + $owner = bin2hex(random_bytes(16)); + $now = $this->format($this->clock->now()); + $expiresAt = $this->expiresAt($ttl); + + $this->database->execute( + 'INSERT INTO %i (name, owner, expires_at, created_at, updated_at) + VALUES (%s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + owner = IF(expires_at <= %s, VALUES(owner), owner), + updated_at = IF(expires_at <= %s, VALUES(updated_at), updated_at), + expires_at = IF(expires_at <= %s, VALUES(expires_at), expires_at)', + $this->database->tableName($this->table), + $name, + $owner, + $this->format($expiresAt), + $now, + $now, + $now, + $now, + $now + ); + + $row = $this->database->row( + 'SELECT owner, expires_at FROM %i WHERE name = %s LIMIT 1', + $this->database->tableName($this->table), + $name + ); + + if ($row === null || ($row['owner'] ?? '') !== $owner) { + return null; + } + + return new LockToken( + name: $name, + owner: $owner, + expiresAt: new DateTimeImmutable((string) $row['expires_at']) + ); + } + + public function release(LockToken $token): bool { + return $this->database->execute( + 'DELETE FROM %i WHERE name = %s AND owner = %s AND expires_at > %s', + $this->database->tableName($this->table), + $token->name, + $token->owner, + $this->format($this->clock->now()) + ) > 0; + } + + /** + * @throws DateMalformedIntervalStringException + */ + public function refresh(LockToken $token, int $ttl): ?LockToken { + $this->assertValidTtl($ttl); + + $expiresAt = $this->expiresAt($ttl); + $updated = $this->database->execute( + 'UPDATE %i SET expires_at = %s, updated_at = %s WHERE name = %s AND owner = %s AND expires_at > %s', + $this->database->tableName($this->table), + $this->format($expiresAt), + $this->format($this->clock->now()), + $token->name, + $token->owner, + $this->format($this->clock->now()) + ); + + if ($updated < 1) { + return null; + } + + return $token->refresh($expiresAt); + } + + public function isAcquired(string $name): bool { + $this->assertValidName($name); + + return $this->database->row( + 'SELECT name FROM %i WHERE name = %s AND expires_at > %s LIMIT 1', + $this->database->tableName($this->table), + $name, + $this->format($this->clock->now()) + ) !== null; + } + + /** + * @throws DateMalformedIntervalStringException + */ + private function expiresAt(int $ttl): DateTimeImmutable { + $this->assertValidTtl($ttl); + + return $this->clock->now()->add(new DateInterval(sprintf('PT%dS', $ttl))); + } + + private function assertValidTtl(int $ttl): void { + if ($ttl < 1) { + throw new InvalidArgumentException('Lock TTL must be greater than zero seconds.'); + } + } + + private function assertValidName(string $name): void { + if (trim($name) === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + } + + private function format(DateTimeImmutable $date): string { + return $date->format('Y-m-d H:i:s'); + } +} diff --git a/src/Database/Migration/Collection.php b/src/Database/Migration/Collection.php new file mode 100644 index 0000000..e7d9e53 --- /dev/null +++ b/src/Database/Migration/Collection.php @@ -0,0 +1,62 @@ + + */ +final class Collection implements IteratorAggregate +{ + /** + * @var list + */ + private array $migrations = []; + + /** + * @param iterable $migrations + */ + public function __construct( + iterable $migrations = [] + ) { + foreach ($migrations as $migration) { + $this->add($migration); + } + } + + /** + * @throws DuplicateMigration + */ + public function add(Migration ...$migrations): void { + foreach ($migrations as $migration) { + foreach ($this->migrations as $registered) { + if ($registered->id() === $migration->id()) { + throw DuplicateMigration::forMigration($migration->id()); + } + } + + $this->migrations[] = $migration; + } + } + + /** + * @return list + */ + public function all(): array { + return $this->migrations; + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable { + return new ArrayIterator($this->migrations); + } +} diff --git a/src/Database/Migration/Migrator.php b/src/Database/Migration/Migrator.php new file mode 100644 index 0000000..ee0eef6 --- /dev/null +++ b/src/Database/Migration/Migrator.php @@ -0,0 +1,87 @@ +store->prepare(); + } + + /** + * Drop the migration subsystem storage. + */ + public function drop(): void { + $this->store->drop(); + } + + /** + * Determine whether the migration subsystem storage is ready. + */ + public function exists(): bool { + return $this->store->exists(); + } + + /** + * Run all pending configured migrations. + */ + public function run(): Result { + return $this->withPreparedStore(fn (): Result => $this->runner->run($this->migrations)); + } + + /** + * Roll back the latest configured migration batch. + */ + public function rollback(?int $batch = null): Result { + return $this->withPreparedStore(fn (): Result => $this->runner->rollback($this->migrations, $batch)); + } + + /** + * Roll back and rerun all configured migrations. + */ + public function refresh(): Result { + return $this->withPreparedStore(fn (): Result => $this->runner->refresh($this->migrations)); + } + + /** + * @return list + */ + public function status(): array { + if (! $this->store->exists()) { + return array_map( + static fn (Migration $migration): Status => Status::pending($migration->id()), + $this->migrations->all() + ); + } + + return $this->runner->status($this->migrations); + } + + /** + * @template T + * + * @param callable(): T $callback + * + * @return T + */ + private function withPreparedStore(callable $callback): mixed { + $this->store->prepare(); + + return $callback(); + } +} diff --git a/src/Database/Migration/Record.php b/src/Database/Migration/Record.php new file mode 100644 index 0000000..21beef3 --- /dev/null +++ b/src/Database/Migration/Record.php @@ -0,0 +1,19 @@ + + */ + public function all(): array { + $records = []; + + foreach ($this->database->rows(sprintf( + 'SELECT id, migration, batch, ran_at FROM %s ORDER BY id ASC', + $this->database->quoteIdentifier($this->database->tableName($this->table)) + )) as $row) { + $record = $this->recordFromRow($row); + + $records[$record->migration] = $record; + } + + return $records; + } + + public function hasRun(string $migration): bool { + return $this->database->row( + 'SELECT id FROM %i WHERE migration = %s LIMIT 1', + $this->database->tableName($this->table), + $migration + ) !== null; + } + + public function recordRun(string $migration, int $batch): Record { + $ranAt = new DateTimeImmutable('now', new DateTimeZone('UTC')); + + $this->database->execute( + 'INSERT INTO %i (migration, batch, ran_at) VALUES (%s, %d, %s)', + $this->database->tableName($this->table), + $migration, + $batch, + $ranAt->format('Y-m-d H:i:s') + ); + + $row = $this->database->row( + 'SELECT id, migration, batch, ran_at FROM %i WHERE migration = %s LIMIT 1', + $this->database->tableName($this->table), + $migration + ); + + if ($row === null) { + return new Record(0, $migration, $batch, $ranAt); + } + + return $this->recordFromRow($row); + } + + public function deleteRun(string $migration): bool { + return $this->database->execute( + 'DELETE FROM %i WHERE migration = %s', + $this->database->tableName($this->table), + $migration + ) > 0; + } + + public function nextBatch(): int { + $latest = $this->latestBatch(); + + return $latest === null ? 1 : $latest + 1; + } + + public function latestBatch(): ?int { + $row = $this->database->row(sprintf( + 'SELECT MAX(batch) AS batch FROM %s', + $this->database->quoteIdentifier($this->database->tableName($this->table)) + )); + + if ($row === null || $row['batch'] === null) { + return null; + } + + return (int) $row['batch']; + } + + /** + * @return list + */ + public function recordsForBatch(int $batch): array { + return array_map( + fn (array $row): Record => $this->recordFromRow($row), + $this->database->rows( + 'SELECT id, migration, batch, ran_at FROM %i WHERE batch = %d ORDER BY id ASC', + $this->database->tableName($this->table), + $batch + ) + ); + } + + /** + * @param array $row + */ + private function recordFromRow(array $row): Record { + return new Record( + id: (int) $row['id'], + migration: (string) $row['migration'], + batch: (int) $row['batch'], + ranAt: new DateTimeImmutable((string) $row['ran_at'], new DateTimeZone('UTC')) + ); + } +} diff --git a/src/Database/Migration/Result.php b/src/Database/Migration/Result.php new file mode 100644 index 0000000..1c5193d --- /dev/null +++ b/src/Database/Migration/Result.php @@ -0,0 +1,25 @@ + $ran + * @param list $rolledBack + * @param list $skipped + */ + public function __construct( + public array $ran = [], + public array $rolledBack = [], + public array $skipped = [] + ) { + } + + public function count(): int { + return count($this->ran) + count($this->rolledBack); + } +} diff --git a/src/Database/Migration/Runner.php b/src/Database/Migration/Runner.php new file mode 100644 index 0000000..6ec2871 --- /dev/null +++ b/src/Database/Migration/Runner.php @@ -0,0 +1,208 @@ + $migrations + */ + public function run(iterable $migrations): Result { + return $this->locked(function () use ($migrations): Result { + $ran = []; + $skipped = []; + $batch = $this->repository->nextBatch(); + $migrations = $this->normalize($migrations); + + foreach ($migrations as $migration) { + if ($this->repository->hasRun($migration->id())) { + $skipped[] = $migration->id(); + continue; + } + + try { + $migration->up($this->schema); + } catch (Throwable $throwable) { + throw MigrationFailed::whileRunning($migration->id(), $throwable); + } + + $this->repository->recordRun($migration->id(), $batch); + $ran[] = $migration->id(); + } + + return new Result(ran: $ran, skipped: $skipped); + }); + } + + /** + * @param iterable $migrations + */ + public function rollback(iterable $migrations, ?int $batch = null): Result { + return $this->locked(function () use ($migrations, $batch): Result { + $batch ??= $this->repository->latestBatch(); + + if ($batch === null) { + return new Result(); + } + + return $this->rollbackRecords( + $this->normalize($migrations), + $this->repository->recordsForBatch($batch) + ); + }); + } + + /** + * @param iterable $migrations + */ + public function refresh(iterable $migrations): Result { + return $this->locked(function () use ($migrations): Result { + $normalized = $this->normalize($migrations); + $rollback = $this->rollbackRecords($normalized, array_values($this->repository->all())); + $run = $this->runWithoutLock($normalized); + + return new Result( + ran: $run->ran, + rolledBack: $rollback->rolledBack, + skipped: $run->skipped + ); + }); + } + + /** + * @param iterable $migrations + * + * @return list + */ + public function status(iterable $migrations): array { + $records = $this->repository->all(); + $statuses = []; + + foreach ($this->normalize($migrations) as $migration) { + $statuses[] = isset($records[$migration->id()]) + ? Status::fromRecord($records[$migration->id()]) + : Status::pending($migration->id()); + } + + return $statuses; + } + + /** + * @param array $migrations + * @param list $records + */ + private function rollbackRecords(array $migrations, array $records): Result { + usort($records, static fn (Record $a, Record $b): int => $b->id <=> $a->id); + + $rolledBack = []; + $skipped = []; + + foreach ($records as $record) { + $migration = $migrations[$record->migration] ?? null; + + if ($migration === null) { + $skipped[] = $record->migration; + continue; + } + + try { + $migration->down($this->schema); + } catch (Throwable $throwable) { + throw MigrationFailed::whileRollingBack($migration->id(), $throwable); + } + + $this->repository->deleteRun($migration->id()); + $rolledBack[] = $migration->id(); + } + + return new Result(rolledBack: $rolledBack, skipped: $skipped); + } + + /** + * @param array $migrations + */ + private function runWithoutLock(array $migrations): Result { + $ran = []; + $skipped = []; + $batch = $this->repository->nextBatch(); + + foreach ($migrations as $migration) { + if ($this->repository->hasRun($migration->id())) { + $skipped[] = $migration->id(); + continue; + } + + try { + $migration->up($this->schema); + } catch (Throwable $throwable) { + throw MigrationFailed::whileRunning($migration->id(), $throwable); + } + + $this->repository->recordRun($migration->id(), $batch); + $ran[] = $migration->id(); + } + + return new Result(ran: $ran, skipped: $skipped); + } + + /** + * @param iterable $migrations + * + * @return array + */ + private function normalize(iterable $migrations): array { + $normalized = []; + + foreach ($migrations as $migration) { + if (isset($normalized[$migration->id()])) { + throw DuplicateMigration::forMigration($migration->id()); + } + + $normalized[$migration->id()] = $migration; + } + + return $normalized; + } + + /** + * @template T + * + * @param callable(): T $callback + * + * @return T + */ + private function locked(callable $callback): mixed { + $token = $this->lock->acquire($this->lockName, $this->lockTtl); + + if ($token === null) { + throw MigrationLockFailed::forLock($this->lockName); + } + + try { + return $callback(); + } finally { + $this->lock->release($token); + } + } +} diff --git a/src/Database/Migration/Status.php b/src/Database/Migration/Status.php new file mode 100644 index 0000000..7e13399 --- /dev/null +++ b/src/Database/Migration/Status.php @@ -0,0 +1,32 @@ +migration, + ran: true, + batch: $record->batch, + ranAt: $record->ranAt + ); + } +} diff --git a/src/Database/Migration/Store.php b/src/Database/Migration/Store.php new file mode 100644 index 0000000..19e2326 --- /dev/null +++ b/src/Database/Migration/Store.php @@ -0,0 +1,37 @@ +tables->create(); + } + + /** + * Drop the migration subsystem tables. + */ + public function drop(): void { + $this->tables->drop(); + } + + /** + * Determine whether the migration subsystem tables are ready. + */ + public function exists(): bool { + return $this->tables->exists(); + } +} diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php new file mode 100644 index 0000000..4278b3a --- /dev/null +++ b/src/Database/Query/Query.php @@ -0,0 +1,54 @@ + $bindings + */ + public function __construct( + private Database $database, + private string $sql, + private array $bindings = [] + ) { + } + + public function toSql(): string { + return $this->sql; + } + + /** + * @return list + */ + public function bindings(): array { + return $this->bindings; + } + + public function toPreparedSql(): string { + return $this->database->prepare($this->sql, ...$this->bindings); + } + + /** + * @return list> + */ + public function get(): array { + return $this->database->rows($this->sql, ...$this->bindings); + } + + /** + * @return array|null + */ + public function first(): ?array { + return $this->database->row($this->sql, ...$this->bindings); + } + + public function value(): mixed { + return $this->database->value($this->sql, ...$this->bindings); + } +} diff --git a/src/Database/Query/QueryBuilder.php b/src/Database/Query/QueryBuilder.php new file mode 100644 index 0000000..b8c90fe --- /dev/null +++ b/src/Database/Query/QueryBuilder.php @@ -0,0 +1,180 @@ + + */ + 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; + + public function __construct( + private readonly Database $database, + private readonly Table|string $table, + private readonly ?string $alias = null + ) { + } + + public function select(string ...$columns): self { + $this->columns = $columns === [] ? ['*'] : array_values($columns); + + return $this; + } + + public function where(string $column, string $operator, mixed $value): self { + $this->where[] = sprintf('%s %s %%s', $this->database->quoteIdentifier($column), $this->operator($operator)); + $this->bindings[] = $value; + + return $this; + } + + 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->database->quoteIdentifier($column), $direction); + + return $this; + } + + 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; + } + + public function query(): Query { + return new Query($this->database, $this->toSql(), $this->bindings()); + } + + 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 list + */ + public function bindings(): array { + $bindings = $this->bindings; + + if ($this->limit !== null) { + $bindings[] = $this->limit; + + if ($this->offset !== null) { + $bindings[] = $this->offset; + } + } + + return $bindings; + } + + public function toPreparedSql(): string { + return $this->database->prepare($this->toSql(), ...$this->bindings()); + } + + /** + * @return list> + */ + public function get(): array { + return $this->queryWithLimitBindings()->get(); + } + + /** + * @return array|null + */ + public function first(): ?array { + return $this->queryWithLimitBindings()->first(); + } + + private function queryWithLimitBindings(): Query { + return new Query($this->database, $this->toSql(), $this->bindings()); + } + + private function selectSql(): string { + if ($this->columns === ['*']) { + return '*'; + } + + return implode(', ', array_map($this->database->quoteIdentifier(...), $this->columns)); + } + + private function aliasSql(): string { + if ($this->alias === null || $this->alias === '') { + return ''; + } + + return ' AS ' . $this->database->quoteIdentifier($this->alias); + } + + 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; + } +} diff --git a/src/Database/README.md b/src/Database/README.md new file mode 100644 index 0000000..9d9c9cc --- /dev/null +++ b/src/Database/README.md @@ -0,0 +1,331 @@ +# Foundation Database + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +## Installation + +```shell +composer require stellarwp/foundation-database +``` + +## Overview + +Foundation Database is a WordPress-backed database package. It provides a configured migrator, migration runner, migration and table collections, `wpdb`/`dbDelta` schema services, a database-backed lock, and a WP-CLI migration command. + +This package intentionally targets WordPress runtime APIs instead of acting as a generic database abstraction. Migration classes depend on a small schema contract so application packages can define migration behavior without calling `wpdb` directly. + +## Registering The Provider + +Register `DatabaseProvider` in the application container when the project needs Foundation-managed migrations: + +```php +use StellarWP\Foundation\Database\DatabaseProvider; + +$container->register(DatabaseProvider::class); +``` + +The provider registers: + +- `StellarWP\Foundation\Database\Database` +- `StellarWP\Foundation\Database\Contracts\Database` +- `StellarWP\Foundation\Database\Schema` +- `StellarWP\Foundation\Database\Table\Collection` +- `StellarWP\Foundation\Database\Table\Tables\MigrationTable` +- `StellarWP\Foundation\Database\Table\Tables\LockTable` +- `StellarWP\Foundation\Database\Contracts\Repository` for the migration ledger +- `StellarWP\Foundation\Database\Migration\Store` +- `StellarWP\Foundation\Database\Migration\Runner` +- `StellarWP\Foundation\Database\Migration\Migrator` +- `StellarWP\Foundation\Database\Lock\DatabaseLock` for the migration runner + +By default, WordPress tables are named: + +- `nexcess_foundation_migrations` +- `nexcess_foundation_locks` + +Configure these through the Foundation config keys `database.migrations_table` and `database.locks_table` when an application needs different table names. Configured table names are treated as exact full table names and are not passed through `Database::tableName()`, so include the WordPress prefix yourself when overriding them. + +Example `config.php` values: + +```php + [ + // Leave empty or omit these keys to use the default WordPress-prefixed names. + 'migrations_table' => $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'] ?? '', + 'locks_table' => $_ENV['FOUNDATION_DATABASE_LOCKS_TABLE'] ?? '', + 'lock_name' => $_ENV['FOUNDATION_DATABASE_LOCK_NAME'] ?? 'foundation-database-migrations', + 'lock_ttl' => (int) ($_ENV['FOUNDATION_DATABASE_LOCK_TTL'] ?? 300), + ], + 'wpcli' => [ + 'command_prefix' => $_ENV['FOUNDATION_WPCLI_COMMAND_PREFIX'] ?? 'nx', + ], +]; +``` + +If overriding table names, provide the full table name: + +```php +return [ + 'database' => [ + 'migrations_table' => 'wp_custom_foundation_migrations', + 'locks_table' => 'wp_custom_foundation_locks', + ], +]; +``` + +## Running Queries + +Application services can inject `StellarWP\Foundation\Database\Contracts\Database` when they need to run queries: + +```php +use StellarWP\Foundation\Database\Contracts\Database; + +final readonly class ReportRepository +{ + public function __construct( + private Database $database + ) { + } + + public function published(): array + { + return $this->database + ->table('reports') + ->select('id', 'title') + ->where('status', '=', 'published') + ->orderBy('id', 'DESC') + ->limit(25) + ->get(); + } +} +``` + +Queries can be inspected before they are executed: + +```php +$query = $database + ->table('reports') + ->where('status', '=', 'published') + ->limit(25); + +$query->toSql(); +$query->bindings(); +$query->toPreparedSql(); +``` + +## Defining Migrations + +Migrations implement `StellarWP\Foundation\Database\Contracts\Migration`: + +```php +use StellarWP\Foundation\Database\Contracts\Migration; +use StellarWP\Foundation\Database\Contracts\Schema; + +final readonly class CreateReportsTable implements Migration +{ + public function id(): string + { + return '2026_06_23_000001_create_reports_table'; + } + + public function up(Schema $schema): void + { + $schema->createOrUpdate( + sprintf( + 'CREATE TABLE %s ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + title varchar(191) NOT NULL, + PRIMARY KEY (id) + );', + $schema->quoteIdentifier('wp_reports') + ) + ); + } + + public function down(Schema $schema): void + { + $schema->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $schema->quoteIdentifier('wp_reports') + )); + } +} +``` + +Applications should add migrations to `DatabaseProvider::MIGRATIONS` with `mergeArrayVar()` so multiple providers/packages can contribute migrations: + +```php +use lucatume\DI52\Container as C; +use StellarWP\Foundation\Database\DatabaseProvider; + +$this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ + $c->get(CreateReportsTable::class), +]); +``` + +If migrations are added before registering `DatabaseProvider`, the provider will preserve the existing values. Other providers may also add migrations after `DatabaseProvider` is registered, as long as they do so before the migration collection or migrator is resolved. + +Application feature tables should usually be represented by migrations. If a table only needs normal create/drop behavior, define it with `StellarWP\Foundation\Database\Contracts\Table`, wrap it in `StellarWP\Foundation\Database\Table\CreateTable`, and add that migration instance to `DatabaseProvider::MIGRATIONS`. + +```php +use StellarWP\Foundation\Database\Contracts\Database; +use StellarWP\Foundation\Database\Contracts\Table; +use StellarWP\Foundation\Database\Table\TableDefinition; + +final readonly class ReportsTable implements Table +{ + public const string ID = 'reports_table'; + + public function __construct( + private Database $database + ) { + } + + public function id(): string { + return self::ID; + } + + public function name(): string { + return $this->database->tableName('reports'); + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id') + ->string('status', 20)->default('draft') + ->longText('payload') + ->dateTime('published_at')->nullable() + ->tinyInteger('failed', 1)->unsigned()->default(false) + ->index('status', 'status'); + } +} +``` + +```php +use lucatume\DI52\Container as C; +use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Database\Table\CreateTable; + +$this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ + new CreateTable($c->get(ReportsTable::class)), // ReportsTable implements Contracts\Table. +]); +``` + +Application code that needs to run migrations should inject `StellarWP\Foundation\Database\Migration\Migrator`. It is the configured entry point for preparing the migration store, running pending migrations, rolling back, refreshing, dropping migration storage, and reading migration status. + +```php +use StellarWP\Foundation\Database\Migration\Migrator; + +final readonly class PluginUpdater +{ + public function __construct( + private Migrator $migrator + ) { + } + + public function update(): void { + $this->migrator->run(); + } +} +``` + +`run()`, `rollback()`, and `refresh()` prepare the migration store automatically before executing migrations. + +## Generators + +If the project also installs `stellarwp/foundation-cli` as a development dependency, scaffold a database provider, table class, and matching migration in a consuming WordPress project: + +```bash +vendor/bin/foundation make:database-provider +vendor/bin/foundation make:database-table Reports_Table +vendor/bin/foundation make:database-migration Create_Reports_Table +``` + +The provider generator reads the project's first `autoload.psr-4` namespace from `composer.json` and writes `src/Database/Provider.php` by default. Register the Foundation `DatabaseProvider` first, then the generated application provider: + +```php +use Acme\Plugin\Database\Provider; +use StellarWP\Foundation\Database\DatabaseProvider; + +protected array $providers = [ + DatabaseProvider::class, + Provider::class, +]; +``` + +The table generator writes a Snake_Case table class under `src/Database/Tables` by default. The migration generator writes under `src/Database/Migrations` by default and references the matching table class. + +Migration names matching `Create_*_Table`, or migrations generated with `--table-class`, use the table-backed migration stub and wrap the table in `CreateTable`. Other migration names use the generic migration stub. + +If `src/Database/Provider.php` exists and contains the generated provider markers, the table and migration generators automatically add imports and registrations to that provider. Pass `--provider=path/to/Provider.php` to update a non-standard provider file. Re-running a generator does not duplicate existing provider imports or registrations. If you generate a custom provider class name or location, pass `--provider` when generating later tables or migrations. + +Common options: + +```bash +vendor/bin/foundation make:database-provider Provider \ + --namespace="Acme\\Plugin\\Database" \ + --path=src/Database + +vendor/bin/foundation make:database-table Reports_Table \ + --namespace="Acme\\Plugin\\Database\\Tables" \ + --path=src/Database/Tables \ + --provider=src/Database/Provider.php \ + --id=reports_table \ + --table=reports + +vendor/bin/foundation make:database-migration Create_Reports_Table \ + --namespace="Acme\\Plugin\\Database\\Migrations" \ + --path=src/Database/Migrations \ + --provider=src/Database/Provider.php \ + --id=2026_06_26_000001_create_reports_table \ + --table-class=Reports_Table \ + --table-namespace="Acme\\Plugin\\Database\\Tables" +``` + +Project-specific stub overrides live in: + +```text +foundation/stubs/database/table.stub +foundation/stubs/database/migration.stub +foundation/stubs/database/table-migration.stub +foundation/stubs/database/provider.stub +``` + +When present, overrides are used instead of the default stubs from the `foundation-database` package. + +Override stubs should use the same context-aware placeholders as the default stubs when writing PHP literals. For example, use `{{ id_php }}` and `{{ table_php }}` for values written into PHP constants, and use the `{{ foundation_database_* }}` import placeholders so Strauss-prefixed projects keep working. + +## WP-CLI + +The package includes a `migrate` command class for projects using `stellarwp/foundation-wpcli`. `DatabaseProvider` adds that command to `StellarWP\Foundation\WPCli\WPCliProvider::COMMANDS`; register the WP-CLI provider once in the consuming application so merged commands are registered on `cli_init`. + +```php +use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\WPCli\WPCliProvider; + +$container->register(WPCliProvider::class); +$container->register(DatabaseProvider::class); +``` + +Run the command under the configured WP-CLI prefix: + +```bash +wp nx migrate --run +``` + +Available flags: + +- `--run` runs pending migrations. +- `--rollback` rolls back the latest migration batch. +- `--refresh` rolls back all known migrations and runs them again. +- `--drop` drops the migrations and lock tables after confirmation. +- `--prepare` prepares the migration store without running migrations. +- `--create-table` is an alias for `--prepare`. +- `--yes` skips confirmation prompts for destructive actions. + +Use only one operation flag at a time. `--yes` is a modifier for confirmation prompts and can be combined with destructive operations. + +Running the command without a flag prints migration status. If the migration store does not exist yet, the command warns first and shows all configured migrations as pending. diff --git a/src/Database/Schema.php b/src/Database/Schema.php new file mode 100644 index 0000000..c41c648 --- /dev/null +++ b/src/Database/Schema.php @@ -0,0 +1,102 @@ +dbDelta ?? $this->loadDbDelta(); + + $dbDelta($sql ?? $this->createTableSql($table)); + } + + public function execute(string $sql): void { + $this->database->execute($sql); + } + + public function hasTable(Table|string $table): bool { + return $this->database->tableExists($table); + } + + public function hasIndex(Table|string $table, string $index): bool { + return $this->database->indexExists($table, $index); + } + + public function dropIndex(Table|string $table, string $index): void { + $this->database->execute(sprintf( + 'ALTER TABLE %s DROP INDEX %s', + $this->database->quoteIdentifier($this->database->tableName($table)), + $this->database->quoteIdentifier($index) + )); + } + + public function drop(Table|string $table): void { + $this->database->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $this->database->quoteIdentifier($this->database->tableName($table)) + )); + } + + public function quoteIdentifier(string $identifier): string { + return $this->database->quoteIdentifier($identifier); + } + + private function createTableSql(Table|string $table): string { + if (is_string($table)) { + return $table; + } + + $definition = $table->definition(); + $definition->assertValid(); + + $parts = []; + + foreach ($definition->columns() as $column) { + $parts[] = ' ' . $column->sql(); + } + + foreach ($definition->indexes() as $index) { + $parts[] = ' ' . $index->sql(); + } + + return sprintf( + "CREATE TABLE %s (\n%s\n) %s;", + $this->database->quoteIdentifier($table->name()), + implode(",\n", $parts), + $this->database->charsetCollate() + ); + } + + /** + * @return Closure(string): mixed + */ + private function loadDbDelta(): Closure { + if (! function_exists('dbDelta') && defined('ABSPATH')) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + if (! function_exists('dbDelta')) { + throw new DatabaseException('WordPress dbDelta() is not available.'); + } + + return dbDelta(...); + } +} diff --git a/src/Database/Table/Collection.php b/src/Database/Table/Collection.php new file mode 100644 index 0000000..5738dc8 --- /dev/null +++ b/src/Database/Table/Collection.php @@ -0,0 +1,78 @@ + + */ +final class Collection implements IteratorAggregate +{ + /** + * @var list + */ + private array $tables = []; + + /** + * @param iterable
$tables + */ + public function __construct( + private readonly Schema $schema, + iterable $tables = [] + ) { + foreach ($tables as $table) { + $this->add($table); + } + } + + public function add(Table ...$tables): void { + foreach ($tables as $table) { + $this->tables[] = $table; + } + } + + /** + * @return list
+ */ + public function all(): array { + return $this->tables; + } + + public function create(): void { + foreach ($this->tables as $table) { + if (! $this->schema->hasTable($table)) { + $this->schema->createOrUpdate($table); + } + } + } + + public function drop(): void { + foreach ($this->tables as $table) { + $this->schema->drop($table); + } + } + + public function exists(): bool { + foreach ($this->tables as $table) { + if (! $this->schema->hasTable($table)) { + return false; + } + } + + return true; + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable { + return new ArrayIterator($this->tables); + } +} diff --git a/src/Database/Table/Column.php b/src/Database/Table/Column.php new file mode 100644 index 0000000..ce63034 --- /dev/null +++ b/src/Database/Table/Column.php @@ -0,0 +1,118 @@ +name), + $this->type, + $this->length === null ? '' : sprintf('(%d)', $this->length), + $this->unsigned ? ' unsigned' : '', + $this->nullable ? ' NULL' : ' NOT NULL' + ); + + if ($this->default !== null || $this->hasDefault) { + $sql .= sprintf(' DEFAULT %s', $this->formatDefault($this->default)); + } + + if ($this->extra !== '') { + $sql .= ' ' . $this->extra; + } + + return $sql; + } + + public function unsigned(bool $unsigned = true): self { + return new self( + $this->name, + $this->type, + $this->length, + $unsigned, + $this->nullable, + $this->default, + $this->extra, + $this->hasDefault + ); + } + + public function nullable(bool $nullable = true): self { + return new self( + $this->name, + $this->type, + $this->length, + $this->unsigned, + $nullable, + $this->default, + $this->extra, + $this->hasDefault + ); + } + + public function default(mixed $default): self { + return new self( + $this->name, + $this->type, + $this->length, + $this->unsigned, + $default === null ? true : $this->nullable, + $default, + $this->extra, + true + ); + } + + public function extra(string $extra): self { + return new self( + $this->name, + $this->type, + $this->length, + $this->unsigned, + $this->nullable, + $this->default, + $extra, + $this->hasDefault + ); + } + + public function autoIncrement(): self { + if (preg_match('/(?:^|\s)AUTO_INCREMENT(?:\s|$)/i', $this->extra) === 1) { + return $this; + } + + return $this->extra(trim($this->extra . ' AUTO_INCREMENT')); + } + + 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; + } + + return "'" . addslashes((string) $default) . "'"; + } +} diff --git a/src/Database/Table/CreateTable.php b/src/Database/Table/CreateTable.php new file mode 100644 index 0000000..17910ef --- /dev/null +++ b/src/Database/Table/CreateTable.php @@ -0,0 +1,32 @@ +table->id(); + } + + public function up(Schema $schema): void { + if (! $schema->hasTable($this->table)) { + $schema->createOrUpdate($this->table); + } + } + + public function down(Schema $schema): void { + $schema->drop($this->table); + } +} diff --git a/src/Database/Table/Index.php b/src/Database/Table/Index.php new file mode 100644 index 0000000..ed8a97f --- /dev/null +++ b/src/Database/Table/Index.php @@ -0,0 +1,36 @@ + $columns + */ + public function __construct( + public string $name, + public array $columns, + public string $type = IndexType::KEY + ) { + } + + public function sql(): string { + $columns = implode(', ', array_map( + static fn (string $column): string => '`' . str_replace('`', '``', $column) . '`', + $this->columns + )); + + return match ($this->type) { + IndexType::PRIMARY => sprintf('PRIMARY KEY (%s)', $columns), + IndexType::UNIQUE => sprintf('UNIQUE KEY %s (%s)', $this->quotedName(), $columns), + default => sprintf('KEY %s (%s)', $this->quotedName(), $columns), + }; + } + + private function quotedName(): string { + return '`' . str_replace('`', '``', $this->name) . '`'; + } +} diff --git a/src/Database/Table/IndexType.php b/src/Database/Table/IndexType.php new file mode 100644 index 0000000..88d816a --- /dev/null +++ b/src/Database/Table/IndexType.php @@ -0,0 +1,16 @@ + + */ + private array $columns = []; + + /** + * @var list + */ + private array $indexes = []; + + private ?string $currentColumn = null; + + private function __construct( + private readonly Table $table + ) { + } + + public static function for(Table $table): self { + return new self($table); + } + + public function bigIncrements(string $name): self { + return $this + ->column(new Column($name, 'bigint', 20)) + ->unsigned() + ->autoIncrement() + ->primary($name); + } + + public function string(string $name, int $length = 191, ?string $default = null): self { + return $this->column(new Column($name, 'varchar', $length, default: $default)); + } + + public function unsignedInteger(string $name, int $length = 10, ?int $default = null): self { + return $this->column(new Column($name, 'int', $length, unsigned: true, default: $default)); + } + + public function integer(string $name, int $length = 10): self { + return $this->column(new Column($name, 'int', $length)); + } + + public function tinyInteger(string $name, int $length = 3): self { + return $this->column(new Column($name, 'tinyint', $length)); + } + + public function bigInteger(string $name, int $length = 20): self { + return $this->column(new Column($name, 'bigint', $length)); + } + + public function dateTime(string $name): self { + return $this->column(new Column($name, 'datetime')); + } + + public function text(string $name): self { + return $this->column(new Column($name, 'text')); + } + + public function longText(string $name): self { + return $this->column(new Column($name, 'longtext')); + } + + public function column(Column $column): self { + $this->columns[$column->name] = $column; + $this->currentColumn = $column->name; + + return $this; + } + + public function unsigned(bool $unsigned = true): self { + return $this->replaceCurrentColumn($this->currentColumn()->unsigned($unsigned)); + } + + public function nullable(bool $nullable = true): self { + return $this->replaceCurrentColumn($this->currentColumn()->nullable($nullable)); + } + + public function notNull(): self { + return $this->nullable(false); + } + + public function default(mixed $default): self { + return $this->replaceCurrentColumn($this->currentColumn()->default($default)); + } + + public function autoIncrement(): self { + return $this->replaceCurrentColumn($this->currentColumn()->autoIncrement()); + } + + public function extra(string $extra): self { + return $this->replaceCurrentColumn($this->currentColumn()->extra($extra)); + } + + private function replaceCurrentColumn(Column $column): self { + $this->columns[$column->name] = $column; + + return $this; + } + + public function primary(string ...$columns): self { + $this->indexes[] = new Index('primary', $this->nonEmptyColumns(array_values($columns)), IndexType::PRIMARY); + $this->currentColumn = null; + + return $this; + } + + public function unique(string $name, string ...$columns): self { + $this->indexes[] = new Index($name, $this->nonEmptyColumns(array_values($columns)), IndexType::UNIQUE); + $this->currentColumn = null; + + return $this; + } + + public function index(string $name, string ...$columns): self { + $this->indexes[] = new Index($name, $this->nonEmptyColumns(array_values($columns)), IndexType::KEY); + $this->currentColumn = null; + + return $this; + } + + /** + * @return list + */ + public function columns(): array { + return array_values($this->columns); + } + + /** + * @return list + */ + public function indexes(): array { + return $this->indexes; + } + + /** + * @return list + */ + public function validationErrors(): array { + $errors = []; + + if ($this->columns === []) { + $errors[] = sprintf('Table %s does not define any columns.', $this->table->id()); + } + + foreach ($this->indexes as $index) { + if ($index->type === IndexType::PRIMARY) { + continue; + } + + foreach ($this->indexesByName($index->name) as $duplicate) { + if ($duplicate !== $index && $duplicate->type !== IndexType::PRIMARY) { + $errors[] = sprintf('Index %s is defined more than once.', $index->name); + break 2; + } + } + } + + if (count(array_filter($this->indexes, static fn (Index $index): bool => $index->type === IndexType::PRIMARY)) > 1) { + $errors[] = 'A table can define only one primary key.'; + } + + foreach ($this->indexes as $index) { + foreach ($index->columns as $column) { + if (! isset($this->columns[$column])) { + $errors[] = sprintf('Index %s references missing column %s.', $index->name, $column); + } + } + } + + return $errors; + } + + public function assertValid(): void { + $errors = $this->validationErrors(); + + if ($errors !== []) { + throw new InvalidArgumentException(implode(' ', $errors)); + } + } + + /** + * @param list $columns + * + * @return non-empty-list + */ + private function nonEmptyColumns(array $columns): array { + if ($columns === []) { + throw new InvalidArgumentException('An index must define at least one column.'); + } + + return $columns; + } + + private function currentColumn(): Column { + if ($this->currentColumn === null || ! isset($this->columns[$this->currentColumn])) { + throw new InvalidArgumentException('A column modifier must follow a column definition.'); + } + + return $this->columns[$this->currentColumn]; + } + + /** + * @return list + */ + private function indexesByName(string $name): array { + return array_values(array_filter( + $this->indexes, + static fn (Index $index): bool => $index->name === $name + )); + } +} diff --git a/src/Database/Table/Tables/LockTable.php b/src/Database/Table/Tables/LockTable.php new file mode 100644 index 0000000..16e903d --- /dev/null +++ b/src/Database/Table/Tables/LockTable.php @@ -0,0 +1,40 @@ +database->tableName($this->table); + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->string('name', 191) + ->string('owner', 64) + ->dateTime('expires_at') + ->dateTime('created_at') + ->dateTime('updated_at') + ->primary('name') + ->index('expires_at', 'expires_at'); + } +} diff --git a/src/Database/Table/Tables/MigrationTable.php b/src/Database/Table/Tables/MigrationTable.php new file mode 100644 index 0000000..788652d --- /dev/null +++ b/src/Database/Table/Tables/MigrationTable.php @@ -0,0 +1,39 @@ +database->tableName($this->table); + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id') + ->string('migration', 191) + ->unsignedInteger('batch') + ->dateTime('ran_at') + ->unique('migration', 'migration') + ->index('batch', 'batch'); + } +} diff --git a/src/Database/composer.json b/src/Database/composer.json new file mode 100644 index 0000000..f9c3ce6 --- /dev/null +++ b/src/Database/composer.json @@ -0,0 +1,26 @@ +{ + "name": "stellarwp/foundation-database", + "type": "library", + "description": "Foundation Database package.", + "license": "GPL-2.0-or-later", + "config": { + "vendor-dir": "vendor", + "preferred-install": "dist" + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^1.4", + "stellarwp/foundation-lock": "^1.4", + "stellarwp/foundation-wpcli": "^1.4" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Database\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "1.4.x-dev" + } + } +} diff --git a/src/Database/stubs/migration.stub b/src/Database/stubs/migration.stub new file mode 100644 index 0000000..6784a25 --- /dev/null +++ b/src/Database/stubs/migration.stub @@ -0,0 +1,25 @@ +register_tables(); + $this->register_migrations(); + } + + private function register_tables(): void { + // foundation:database-tables + } + + private function register_migrations(): void { + $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + ] ); + } + +} diff --git a/src/Database/stubs/table-migration.stub b/src/Database/stubs/table-migration.stub new file mode 100644 index 0000000..08afb1d --- /dev/null +++ b/src/Database/stubs/table-migration.stub @@ -0,0 +1,31 @@ +table ) )->up( $schema ); + } + + public function down( Schema $schema ): void { + ( new CreateTable( $this->table ) )->down( $schema ); + } + +} diff --git a/src/Database/stubs/table.stub b/src/Database/stubs/table.stub new file mode 100644 index 0000000..c90ebb7 --- /dev/null +++ b/src/Database/stubs/table.stub @@ -0,0 +1,37 @@ +database->tableName( self::TABLE ); + } + + public function definition(): TableDefinition { + return TableDefinition::for( $this ) + ->bigIncrements( 'id' ) + ->string( 'status', 20 )->default( 'draft' ) + ->longText( 'payload' ) + ->dateTime( 'created_at' ) + ->dateTime( 'updated_at' )->nullable() + ->index( 'status', 'status' ); + } + +} diff --git a/src/Identifier/.gitattributes b/src/Identifier/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Identifier/.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/Identifier/.github/workflows/close-pull-request.yml b/src/Identifier/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/Identifier/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Identifier/.gitignore b/src/Identifier/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Identifier/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Identifier/Contracts/IdentifierGenerator.php b/src/Identifier/Contracts/IdentifierGenerator.php new file mode 100644 index 0000000..c7c44da --- /dev/null +++ b/src/Identifier/Contracts/IdentifierGenerator.php @@ -0,0 +1,11 @@ +container->singleton(RandomizerEntropy::class); + $this->container->singleton(SystemMillisecondClock::class); + $this->container->singleton(UlidGenerator::class); + $this->container->singleton(UlidValidator::class); + + $this->container->when(RandomizerEntropy::class) + ->needs(Randomizer::class) + ->give(static fn (): Randomizer => new Randomizer(new Secure())); + + $this->container->bind(Entropy::class, static fn (C $c): RandomizerEntropy => $c->get(RandomizerEntropy::class)); + $this->container->bind(MillisecondClock::class, static fn (C $c): SystemMillisecondClock => $c->get(SystemMillisecondClock::class)); + $this->container->bind(UlidGeneratorContract::class, static fn (C $c): UlidGenerator => $c->get(UlidGenerator::class)); + } +} diff --git a/src/Identifier/README.md b/src/Identifier/README.md new file mode 100644 index 0000000..630438d --- /dev/null +++ b/src/Identifier/README.md @@ -0,0 +1,52 @@ +# Foundation Identifier + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +## Installation + +```shell +composer require stellarwp/foundation-identifier +``` + +## Usage + +`foundation-identifier` provides injectable identifier generation contracts and a ULID implementation. + +```php +use StellarWP\Foundation\Identifier\Ulid\Contracts\UlidGenerator; + +final class CreateJob +{ + public function __construct( + private readonly UlidGenerator $identifiers + ) { + } + + public function __invoke(): string { + return $this->identifiers->generate(); + } +} +``` + +Consumers using Foundation's container can register `StellarWP\Foundation\Identifier\IdentifierProvider` to make the ULID services available. The provider does not bind `IdentifierGenerator` globally; applications should decide which identifier strategy satisfies that contract. + +The provider binds `StellarWP\Foundation\Identifier\Ulid\Contracts\UlidGenerator` to the default ULID implementation. If ULIDs should be the application's default identifier strategy, bind the broader `IdentifierGenerator` contract in an application provider: + +```php +use lucatume\DI52\Container as C; +use StellarWP\Foundation\Container\Contracts\Provider as ServiceProvider; +use StellarWP\Foundation\Identifier\Contracts\IdentifierGenerator; +use StellarWP\Foundation\Identifier\IdentifierProvider as FoundationIdentifierProvider; +use StellarWP\Foundation\Identifier\Ulid\Contracts\UlidGenerator; + +final class IdentifierProvider extends ServiceProvider +{ + public function register(): void { + $this->container->register(FoundationIdentifierProvider::class); + $this->container->bind(IdentifierGenerator::class, static fn (C $c): UlidGenerator => $c->get(UlidGenerator::class)); + } +} +``` + +The default generator returns canonical uppercase ULIDs, such as `01ARYZ6S410000000000000000`. Use `StellarWP\Foundation\Identifier\Ulid\UlidValidator` when accepting ULIDs from external input. diff --git a/src/Identifier/Ulid/Contracts/Entropy.php b/src/Identifier/Ulid/Contracts/Entropy.php new file mode 100644 index 0000000..5c83a7b --- /dev/null +++ b/src/Identifier/Ulid/Contracts/Entropy.php @@ -0,0 +1,11 @@ +randomizer->getBytes($length); + } +} diff --git a/src/Identifier/Ulid/SystemMillisecondClock.php b/src/Identifier/Ulid/SystemMillisecondClock.php new file mode 100644 index 0000000..a064299 --- /dev/null +++ b/src/Identifier/Ulid/SystemMillisecondClock.php @@ -0,0 +1,15 @@ +encodeTimestamp($this->clock->milliseconds()) + . $this->encodeRandomness($this->entropy->bytes(self::RANDOM_BYTES)); + } + + private function encodeTimestamp(int $timestamp): string { + if ($timestamp < 0 || $timestamp > self::MAX_TIMESTAMP) { + throw new OutOfRangeException(sprintf('ULID timestamps must be between 0 and %d milliseconds.', self::MAX_TIMESTAMP)); + } + + $encoded = ''; + + for ($i = 0; $i < self::TIMESTAMP_LENGTH; $i++) { + $encoded = self::ALPHABET[$timestamp % 32] . $encoded; + $timestamp = intdiv($timestamp, 32); + } + + return $encoded; + } + + private function encodeRandomness(string $bytes): string { + if (strlen($bytes) !== self::RANDOM_BYTES) { + throw new RuntimeException(sprintf('ULID generation requires exactly %d random bytes.', self::RANDOM_BYTES)); + } + + $encoded = ''; + $buffer = 0; + $bitCount = 0; + + for ($i = 0; $i < self::RANDOM_BYTES; $i++) { + $buffer = ($buffer << 8) | ord($bytes[$i]); + $bitCount += 8; + + while ($bitCount >= 5) { + $bitCount -= 5; + $encoded .= self::ALPHABET[($buffer >> $bitCount) & 31]; + $buffer &= (1 << $bitCount) - 1; + } + } + + return $encoded; + } +} diff --git a/src/Identifier/Ulid/UlidValidator.php b/src/Identifier/Ulid/UlidValidator.php new file mode 100644 index 0000000..e111db4 --- /dev/null +++ b/src/Identifier/Ulid/UlidValidator.php @@ -0,0 +1,15 @@ +=8.3", + "arokettu/random-polyfill": ">=1.0.6 <1.99", + "stellarwp/foundation-container": "^1.4" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Identifier\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "1.4.x-dev" + } + } +} diff --git a/src/Lock/.gitattributes b/src/Lock/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Lock/.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/Lock/.github/workflows/close-pull-request.yml b/src/Lock/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/Lock/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Lock/.gitignore b/src/Lock/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Lock/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Lock/Contracts/Clock.php b/src/Lock/Contracts/Clock.php new file mode 100644 index 0000000..7fc8329 --- /dev/null +++ b/src/Lock/Contracts/Clock.php @@ -0,0 +1,16 @@ + + */ + private array $locks = []; + + public function __construct( + private readonly Clock $clock = new SystemClock() + ) { + } + + /** + * {@inheritDoc} + * + * @throws RandomException + * @throws DateMalformedIntervalStringException + */ + public function acquire(string $name, int $ttl): ?LockToken { + $this->assertValidName($name); + $this->assertValidTtl($ttl); + $this->releaseIfExpired($name); + + if (isset($this->locks[$name])) { + return null; + } + + $token = new LockToken( + name: $name, + owner: bin2hex(random_bytes(16)), + expiresAt: $this->expiresAt($ttl) + ); + + $this->locks[$name] = $token; + + return $token; + } + + public function release(LockToken $token): bool { + $this->releaseIfExpired($token->name); + + if (! isset($this->locks[$token->name]) || ! $this->locks[$token->name]->matches($token)) { + return false; + } + + unset($this->locks[$token->name]); + + return true; + } + + /** + * @throws DateMalformedIntervalStringException + */ + public function refresh(LockToken $token, int $ttl): ?LockToken { + $this->assertValidTtl($ttl); + $this->releaseIfExpired($token->name); + + if (! isset($this->locks[$token->name]) || ! $this->locks[$token->name]->matches($token)) { + return null; + } + + $refreshed = $token->refresh($this->expiresAt($ttl)); + + $this->locks[$token->name] = $refreshed; + + return $refreshed; + } + + public function isAcquired(string $name): bool { + $this->assertValidName($name); + $this->releaseIfExpired($name); + + return isset($this->locks[$name]); + } + + /** + * @throws DateMalformedIntervalStringException + * @throws InvalidArgumentException + */ + private function expiresAt(int $ttl): DateTimeImmutable { + $this->assertValidTtl($ttl); + + return $this->clock->now()->add(new DateInterval(sprintf('PT%dS', $ttl))); + } + + private function assertValidTtl(int $ttl): void { + if ($ttl < 1) { + throw new InvalidArgumentException('Lock TTL must be greater than zero seconds.'); + } + } + + private function releaseIfExpired(string $name): void { + if (! isset($this->locks[$name])) { + return; + } + + if (! $this->locks[$name]->isExpired($this->clock->now())) { + return; + } + + unset($this->locks[$name]); + } + + /** + * @throws InvalidArgumentException + */ + private function assertValidName(string $name): void { + if (trim($name) === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + } +} diff --git a/src/Lock/LockToken.php b/src/Lock/LockToken.php new file mode 100644 index 0000000..91c2bd7 --- /dev/null +++ b/src/Lock/LockToken.php @@ -0,0 +1,59 @@ +name) === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + + if (trim($this->owner) === '') { + throw new InvalidArgumentException('Lock owner cannot be empty.'); + } + } + + /** + * Determine whether the token has expired at the provided time. + * + * When no time is provided, the local system clock is used as a convenience + * check. Lock implementations should pass their authoritative clock value. + */ + public function isExpired(?DateTimeImmutable $now = null): bool { + return $this->expiresAt <= ($now ?? new DateTimeImmutable()); + } + + /** + * Determine whether another token represents the same lock owner. + */ + public function matches(self $token): bool { + return $this->name === $token->name && $this->owner === $token->owner; + } + + /** + * Return a new token for the same owner with a later expiration time. + */ + public function refresh(DateTimeImmutable $expiresAt): self { + return new self( + name: $this->name, + owner: $this->owner, + expiresAt: $expiresAt + ); + } +} diff --git a/src/Lock/README.md b/src/Lock/README.md new file mode 100644 index 0000000..efa98f0 --- /dev/null +++ b/src/Lock/README.md @@ -0,0 +1,34 @@ +# Foundation Lock + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +## Installation + +```shell +composer require stellarwp/foundation-lock +``` + +## Usage + +`foundation-lock` defines portable lock contracts and a process-local in-memory implementation. The in-memory lock is useful for tests and single-process work, but it is not a cross-request or distributed lock. + +```php +use StellarWP\Foundation\Lock\InMemoryLock; + +$lock = new InMemoryLock(); + +$token = $lock->acquire('queue:sync', 60); + +if ($token === null) { + return; +} + +try { + // Run exclusive work here. +} finally { + $lock->release($token); +} +``` + +Persistent implementations, such as database-backed locks, should implement `StellarWP\Foundation\Lock\Contracts\Lock` and use `LockToken` ownership checks before releasing or refreshing locks. diff --git a/src/Lock/SystemClock.php b/src/Lock/SystemClock.php new file mode 100644 index 0000000..5d03eea --- /dev/null +++ b/src/Lock/SystemClock.php @@ -0,0 +1,16 @@ +=8.3" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Lock\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "1.4.x-dev" + } + } +} diff --git a/src/WPCli/README.md b/src/WPCli/README.md index d3f2413..15ee7ce 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -113,7 +113,7 @@ final class {{ class }} extends Command ## Provider Setup -Applications should register their own provider so they control the command namespace and command list. +Applications should register `StellarWP\Foundation\WPCli\Provider` once in the application provider list. Feature-specific providers can then add command classes to the shared command list with `mergeArrayVar()`. Do not register `StellarWP\Foundation\Cli\CliProvider` in a WordPress plugin. That provider belongs to the developer-facing `foundation` console binary, not plugin runtime bootstrap. @@ -125,10 +125,7 @@ Generated command classes use Strauss-prefixed Foundation imports automatically namespace Acme\App\Cli; use StellarWP\Foundation\Container\Contracts\Provider; -use StellarWP\Foundation\WPCli\Command; -use StellarWP\Foundation\WPCli\TimestampedLogger; -use WP_CLI; -use WP_CLI\Loggers\Regular; +use StellarWP\Foundation\WPCli\WPCliProvider as WPCliProvider; final class Wp_Cli_Provider extends Provider { @@ -142,42 +139,12 @@ final class Wp_Cli_Provider extends Provider ]; public function register(): void { - if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { - return; - } - - $this->configureCommands(); - $this->registerTimestampedLogger(); - - add_action( 'cli_init', function (): void { - foreach ( self::COMMANDS as $commandClass ) { - $command = $this->container->get( $commandClass ); - - if ( $command instanceof Command ) { - $command->register(); - } - } - }, 0, 0 ); - } - - private function configureCommands(): void { - foreach ( self::COMMANDS as $commandClass ) { - $this->container->when( $commandClass ) - ->needs( '$commandPrefix' ) - ->give( self::COMMAND_PREFIX ); - } - } - - private function registerTimestampedLogger(): void { - $wpCliLogger = WP_CLI::get_logger(); - - if ( $wpCliLogger instanceof Regular ) { - WP_CLI::set_logger( new TimestampedLogger( $wpCliLogger ) ); - } + $this->container->singleton( WPCliProvider::COMMAND_PREFIX, self::COMMAND_PREFIX ); + $this->container->mergeArrayVar( WPCliProvider::COMMANDS, self::COMMANDS ); } } ``` -Use `cli_init` so commands are registered only during WP-CLI command bootstrap, after WordPress has loaded enough for plugin providers and hooks to be available. +The Foundation WP-CLI provider uses `cli_init` internally so commands are registered only during WP-CLI command bootstrap, after all application providers have had a chance to add command classes. -If your application does not use WordPress hooks during bootstrap, call the command registration loop at the point where WP-CLI is active and your container has been configured. +If your application wants a different default command prefix without a feature-specific CLI provider, bind `WPCliProvider::COMMAND_PREFIX` before WP-CLI's `cli_init` hook runs. diff --git a/src/WPCli/WPCliProvider.php b/src/WPCli/WPCliProvider.php new file mode 100644 index 0000000..005e24a --- /dev/null +++ b/src/WPCli/WPCliProvider.php @@ -0,0 +1,39 @@ +container->mergeArrayVar(self::COMMANDS, []); + $this->container->bind(self::COMMAND_PREFIX, $this->config->get('wpcli.command_prefix', 'nx')); + + add_action('cli_init', function (): void { + $this->registerCommands(); + }, 0, 0); + } + + private function registerCommands(): void { + $commands = $this->container->get(self::COMMANDS); + + foreach ($commands as $command) { + if (! $command instanceof Command) { + continue; + } + + $command->register(); + } + } +} diff --git a/tests/CodeceptionSupport/.gitignore b/tests/CodeceptionSupport/.gitignore new file mode 100644 index 0000000..36e264c --- /dev/null +++ b/tests/CodeceptionSupport/.gitignore @@ -0,0 +1 @@ +_generated diff --git a/tests/CodeceptionSupport/FeatureTester.php b/tests/CodeceptionSupport/FeatureTester.php new file mode 100644 index 0000000..34ad51b --- /dev/null +++ b/tests/CodeceptionSupport/FeatureTester.php @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..33553e6 --- /dev/null +++ b/tests/Support/Fixtures/Database/FakeDatabase.php @@ -0,0 +1,159 @@ + + */ + 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 function table(Table|string $table, ?string $alias = null): QueryBuilder { + return new QueryBuilder($this, $table, $alias); + } + + public function tableName(Table|string $table): string { + if ($table instanceof Table) { + return $table->name(); + } + + if (str_starts_with($table, 'wp_')) { + return $table; + } + + return 'wp_' . $table; + } + + public function tableExists(Table|string $table): bool { + return $this->row('SHOW TABLES LIKE %s', $this->escLike($this->tableName($table))) !== null; + } + + public function columnExists(Table|string $table, string $column): bool { + return $this->row('SHOW COLUMNS FROM %i LIKE %s', $this->tableName($table), $this->escLike($column)) !== null; + } + + public function indexExists(Table|string $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; + + $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|string $table, array $data): int { + $this->executed[] = 'INSERT ' . $this->tableName($table); + + return $this->insertId; + } + + public function update(Table|string $table, array $data, array $where): int { + $this->executed[] = 'UPDATE ' . $this->tableName($table); + + return array_shift($this->executeResults) ?? 1; + } + + public function delete(Table|string $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('/(? + */ + 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): Record { + $record = new Record( + id: $this->nextId++, + migration: $migration, + batch: $batch, + ranAt: new DateTimeImmutable('2026-01-01 00:00:00') + ); + + $this->records[$migration] = $record; + + return $record; + } + + public function deleteRun(string $migration): bool { + if (! isset($this->records[$migration])) { + return false; + } + + unset($this->records[$migration]); + + return true; + } + + public function nextBatch(): int { + $latest = $this->latestBatch(); + + return $latest === null ? 1 : $latest + 1; + } + + 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/RecordingSchema.php b/tests/Support/Fixtures/Database/RecordingSchema.php new file mode 100644 index 0000000..9363bb9 --- /dev/null +++ b/tests/Support/Fixtures/Database/RecordingSchema.php @@ -0,0 +1,66 @@ + + */ + public array $statements = []; + + /** + * @var array + */ + public array $tables = []; + + /** + * @var array> + */ + public array $indexes = []; + + public function createOrUpdate(Table|string $table, ?string $sql = null): void { + $name = $table instanceof Table ? $table->name() : $table; + $this->tables[$name] = true; + $this->statements[] = 'createOrUpdate:' . ($sql ?? $name); + } + + public function execute(string $sql): void { + $this->statements[] = $sql; + } + + public function hasTable(Table|string $table): bool { + $name = $table instanceof Table ? $table->name() : $table; + + return $this->tables[$name] ?? false; + } + + public function hasIndex(Table|string $table, string $index): bool { + $name = $table instanceof Table ? $table->name() : $table; + + return $this->indexes[$name][$index] ?? false; + } + + public function dropIndex(Table|string $table, string $index): void { + $name = $table instanceof Table ? $table->name() : $table; + + unset($this->indexes[$name][$index]); + + $this->statements[] = sprintf('dropIndex:%s:%s', $name, $index); + } + + public function drop(Table|string $table): void { + $name = $table instanceof Table ? $table->name() : $table; + + 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/TestMigration.php b/tests/Support/Fixtures/Database/TestMigration.php new file mode 100644 index 0000000..917f47f --- /dev/null +++ b/tests/Support/Fixtures/Database/TestMigration.php @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..585bd4f --- /dev/null +++ b/tests/Support/Fixtures/Database/TestTable.php @@ -0,0 +1,28 @@ +id; + } + + public function name(): string { + return $this->name; + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id'); + } +} diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php new file mode 100644 index 0000000..f43b005 --- /dev/null +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -0,0 +1,94 @@ +bind(Container::class, $container); + $container->bind(ContainerInterface::class, $container); + $container->singleton(Dot::class, new Dot()); + + $database = new Database($wpdb); + $schema = new Schema($database); + $migrationTable = $wpdb->prefix . 'foundation_cli_migrations'; + $lockTable = $wpdb->prefix . 'foundation_cli_locks'; + $exampleTable = $wpdb->prefix . 'foundation_cli_example'; + + $migration = new class($exampleTable) implements Migration { + public function __construct( + private readonly string $exampleTable + ) { + } + + public function id(): string { + return '2026_06_23_000001_create_foundation_cli_example'; + } + + public function up(SchemaContract $schema): void { + $schema->createOrUpdate(sprintf( + 'CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) NOT NULL, + PRIMARY KEY (id) + );', + $schema->quoteIdentifier($this->exampleTable) + )); + } + + public function down(SchemaContract $schema): void { + $schema->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $schema->quoteIdentifier($this->exampleTable) + )); + } + }; + + $command = new Migrate( + $container, + 'foundation', + new Migrator( + new Store(new TableCollection($schema, [ + new MigrationTable($database, $migrationTable), + new LockTable($database, $lockTable), + ])), + new Runner( + new Repository($database, $migrationTable), + $schema, + new DatabaseLock($database, $lockTable) + ), + new MigrationCollection([$migration]) + ) + ); + + $command->register(); +}); diff --git a/tests/Support/Fixtures/Identifier/Ulid/FixedEntropy.php b/tests/Support/Fixtures/Identifier/Ulid/FixedEntropy.php new file mode 100644 index 0000000..fbe8533 --- /dev/null +++ b/tests/Support/Fixtures/Identifier/Ulid/FixedEntropy.php @@ -0,0 +1,31 @@ +requestedLengths[] = $length; + + return $this->bytes; + } + + /** + * @return int[] + */ + public function requestedLengths(): array { + return $this->requestedLengths; + } +} diff --git a/tests/Support/Fixtures/Identifier/Ulid/FixedMillisecondClock.php b/tests/Support/Fixtures/Identifier/Ulid/FixedMillisecondClock.php new file mode 100644 index 0000000..d28e990 --- /dev/null +++ b/tests/Support/Fixtures/Identifier/Ulid/FixedMillisecondClock.php @@ -0,0 +1,17 @@ +milliseconds; + } +} diff --git a/tests/Support/Fixtures/Lock/MutableClock.php b/tests/Support/Fixtures/Lock/MutableClock.php new file mode 100644 index 0000000..8c32563 --- /dev/null +++ b/tests/Support/Fixtures/Lock/MutableClock.php @@ -0,0 +1,23 @@ +now; + } + + public function advance(int $seconds): void { + $this->now = $this->now->add(new DateInterval(sprintf('PT%dS', $seconds))); + } +} diff --git a/tests/Support/Fixtures/WPCli/RecordingCommand.php b/tests/Support/Fixtures/WPCli/RecordingCommand.php new file mode 100644 index 0000000..7f805e2 --- /dev/null +++ b/tests/Support/Fixtures/WPCli/RecordingCommand.php @@ -0,0 +1,30 @@ +registered = true; + } + + protected function subcommand(): string { + return 'recording'; + } + + protected function description(): string { + return 'Recording command.'; + } + + protected function arguments(): array { + return []; + } +} diff --git a/tests/Unit/Cli/CliProviderTest.php b/tests/Unit/Cli/CliProviderTest.php index a802d90..b94fa6a 100644 --- a/tests/Unit/Cli/CliProviderTest.php +++ b/tests/Unit/Cli/CliProviderTest.php @@ -7,6 +7,9 @@ use StellarWP\ContainerContract\ContainerInterface; use StellarWP\Foundation\Cli\Application; use StellarWP\Foundation\Cli\CliProvider; +use StellarWP\Foundation\Cli\Commands\Make\Database\MigrationCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\TableCommand; use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand; use StellarWP\Foundation\Cli\Commands\Package\Contracts\PackageRepositoryCreator; use StellarWP\Foundation\Cli\Commands\Package\CreateCommand; @@ -33,6 +36,9 @@ public function test_it_registers_cli_services(): void { $this->assertInstanceOf(Application::class, $container->get(Application::class)); $this->assertInstanceOf(CreateCommand::class, $container->get(CreateCommand::class)); + $this->assertInstanceOf(MigrationCommand::class, $container->get(MigrationCommand::class)); + $this->assertInstanceOf(ProviderCommand::class, $container->get(ProviderCommand::class)); + $this->assertInstanceOf(TableCommand::class, $container->get(TableCommand::class)); $this->assertInstanceOf(WPCliCommand::class, $container->get(WPCliCommand::class)); $this->assertInstanceOf(PackageResolver::class, $container->get(PackageResolver::class)); $this->assertInstanceOf(PackageScaffolder::class, $container->get(PackageScaffolder::class)); @@ -43,6 +49,9 @@ public function test_it_registers_cli_services(): void { $this->assertInstanceOf(StubResolver::class, $container->get(StubResolver::class)); $this->assertInstanceOf(GitHubPackageRepositoryCreator::class, $container->get(PackageRepositoryCreator::class)); $this->assertTrue($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')); } } diff --git a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php new file mode 100644 index 0000000..bbfb404 --- /dev/null +++ b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php @@ -0,0 +1,1298 @@ + + */ + private array $temporaryRoots = []; + + private string $tempDir; + + protected function setUp(): void { + parent::setUp(); + + $this->tempDir = $this->prepare_temp_dir('make-database-command'); + } + + protected function tearDown(): void { + foreach ($this->temporaryRoots as $temporaryRoot) { + $this->removeDirectory($temporaryRoot); + } + + parent::tearDown(); + } + + public function test_it_generates_a_database_table_from_project_autoload_defaults(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + ]); + + $path = $root . '/src/Database/Tables/Reports_Table.php'; + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($path); + $this->assertStringContainsString('Created: src/Database/Tables/Reports_Table.php', $tester->getDisplay()); + + $contents = (string) file_get_contents($path); + + $this->assertStringContainsString('namespace Acme\\Plugin\\Database\\Tables;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Database;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Table;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Table\\TableDefinition;', $contents); + $this->assertStringContainsString('final readonly class Reports_Table implements Table {', $contents); + $this->assertStringContainsString("public const string ID = 'reports_table';", $contents); + $this->assertStringContainsString("public const string TABLE = 'reports';", $contents); + $this->assertStringContainsString('return $this->database->tableName( self::TABLE );', $contents); + $this->assertStringContainsString("->longText( 'payload' )", $contents); + } + + 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', + '--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 StellarWP\\Foundation\\Database\\Table\\CreateTable;', $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('( new CreateTable( $this->table ) )->up( $schema );', $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\\Exceptions\\IrreversibleMigration;', $contents); + $this->assertStringContainsString("public const string ID = '2026_06_26_000003_bump_version';", $contents); + $this->assertStringContainsString('throw new IrreversibleMigration( self::ID );', $contents); + $this->assertStringNotContainsString('CreateTable', $contents); + $this->assertStringNotContainsString('Bump_Version_Table', $contents); + } + + public function test_it_generates_a_database_provider_from_project_autoload_defaults(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $path = $root . '/src/Database/Provider.php'; + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($path); + $this->assertStringContainsString('Created: src/Database/Provider.php', $tester->getDisplay()); + + $contents = (string) file_get_contents($path); + + $this->assertStringContainsString('namespace Acme\\Plugin\\Database;', $contents); + $this->assertStringContainsString('use lucatume\\DI52\\Container 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_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', + '--id' => 'audit_log_storage', + '--table' => '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', + '--table-class' => 'Audit_Log', + '--table-namespace' => 'Acme\\Plugin\\Storage', + ]); + + $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("public const string ID = 'audit_log_storage';", $tableContents); + $this->assertStringContainsString("public const string TABLE = '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_provider_generator_accepts_generation_options(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'Database_Provider', + '--namespace' => 'Acme\\Plugin\\Storage', + '--path' => 'custom/providers', + ]); + + $contents = (string) file_get_contents($root . '/custom/providers/Database_Provider.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('namespace Acme\\Plugin\\Storage;', $contents); + $this->assertStringContainsString('final class Database_Provider extends Service_Provider {', $contents); + } + + public function test_database_provider_generator_accepts_an_absolute_output_path(): void { + $root = $this->temporaryProject(); + $outputRoot = $this->temporaryRoot('foundation-make-database-provider-output-'); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([ + '--namespace' => 'Acme\\External\\Database', + '--path' => $outputRoot, + ]); + + $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', + '--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->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); + + (new CommandTester($this->tableCommand($root)))->execute([ + 'name' => 'reports', + '--force' => true, + ]); + (new CommandTester($this->migrationCommand($root)))->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + '--force' => true, + ]); + + $this->assertSame($contents, (string) file_get_contents($root . '/src/Database/Provider.php')); + } + + 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', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + (new CommandTester($this->migrationCommand($root)))->execute([ + 'name' => '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', + '--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_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', + '--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_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->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $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->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString('$this->container->get(Create_Reports_Table::class),', $contents); + } + + 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->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $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->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $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', + '--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_explicit_database_provider_migration_update_fails_when_the_callback_has_no_container_parameter(): 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 (): array => [ + ] ); + } +} +PHP); + + $tester = new CommandTester($this->migrationCommand($root)); + $statusCode = $tester->execute([ + 'name' => '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_database_provider_migration_update_ignores_unrelated_database_provider_imports(): 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 => [ + ] ); + } +} +PHP); + + $status = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $this->assertSame(ProviderRegistrationEditor::MISSING_ANCHOR, $status); + $this->assertStringNotContainsString('Create_Reports_Table', (string) file_get_contents($providerPath)); + } + + public function test_explicit_database_provider_update_fails_when_the_provider_cannot_be_parsed(): 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 could not be parsed as PHP', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_database_provider_updates_ignore_marker_text_that_is_not_on_a_marker_line(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, str_replace( + 'private function register_tables(): void {', + "/**\n\t * Example text: // foundation:database-tables\n\t */\n\tprivate function register_tables(): void {", + (string) file_get_contents($providerPath) + )); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + ]); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertSame(1, substr_count($contents, '$this->container->singleton(Reports_Table::class);')); + $this->assertStringContainsString('Example text: // foundation:database-tables', $contents); + } + + public function test_database_provider_updater_adds_import_after_namespace_when_no_imports_exist(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString("namespace Acme\\Plugin\\Database;\n\nuse Acme\\Plugin\\Database\\Tables\\Reports_Table;\n\nfinal class Provider", $contents); + $this->assertStringContainsString("\t\t\$this->container->singleton(Reports_Table::class);\n\t\t// foundation:database-tables", $contents); + $this->assertStringNotContainsString('Array$this', $contents); + } + + public function test_database_provider_updater_adds_import_when_same_class_uses_a_different_alias(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table as Existing_Reports_Table;', $contents); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); + $this->assertStringContainsString("\t\t\$this->container->singleton(Reports_Table::class);\n\t\t// foundation:database-tables", $contents); + } + + public function test_database_provider_updater_preserves_inline_comments_when_adding_imports(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString("use Acme\\Plugin\\Database\\Existing_Table; // keep this comment here\nuse Acme\\Plugin\\Database\\Tables\\Reports_Table;", $contents); + } + + public function test_database_provider_updater_ignores_marker_text_inside_non_marker_line_comments(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $this->assertSame(ProviderRegistrationEditor::MISSING_MARKER, $status); + $this->assertSame(0, substr_count((string) file_get_contents($providerPath), '$this->container->singleton(Reports_Table::class);')); + } + + public function test_database_provider_updater_is_idempotent_with_grouped_imports(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->singleton(Reports_Table::class); + // foundation:database-tables + } +} +PHP); + + $contents = (string) file_get_contents($providerPath); + $status = $this->providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $this->assertSame(ProviderRegistrationEditor::ALREADY_REGISTERED, $status); + $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' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $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;", + (string) file_get_contents($providerPath) + )); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('a different imported class uses the same short class name', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_explicit_database_provider_update_fails_on_grouped_import_short_name_collisions(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $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;", + (string) file_get_contents($providerPath) + )); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('a different imported class uses the same short class name', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_explicit_database_provider_update_fails_on_aliased_import_short_name_collisions(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $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;", + (string) file_get_contents($providerPath) + )); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('a different imported class uses the same short class name', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_database_table_generator_accepts_an_absolute_output_path(): void { + $root = $this->temporaryProject(); + $outputRoot = $this->temporaryRoot('foundation-make-database-output-'); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + '--path' => $outputRoot, + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($outputRoot . '/Reports_Table.php'); + $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', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $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'); + + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Contracts\\Database;', $tableContents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Contracts\\Migration;', $migrationContents); + $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Database;', $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' => [ + 'strauss' => [ + 'namespace_prefix' => 'Acme\\Product\\', + ], + ], + ]); + + $statusCode = (new CommandTester($this->providerCommand($root)))->execute([]); + + $contents = (string) file_get_contents($root . '/src/Database/Provider.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $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\\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', 'Generated table {{ class }} in {{ namespace }}'); + file_put_contents($root . '/foundation/stubs/database/table-migration.stub', 'Generated migration {{ class }} with {{ table_class }}'); + file_put_contents($root . '/foundation/stubs/database/migration.stub', 'Generated migration {{ class }}'); + file_put_contents($root . '/foundation/stubs/database/provider.stub', 'Generated provider {{ class }} in {{ namespace }}'); + + (new CommandTester($this->tableCommand($root)))->execute([ + 'name' => 'reports', + ]); + (new CommandTester($this->migrationCommand($root)))->execute([ + 'name' => '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->providerCommand($root)))->execute([]); + + $this->assertSame( + 'Generated table Reports_Table in Acme\\Plugin\\Database\\Tables', + (string) file_get_contents($root . '/src/Database/Tables/Reports_Table.php') + ); + $this->assertSame( + 'Generated migration Create_Reports_Table with Reports_Table', + (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php') + ); + $this->assertSame( + 'Generated migration Bump_Version', + (string) file_get_contents($root . '/src/Database/Migrations/Bump_Version.php') + ); + $this->assertSame( + 'Generated provider Provider in Acme\\Plugin\\Database', + (string) file_get_contents($root . '/src/Database/Provider.php') + ); + } + + public function test_database_generators_warn_when_the_runtime_dependency_is_missing_from_production_requirements(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('Runtime dependency missing:', $tester->getDisplay()); + $this->assertStringContainsString('composer require stellarwp/foundation-database', $tester->getDisplay()); + } + + public function test_database_provider_generator_warns_when_the_runtime_dependency_is_missing_from_production_requirements(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('Runtime dependency missing:', $tester->getDisplay()); + $this->assertStringContainsString('composer require stellarwp/foundation-database', $tester->getDisplay()); + } + + public function test_database_provider_generator_warns_when_the_runtime_dependency_is_only_a_development_dependency(): void { + $root = $this->temporaryProject([ + 'require-dev' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('Runtime dependency missing:', $tester->getDisplay()); + $this->assertStringContainsString('only in require-dev', $tester->getDisplay()); + } + + public function test_database_provider_generator_does_not_warn_when_the_runtime_dependency_is_in_production_requirements(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringNotContainsString('Runtime dependency missing:', $tester->getDisplay()); + } + + public function test_database_provider_generator_does_not_warn_when_the_aggregate_runtime_dependency_is_in_production_requirements(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation' => '^1.2', + ], + ]); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $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', + '--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', + '--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)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + '--namespace' => 'Acme Plugin\\Database\\Tables', + '--path' => 'custom/tables', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('Namespace "Acme Plugin\\Database\\Tables" is not a valid PHP namespace.', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/custom/tables/Reports_Table.php'); + } + + public function test_database_provider_generator_rejects_invalid_namespaces_before_writing_files(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([ + '--namespace' => 'Acme Plugin\\Database', + '--path' => 'custom/providers', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('Namespace "Acme Plugin\\Database" is not a valid PHP namespace.', $tester->getDisplay()); + $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', + '--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)); + + $statusCode = $tester->execute([ + '--namespace' => 'Acme\\PluginTools\\Database', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('Namespace "Acme\\PluginTools\\Database" is outside the Composer PSR-4 namespaces in composer.json.', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Tools/Database/Provider.php'); + } + + private function tableCommand(string $root): TableCommand { + return new TableCommand( + rootPath: $root, + autoloadResolver: new ComposerAutoloadResolver($root), + classNameResolver: new WordPressClassNameResolver(), + stubResolver: new StubResolver($root), + stubRenderer: new StubRenderer(), + fileWriter: new GeneratedFileWriter(), + providerUpdater: $this->providerUpdater() + ); + } + + private function migrationCommand(string $root): MigrationCommand { + return new MigrationCommand( + rootPath: $root, + autoloadResolver: new ComposerAutoloadResolver($root), + classNameResolver: new WordPressClassNameResolver(), + stubResolver: new StubResolver($root), + stubRenderer: new StubRenderer(), + fileWriter: new GeneratedFileWriter(), + providerUpdater: $this->providerUpdater() + ); + } + + private function providerCommand(string $root): ProviderCommand { + return new ProviderCommand( + rootPath: $root, + autoloadResolver: new ComposerAutoloadResolver($root), + classNameResolver: new WordPressClassNameResolver(), + stubResolver: new StubResolver($root), + stubRenderer: new StubRenderer(), + fileWriter: new GeneratedFileWriter() + ); + } + + private function providerUpdater(): ProviderRegistrationEditor { + return new ProviderRegistrationEditor( + sourceEditor: new PhpSourceEditor( + parserFactory: new ParserFactory(), + lexer: new Lexer() + ) + ); + } + + /** + * @param array $composer + */ + private function temporaryProject(array $composer = []): string { + $root = $this->temporaryRoot('foundation-make-database-test-'); + + file_put_contents($root . '/composer.json', json_encode(array_replace_recursive([ + 'autoload' => [ + 'psr-4' => [ + 'Acme\\Plugin\\' => 'src', + ], + ], + ], $composer), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $root; + } + + private function temporaryRoot(string $prefix): string { + $root = $this->tempDir . '/' . $prefix . bin2hex(random_bytes(8)); + + if (! mkdir($root, 0777, true) && ! is_dir($root)) { + $this->fail(sprintf('Could not create temporary root "%s".', $root)); + } + + $this->temporaryRoots[] = $root; + + return $root; + } + + private function removeDirectory(string $directory): void { + if (! is_dir($directory)) { + return; + } + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + + rmdir($directory); + } +} diff --git a/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php b/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php new file mode 100644 index 0000000..2a32cc2 --- /dev/null +++ b/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php @@ -0,0 +1,103 @@ +tempDir = $this->prepare_temp_dir('generated-file-writer'); + } + + public function test_it_writes_generated_files_to_nested_directories(): void { + $file = new GeneratedFile( + path: $this->tempDir . '/nested/Generated.php', + relativePath: 'nested/Generated.php', + contents: 'write($file); + + $this->assertFileExists($file->path); + $this->assertSame($file->contents, (string) file_get_contents($file->path)); + } + + public function test_it_refuses_to_overwrite_existing_files_without_force(): void { + $path = $this->tempDir . '/Generated.php'; + + file_put_contents($path, 'existing'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('File already exists: Generated.php. Use --force to overwrite it.'); + + (new GeneratedFileWriter())->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'replacement' + )); + } + + public function test_it_overwrites_existing_files_when_forced(): void { + $path = $this->tempDir . '/Generated.php'; + + file_put_contents($path, 'existing'); + + (new GeneratedFileWriter())->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'replacement' + ), force: true); + + $this->assertSame('replacement', (string) file_get_contents($path)); + } + + public function test_it_fails_when_the_target_directory_cannot_be_created(): void { + $path = $this->tempDir . '/blocked'; + + file_put_contents($path, 'file'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf('Could not create directory "%s/Generated.php".', $path)); + + set_error_handler(static fn (): bool => true); + + try { + (new GeneratedFileWriter())->write(new GeneratedFile( + path: $path . '/Generated.php/File.php', + relativePath: 'blocked/Generated.php/File.php', + contents: 'content' + )); + } finally { + restore_error_handler(); + } + } + + public function test_it_fails_when_the_generated_file_cannot_be_written(): void { + $path = $this->tempDir . '/Generated.php'; + + mkdir($path); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not write generated file "Generated.php".'); + + set_error_handler(static fn (): bool => true); + + try { + (new GeneratedFileWriter())->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'content' + ), force: true); + } finally { + restore_error_handler(); + } + } +} diff --git a/tests/Unit/Cli/Generation/PhpSourceEditorTest.php b/tests/Unit/Cli/Generation/PhpSourceEditorTest.php new file mode 100644 index 0000000..28f56ff --- /dev/null +++ b/tests/Unit/Cli/Generation/PhpSourceEditorTest.php @@ -0,0 +1,137 @@ +editor(); + + $this->assertFalse($editor->canParse('assertNull($editor->addImport('assertFalse($editor->canInsertIntoMergeArrayVar( + 'assertNull($editor->insertIntoMergeArrayVar( + 'get(Generated::class),' + )); + } + + public function test_it_returns_existing_source_when_an_import_already_exists(): void { + $contents = $this->fixture('existing-import'); + + $this->assertSame($contents, $this->editor()->addImport($contents, 'Acme\\Generated')); + } + + public function test_it_returns_null_when_a_line_comment_cannot_be_found(): void { + $this->assertNull($this->editor()->insertBeforeLineComment( + 'container->singleton(Generated::class);' + )); + } + + public function test_it_adds_imports_to_files_without_namespaces_or_opening_tags(): void { + $editor = $this->editor(); + + $this->assertStringStartsWith( + "addImport('assertStringContainsString( + 'use Acme\\Generated;final class Provider {}', + (string) $editor->addImport('final class Provider {}', 'Acme\\Generated') + ); + } + + public function test_it_adds_imports_after_a_last_use_without_a_trailing_newline(): void { + $contents = <<<'PHP' +assertStringContainsString( + "use Acme\\Existing;\nuse Acme\\Generated;\nfinal class Provider", + (string) $this->editor()->addImport($contents, 'Acme\\Generated') + ); + } + + public function test_it_ignores_function_imports_when_resolving_class_imports(): void { + $this->assertFalse($this->editor()->hasImport($this->fixture('function-import'), 'Acme\\Generated')); + } + + 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'; + + $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')); + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('wrong-constant-merge-array-var'), $class, 'MIGRATIONS')); + } + + 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', + 'MIGRATIONS', + '$this->container->get(Generated::class),' + ); + + $this->assertStringContainsString('$this->container->get(Generated::class),', (string) $updated); + } + + 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', + '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'; + + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('closure-without-container-parameter'), $class, 'MIGRATIONS')); + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('closure-without-array-return'), $class, 'MIGRATIONS')); + } + + 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', + 'MIGRATIONS', + '$this->container->get(Generated::class),' + ); + + $this->assertStringContainsString( + " \$this->container->get(Existing::class),\n \$this->container->get(Generated::class),", + (string) $updated + ); + } + + private function editor(): PhpSourceEditor { + return new PhpSourceEditor( + parserFactory: new ParserFactory(), + lexer: new Lexer() + ); + } + + private function fixture(string $name): string { + return (string) file_get_contents($this->data_dir('cli/generation/php-source-editor/' . $name . '.stub')); + } +} diff --git a/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php b/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php index 0270214..a18b11b 100644 --- a/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php +++ b/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php @@ -53,6 +53,30 @@ public function test_it_creates_a_description_from_a_command_class(): void { $this->assertSame('Sync products.', (new WordPressClassNameResolver())->description('Sync_Products_Command')); } + public function test_it_normalizes_generic_wordpress_class_names(): void { + $this->assertSame('Bump_Version', (new WordPressClassNameResolver())->className('bump-version')); + } + + public function test_it_normalizes_table_class_names(): void { + $this->assertSame('Reports_Table', (new WordPressClassNameResolver())->tableClass('reports')); + $this->assertSame('Reports_Table', (new WordPressClassNameResolver())->tableClass('Reports_Table')); + } + + public function test_it_creates_table_names_from_table_classes(): void { + $this->assertSame('reports', (new WordPressClassNameResolver())->tableName('Reports_Table')); + } + + public function test_it_uses_the_lowercase_class_name_when_a_table_name_has_no_words(): void { + $this->assertSame('@@@', (new WordPressClassNameResolver())->tableName('@@@')); + } + + public function test_it_creates_timestamped_migration_ids_from_migration_classes(): void { + $this->assertSame( + '2026_06_26_120000_create_reports_table', + (new WordPressClassNameResolver())->migrationId('Create_Reports_Table', new \DateTimeImmutable('2026-06-26 12:00:00')) + ); + } + public function test_it_fails_when_input_cannot_be_normalized_to_a_class_name(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not create a class name from "@@@".'); @@ -60,6 +84,13 @@ public function test_it_fails_when_input_cannot_be_normalized_to_a_class_name(): (new WordPressClassNameResolver())->commandClass('@@@'); } + public function test_it_fails_when_generic_class_input_cannot_be_normalized_to_a_class_name(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a class name from "@@@".'); + + (new WordPressClassNameResolver())->className('@@@'); + } + public function test_it_fails_when_the_generated_class_name_would_start_with_a_number(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not create a valid PHP class name from "2fa-sync".'); @@ -67,6 +98,13 @@ public function test_it_fails_when_the_generated_class_name_would_start_with_a_n (new WordPressClassNameResolver())->commandClass('2fa-sync'); } + public function test_it_fails_when_the_generic_class_name_would_start_with_a_number(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a valid PHP class name from "2fa-sync".'); + + (new WordPressClassNameResolver())->className('2fa-sync'); + } + public function test_it_fails_when_the_generated_class_name_would_conflict_with_the_base_command(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not create a command class named "Command" from "Command"'); @@ -74,6 +112,27 @@ public function test_it_fails_when_the_generated_class_name_would_conflict_with_ (new WordPressClassNameResolver())->commandClass('Command'); } + public function test_it_fails_when_table_input_cannot_be_normalized_to_a_class_name(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a table class name from "@@@".'); + + (new WordPressClassNameResolver())->tableClass('@@@'); + } + + public function test_it_fails_when_the_table_class_name_would_start_with_a_number(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a valid PHP class name from "2fa".'); + + (new WordPressClassNameResolver())->tableClass('2fa'); + } + + public function test_it_fails_when_migration_input_cannot_be_normalized_to_an_id(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a migration id from "@@@".'); + + (new WordPressClassNameResolver())->migrationId('@@@'); + } + public function test_it_uses_a_default_description_when_the_class_has_no_words(): void { $this->assertSame('Run the command.', (new WordPressClassNameResolver())->description('Command')); } diff --git a/tests/Unit/Container/ContainerAdapterTest.php b/tests/Unit/Container/ContainerAdapterTest.php index 6c6c27b..b94c6d1 100644 --- a/tests/Unit/Container/ContainerAdapterTest.php +++ b/tests/Unit/Container/ContainerAdapterTest.php @@ -25,6 +25,15 @@ public function test_it_returns_callbacks_from_the_wrapped_container(): void { $this->assertSame('value', $callback()); } + public function test_it_merges_array_bindings_on_the_wrapped_container(): void { + $adapter = new ContainerAdapter(new DI52Container()); + + $adapter->mergeArrayVar('values', ['first']); + $adapter->mergeArrayVar('values', static fn (): array => ['second']); + + $this->assertSame(['first', 'second'], $adapter->get('values')); + } + public function test_it_forwards_unknown_method_calls_to_the_wrapped_container(): void { $adapter = new ContainerAdapter(new DI52Container()); diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php new file mode 100644 index 0000000..26a72db --- /dev/null +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -0,0 +1,257 @@ +loadWpCliUtilities(); + + $database = new FakeDatabase(); + $wpSchema = new DatabaseSchema($database, static fn (string $sql): array => []); + $command = new Migrate( + $this->container, + 'foundation', + new Migrator( + new Store(new TableCollection($wpSchema, [ + new MigrationTable($database, 'wp_nexcess_foundation_migrations'), + new LockTable($database, 'wp_nexcess_foundation_locks'), + ])), + new Runner(new InMemoryRepository(), new RecordingSchema(), new InMemoryLock()), + new MigrationCollection() + ) + ); + + $command->register(); + + $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', + 'description' => 'Drop Foundation database tables.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => 'flag', + 'name' => 'prepare', + 'description' => 'Prepare Foundation migration storage without running migrations.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => 'flag', + 'name' => 'create-table', + 'description' => 'Alias for --prepare.', + '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_creates_database_tables_without_running_migrations(): void { + [$command, $repository, $schema] = $this->newCommand(); + + $this->assertSame(0, $command->runCommand([], ['prepare' => true])); + + $this->assertSame([], $repository->all()); + $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nexcess_foundation_locks', + ], $schema->statements); + } + + public function test_it_supports_create_table_as_an_alias_for_prepare(): void { + [$command, $repository, $schema] = $this->newCommand(); + + $this->assertSame(0, $command->runCommand([], ['create-table' => true])); + + $this->assertSame([], $repository->all()); + $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nexcess_foundation_locks', + ], $schema->statements); + } + + public function test_it_rejects_conflicting_migration_operations(): void { + [$command, $repository, $schema] = $this->newCommand(); + + $this->assertSame(1, $command->runCommand([], [ + 'run' => true, + 'prepare' => true, + ])); + + $this->assertSame([], $repository->all()); + $this->assertSame([], $schema->statements); + } + + public function test_it_runs_pending_migrations(): void { + [$command, $repository, $schema] = $this->newCommand(); + + $this->assertSame(0, $command->runCommand([], ['run' => true])); + + $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nexcess_foundation_locks', + '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([], ['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([], ['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_database_tables(): void { + [$command, , $schema] = $this->newCommand(); + + $command->runCommand([], ['create-table' => true]); + + $this->assertSame(0, $command->runCommand([], [ + 'drop' => true, + 'yes' => true, + ])); + + $this->assertSame([], $schema->tables); + $this->assertContains('drop:wp_nexcess_foundation_migrations', $schema->statements); + $this->assertContains('drop:wp_nexcess_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([], ['run' => true]); + + $this->expectOutputRegex('/2026_06_23_000001_create_example\s+ran\s+1\s+2026-01-01 00:00:00/'); + + $this->assertSame(0, $command->runCommand()); + } + + /** + * @return array{Migrate, InMemoryRepository, RecordingSchema} + */ + private function newCommand(): array { + $this->loadWpCliUtilities(); + + $database = new FakeDatabase(); + $wpSchema = new RecordingSchema(); + $repository = new InMemoryRepository(); + $runner = new Runner($repository, $wpSchema, new InMemoryLock()); + $command = new Migrate( + $this->container, + 'foundation', + new Migrator( + new Store(new TableCollection($wpSchema, [ + new MigrationTable($database, 'wp_nexcess_foundation_migrations'), + new LockTable($database, 'wp_nexcess_foundation_locks'), + ])), + $runner, + new MigrationCollection([ + new TestMigration('2026_06_23_000001_create_example'), + ]) + ) + ); + + return [$command, $repository, $wpSchema]; + } + + private function loadWpCliUtilities(): void { + $wpCliRoot = dirname(__DIR__, 4) . '/vendor/wp-cli/wp-cli'; + + if (! defined('WP_CLI_ROOT')) { + define('WP_CLI_ROOT', $wpCliRoot); + } + + require_once $wpCliRoot . '/php/utils.php'; + } +} diff --git a/tests/Unit/Database/Exceptions/QueryExceptionTest.php b/tests/Unit/Database/Exceptions/QueryExceptionTest.php new file mode 100644 index 0000000..cf53e54 --- /dev/null +++ b/tests/Unit/Database/Exceptions/QueryExceptionTest.php @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..7be661e --- /dev/null +++ b/tests/Unit/Database/Lock/DatabaseLockTest.php @@ -0,0 +1,102 @@ +database = new FakeDatabase(); + $this->clock = new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')); + $this->lock = new DatabaseLock($this->database, 'wp_nexcess_foundation_locks', $this->clock); + } + + public function test_it_acquires_a_database_lock_when_the_written_owner_matches(): void { + $this->database->rowResults[] = fn (string $sql, FakeDatabase $database): array => [ + 'owner' => $this->extractOwnerFromInsert($database->executed[0]), + 'expires_at' => '2026-01-01 00:01:00', + ]; + + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertSame('queue:sync', $token->name); + $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $this->database->executed[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_nexcess_foundation_locks`', $this->database->executed[0]); + $this->assertStringContainsString('owner', $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; + + $refreshed = $this->lock->refresh($token, 120); + + $this->assertInstanceOf(LockToken::class, $refreshed); + $this->assertSame('2026-01-01 00:02:00', $refreshed->expiresAt->format('Y-m-d H:i:s')); + $this->assertStringContainsString('UPDATE `wp_nexcess_foundation_locks`', $this->database->executed[0]); + } + + public function test_it_returns_null_when_refresh_does_not_update_a_row(): 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_checks_whether_a_lock_is_acquired(): void { + $this->database->rowResults[] = ['name' => 'queue:sync']; + + $this->assertTrue($this->lock->isAcquired('queue:sync')); + $this->assertStringContainsString("expires_at > '2026-01-01 00:00:00'", $this->database->rowQueries[0]); + } + + public function test_it_rejects_an_invalid_ttl(): void { + $this->expectException(InvalidArgumentException::class); + + $this->lock->acquire('queue:sync', 0); + } + + private function extractOwnerFromInsert(string $sql): string { + preg_match("/VALUES \\('queue:sync', '([a-f0-9]{32})', /", $sql, $matches); + + return $matches[1] ?? ''; + } +} diff --git a/tests/Unit/Database/Migration/CollectionTest.php b/tests/Unit/Database/Migration/CollectionTest.php new file mode 100644 index 0000000..d3f8dc3 --- /dev/null +++ b/tests/Unit/Database/Migration/CollectionTest.php @@ -0,0 +1,31 @@ +add($second); + + $this->assertSame([$first, $second], $collection->all()); + $this->assertSame([$first, $second], iterator_to_array($collection)); + } + + 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'), + ]); + } +} diff --git a/tests/Unit/Database/Migration/MigratorTest.php b/tests/Unit/Database/Migration/MigratorTest.php new file mode 100644 index 0000000..a271adf --- /dev/null +++ b/tests/Unit/Database/Migration/MigratorTest.php @@ -0,0 +1,123 @@ +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([ + 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nexcess_foundation_locks', + 'up:2026_06_23_000001_create_example', + ], $schema->statements); + } + + public function test_it_prepares_the_store_before_rolling_back_configured_migrations(): void { + [$migrator, $repository, $schema] = $this->newMigrator(); + + $migrator->run(); + $migrator->drop(); + $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([ + 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nexcess_foundation_locks', + 'down:2026_06_23_000001_create_example', + ], $schema->statements); + } + + public function test_it_prepares_the_store_before_refreshing_configured_migrations(): void { + [$migrator, $repository, $schema] = $this->newMigrator(); + + $migrator->run(); + $migrator->drop(); + $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([ + 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nexcess_foundation_locks', + '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->assertFalse($migrator->status()[0]->ran); + $this->assertSame([], $schema->statements); + + $migrator->run(); + + $this->assertTrue($migrator->status()[0]->ran); + } + + public function test_it_prepares_and_drops_the_migration_store(): void { + [$migrator, , $schema] = $this->newMigrator(); + + $this->assertFalse($migrator->exists()); + + $migrator->prepare(); + + $this->assertTrue($migrator->exists()); + + $migrator->drop(); + + $this->assertFalse($migrator->exists()); + $this->assertContains('drop:wp_nexcess_foundation_migrations', $schema->statements); + $this->assertContains('drop:wp_nexcess_foundation_locks', $schema->statements); + } + + /** + * @return array{Migrator, InMemoryRepository, RecordingSchema} + */ + private function newMigrator(): array { + $database = new FakeDatabase(); + $schema = new RecordingSchema(); + $repository = new InMemoryRepository(); + + return [ + new Migrator( + new Store(new TableCollection($schema, [ + new MigrationTable($database, 'wp_nexcess_foundation_migrations'), + new LockTable($database, 'wp_nexcess_foundation_locks'), + ])), + new Runner($repository, $schema, new InMemoryLock()), + new Collection([ + new TestMigration('2026_06_23_000001_create_example'), + ]) + ), + $repository, + $schema, + ]; + } +} diff --git a/tests/Unit/Database/Migration/RepositoryTest.php b/tests/Unit/Database/Migration/RepositoryTest.php new file mode 100644 index 0000000..2f77b79 --- /dev/null +++ b/tests/Unit/Database/Migration/RepositoryTest.php @@ -0,0 +1,80 @@ +database = new FakeDatabase(); + $this->repository = new Repository($this->database, 'wp_nexcess_foundation_migrations'); + } + + 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); + } + + public function test_it_records_a_migration_run(): void { + $this->database->rowResults[] = [ + 'id' => 1, + 'migration' => '2026_01_01_000001_create_users', + 'batch' => 2, + 'ran_at' => '2026-01-01 00:00:00', + ]; + + $record = $this->repository->recordRun('2026_01_01_000001_create_users', 2); + + $this->assertSame(2, $record->batch); + $this->assertStringContainsString('INSERT INTO `wp_nexcess_foundation_migrations`', $this->database->executed[0]); + } + + 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->assertStringContainsString('DELETE FROM `wp_nexcess_foundation_migrations`', $this->database->executed[0]); + } + + public function test_it_calculates_the_next_batch(): void { + $this->database->rowResults[] = ['batch' => 4]; + + $this->assertSame(5, $this->repository->nextBatch()); + } + + 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); + } +} diff --git a/tests/Unit/Database/Migration/RunnerTest.php b/tests/Unit/Database/Migration/RunnerTest.php new file mode 100644 index 0000000..30a1655 --- /dev/null +++ b/tests/Unit/Database/Migration/RunnerTest.php @@ -0,0 +1,222 @@ +repository = new InMemoryRepository(); + $this->schema = new RecordingSchema(); + $this->lock = new InMemoryLock(new MutableClock(new \DateTimeImmutable('2026-01-01 00:00:00'))); + $this->runner = new Runner($this->repository, $this->schema, $this->lock); + } + + public function test_it_runs_pending_migrations_in_order(): void { + $result = $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + ]); + + $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_skips_migrations_that_have_already_run(): void { + $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + + $result = $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + ]); + + $this->assertSame(['2026_01_01_000002_create_posts'], $result->ran); + $this->assertSame(['2026_01_01_000001_create_users'], $result->skipped); + $this->assertSame(2, $this->repository->all()['2026_01_01_000002_create_posts']->batch); + } + + public function test_it_rolls_back_the_latest_batch_in_reverse_order(): void { + $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + $this->runner->run([ + new TestMigration('2026_01_01_000002_create_posts'), + new TestMigration('2026_01_01_000003_create_comments'), + ]); + + $this->schema->statements = []; + + $result = $this->runner->rollback([ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + new TestMigration('2026_01_01_000003_create_comments'), + ]); + + $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_returns_an_empty_result_when_there_is_no_batch_to_roll_back(): void { + $result = $this->runner->rollback([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + + $this->assertSame([], $result->rolledBack); + $this->assertSame(0, $result->count()); + } + + public function test_it_skips_rollback_records_without_a_matching_migration(): void { + $this->repository->recordRun('2026_01_01_000001_missing_migration', 1); + + $result = $this->runner->rollback([ + new TestMigration('2026_01_01_000002_create_posts'), + ]); + + $this->assertSame([], $result->rolledBack); + $this->assertSame(['2026_01_01_000001_missing_migration'], $result->skipped); + } + + public function test_it_refreshes_all_ran_migrations_then_runs_them_again(): void { + $migrations = [ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + ]; + + $this->runner->run($migrations); + $this->schema->statements = []; + + $result = $this->runner->refresh($migrations); + + $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_returns_status_for_configured_migrations(): void { + $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + + $statuses = $this->runner->status([ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + ]); + + $this->assertTrue($statuses[0]->ran); + $this->assertSame(1, $statuses[0]->batch); + $this->assertFalse($statuses[1]->ran); + $this->assertNull($statuses[1]->batch); + } + + public function test_migration_results_count_ran_and_rolled_back_migrations(): void { + $result = new Result( + ran: ['2026_01_01_000001_create_users'], + rolledBack: ['2026_01_01_000002_create_posts'], + skipped: ['2026_01_01_000003_create_comments'] + ); + + $this->assertSame(2, $result->count()); + } + + public function test_it_rejects_duplicate_migration_ids(): void { + $this->expectException(DuplicateMigration::class); + $this->expectExceptionMessage('Duplicate migration ID'); + + $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000001_create_users'), + ]); + } + + public function test_it_fails_when_the_migration_lock_is_already_owned(): void { + $this->lock->acquire('foundation-database-migrations', 300); + + $this->expectException(MigrationLockFailed::class); + $this->expectExceptionMessage('Could not acquire migration lock'); + + $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + } + + public function test_it_does_not_record_a_failed_migration(): void { + $this->expectException(MigrationFailed::class); + $this->expectExceptionMessage('failed while running'); + + try { + $this->runner->run([ + new FailingMigration('2026_01_01_000001_create_users', failUp: true), + ]); + } finally { + $this->assertFalse($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + + public function test_it_does_not_delete_a_record_when_rollback_fails(): void { + $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + + $this->expectException(MigrationFailed::class); + $this->expectExceptionMessage('failed while rolling back'); + + try { + $this->runner->rollback([ + new FailingMigration('2026_01_01_000001_create_users', failDown: true), + ]); + } finally { + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } +} diff --git a/tests/Unit/Database/Query/QueryBuilderTest.php b/tests/Unit/Database/Query/QueryBuilderTest.php new file mode 100644 index 0000000..d8b63dc --- /dev/null +++ b/tests/Unit/Database/Query/QueryBuilderTest.php @@ -0,0 +1,84 @@ +table($table, '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_rejects_unsupported_operators(): void { + $this->expectException(InvalidArgumentException::class); + + (new FakeDatabase())->table('reports')->where('status', 'BETWEEN', ['a', 'z']); + } + + public function test_it_rejects_invalid_order_directions(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Order direction must be ASC or DESC.'); + + (new FakeDatabase())->table('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.'); + + (new FakeDatabase())->table('reports')->limit(0); + } + + public function test_it_rejects_negative_offsets(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Query offset cannot be negative.'); + + (new FakeDatabase())->table('reports')->limit(10, -1); + } + + public function test_it_reads_the_first_row(): void { + $database = new FakeDatabase(); + $database->rowResults[] = ['name' => 'first']; + + $this->assertSame( + ['name' => 'first'], + $database->table('reports')->where('id', '=', 1)->first() + ); + } + + public function test_it_selects_all_columns_by_default(): void { + $query = (new FakeDatabase())->table('reports'); + + $this->assertSame('SELECT * FROM `wp_reports`', $query->toSql()); + } + + public function test_it_builds_query_objects(): void { + $query = (new FakeDatabase())->table('reports')->where('id', '=', 10)->query(); + + $this->assertInstanceOf(Query::class, $query); + $this->assertSame('SELECT * FROM `wp_reports` WHERE `id` = %s', $query->toSql()); + $this->assertSame([10], $query->bindings()); + } +} diff --git a/tests/Unit/Database/Query/QueryTest.php b/tests/Unit/Database/Query/QueryTest.php new file mode 100644 index 0000000..089af6c --- /dev/null +++ b/tests/Unit/Database/Query/QueryTest.php @@ -0,0 +1,32 @@ +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 = new FakeDatabase(); + $query = new Query($database, 'SELECT name FROM %i WHERE id = %d', ['wp_reports', 1]); + + $database->rowsResults[] = [['name' => 'first']]; + $database->rowResults[] = ['name' => 'first']; + $database->rowResults[] = ['count' => 3]; + + $this->assertSame([['name' => 'first']], $query->get()); + $this->assertSame(['name' => 'first'], $query->first()); + $this->assertSame(3, $query->value()); + } +} diff --git a/tests/Unit/Database/SchemaTest.php b/tests/Unit/Database/SchemaTest.php new file mode 100644 index 0000000..23da7af --- /dev/null +++ b/tests/Unit/Database/SchemaTest.php @@ -0,0 +1,48 @@ +createOrUpdate('CREATE TABLE example (id bigint)'); + + $this->assertSame(['CREATE TABLE example (id bigint)'], $statements); + } + + public function test_it_checks_tables_and_indexes(): void { + $database = new FakeDatabase(); + $database->rowResults[] = ['table' => 'wp_example']; + $database->rowResults[] = ['Key_name' => 'example_key']; + $schema = new Schema($database, static fn (string $sql): array => []); + + $this->assertTrue($schema->hasTable('wp_example%')); + $this->assertTrue($schema->hasIndex('wp_example', '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_drops_indexes(): void { + $database = new FakeDatabase(); + $schema = new Schema($database, static fn (string $sql): array => []); + + $schema->dropIndex('wp_example', 'example_key'); + + $this->assertSame('ALTER TABLE `wp_example` DROP INDEX `example_key`', $database->executed[0]); + } + + public function test_it_exposes_identifier_helpers(): void { + $schema = new Schema(new FakeDatabase(), static fn (string $sql): array => []); + + $this->assertSame('`weird``table`', $schema->quoteIdentifier('weird`table')); + } +} diff --git a/tests/Unit/Database/Table/CollectionTest.php b/tests/Unit/Database/Table/CollectionTest.php new file mode 100644 index 0000000..cc6e58f --- /dev/null +++ b/tests/Unit/Database/Table/CollectionTest.php @@ -0,0 +1,57 @@ +tables['existing'] = true; + $collection = new Collection($schema, [$existing, $missing]); + + $collection->create(); + + $this->assertSame(['createOrUpdate:missing'], $schema->statements); + $this->assertTrue($schema->hasTable($existing)); + $this->assertTrue($schema->hasTable($missing)); + } + + public function test_it_drops_all_tables(): void { + $first = new TestTable('first_table', 'first'); + $second = new TestTable('second_table', 'second'); + $schema = new RecordingSchema(); + + $collection = new Collection($schema, [$first]); + $collection->add($second); + $collection->drop(); + + $this->assertSame(['drop:first', 'drop:second'], $schema->statements); + $this->assertSame([$first, $second], $collection->all()); + $this->assertSame([$first, $second], iterator_to_array($collection)); + } + + public function test_it_checks_whether_all_tables_exist(): void { + $schema = new RecordingSchema(); + $first = new TestTable('first_table', 'first'); + $second = new TestTable('second_table', 'second'); + + $schema->tables = [ + 'first' => true, + 'second' => true, + ]; + + $this->assertTrue((new Collection($schema, [$first, $second]))->exists()); + + unset($schema->tables['second']); + + $this->assertFalse((new Collection($schema, [$first, $second]))->exists()); + } +} diff --git a/tests/Unit/Database/Table/ColumnTest.php b/tests/Unit/Database/Table/ColumnTest.php new file mode 100644 index 0000000..051947c --- /dev/null +++ b/tests/Unit/Database/Table/ColumnTest.php @@ -0,0 +1,59 @@ +assertSame('`queue_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $column->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'))->sql() + ); + + $this->assertSame( + '`attempts` int(10) unsigned NOT NULL DEFAULT 0', + (new Column('attempts', 'int', 10, unsigned: true, default: 0))->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()->default(null)->sql() + ); + + $this->assertSame( + '`enabled` tinyint(1) unsigned NOT NULL DEFAULT 1', + (new Column('enabled', 'tinyint', 1))->unsigned()->default(true)->sql() + ); + } + + public function test_it_returns_modified_column_copies(): void { + $column = new Column('id', 'bigint', 20); + + $this->assertSame('`id` bigint(20) NOT NULL', $column->sql()); + $this->assertSame('`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $column->unsigned()->autoIncrement()->sql()); + } + + public function test_auto_increment_is_idempotent(): void { + $this->assertSame( + '`id` bigint(20) NOT NULL AUTO_INCREMENT', + (new Column('id', 'bigint', 20))->autoIncrement()->autoIncrement()->sql() + ); + } +} diff --git a/tests/Unit/Database/Table/CreateTableTest.php b/tests/Unit/Database/Table/CreateTableTest.php new file mode 100644 index 0000000..0ab960b --- /dev/null +++ b/tests/Unit/Database/Table/CreateTableTest.php @@ -0,0 +1,51 @@ +assertSame('foundation_example_table', $migration->id()); + } + + public function test_it_creates_missing_tables(): void { + $table = new TestTable('foundation_example_table', 'wp_example'); + $migration = new CreateTable($table); + $schema = new RecordingSchema(); + + $migration->up($schema); + + $this->assertTrue($schema->hasTable($table)); + } + + public function test_it_does_not_create_existing_tables(): void { + $table = new TestTable('foundation_example_table', 'wp_example'); + $migration = new CreateTable($table); + $schema = new RecordingSchema(); + + $schema->tables['wp_example'] = true; + + $migration->up($schema); + + $this->assertSame([], $schema->statements); + } + + public function test_it_drops_tables_when_rolled_back(): void { + $table = new TestTable('foundation_example_table', 'wp_example'); + $migration = new CreateTable($table); + $schema = new RecordingSchema(); + + $schema->tables['wp_example'] = true; + + $migration->down($schema); + + $this->assertFalse($schema->hasTable($table)); + } +} diff --git a/tests/Unit/Database/Table/TableDefinitionTest.php b/tests/Unit/Database/Table/TableDefinitionTest.php new file mode 100644 index 0000000..acc5c90 --- /dev/null +++ b/tests/Unit/Database/Table/TableDefinitionTest.php @@ -0,0 +1,132 @@ +bigIncrements('id') + ->string('status', 20) + ->text('payload') + ->dateTime('created_at') + ->index('status', 'status'); + + $this->assertCount(4, $definition->columns()); + $this->assertCount(2, $definition->indexes()); + $this->assertSame([], $definition->validationErrors()); + } + + public function test_it_defines_queue_style_columns_with_modifiers(): void { + $definition = TableDefinition::for(new TestTable('queue_table', 'wp_queue')) + ->bigIncrements('id') + ->string('queue', 255) + ->string('task_handler', 255) + ->longText('args') + ->integer('priority', 3)->nullable() + ->dateTime('run_after')->default('0000-00-00 00:00:00') + ->integer('taken')->default(0) + ->integer('done')->nullable()->default(0) + ->tinyInteger('tries')->unsigned()->default(0) + ->tinyInteger('failed', 1)->unsigned()->default(false) + ->index('done', 'done') + ->index('taken_failed', 'taken', 'failed') + ->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->validationErrors()); + } + + public function test_it_defines_less_common_column_helpers(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->bigInteger('remote_id')->unsigned() + ->string('status')->nullable()->notNull()->default('draft') + ->text('payload')->extra('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())); + } + + public function test_it_rejects_indexes_that_reference_missing_columns(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->string('status', 20) + ->index('missing_index', 'missing'); + + $this->assertSame(['Index missing_index references missing column missing.'], $definition->validationErrors()); + + $this->expectException(InvalidArgumentException::class); + + $definition->assertValid(); + } + + public function test_it_rejects_tables_without_columns(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')); + + $this->assertSame(['Table reports_table does not define any columns.'], $definition->validationErrors()); + } + + public function test_it_rejects_indexes_without_columns(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('An index must define at least one column.'); + + TableDefinition::for(new TestTable('reports_table', 'wp_reports'))->index('empty_index'); + } + + public function test_it_rejects_column_modifiers_without_a_column(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A column modifier must follow a column definition.'); + + TableDefinition::for(new TestTable('reports_table', 'wp_reports'))->nullable(); + } + + public function test_it_rejects_column_modifiers_after_index_definitions(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A column modifier must follow a column definition.'); + + TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->string('status') + ->index('status', 'status') + ->default('draft'); + } + + public function test_it_reports_duplicate_primary_keys(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->bigIncrements('id') + ->string('status') + ->primary('status'); + + $this->assertContains('A table can define only one primary key.', $definition->validationErrors()); + } + + public function test_it_reports_duplicate_index_names(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->bigIncrements('id') + ->string('status') + ->string('type') + ->index('status_lookup', 'status') + ->index('status_lookup', 'type'); + + $this->assertContains('Index status_lookup is defined more than once.', $definition->validationErrors()); + } +} diff --git a/tests/Unit/Database/Table/Tables/LockTableTest.php b/tests/Unit/Database/Table/Tables/LockTableTest.php new file mode 100644 index 0000000..c63e689 --- /dev/null +++ b/tests/Unit/Database/Table/Tables/LockTableTest.php @@ -0,0 +1,38 @@ +createOrUpdate($table); + + $this->assertSame(LockTable::ID, $table->id()); + $this->assertSame('wp_nexcess_foundation_locks', $table->name()); + $this->assertStringContainsString('CREATE TABLE `wp_nexcess_foundation_locks`', $statements[0]); + $this->assertStringContainsString('PRIMARY KEY (`name`)', $statements[0]); + $this->assertStringContainsString('KEY `expires_at`', $statements[0]); + } + + public function test_it_drops_the_lock_table(): void { + $database = new FakeDatabase(); + $schema = new DatabaseSchema($database, static fn (string $sql): array => []); + $table = new LockTable($database, 'wp_nexcess_foundation_locks'); + + $schema->drop($table); + + $this->assertSame('DROP TABLE IF EXISTS `wp_nexcess_foundation_locks`', $database->executed[0]); + } +} diff --git a/tests/Unit/Database/Table/Tables/MigrationTableTest.php b/tests/Unit/Database/Table/Tables/MigrationTableTest.php new file mode 100644 index 0000000..ca53661 --- /dev/null +++ b/tests/Unit/Database/Table/Tables/MigrationTableTest.php @@ -0,0 +1,37 @@ +createOrUpdate($table); + + $this->assertSame(MigrationTable::ID, $table->id()); + $this->assertSame('wp_nexcess_foundation_migrations', $table->name()); + $this->assertStringContainsString('CREATE TABLE `wp_nexcess_foundation_migrations`', $statements[0]); + $this->assertStringContainsString('UNIQUE KEY `migration`', $statements[0]); + } + + public function test_it_drops_the_migration_table(): void { + $database = new FakeDatabase(); + $schema = new DatabaseSchema($database, static fn (string $sql): array => []); + $table = new MigrationTable($database, 'wp_nexcess_foundation_migrations'); + + $schema->drop($table); + + $this->assertSame('DROP TABLE IF EXISTS `wp_nexcess_foundation_migrations`', $database->executed[0]); + } +} diff --git a/tests/Unit/Identifier/IdentifierProviderTest.php b/tests/Unit/Identifier/IdentifierProviderTest.php new file mode 100644 index 0000000..5f162fa --- /dev/null +++ b/tests/Unit/Identifier/IdentifierProviderTest.php @@ -0,0 +1,54 @@ +container->register(IdentifierProvider::class); + + $this->assertInstanceOf(RandomizerEntropy::class, $this->container->get(Entropy::class)); + $this->assertInstanceOf(SystemMillisecondClock::class, $this->container->get(MillisecondClock::class)); + $this->assertInstanceOf(UlidGenerator::class, $this->container->get(UlidGeneratorContract::class)); + $this->assertInstanceOf(UlidGenerator::class, $this->container->get(UlidGenerator::class)); + $this->assertInstanceOf(UlidValidator::class, $this->container->get(UlidValidator::class)); + } + + public function test_consumers_can_bind_ulids_as_the_default_identifier_strategy(): void { + $this->container->register(IdentifierProvider::class); + $this->container->bind(IdentifierGenerator::class, static fn (C $c): UlidGeneratorContract => $c->get(UlidGeneratorContract::class)); + + $this->assertInstanceOf(UlidGeneratorContract::class, $this->container->get(IdentifierGenerator::class)); + } + + public function test_it_does_not_bind_the_generic_identifier_contract_by_default(): void { + $this->container->register(IdentifierProvider::class); + + $this->assertFalse($this->container->has(IdentifierGenerator::class)); + } + + public function test_it_generates_ulids_with_configured_provider_dependencies(): void { + $entropy = new FixedEntropy(str_repeat("\0", 10)); + + $this->container->register(IdentifierProvider::class); + $this->container->bind(Entropy::class, $entropy); + $this->container->bind(MillisecondClock::class, new FixedMillisecondClock(1_469_918_176_385)); + + $this->assertSame('01ARYZ6S410000000000000000', $this->container->get(UlidGeneratorContract::class)->generate()); + $this->assertSame([10], $entropy->requestedLengths()); + } +} diff --git a/tests/Unit/Identifier/Ulid/RandomizerEntropyTest.php b/tests/Unit/Identifier/Ulid/RandomizerEntropyTest.php new file mode 100644 index 0000000..4f3e8e0 --- /dev/null +++ b/tests/Unit/Identifier/Ulid/RandomizerEntropyTest.php @@ -0,0 +1,15 @@ +assertSame(10, strlen((new RandomizerEntropy(new Randomizer(new Secure())))->bytes(10))); + } +} diff --git a/tests/Unit/Identifier/Ulid/SystemMillisecondClockTest.php b/tests/Unit/Identifier/Ulid/SystemMillisecondClockTest.php new file mode 100644 index 0000000..c35418e --- /dev/null +++ b/tests/Unit/Identifier/Ulid/SystemMillisecondClockTest.php @@ -0,0 +1,18 @@ +milliseconds(); + $after = (int) floor(microtime(true) * 1000); + + $this->assertGreaterThanOrEqual($before, $now); + $this->assertLessThanOrEqual($after, $now); + } +} diff --git a/tests/Unit/Identifier/Ulid/UlidGeneratorTest.php b/tests/Unit/Identifier/Ulid/UlidGeneratorTest.php new file mode 100644 index 0000000..905e7c4 --- /dev/null +++ b/tests/Unit/Identifier/Ulid/UlidGeneratorTest.php @@ -0,0 +1,96 @@ +assertInstanceOf(IdentifierGenerator::class, $generator); + $this->assertInstanceOf(UlidGeneratorContract::class, $generator); + } + + public function test_it_generates_valid_ulids(): void { + $identifier = (new UlidGenerator( + new FixedEntropy(str_repeat("\0", 10)), + new FixedMillisecondClock(0) + ))->generate(); + + $this->assertSame(26, strlen($identifier)); + $this->assertTrue((new UlidValidator())->isValid($identifier)); + } + + public function test_it_encodes_the_timestamp_before_randomness(): void { + $entropy = new FixedEntropy(str_repeat("\0", 10)); + + $generator = new UlidGenerator( + $entropy, + new FixedMillisecondClock(1_469_918_176_385) + ); + + $this->assertSame('01ARYZ6S410000000000000000', $generator->generate()); + $this->assertSame([10], $entropy->requestedLengths()); + } + + public function test_it_accepts_the_maximum_ulid_timestamp(): void { + $generator = new UlidGenerator( + new FixedEntropy(str_repeat("\0", 10)), + new FixedMillisecondClock(281_474_976_710_655) + ); + + $this->assertSame('7ZZZZZZZZZ0000000000000000', $generator->generate()); + } + + public function test_it_encodes_randomness(): void { + $generator = new UlidGenerator( + new FixedEntropy(str_repeat("\xff", 10)), + new FixedMillisecondClock(0) + ); + + $this->assertSame('0000000000ZZZZZZZZZZZZZZZZ', $generator->generate()); + } + + public function test_it_rejects_timestamps_outside_the_ulid_range(): void { + $this->expectException(OutOfRangeException::class); + $this->expectExceptionMessage('ULID timestamps must be between 0 and 281474976710655 milliseconds.'); + + (new UlidGenerator( + new FixedEntropy(str_repeat("\0", 10)), + new FixedMillisecondClock(-1) + ))->generate(); + } + + public function test_it_rejects_timestamps_above_the_ulid_range(): void { + $this->expectException(OutOfRangeException::class); + $this->expectExceptionMessage('ULID timestamps must be between 0 and 281474976710655 milliseconds.'); + + (new UlidGenerator( + new FixedEntropy(str_repeat("\0", 10)), + new FixedMillisecondClock(281_474_976_710_656) + ))->generate(); + } + + public function test_it_rejects_entropy_that_does_not_return_ten_bytes(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('ULID generation requires exactly 10 random bytes.'); + + (new UlidGenerator( + new FixedEntropy(str_repeat("\0", 9)), + new FixedMillisecondClock(0) + ))->generate(); + } +} diff --git a/tests/Unit/Identifier/Ulid/UlidValidatorTest.php b/tests/Unit/Identifier/Ulid/UlidValidatorTest.php new file mode 100644 index 0000000..3e69363 --- /dev/null +++ b/tests/Unit/Identifier/Ulid/UlidValidatorTest.php @@ -0,0 +1,43 @@ + + */ + public static function invalidUlidProvider(): array { + return [ + 'empty' => ['identifier' => ''], + 'too short' => ['identifier' => '01ARYZ6S41000000000000000'], + 'too long' => ['identifier' => '01ARYZ6S4100000000000000000'], + 'lowercase' => ['identifier' => '01aryz6s410000000000000000'], + 'ambiguous i' => ['identifier' => '01ARYZ6S41000000000000000I'], + 'ambiguous l' => ['identifier' => '01ARYZ6S41000000000000000L'], + 'ambiguous o' => ['identifier' => '01ARYZ6S41000000000000000O'], + 'excluded u' => ['identifier' => '01ARYZ6S41000000000000000U'], + 'timestamp above' => ['identifier' => '81ARYZ6S410000000000000000'], + ]; + } + + public function test_it_accepts_canonical_ulids(): void { + $this->assertTrue((new UlidValidator())->isValid('01ARYZ6S410000000000000000')); + } + + public function test_it_accepts_canonical_ulids_at_the_maximum_timestamp(): void { + $this->assertTrue((new UlidValidator())->isValid('7ZZZZZZZZZ0000000000000000')); + } + + /** + * @dataProvider invalidUlidProvider + */ + #[DataProvider('invalidUlidProvider')] + public function test_it_rejects_invalid_ulids(string $identifier): void { + $this->assertFalse((new UlidValidator())->isValid($identifier)); + } +} diff --git a/tests/Unit/Lock/InMemoryLockTest.php b/tests/Unit/Lock/InMemoryLockTest.php new file mode 100644 index 0000000..21325b9 --- /dev/null +++ b/tests/Unit/Lock/InMemoryLockTest.php @@ -0,0 +1,196 @@ +clock = new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')); + $this->lock = new InMemoryLock($this->clock); + } + + public function test_it_acquires_a_named_lock(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertSame('queue:sync', $token->name); + $this->assertTrue($this->lock->isAcquired('queue:sync')); + } + + public function test_it_refuses_to_acquire_a_lock_that_is_already_owned(): void { + $first = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $first); + $this->assertNull($this->lock->acquire('queue:sync', 60)); + } + + public function test_it_tracks_named_locks_independently(): void { + $sync = $this->lock->acquire('queue:sync', 60); + $cleanup = $this->lock->acquire('queue:cleanup', 60); + + $this->assertInstanceOf(LockToken::class, $sync); + $this->assertInstanceOf(LockToken::class, $cleanup); + + $this->assertTrue($this->lock->release($sync)); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + $this->assertTrue($this->lock->isAcquired('queue:cleanup')); + } + + public function test_it_expires_named_locks_independently(): void { + $sync = $this->lock->acquire('queue:sync', 30); + $cleanup = $this->lock->acquire('queue:cleanup', 90); + + $this->clock->advance(31); + + $this->assertInstanceOf(LockToken::class, $sync); + $this->assertInstanceOf(LockToken::class, $cleanup); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + $this->assertTrue($this->lock->isAcquired('queue:cleanup')); + } + + public function test_it_allows_a_lock_to_be_acquired_after_it_expires(): void { + $first = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(61); + + $second = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $first); + $this->assertInstanceOf(LockToken::class, $second); + $this->assertNotSame($first->owner, $second->owner); + } + + public function test_it_treats_a_lock_as_expired_at_the_expiration_boundary(): void { + $first = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(60); + + $second = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $first); + $this->assertFalse($this->lock->release($first)); + $this->assertInstanceOf(LockToken::class, $second); + $this->assertNotSame($first->owner, $second->owner); + } + + public function test_it_releases_a_lock_with_the_matching_token(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertTrue($this->lock->release($token)); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + } + + public function test_it_refuses_to_release_a_lock_with_a_different_token(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertFalse($this->lock->release(new LockToken( + name: 'queue:sync', + owner: 'other-owner', + expiresAt: $token->expiresAt + ))); + $this->assertTrue($this->lock->isAcquired('queue:sync')); + } + + public function test_it_does_not_release_an_expired_lock(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(61); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertFalse($this->lock->release($token)); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + } + + public function test_it_refreshes_a_lock_with_the_matching_token(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(30); + + $this->assertInstanceOf(LockToken::class, $token); + + $refreshed = $this->lock->refresh($token, 120); + + $this->assertInstanceOf(LockToken::class, $refreshed); + $this->assertSame($token->name, $refreshed->name); + $this->assertSame($token->owner, $refreshed->owner); + $this->assertSame('2026-01-01 00:02:30', $refreshed->expiresAt->format('Y-m-d H:i:s')); + + $this->clock->advance(31); + + $this->assertTrue($this->lock->isAcquired('queue:sync')); + + $this->clock->advance(89); + + $this->assertFalse($this->lock->isAcquired('queue:sync')); + } + + public function test_it_refuses_to_refresh_a_lock_with_a_different_token(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertNull($this->lock->refresh(new LockToken( + name: 'queue:sync', + owner: 'other-owner', + expiresAt: $token->expiresAt + ), 120)); + } + + public function test_it_refuses_to_refresh_an_expired_lock(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(61); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertNull($this->lock->refresh($token, 120)); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + } + + public function test_it_rejects_an_empty_lock_name(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock name cannot be empty.'); + + $this->lock->acquire('', 60); + } + + public function test_it_rejects_an_invalid_ttl(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->acquire('queue:sync', 0); + } + + public function test_it_rejects_an_invalid_ttl_when_the_lock_is_already_owned(): void { + $this->lock->acquire('queue:sync', 60); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->acquire('queue:sync', 0); + } + + public function test_it_rejects_an_invalid_ttl_when_refreshing_a_lock(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->refresh($token, 0); + } +} diff --git a/tests/Unit/Lock/LockTokenTest.php b/tests/Unit/Lock/LockTokenTest.php new file mode 100644 index 0000000..2f0425f --- /dev/null +++ b/tests/Unit/Lock/LockTokenTest.php @@ -0,0 +1,82 @@ +assertFalse($token->isExpired(new DateTimeImmutable('2026-01-01 00:00:59'))); + $this->assertTrue($token->isExpired(new DateTimeImmutable('2026-01-01 00:01:00'))); + } + + public function test_it_matches_tokens_for_the_same_lock_owner(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->assertTrue($token->matches(new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:02:00') + ))); + $this->assertFalse($token->matches(new LockToken( + name: 'queue:sync', + owner: 'other-owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ))); + $this->assertFalse($token->matches(new LockToken( + name: 'queue:cleanup', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ))); + } + + public function test_it_refreshes_with_the_same_lock_name_and_owner(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $refreshed = $token->refresh(new DateTimeImmutable('2026-01-01 00:02:00')); + + $this->assertSame($token->name, $refreshed->name); + $this->assertSame($token->owner, $refreshed->owner); + $this->assertSame('2026-01-01 00:02:00', $refreshed->expiresAt->format('Y-m-d H:i:s')); + } + + public function test_it_rejects_an_empty_lock_name(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock name cannot be empty.'); + + new LockToken( + name: '', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + } + + public function test_it_rejects_an_empty_owner(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock owner cannot be empty.'); + + new LockToken( + name: 'queue:sync', + owner: '', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + } +} diff --git a/tests/Unit/Lock/SystemClockTest.php b/tests/Unit/Lock/SystemClockTest.php new file mode 100644 index 0000000..03ec926 --- /dev/null +++ b/tests/Unit/Lock/SystemClockTest.php @@ -0,0 +1,19 @@ +now(); + $after = new DateTimeImmutable(); + + $this->assertGreaterThanOrEqual($before, $now); + $this->assertLessThanOrEqual($after, $now); + } +} diff --git a/tests/WPUnitSupport/WPTestCase.php b/tests/WPUnitSupport/WPTestCase.php index 910d806..92be16d 100644 --- a/tests/WPUnitSupport/WPTestCase.php +++ b/tests/WPUnitSupport/WPTestCase.php @@ -2,7 +2,11 @@ namespace StellarWP\Foundation\Tests\WPUnitSupport; +use Adbar\Dot; use lucatume\WPBrowser\TestCase\WPTestCase as CodeceptionWPTestCase; +use StellarWP\ContainerContract\ContainerInterface; +use StellarWP\Foundation\Container\ContainerAdapter; +use StellarWP\Foundation\Container\Contracts\Container; /** * Base test case for WordPress integration tests. @@ -12,4 +16,14 @@ */ abstract class WPTestCase extends CodeceptionWPTestCase { + protected Container $container; + + protected function setUp(): void { + parent::setUp(); + + $this->container = new ContainerAdapter(new \lucatume\DI52\Container()); + $this->container->bind(Container::class, $this->container); + $this->container->bind(ContainerInterface::class, $this->container); + $this->container->singleton(Dot::class, new Dot(require dirname(__DIR__) . '/config.php')); + } } 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 new file mode 100644 index 0000000..0a87e88 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/arrow-callback-without-registration-list.stub @@ -0,0 +1,13 @@ +container->mergeArrayVar( DatabaseProvider::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 new file mode 100644 index 0000000..b5a838a --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/closure-without-array-return.stub @@ -0,0 +1,15 @@ +container->mergeArrayVar( DatabaseProvider::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 new file mode 100644 index 0000000..eeebc5a --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/closure-without-container-parameter.stub @@ -0,0 +1,15 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static function (): array { + return [ + ]; + } ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/existing-import.stub b/tests/_data/cli/generation/php-source-editor/existing-import.stub new file mode 100644 index 0000000..66bad60 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/existing-import.stub @@ -0,0 +1,7 @@ +mergeArrayVar( DatabaseProvider::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 new file mode 100644 index 0000000..2cc95e8 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/space-indented-registration-list.stub @@ -0,0 +1,14 @@ +container->mergeArrayVar( DatabaseProvider::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 new file mode 100644 index 0000000..f0b7482 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/strauss-prefixed-database-provider.stub @@ -0,0 +1,11 @@ +container->mergeArrayVar( \Acme\Product\StellarWP\Foundation\Database\DatabaseProvider::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 new file mode 100644 index 0000000..8870933 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/wrong-constant-merge-array-var.stub @@ -0,0 +1,12 @@ +container->mergeArrayVar( DatabaseProvider::TABLES, [ ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/wrong-first-argument-merge-array-var.stub b/tests/_data/cli/generation/php-source-editor/wrong-first-argument-merge-array-var.stub new file mode 100644 index 0000000..5399fc6 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/wrong-first-argument-merge-array-var.stub @@ -0,0 +1,10 @@ +container->mergeArrayVar( 'migrations', [ ] ); + } +} diff --git a/tests/_output/coverage/.gitignore b/tests/_output/coverage/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/tests/_output/coverage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/config.php b/tests/config.php index e5d30d6..33b8903 100644 --- a/tests/config.php +++ b/tests/config.php @@ -6,9 +6,10 @@ * @see \StellarWP\Foundation\Tests\TestCase::setUp() * @see \Adbar\Dot * @see phpunit.xml.dist + * @see .env.testing.slic */ return [ - 'log' => [ + 'log' => [ 'level' => $_ENV['TEST_LOG_LEVEL'] ?? 'debug', 'channel' => $_ENV['TEST_LOG_CHANNEL'] ?? 'null', 'channels' => [ @@ -25,4 +26,7 @@ ], ], ], + 'wpcli' => [ + 'command_prefix' => $_ENV['TEST_COMMAND_PREFIX'] ?? 'nxtest', + ], ]; diff --git a/tests/integration.suite.dist.yml b/tests/integration.suite.dist.yml new file mode 100644 index 0000000..8788dab --- /dev/null +++ b/tests/integration.suite.dist.yml @@ -0,0 +1,20 @@ +# Codeception Test Suite Configuration + +actor: IntegrationTester +path: integration +modules: + enabled: + - lucatume\WPBrowser\Module\WPLoader + - lucatume\WPBrowser\Module\WPQueries + config: + lucatume\WPBrowser\Module\WPLoader: + wpRootFolder: %WP_ROOT_FOLDER% + dbName: %WP_TEST_DB_NAME% + dbHost: %WP_TEST_DB_HOST% + dbUser: %WP_TEST_DB_USER% + dbPassword: %WP_TEST_DB_PASSWORD% + tablePrefix: %WP_TABLE_PREFIX% + domain: %WP_DOMAIN% + adminEmail: admin@stellarwp.com + title: 'Foundation Tests' + theme: twentytwentythree diff --git a/tests/integration/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php new file mode 100644 index 0000000..9dda621 --- /dev/null +++ b/tests/integration/Database/DatabaseProviderTest.php @@ -0,0 +1,106 @@ +container->register(WPCliProvider::class); + $this->container->register(DatabaseProvider::class); + + $commands = $this->container->get(WPCliProvider::COMMANDS); + + $this->assertSame([], $this->container->get(DatabaseProvider::MIGRATIONS)); + $this->assertSame('foundation-database-migrations', $this->container->get(DatabaseProvider::LOCK_NAME)); + $this->assertSame(300, $this->container->get(DatabaseProvider::LOCK_TTL)); + $this->assertContainsOnlyInstancesOf(Command::class, $commands); + $this->assertTrue($this->containsMigrateCommand((array) $commands)); + $this->assertInstanceOf(Migrator::class, $this->container->get(Migrator::class)); + $this->assertInstanceOf(Migrate::class, $this->container->get(Migrate::class)); + } + + public function test_it_registers_configured_database_configuration(): void { + $container = $this->newContainer([ + '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('custom_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); + $this->assertSame('custom_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); + $this->assertSame('custom-migrations', $container->get(DatabaseProvider::LOCK_NAME)); + $this->assertSame(120, $container->get(DatabaseProvider::LOCK_TTL)); + $this->assertSame('custom', $container->get(WPCliProvider::COMMAND_PREFIX)); + } + + public function test_it_preserves_preconfigured_migrations(): void { + $migration = new TestMigration('2026_06_23_000001_create_example'); + $container = $this->newContainer(); + $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); + + $container->register(WPCliProvider::class); + $container->register(DatabaseProvider::class); + + $this->assertSame([$migration], $container->get(DatabaseProvider::MIGRATIONS)); + $this->assertSame([$migration], $container->get(Collection::class)->all()); + } + + 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], $container->get(Collection::class)->all()); + } + + /** + * @param array $config + */ + private function newContainer(array $config = []): Container { + $container = new ContainerAdapter(new DI52Container()); + $container->bind(Container::class, $container); + $container->bind(ContainerInterface::class, $container); + $container->singleton(Dot::class, new Dot($config)); + + return $container; + } + + /** + * @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/WPCli/WPCliProviderTest.php b/tests/integration/WPCli/WPCliProviderTest.php new file mode 100644 index 0000000..cdefac8 --- /dev/null +++ b/tests/integration/WPCli/WPCliProviderTest.php @@ -0,0 +1,28 @@ +container->when(RecordingCommand::class) + ->needs('$commandPrefix') + ->give(static fn (C $c): string => $c->get(WPCliProvider::COMMAND_PREFIX)); + + $this->container->singleton(RecordingCommand::class); + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $c->get(RecordingCommand::class), + ]); + + $this->container->register(WPCliProvider::class); + + do_action('cli_init'); + + $this->assertTrue($this->container->get(RecordingCommand::class)->registered); + } +} diff --git a/tests/wpcli.suite.dist.yml b/tests/wpcli.suite.dist.yml new file mode 100644 index 0000000..f5305da --- /dev/null +++ b/tests/wpcli.suite.dist.yml @@ -0,0 +1,16 @@ +# Codeception Test Suite Configuration + +actor: WPCLITester +path: wpcli +modules: + enabled: + - lucatume\WPBrowser\Module\WPCLI + config: + lucatume\WPBrowser\Module\WPCLI: + path: %WP_ROOT_FOLDER% + url: %WP_URL% + user: %WP_ADMIN_USERNAME% + require: + - /var/www/html/wp-content/plugins/foundation/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php + throw: false + allow-root: true diff --git a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php new file mode 100644 index 0000000..975c8bd --- /dev/null +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -0,0 +1,76 @@ +dropTables($I); + } + + 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', '--create-table']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Foundation database tables are ready.'); + + $I->cli(['foundation', 'migrate', '--run']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Ran 1 migrations.'); + + $I->cli(['foundation', 'migrate']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('2026_06_23_000001_create_foundation_cli_example'); + $I->seeInShellOutput('ran'); + + $I->cli(['foundation', 'migrate', '--rollback']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Rolled back 1 migrations.'); + } + + public function test_it_refreshes_and_drops_database_tables_through_wp_cli(WPCLITester $I): void { + $I->cli(['foundation', 'migrate', '--run']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Ran 1 migrations.'); + + $I->cli(['foundation', 'migrate', '--refresh', '--yes']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Rolled back 1 migrations and ran 1 migrations.'); + + $I->cli(['foundation', 'migrate', '--drop', '--yes']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Foundation database tables were dropped.'); + + $I->cli(['foundation', 'migrate']); + $I->seeResultCodeIs(0); + Assert::assertStringContainsString('The Foundation database tables do not exist.', $I->grabLastShellErrorOutput()); + } + + public function test_it_warns_when_showing_status_before_tables_exist(WPCLITester $I): void { + $I->cli(['foundation', 'migrate']); + $I->seeResultCodeIs(0); + Assert::assertStringContainsString('The Foundation database tables do not exist.', $I->grabLastShellErrorOutput()); + } + + private function dropTables(WPCLITester $I): void { + $I->cli(['db', 'prefix']); + $I->seeResultCodeIs(0); + + $prefix = trim($I->grabLastShellOutput()); + + $I->cli([ + 'db', + 'query', + sprintf( + 'DROP TABLE IF EXISTS %sfoundation_cli_migrations, %sfoundation_cli_locks, %sfoundation_cli_example', + $prefix, + $prefix, + $prefix + ), + ]); + $I->seeResultCodeIs(0); + } +} diff --git a/tests/wpunit.suite.dist.yml b/tests/wpunit.suite.dist.yml index 0b6007e..1792056 100644 --- a/tests/wpunit.suite.dist.yml +++ b/tests/wpunit.suite.dist.yml @@ -13,7 +13,7 @@ modules: dbHost: %WP_TEST_DB_HOST% dbUser: %WP_TEST_DB_USER% dbPassword: %WP_TEST_DB_PASSWORD% - tablePrefix: test_ + tablePrefix: %WP_TABLE_PREFIX% domain: %WP_DOMAIN% adminEmail: admin@stellarwp.com title: 'Foundation Tests' diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php new file mode 100644 index 0000000..54a2654 --- /dev/null +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -0,0 +1,337 @@ + + */ + private array $tables = []; + + protected function setUp(): void { + parent::setUp(); + + $this->database = new Database($GLOBALS['wpdb']); + } + + 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 { + $table = $this->table('database'); + + $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('example')); + $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'], + ], $this->database->table($table)->select('name')->where('id', '=', 1)->get()); + } + + public function test_database_crud_helpers_and_schema_inspection_use_wordpress(): void { + $table = $this->table('crud'); + + $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($table)); + $this->assertTrue($this->database->columnExists($table, 'status')); + $this->assertTrue($this->database->indexExists($table, 'status')); + $this->assertFalse($this->database->columnExists($table, 'missing')); + $this->assertFalse($this->database->indexExists($table, 'missing')); + + $id = $this->database->insert($table, [ + '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($table, ['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($table, ['id' => $id])); + $this->assertSame('0', (string) $this->database->value('SELECT COUNT(*) FROM %i', $table)); + } + + public function test_database_returns_null_for_missing_values_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)); + } + + public function test_database_wraps_wordpress_query_failures(): void { + $previous = $GLOBALS['wpdb']->suppress_errors(true); + + try { + $this->assertQueryFails(fn (): mixed => $this->database->row('SELECT * FROM %i', 'missing_foundation_table')); + $this->assertSame([], $this->database->rows('SELECT * FROM %i', 'missing_foundation_table')); + $this->assertQueryFails(fn (): mixed => $this->database->execute('SELECT * FROM %i', 'missing_foundation_table')); + $this->assertQueryFails(fn (): mixed => $this->database->insert('missing_foundation_table', ['name' => 'test'])); + $this->assertQueryFails(fn (): mixed => $this->database->update('missing_foundation_table', ['name' => 'updated'], ['id' => 1])); + $this->assertQueryFails(fn (): mixed => $this->database->delete('missing_foundation_table', ['id' => 1])); + } finally { + $GLOBALS['wpdb']->suppress_errors($previous); + } + } + + public function test_schema_creates_inspects_and_changes_tables_through_wordpress(): void { + $table = $this->table('schema'); + $schema = new Schema($this->database); + + $schema->createOrUpdate(sprintf( + 'CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) NOT NULL, + PRIMARY KEY (id), + KEY name (name) + ) %s;', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + $this->assertTrue($schema->hasTable($table)); + $this->assertTrue($schema->hasIndex($table, 'name')); + + $schema->dropIndex($table, 'name'); + + $this->assertFalse($schema->hasIndex($table, 'name')); + + $schema->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $this->database->quoteIdentifier($table) + )); + + $this->assertFalse($schema->hasTable($table)); + } + + public function test_schema_creates_queue_style_table_definitions_through_wordpress(): void { + $table = $this->table('queue_schema'); + $schema = new Schema($this->database); + $queue = new class($this->database, $table) implements Table { + public function __construct( + private DatabaseContract $database, + private string $table + ) { + } + + public function id(): string { + return 'queue_schema_table'; + } + + public function name(): string { + return $this->database->tableName($this->table); + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id') + ->string('queue', 255) + ->string('task_handler', 255) + ->longText('args') + ->integer('priority', 3)->nullable() + ->dateTime('run_after')->default('0000-00-00 00:00:00') + ->integer('taken')->default(0) + ->integer('done')->nullable()->default(0) + ->tinyInteger('tries')->unsigned()->default(0) + ->tinyInteger('failed', 1)->unsigned()->default(false) + ->index('done', 'done') + ->index('taken_failed', 'taken', 'failed') + ->index('taken_failed_done', 'taken', 'failed', 'done'); + } + }; + + $schema->createOrUpdate($queue); + + $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_migration_repository_persists_records_in_wordpress(): void { + $table = $this->table('migrations'); + $schema = new Schema($this->database); + $migrationTable = new MigrationTable($this->database, $table); + $repository = new Repository($this->database, $table); + + $this->assertFalse($schema->hasTable($migrationTable)); + + $schema->createOrUpdate($migrationTable); + + $this->assertTrue($schema->hasTable($migrationTable)); + $this->assertSame($table, $migrationTable->name()); + $this->assertSame(1, $repository->nextBatch()); + + $record = $repository->recordRun('2026_06_23_000001_create_example_table', 1); + + $this->assertGreaterThan(0, $record->id); + $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example_table')); + $this->assertSame(2, $repository->nextBatch()); + $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')); + + $schema->drop($migrationTable); + + $this->assertFalse($schema->hasTable($migrationTable)); + } + + public function test_database_lock_coordinates_ownership_in_wordpress(): void { + $table = $this->table('locks'); + $wpSchema = new Schema($this->database); + $lockTable = new LockTable($this->database, $table); + $lock = new DatabaseLock($this->database, $table); + + $this->assertFalse($wpSchema->hasTable($lockTable)); + + $wpSchema->createOrUpdate($lockTable); + + $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_provider_registers_wordpress_prefixed_database_services(): void { + $container = $this->newContainer(); + + $container->register(DatabaseProvider::class); + + $this->assertSame($GLOBALS['wpdb']->prefix . 'nexcess_foundation_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); + $this->assertSame($GLOBALS['wpdb']->prefix . 'nexcess_foundation_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); + $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->assertInstanceOf(TableCollection::class, $container->get(TableCollection::class)); + $this->assertInstanceOf(MigrationTable::class, $container->get(MigrationTable::class)); + $this->assertInstanceOf(LockTable::class, $container->get(LockTable::class)); + $this->assertInstanceOf(Repository::class, $container->get(MigrationRecordRepositoryContract::class)); + $this->assertInstanceOf(Runner::class, $container->get(Runner::class)); + $this->assertFalse($container->has(Lock::class)); + } + + private function table(string $suffix): string { + $table = $GLOBALS['wpdb']->prefix . 'foundation_' . $suffix . '_' . str_replace('.', '_', uniqid('', true)); + + $this->tables[] = $table; + + return $table; + } + + /** + * @param callable(): mixed $callback + */ + private function assertQueryFails(callable $callback): void { + try { + $callback(); + } catch (QueryException $exception) { + $this->assertNotSame('', $exception->getMessage()); + + return; + } + + $this->fail('Expected the database operation to throw a query exception.'); + } + + private function newContainer(): Container { + $container = new ContainerAdapter(new DI52Container()); + $container->bind(Container::class, $container); + $container->bind(ContainerInterface::class, $container); + $container->singleton(Dot::class, new Dot()); + + return $container; + } +}