diff --git a/README.md b/README.md index af1ed89..c3f0181 100644 --- a/README.md +++ b/README.md @@ -14,28 +14,89 @@ the library. ## The shadow `php-db/phpdb` maps the `PhpDb` root namespace to its `src/`. This package declares the same root -against its own `src/`, and Composer registers each package's PSR-4 directories in dependency order — -a package before the packages it requires — so this package's `src/` is consulted **first** for every -`PhpDb` class: +against its own `src/`, and Composer registers each package's PSR-4 directories in dependency order +(a package before the packages it requires), so this package's `src/` is consulted **first** for +every `PhpDb` class: - a class defined here **replaces** the `php-db/phpdb` class of the same name; - every class this package does not define still resolves to `php-db/phpdb`, unchanged. -`php-db/phpdb` is therefore a hard `require` of this package — without it there is nothing to shadow. +`php-db/phpdb` is therefore a hard `require` of this package: without it there is nothing to shadow. **One name must not be repeated.** A second `PhpDb\ConfigProvider` would be an ambiguous class resolution for Composer's optimized autoloader, so this package's provider is `PhpDb\WebwareProvider`. `php-db/phpdb`'s own provider keeps its name and both load; a consumer merging this one after it layers the bridge's wiring on top of PhpDb's. -## Installation +## Quickstart + +1. Install the package: ```bash composer require webware/webware-phpdb ``` -Register `PhpDb\WebwareProvider` after `PhpDb\ConfigProvider` in the consumer's config aggregator, -so these rules win where the two overlays meet. +2. Merge its providers in the consumer's config aggregator: + +```php +use Laminas\ConfigAggregator\ConfigAggregator; + +$aggregator = new ConfigAggregator([ + PhpDb\ConfigProvider::class, + PhpDb\WebwareProvider::class, + // ... + Mezzio\Session\ConfigProvider::class, + // After every provider that declares SessionPersistenceInterface: + // it re-points that alias at the database persistence. + PhpDb\Session\SessionProvider::class, +]); +``` + +`PhpDb\WebwareProvider` shares no config keys with `PhpDb\ConfigProvider`, so their relative order +does not change the merged result. `SessionProvider` is the one with an ordering requirement. + +3. Build table identifiers from a schema enum, so raw table names stay out of the application +layer: + +```php +use PhpDb\SchemaFactory; +use PhpDb\SchemaInterface; + +enum WebwareSchema: string implements SchemaInterface +{ + public const string NAME = 'webware'; + + case Session = 'session'; +} + +$factory = $container->get(SchemaFactory::class); +$identifier = $factory(WebwareSchema::Session); + +$identifier->getTable(); // 'session' until a prefix is configured +$identifier->getSchema(); // 'webware' +``` + +4. Create the session table, and let Mezzio session middleware use it: + +```bash +vendor/bin/webware session:init-db +``` + +With `SessionProvider` merged, `Mezzio\Session\SessionPersistenceInterface` resolves to +`PhpDb\Session\PhpDbSessionPersistence`, which reads and writes that table. + +## Documentation + +Versioned documentation lives under [`docs/`](docs/): + +| Document | Contents | +|---|---| +| [Overview](docs/v1/index.md) | The shadow, what ships, how the pieces fit | +| [Installation](docs/v1/installation.md) | Requirements, providers, optional dependencies | +| [Schema factory](docs/v1/schema-factory.md) | `SchemaInterface`, identifier precedence, backup identifiers | +| [Database session persistence](docs/v1/session.md) | Persistence, handler, config keys, the session table | +| [Table gateway event dispatcher](docs/v1/event-dispatcher.md) | The PSR-14 table gateway feature and its hooks | +| [Development](docs/v1/development.md) | Commands, gates, baselines, tests, CI parameters | ## What ships here @@ -46,7 +107,7 @@ config: | Path | Role | |---|---| | `mago.toml` | Extends the centre (`vendor/webware/webware-tools/mago.toml`) and overrides `php-version` only. Never re-add general rules locally. | -| `webware-ci.json` | The required CI workflow's parameter contract — read from the repository root by `webinertia/.github`. | +| `webware-ci.json` | The required CI workflow's parameter contract, read from the repository root by `webinertia/.github`. | | `phpunit.xml.dist` | PHPUnit 13 strict mode: `requireCoverageMetadata`, `failOnNotice`, `failOnWarning`, `failOnDeprecation`. | | `compose.yml` / `Dockerfile` / `.devcontainer/` | The containerized toolchain (Composer, PHPUnit, Mago, Infection, PHPBench, roave BC-check). | | `src/WebwareProvider.php` | The package wiring entry point, declared under `extra.laminas.config-provider`. Named for the collision reason in "The shadow" above. | @@ -54,12 +115,12 @@ config: `mago.toml`, `phpunit.xml.dist`, `.gitattributes`, `codecov.yml`, `Dockerfile`, `.dockerignore`, `infection.json5.dist`, `phpbench.json.dist` and devcontainer config are byte-identical to the canonical artifacts in -`webware-tools/presets/webware-alignment/artifacts/` — copy updates from there rather than +`webware-tools/presets/webware-alignment/artifacts/`. Copy updates from there rather than editing them here. ## Quality gates -Both MSI gates are set to **95** — the ecosystem standard, not a starting point. Lower them +Both MSI gates are set to **95**, the ecosystem standard, not a starting point. Lower them only with a deliberate decision, and never silently: ```json @@ -69,7 +130,7 @@ only with a deliberate decision, and never silently: Four Mago gates run in CI and must be clean: `format --check`, `lint`, `analyze`, `guard`. Run `mago fmt` first when making changes, and fix findings at source rather than adding -`@mago-expect` — a suppression needs to be a decision, not a reflex. +`@mago-expect`; a suppression needs to be a decision, not a reflex. ## Development diff --git a/analysis-baseline.toml b/analysis-baseline.toml index ddf067d..87287c4 100644 --- a/analysis-baseline.toml +++ b/analysis-baseline.toml @@ -1,2 +1,7 @@ variant = "loose" -issues = [] + +[[issues]] +file = "vendor/php-db/phpdb/src/TableGateway/Feature/AbstractFeature.php" +code = "uninitialized-property" +message = 'Property `$tableGateway` is not initialized in the constructor of class `PhpDb\TableGateway\Feature\EventDispatcherFeature`.' +count = 1 diff --git a/composer.json b/composer.json index ffcf92b..1aacb15 100644 --- a/composer.json +++ b/composer.json @@ -41,11 +41,13 @@ "roave/backward-compatibility-check": "^8.21.0", "roave/security-advisories": "dev-master", "webware/webware-console": "1.0.x-dev", + "webware/webware-event": "1.0.0-alpha.3", "webware/webware-tools": "^1.0.0-beta.3" }, "suggest": { "mezzio/mezzio-session": "Required to use the database-backed session persistence", - "webware/webware-console": "Provides the console that runs session:init-db to create the session table" + "webware/webware-console": "Provides the console that runs session:init-db to create the session table", + "webware/webware-event": "Required to use the PSR-14 table gateway event dispatcher feature (PhpDb\\TableGateway\\Feature\\EventDispatcherFeature)" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 3cb9048..2fd6737 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3afa1b2e48ac9cd3b248b58581797af2", + "content-hash": "234725b71f523678eb013774f409da88", "packages": [ { "name": "brick/varexporter", @@ -2590,6 +2590,67 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phly/phly-event-dispatcher", + "version": "1.6.0", + "source": { + "type": "git", + "url": "https://github.com/phly/phly-event-dispatcher.git", + "reference": "09b86c00d16e34b5d33123191cb613307ff54be7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phly/phly-event-dispatcher/zipball/09b86c00d16e34b5d33123191cb613307ff54be7", + "reference": "09b86c00d16e34b5d33123191cb613307ff54be7", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/container": "^1.0 || ^2.0", + "psr/event-dispatcher": "^1.0" + }, + "conflict": { + "phpspec/prophecy": "<1.7.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "^1.0" + }, + "require-dev": { + "fig/event-dispatcher-util": "^1.0", + "laminas/laminas-coding-standard": "~2.3.0", + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Phly\\EventDispatcher\\ConfigProvider" + } + }, + "autoload": { + "files": [ + "src/functions/lazy_listener.php" + ], + "psr-4": { + "Phly\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Experimental event dispatcher for PSR-14", + "keywords": [ + "components", + "event-dispatcher", + "psr-14" + ], + "support": { + "issues": "https://github.com/phly/phly-event-dispatcher/issues", + "rss": "https://github.com/phly/phly-event-dispatcher/releases.atom", + "source": "https://github.com/phly/phly-event-dispatcher" + }, + "time": "2026-09-09T16:22:59+00:00" + }, { "name": "php-db/phpdb-mysql", "version": "0.5.x-dev", @@ -5575,6 +5636,56 @@ }, "time": "2022-11-25T14:36:26+00:00" }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, { "name": "psr/http-factory", "version": "1.1.0", @@ -10687,18 +10798,78 @@ }, "time": "2026-09-29T05:26:25+00:00" }, + { + "name": "webware/webware-event", + "version": "1.0.0-alpha.3", + "source": { + "type": "git", + "url": "https://github.com/webinertia/webware-event.git", + "reference": "44b8bf4dcb3cd405164bc9531767c3f28fc07f39" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webinertia/webware-event/zipball/44b8bf4dcb3cd405164bc9531767c3f28fc07f39", + "reference": "44b8bf4dcb3cd405164bc9531767c3f28fc07f39", + "shasum": "" + }, + "require": { + "phly/phly-event-dispatcher": "^1.5.0", + "php": "~8.4.1 || ~8.5.0", + "psr/http-message": "^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.35.0", + "laminas/laminas-servicemanager": "^4.5", + "phpbench/phpbench": "^1.7", + "phpunit/phpunit": "^13.3.0", + "roave/backward-compatibility-check": "^8.21.0", + "roave/security-advisories": "dev-master", + "webware/webware-tools": "^1.0.0-beta.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Webware\\Event\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Joey Smith", + "email": "jsmith@webinertia.net", + "role": "Developer" + } + ], + "description": "Event system for Webware", + "keywords": [ + "dispatcher", + "event", + "psr-14", + "webware" + ], + "support": { + "issues": "https://github.com/webinertia/webware-event/issues", + "source": "https://github.com/webinertia/webware-event/tree/1.0.0-alpha.3" + }, + "time": "2026-09-29T05:32:32+00:00" + }, { "name": "webware/webware-tools", - "version": "1.0.0-beta.9", + "version": "1.0.0-rc.2", "source": { "type": "git", "url": "https://github.com/webinertia/webware-tools.git", - "reference": "bec8ccaa8ca17896d2ee22b3a7067e53c33b79e2" + "reference": "5f6927e02fab45a591e19233a75745700247b51c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webinertia/webware-tools/zipball/bec8ccaa8ca17896d2ee22b3a7067e53c33b79e2", - "reference": "bec8ccaa8ca17896d2ee22b3a7067e53c33b79e2", + "url": "https://api.github.com/repos/webinertia/webware-tools/zipball/5f6927e02fab45a591e19233a75745700247b51c", + "reference": "5f6927e02fab45a591e19233a75745700247b51c", "shasum": "" }, "require": { @@ -10727,9 +10898,9 @@ ], "support": { "issues": "https://github.com/webinertia/webware-tools/issues", - "source": "https://github.com/webinertia/webware-tools/tree/1.0.0-beta.9" + "source": "https://github.com/webinertia/webware-tools/tree/1.0.0-rc.2" }, - "time": "2026-10-03T14:52:08+00:00" + "time": "2026-10-09T01:57:15+00:00" } ], "aliases": [], diff --git a/docs/v1/development.md b/docs/v1/development.md new file mode 100644 index 0000000..804d043 --- /dev/null +++ b/docs/v1/development.md @@ -0,0 +1,93 @@ +# Development + +## Commands + +| Command | Runs | +|---|---| +| `composer test` | Unit suite | +| `composer test-integration` | Integration suite | +| `composer test-coverage` | Full suite with Clover and text coverage | +| `composer mutation-test` | Infection | +| `composer test-all` | Unit, integration, then mutation testing | + +Mago gates run through the pinned binary, one command each: + +```bash +mago format --check +mago lint +mago analyze +mago guard +``` + +`mago fmt` rewrites files, and the tool can reorder class methods. Fix findings at the source +rather than adding expectations; this repository carries one deliberate inline +`@mago-expect lint:too-many-methods`, on `EventDispatcherFeature`, whose method count is the hook +set the interface fixes. + +Mago's version has a single home: the `version` pin in +`vendor/webware/webware-tools/mago.toml`, which `composer.lock` selects. Do not add a version +anywhere else. + +## Toolchain + +The tooling container carries Composer, PHPUnit, Mago, Infection and PHPBench: + +```bash +docker compose up -d +docker compose exec tooling composer install +docker compose exec tooling composer test +docker compose down +``` + +## Baselines + +| File | Holds | +|---|---| +| `lint-baseline.toml` | Lint findings | +| `analysis-baseline.toml` | Analyzer findings | + +Entries are keyed by file path, so moving a class drops its suppression. Both are generated with +`--generate-baseline`, never by hand. The one analyzer entry here covers php-db's +`AbstractFeature::$tableGateway`, which `FeatureSet::setTableGateway()` assigns after construction. +Guard findings are not baselineable. + +## Tests + +PHPUnit 13 runs in strict mode: `requireCoverageMetadata`, `failOnNotice`, `failOnWarning` and +`failOnDeprecation` are all enabled. Every test class therefore needs `#[CoversClass]`, and +`#[CoversMethod]` for the methods it exercises. A double that only returns values is created with +`createStub()`; `createMock()` is only for a double that carries `expects()`. + +The integration suite needs MySQL, matching CI: + +| Setting | Value | +|---|---| +| Host and port | `127.0.0.1:3306` | +| Database | `webware` | +| User | `root` | +| Password | empty | + +`phpunit.xml.dist` sets the `TESTS_PHPDB_ADAPTER_*` environment variables, including the +PostgreSQL and SQLite toggles that `webware-ci.json` disables for this repository: + +```php +TESTS_PHPDB_ADAPTER_PGSQL: '0', +TESTS_PHPDB_ADAPTER_SQLITE: '0', +``` + +## CI + +`.github` carries no workflow. The org ruleset invokes +`webinertia/.github/.github/workflows/org-required-ci.yml` directly, and that workflow reads +`webware-ci.json` from this repository root for its parameters: PHP versions, integration toggle, +database image, coverage version, and the two MSI floors. + +| Setting | Value | +|---|---| +| `min_msi` | `95` | +| `min_covered_msi` | `95` | +| `coverage_php_version` | `8.5` | +| `db_image` | `mysql:9.7` | +| `enable_codecov`, `enable_infection` | `true` | + +Both MSI floors are the ecosystem standard. Lower them only as a deliberate decision. diff --git a/docs/v1/event-dispatcher.md b/docs/v1/event-dispatcher.md new file mode 100644 index 0000000..75eb1d8 --- /dev/null +++ b/docs/v1/event-dispatcher.md @@ -0,0 +1,99 @@ +# Table gateway event dispatcher + +`PhpDb\TableGateway\Feature\EventDispatcherFeature` publishes php-db's table gateway lifecycle +hooks to a PSR-14 dispatcher. It is the PSR-14 counterpart to php-db's own laminas-based +`EventFeature`: same hook contract, different transport. + +It implements `PhpDb\TableGateway\Feature\EventFeatureEventsInterface`, so a table gateway accepts +it exactly where php-db's feature is accepted, and it extends `AbstractFeature`, so it can be +combined with other features in a `FeatureSet`. + +## Requirements + +The event class extends `Webware\Event\Event`, so `webware/webware-event` must be installed to use +this feature. The bridge lists it under `suggest` and installs it for development: + +```bash +composer require webware/webware-event +``` + +## Usage + +Pass the feature to the gateway constructor: + +```php +use PhpDb\TableGateway\Feature\EventDispatcherFeature; +use PhpDb\TableGateway\TableGateway; +use Psr\EventDispatcher\EventDispatcherInterface; + +$feature = new EventDispatcherFeature(eventDispatcher: $dispatcher); + +$gateway = new TableGateway( + table : 'session', + adapter : $adapter, + features: $feature, +); +``` + +Add it to an existing feature set when other features are in play: + +```php +$featureSet = $gateway->getFeatureSet(); +$featureSet->addFeature($feature); +``` + +Every hook publishes a new `PhpDb\TableGateway\Feature\EventDispatcher\TableGatewayEvent`: + +```php +use PhpDb\TableGateway\Feature\EventDispatcher\TableGatewayEvent; + +final class SessionWriteLogger +{ + public function __invoke(TableGatewayEvent $event): void + { + if ('postInsert' !== $event->getName()) { + return; + } + + $insert = $event->getParam('insert'); // PhpDb\Sql\Insert + $result = $event->getParam('result'); // PhpDb\Adapter\Driver\ResultInterface + $gateway = $event->getTarget(); // PhpDb\TableGateway\AbstractTableGateway + } +} +``` + +The dispatcher is whatever PSR-14 implementation the consumer uses, for example the one +`webware/webware-event` provides. + +## Hooks + +The event name is the hook being run, taken from `EventFeatureEventsInterface`. The target is the +table gateway the hook belongs to. + +| Hook | Event name | Params | +|---|---|---| +| `preInitialize()` | `preInitialize` | none | +| `postInitialize()` | `postInitialize` | none | +| `preSelect(Select $select)` | `preSelect` | `select` | +| `postSelect(StatementInterface $statement, ResultInterface $result, ResultSetInterface $resultSet)` | `postSelect` | `statement`, `result`, `result_set` | +| `preInsert(Insert $insert)` | `preInsert` | `insert` | +| `postInsert(StatementInterface $statement, ResultInterface $result)` | `postInsert` | `statement`, `result` | +| `preUpdate(Update $update)` | `preUpdate` | `update` | +| `postUpdate(StatementInterface $statement, ResultInterface $result)` | `postUpdate` | `statement`, `result` | +| `preDelete(Delete $delete)` | `preDelete` | `delete` | +| `postDelete(StatementInterface $statement, ResultInterface $result)` | `postDelete` | `statement`, `result` | + +The param keys match the ones php-db's `EventFeature` uses, so a listener written for one feature +reads the same keys from the other. Read them with `getParam($name)` or `getParams()`. + +## Notes + +`EventDispatcherFeature` publishes a fresh event per hook rather than mutating one shared instance, +so a listener may keep the event it receives. `TableGatewayEvent` itself is mutable, inheriting +`setName()`, `setParams()` and `setTarget()` from `Webware\Event\Event`; a dispatch of +`new TableGatewayEvent()` with no name reports the class name, because that is how +`Webware\Event\Event::getName()` treats a null name. + +The gateway is available as the event target because `AbstractFeature::$tableGateway` is assigned +by the feature set when the feature is attached. Building the feature and reading a target before +attaching it is a programming error, not a supported state. diff --git a/docs/v1/index.md b/docs/v1/index.md new file mode 100644 index 0000000..0c67ea6 --- /dev/null +++ b/docs/v1/index.md @@ -0,0 +1,65 @@ +# webware/webware-phpdb + +PhpDb bridge for the Webware stack. This package declares the `PhpDb` root namespace against its +own `src/`, so Webware packages can replace or extend individual `php-db/phpdb` classes without +forking the library. + +## The shadow + +`php-db/phpdb` maps `PhpDb` to its own `src/`. This package declares the same prefix against its +own `src/`, and Composer registers PSR-4 directories in dependency order. The generated autoload +map shows the result: + +```php +// vendor/composer/autoload_psr4.php +'PhpDb\\' => array($baseDir . '/src', $vendorDir . '/php-db/phpdb/src'), +``` + +This package's `src/` is consulted first for every `PhpDb` class: + +| Class | Resolves to | +|---|---| +| A class defined here | This package's copy, which replaces the `php-db/phpdb` class of the same name | +| Any class not defined here | `php-db/phpdb`, unchanged | + +`php-db/phpdb` is a hard `require` of this package. Without it there is nothing to shadow. + +One name must not be repeated: a second `PhpDb\ConfigProvider` would be an ambiguous class +resolution for Composer's optimized autoloader. This package's root provider is therefore +`PhpDb\WebwareProvider`, and its session provider is `PhpDb\Session\SessionProvider`. `php-db/phpdb` +keeps `PhpDb\ConfigProvider`; both load, and a consumer that merges this package's providers after +it layers the bridge wiring on top. + +## What ships + +| Namespace | Contents | +|---|---| +| `PhpDb` | `SchemaInterface`, `SchemaFactory`, `WebwareProvider` | +| `PhpDb\Container` | `SchemaFactoryFactory` | +| `PhpDb\Session` | `PhpDbSessionPersistence`, `DbSessionHandler`, `SessionPayload`, `IniDefaults`, `SessionTable`, `Sql\Insert`, `SessionProvider` | +| `PhpDb\Session\Console` | `InitDbCommand`, `Schema\SessionSchema`, `Ddl\Column\MediumText`, `Container\InitDbCommandFactory` | +| `PhpDb\TableGateway\Feature` | `EventDispatcherFeature`, `EventDispatcher\TableGatewayEvent` | + +```mermaid +graph TD + A[Consumer config aggregator] --> B[PhpDb WebwareProvider] + A --> C[PhpDb Session SessionProvider] + B --> D[PhpDb SchemaFactory] + C --> E[PhpDb Session PhpDbSessionPersistence] + C --> F[PhpDb Session DbSessionHandler] + C --> G[PhpDb Session InitDbCommand] + H[php-db/phpdb] --> I[AdapterInterface] + I --> E + I --> F + I --> G +``` + +## Documentation + +| Page | Covers | +|---|---| +| [installation.md](installation.md) | Requirements, install, provider registration | +| [schema-factory.md](schema-factory.md) | `SchemaInterface`, `SchemaFactory`, identifier precedence | +| [session.md](session.md) | Database session persistence, the `session:init-db` command, its table | +| [event-dispatcher.md](event-dispatcher.md) | The PSR-14 table gateway event feature | +| [development.md](development.md) | Gates, tooling, tests, local database | diff --git a/docs/v1/installation.md b/docs/v1/installation.md new file mode 100644 index 0000000..172190d --- /dev/null +++ b/docs/v1/installation.md @@ -0,0 +1,79 @@ +# Installation + +## Requirements + +| Requirement | Constraint | +|---|---| +| PHP | `~8.4.1 \|\| ~8.5.0` | +| `php-db/phpdb` | `0.6.x-dev` (hard requirement, and what this package shadows) | +| `psr/container` | `^2.0` | + +A `php-db` driver package for your database is required in practice, for example +`php-db/phpdb-mysql`, `php-db/phpdb-pgsql` or `php-db/phpdb-sqlite`, though this package does not +declare one. + +## Consumer applications + +Install from within the consuming application: + +```bash +composer require webware/webware-phpdb +``` + +Add the providers to the config aggregator: + +```php +use Laminas\ConfigAggregator\ConfigAggregator; + +$aggregator = new ConfigAggregator([ + PhpDb\ConfigProvider::class, + PhpDb\WebwareProvider::class, + // ... + Mezzio\Session\ConfigProvider::class, + // Merge this after every provider that declares SessionPersistenceInterface: + // it re-points that alias at the database persistence. + PhpDb\Session\SessionProvider::class, +]); +``` + +`WebwareProvider` and php-db's `ConfigProvider` share no keys. php-db's owns +`dependencies.abstract_factories`, the `AdapterInterface` alias, the `Adapter` and +`TableIdentifierFactory` factories, and the `adapters` key; this package's owns the `SchemaFactory` +factory and the `SchemaInterface::class` config key. Their relative order does not change the +merged result. + +The package declares both of its providers under `extra.laminas.config-provider`: + +```json +"extra": { + "laminas": { "config-provider": [ + "PhpDb\\WebwareProvider", + "PhpDb\\Session\\SessionProvider" + ] } +} +``` + +An installer or config aggregator that reads that key merges them automatically, and the explicit +list above is then unnecessary. The ordering rule for `SessionProvider` still applies. + +Load `SessionProvider` only when `mezzio/mezzio-session` and `webware/webware-console` are +installed: it aliases `SessionPersistenceInterface` and registers a console command. Both packages +are listed under `suggest` in this package. + +## Optional dependencies + +| Package | Needed for | +|---|---| +| `mezzio/mezzio-session` | Database session persistence | +| `webware/webware-console` | The console that runs `session:init-db` | +| `webware/webware-event` | The PSR-14 table gateway event dispatcher feature | + +## Working on the package + +```bash +composer install +composer test +composer test-integration +``` + +The integration suite needs a MySQL database. See [development.md](development.md). diff --git a/docs/v1/schema-factory.md b/docs/v1/schema-factory.md new file mode 100644 index 0000000..b91c798 --- /dev/null +++ b/docs/v1/schema-factory.md @@ -0,0 +1,161 @@ +# Schema factory + +`PhpDb\SchemaFactory` is a callable that turns a `SchemaInterface` enum case into a +`PhpDb\Sql\TableIdentifier`, applying a configured table prefix and schema. Raw string table names +never need to appear in application code. + +## Declaring a schema + +A `SchemaInterface` is a string-backed enum whose case values are unprefixed table names. The +`NAME` constant declares the schema shared by every table in the enum; it defaults to an empty +string, which means no explicit schema. + +```php +use PhpDb\SchemaInterface; + +enum WebwareSchema: string implements SchemaInterface +{ + public const string NAME = 'webware'; + + case Session = 'session'; +} +``` + +An enum that leaves `NAME` alone produces identifiers with a `null` schema, leaving the choice to +the connection. The session table ships that way: + +```php +// PhpDb\Session\SessionTable +enum SessionTable: string implements SchemaInterface +{ + case Session = 'session'; +} +``` + +## Producing an identifier + +```php +use PhpDb\SchemaFactory; + +$factory = new SchemaFactory(['prefix' => 'ww']); + +$identifier = $factory(WebwareSchema::Session); + +$identifier->getTable(); // 'ww_session' +$identifier->getSchema(); // 'webware', from the enum NAME +$identifier->getPrefix(); // 'ww' +$identifier->getSeparator(); // '_' +$identifier->getUnprefixedTable(); // 'session' +$identifier->getTableAndSchema(); // ['ww_session', 'webware'] +``` + +`TableIdentifier::getTable()` applies the prefix and separator; `getUnprefixedTable()` returns the +enum value exactly as declared. The class has no `__toString()`. + +## Precedence + +Each part of the identifier resolves independently, highest priority first: + +| Part | Order | +|---|---| +| table | The enum case value, always | +| schema | Call-time `$schemaName`, then `schemas[$table]`, then `schema`, then the enum `NAME` | +| prefix | Call-time `$prefix`, then `prefixes[$table]`, then `prefix` | +| separator | Call-time `$separator`, then `separator`, then `TableIdentifier::SEPARATOR` (`'_'`) | + +What the values look like: + +```php +$factory = new SchemaFactory([ + 'prefix' => 'ww', + 'prefixes' => ['session' => 'sess'], + 'schemas' => ['session' => 'tenant'], +]); + +$factory(WebwareSchema::Session)->getTable(); // 'sess_session' (per-table prefix wins) +$factory(WebwareSchema::Session)->getSchema(); // 'tenant' (per-table schema wins) +$factory(WebwareSchema::Session, prefix: 'x')->getTable(); // 'x_session' (call time wins) +``` + +## Backup identifiers + +`backup()` builds the identifier for a backup copy of the same table, for example before mutating +the live table. It resolves each part in this order: + +| Part | Order | +|---|---| +| schema | Call-time `$schemaName`, then `schemas[$table]`, then `backup_schema`, then `schema`, then the enum `NAME` | +| prefix | Call-time `$prefix`, then `backup_prefix` | +| separator | Call-time `$separator`, then `separator`, then `TableIdentifier::SEPARATOR` | + +```php +$factory = new SchemaFactory([ + 'prefix' => 'ww', + 'backup_prefix' => 'bck', + 'backup_schema' => 'backup', +]); + +$factory->backup(WebwareSchema::Session)->getTable(); // 'bck_session' +$factory->backup(WebwareSchema::Session)->getSchema(); // 'backup' +``` + +Two consequences of that resolution order: + +- `backup_prefix` replaces the live prefix rather than extending it, so backing up a table + prefixed `ww_` with `backup_prefix: 'bck'` yields `bck_session`, not `bck_ww_session`. +- When `backup_prefix` is not configured, the backup identifier carries no prefix at all. It does + not fall back to `prefix` or `prefixes[$table]`. The `backup_schema` does fall back, to whatever + the live lookup resolves, here the enum `NAME`. + +## Configuration + +`SchemaFactory` accepts config read from the top-level `SchemaInterface::class` key, validated +against a `Psl\Type` shape. Every key is optional and every value must be a non-empty string. + +| Key | Type | Purpose | +|---|---|---| +| `prefix` | `non-empty-string` | App-wide table prefix | +| `separator` | `non-empty-string` | Prefix and table separator, defaults to `'_'` | +| `schema` | `non-empty-string` | App-wide schema | +| `prefixes` | `array` | Per-table prefixes, keyed by unprefixed table name | +| `schemas` | `array` | Per-table schemas, keyed by unprefixed table name | +| `backup_prefix` | `non-empty-string` | Prefix for `backup()` targets | +| `backup_schema` | `non-empty-string` | Schema for `backup()` targets | + +`PhpDb\WebwareProvider` publishes this config and defaults the separator: + +```php +// PhpDb\WebwareProvider::getSchemaConfig() +return ['separator' => TableIdentifier::SEPARATOR]; +``` + +Consumer overrides merge on top of it. The provider also registers the factory: + +```php +'dependencies' => [ + 'factories' => [ + SchemaFactory::class => SchemaFactoryFactory::class, + ], +], +``` + +`SchemaFactoryFactory` reads the `config` service when the container has one, takes +`$config[SchemaInterface::class]` when present, and constructs the factory. An absent config +service, or an absent key, produces a factory with defaults. + +## Failures + +| Situation | Exception | +|---|---| +| Config does not match the shape, for example a non-string or empty value | `Psl\Type\Exception\AssertException` | +| Config carries a key that is not one of the seven above | `Psl\Type\Exception\AssertException` | +| Enum case value is an empty string, for example `case Session = '';` | `Psl\Type\Exception\AssertException` | +| A call-time override is an empty string | `PhpDb\Sql\Exception\InvalidArgumentException` | + +An empty enum value is rejected on purpose. Use the `NAME` constant, not an empty case, to express +that a table belongs to the connection's default schema. + +An empty value in configuration never reaches `TableIdentifier`: the shape assertion rejects it +while `SchemaFactory` is constructed, so only a call-time override can produce +`InvalidArgumentException` from the identifier itself. The shape is closed, so an unrecognized key +such as `prefx` fails at construction rather than being ignored. \ No newline at end of file diff --git a/docs/v1/session.md b/docs/v1/session.md new file mode 100644 index 0000000..c44dd15 --- /dev/null +++ b/docs/v1/session.md @@ -0,0 +1,134 @@ +# Database session persistence + +Two classes read and write the session table, and either can be used on its own. + +| Class | Contract | Use it when | +|---|---|---| +| `PhpDb\Session\PhpDbSessionPersistence` | `Mezzio\Session\SessionPersistenceInterface`, `InitializePersistenceIdInterface` | You want Mezzio session middleware with no ext-session state | +| `PhpDb\Session\DbSessionHandler` | `SessionHandlerInterface` | You want a native PHP session save handler | + +Both write the same four columns to the same table, so switching between them does not migrate +data. `PhpDb\Session\SessionTable` names that table once (`session`), for the handler, the +persistence and the DDL. + +## Persistence + +`PhpDbSessionPersistence` holds no process-global state. Session identity is read from the session +object rather than from ext-session, which makes it safe to use under concurrent async runtimes. +Payloads are stored with `serialize()`, so objects kept in a session must implement +`__serialize()` and `__unserialize()` to round-trip. + +```php +use PhpDb\Session\PhpDbSessionPersistence; + +$persistence = new PhpDbSessionPersistence( + adapter : $adapter, + sessionConfig: ['cookie_samesite' => 'Lax', 'gc_maxlifetime' => 3600], +); +``` + +Config passed as `sessionConfig` overrides the php.ini defaults key by key. + +| Key | Type | php.ini source | Fallback when unset | +|---|---|---|---| +| `gc_maxlifetime` | `int` | `session.gc_maxlifetime` | `1440` | +| `cache_limiter` | `string` | `session.cache_limiter` | `'nocache'` | +| `cache_expire` | `int` | `session.cache_expire` | `0` | +| `name` | `string` | `session.name` | `'PHPSESSID'` | +| `cookie_lifetime` | `int` | `session.cookie_lifetime` | `0` | +| `cookie_path` | `string` | `session.cookie_path` | `'/'` | +| `cookie_domain` | `string` | `session.cookie_domain` | `''` | +| `cookie_secure` | `bool` | `session.cookie_secure` | `false` | +| `cookie_httponly` | `bool` | `session.cookie_httponly` | `false` | +| `cookie_samesite` | `string` | `session.cookie_samesite` | `''` | + +Integer keys treat a php.ini value of `0` as unset and fall back to the default in the table. + +What a persist does: + +1. Regenerates the identifier when the session asks for it, deleting the old row, or when a new + session has data but no identifier yet. +2. Returns without touching the database when the session has no identifier, or has not changed. +3. Generates identifiers with `bin2hex(random_bytes(16))`, 32 hexadecimal characters. +4. Upserts the row with `expires_at` set to now plus the session lifetime, or `gc_maxlifetime` when + the session carries none, and adds the session cookie and cache headers to the response. + +A read selects the payload for the identifier where `expires_at` is still in the future. A missing +or expired row yields an empty session that keeps the same identifier, so a browser holding a stale +cookie writes a fresh row rather than a second one. + +## Session handler + +`DbSessionHandler` implements the native `SessionHandlerInterface`. It reads and deletes by the +same rules, and takes the write lifetime from `session.gc_maxlifetime` in php.ini at write time; it +does not read the `session` config section. + +## Wiring + +`PhpDb\Session\SessionProvider` registers everything: + +```php +'dependencies' => [ + 'aliases' => [ + SessionPersistenceInterface::class => PhpDbSessionPersistence::class, + ], + 'factories' => [ + DbSessionHandler::class => DbSessionHandlerFactory::class, + PhpDbSessionPersistence::class => PhpDbSessionPersistenceFactory::class, + InitDbCommand::class => InitDbCommandFactory::class, + ], +], +``` + +It also publishes the console command, so a console that reads +`Webware\Console\ConsoleInterface::class` discovers it: + +```php +ConsoleInterface::class => [ + 'commands' => ['session:init-db' => InitDbCommand::class], +], +``` + +`PhpDbSessionPersistenceFactory` reads `config['session']` (the table above) and fetches +`AdapterInterface` from the container. `DbSessionHandlerFactory` and `InitDbCommandFactory` fetch +`AdapterInterface` only. + +```php +// config/autoload/session.global.php +return [ + // Idle lifetime in seconds; overrides php.ini session.gc_maxlifetime (1440). + 'session' => ['gc_maxlifetime' => 28800], +]; +``` + +## Creating the table + +```bash +vendor/bin/webware session:init-db +vendor/bin/webware session:init-db --drop +``` + +`session:init-db` builds the table with php-db's DDL classes and runs it through the adapter. +`--drop` issues `DROP TABLE IF EXISTS` first, then recreates. `CREATE TABLE IF NOT EXISTS` means a +plain run against an existing table changes nothing. + +| Column | Type | Nullable | +|---|---|---| +| `id` | `VARCHAR(64)` | no | +| `payload` | `MEDIUMTEXT` | no | +| `modified_at` | `DATETIME` | no | +| `expires_at` | `DATETIME` | no | + +The primary key is `id` and there is an index named `idx_expires_at` on `expires_at`. Table options +are `ENGINE=InnoDB`, `DEFAULT CHARSET=utf8mb4`, `COLLATE=utf8mb4_0900_ai_ci`. Timestamps are +formatted `Y-m-d H:i:s`. + +`MediumText` exists because php-db ships `TEXT` but no `MEDIUMTEXT`, and a serialized session can +exceed the 64 KB that `TEXT` holds. + +## MySQL assumptions + +The shipped DDL and the upsert are both MySQL. `SessionSchema` emits MySQL table options, and +`PhpDb\Session\Sql\Insert` appends `ON DUPLICATE KEY UPDATE` directly rather than going through a +platform builder, which its own `@todo` records as the portability gap to close. The integration +suite runs against MySQL. diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 826af98..163b27d 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -81,7 +81,7 @@ private static function schemaConfigType(): Type\TypeInterface * * The backup prefix is independent of the live prefix: a configured * `backup_prefix` replaces the live prefix entirely, so backing up - * `acl_role` with `backup_prefix: 'bck'` yields `bck_acl_role`. + * `session` with `backup_prefix: 'bck'` yields `bck_session`. * * @throws InvalidArgumentException If an override or configured value is an empty string. * @throws Type\Exception\AssertException If the enum value is not a non-empty string. diff --git a/src/TableGateway/Feature/EventDispatcher/TableGatewayEvent.php b/src/TableGateway/Feature/EventDispatcher/TableGatewayEvent.php new file mode 100644 index 0000000..01f72e3 --- /dev/null +++ b/src/TableGateway/Feature/EventDispatcher/TableGatewayEvent.php @@ -0,0 +1,45 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\TableGateway\Feature\EventDispatcher; + +use Webware\Event\Event; + +/** + * The event php-db's table gateway lifecycle hooks are published as. + * + * The name is the hook being run (`preSelect`, `postInsert`, …), taken from + * {@see \PhpDb\TableGateway\Feature\EventFeatureEventsInterface}; the target is the table gateway + * the hook belongs to; the params are the hook's own arguments, keyed as php-db's + * {@see \PhpDb\TableGateway\Feature\EventFeature} keys them. + * + * @api + */ +final class TableGatewayEvent extends Event +{ + /** + * @param array $params + */ + public function __construct( + ?string $name = null, + ?object $target = null, + array $params = [], + ) { + parent::__construct( + name : $name, + target: $target, + params: $params, + ); + } +} diff --git a/src/TableGateway/Feature/EventDispatcherFeature.php b/src/TableGateway/Feature/EventDispatcherFeature.php new file mode 100644 index 0000000..9a0a480 --- /dev/null +++ b/src/TableGateway/Feature/EventDispatcherFeature.php @@ -0,0 +1,203 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\TableGateway\Feature; + +use PhpDb\Adapter\Driver\ResultInterface; +use PhpDb\Adapter\Driver\StatementInterface; +use PhpDb\ResultSet\ResultSetInterface; +use PhpDb\Sql\Delete; +use PhpDb\Sql\Insert; +use PhpDb\Sql\Select; +use PhpDb\Sql\Update; +use PhpDb\TableGateway\Feature\EventDispatcher\TableGatewayEvent; +use Psr\EventDispatcher\EventDispatcherInterface; + +/** + * PSR-14 counterpart to php-db's laminas-based {@see EventFeature}. + * + * Implements the same {@see EventFeatureEventsInterface} contract, so a table gateway accepts it + * exactly where php-db's own feature is accepted, and publishes each lifecycle hook as a + * {@see TableGatewayEvent} through the composed PSR-14 dispatcher instead of a laminas event + * manager. + * + * php-db's feature attaches the gateway as the event target once and reuses that event, so every + * hook here carries the gateway as the target of the event it publishes. + * + * @api + */ +// @mago-expect lint:too-many-methods - accepted: the method count is the EventFeatureEventsInterface hook set, plus the constructor and the dispatcher accessor; the interface fixes it. +final class EventDispatcherFeature extends AbstractFeature implements EventFeatureEventsInterface +{ + public function __construct( + private readonly EventDispatcherInterface $eventDispatcher, + ) {} + + /** + * Retrieve the composed event dispatcher instance. + */ + public function getEventDispatcher(): EventDispatcherInterface + { + return $this->eventDispatcher; + } + + /** + * Dispatch the "postDelete" event, mapping: + * - $statement as "statement" + * - $result as "result" + */ + public function postDelete(StatementInterface $statement, ResultInterface $result): void + { + $this->dispatch( + name: static::EVENT_POST_DELETE, + params: [ + 'statement' => $statement, + 'result' => $result, + ], + ); + } + + /** + * Dispatch the "postInitialize" event. + */ + public function postInitialize(): void + { + $this->dispatch(name: static::EVENT_POST_INITIALIZE); + } + + /** + * Dispatch the "postInsert" event, mapping: + * - $statement as "statement" + * - $result as "result" + */ + public function postInsert(StatementInterface $statement, ResultInterface $result): void + { + $this->dispatch( + name: static::EVENT_POST_INSERT, + params: [ + 'statement' => $statement, + 'result' => $result, + ], + ); + } + + /** + * Dispatch the "postSelect" event, mapping: + * - $statement as "statement" + * - $result as "result" + * - $resultSet as "result_set" + */ + public function postSelect( + StatementInterface $statement, + ResultInterface $result, + ResultSetInterface $resultSet, + ): void { + $this->dispatch( + name: static::EVENT_POST_SELECT, + params: [ + 'statement' => $statement, + 'result' => $result, + 'result_set' => $resultSet, + ], + ); + } + + /** + * Dispatch the "postUpdate" event, mapping: + * - $statement as "statement" + * - $result as "result" + */ + public function postUpdate(StatementInterface $statement, ResultInterface $result): void + { + $this->dispatch( + name: static::EVENT_POST_UPDATE, + params: [ + 'statement' => $statement, + 'result' => $result, + ], + ); + } + + /** + * Dispatch the "preDelete" event, mapping: + * - $delete as "delete" + */ + public function preDelete(Delete $delete): void + { + $this->dispatch( + name: static::EVENT_PRE_DELETE, + params: ['delete' => $delete], + ); + } + + /** + * Dispatch the "preInitialize" event. + */ + public function preInitialize(): void + { + $this->dispatch(name: static::EVENT_PRE_INITIALIZE); + } + + /** + * Dispatch the "preInsert" event, mapping: + * - $insert as "insert" + */ + public function preInsert(Insert $insert): void + { + $this->dispatch( + name: static::EVENT_PRE_INSERT, + params: ['insert' => $insert], + ); + } + + /** + * Dispatch the "preSelect" event, mapping: + * - $select as "select" + */ + public function preSelect(Select $select): void + { + $this->dispatch( + name: static::EVENT_PRE_SELECT, + params: ['select' => $select], + ); + } + + /** + * Dispatch the "preUpdate" event, mapping: + * - $update as "update" + */ + public function preUpdate(Update $update): void + { + $this->dispatch( + name: static::EVENT_PRE_UPDATE, + params: ['update' => $update], + ); + } + + /** + * Publish one lifecycle hook as a {@see TableGatewayEvent}. + * + * @param array $params + */ + private function dispatch(string $name, array $params = []): void + { + $this->eventDispatcher->dispatch( + new TableGatewayEvent( + name : $name, + target: $this->tableGateway, + params: $params, + ), + ); + } +} diff --git a/test/integration/TestSchema.php b/test/integration/IntegrationSchema.php similarity index 54% rename from test/integration/TestSchema.php rename to test/integration/IntegrationSchema.php index 203d294..e9f0c71 100644 --- a/test/integration/TestSchema.php +++ b/test/integration/IntegrationSchema.php @@ -6,7 +6,7 @@ use PhpDb\SchemaInterface; -enum TestSchema: string implements SchemaInterface +enum IntegrationSchema: string implements SchemaInterface { - case Roles = 'core_role'; + case Session = 'session'; } diff --git a/test/integration/SchemaFactoryIntegrationTest.php b/test/integration/SchemaFactoryIntegrationTest.php index 9e4ccf1..9a8ec85 100644 --- a/test/integration/SchemaFactoryIntegrationTest.php +++ b/test/integration/SchemaFactoryIntegrationTest.php @@ -67,7 +67,7 @@ public function itCreatesPrefixedTableOnMysql(): void schemaFactory: $schemaFactory, adapter : $adapter, schema : 'webware', - catalogSql : "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'webware' AND TABLE_NAME = 'ww_core_role'", + catalogSql : "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'webware' AND TABLE_NAME = 'ww_session'", ); } @@ -105,7 +105,7 @@ public function itCreatesPrefixedTableOnPgsql(): void schemaFactory: $schemaFactory, adapter : $adapter, schema : 'public', - catalogSql : "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ww_core_role'", + catalogSql : "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ww_session'", ); } @@ -138,7 +138,7 @@ public function itCreatesPrefixedTableOnSqlite(): void schemaFactory: $schemaFactory, adapter : $adapter, schema : null, - catalogSql : "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ww_core_role'", + catalogSql : "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ww_session'", ); } @@ -148,9 +148,9 @@ private function assertTableRoundTrips( ?string $schema, string $catalogSql, ): void { - $identifier = $schemaFactory(TestSchema::Roles); + $identifier = $schemaFactory(IntegrationSchema::Session); - self::assertSame('ww_core_role', $identifier->getTable()); + self::assertSame('ww_session', $identifier->getTable()); self::assertSame($schema, $identifier->getSchema()); $createTable = new CreateTable(table: $identifier); diff --git a/test/integration/Session/PhpDbSessionPersistenceTest.php b/test/integration/Session/PhpDbSessionPersistenceTest.php index 2a244ce..59da0dc 100644 --- a/test/integration/Session/PhpDbSessionPersistenceTest.php +++ b/test/integration/Session/PhpDbSessionPersistenceTest.php @@ -279,7 +279,7 @@ private function persistence(array $config = []): PhpDbSessionPersistence $container->setService('config', ['session' => $config]); $container->setService(AdapterInterface::class, $this->adapter); - return (new PhpDbSessionPersistenceFactory())($container); + return new PhpDbSessionPersistenceFactory()($container); } private function requestWithCookie(string $value, string $name = 'PHPSESSID'): ServerRequestInterface diff --git a/test/unit/Container/SchemaFactoryFactoryTest.php b/test/unit/Container/SchemaFactoryFactoryTest.php index 5176a02..2608e11 100644 --- a/test/unit/Container/SchemaFactoryFactoryTest.php +++ b/test/unit/Container/SchemaFactoryFactoryTest.php @@ -20,13 +20,13 @@ final class SchemaFactoryFactoryTest extends TestCase #[Test] public function itBuildsFactoryFromConfig(): void { - $factory = (new SchemaFactoryFactory())($this->container([ + $factory = new SchemaFactoryFactory()($this->container([ SchemaInterface::class => [ 'prefix' => 'ww', 'separator' => '__', 'schema' => 'public', - 'prefixes' => ['acl_role' => 'acl'], - 'schemas' => ['acl_role' => 'tenant'], + 'prefixes' => ['session' => 'sess'], + 'schemas' => ['session' => 'tenant'], 'backup_prefix' => 'bck', 'backup_schema' => 'backup', ], @@ -42,7 +42,7 @@ public function itBuildsFactoryFromConfig(): void #[Test] public function itUsesDefaultsWhenConfigServiceAbsent(): void { - $factory = (new SchemaFactoryFactory())($this->container(null)); + $factory = new SchemaFactoryFactory()($this->container(null)); self::assertNull($factory->getPrefix()); self::assertNull($factory->getSchema()); @@ -54,7 +54,7 @@ public function itUsesDefaultsWhenConfigServiceAbsent(): void #[Test] public function itUsesDefaultsWhenSchemaConfigAbsent(): void { - $factory = (new SchemaFactoryFactory())($this->container([])); + $factory = new SchemaFactoryFactory()($this->container([])); self::assertNull($factory->getPrefix()); self::assertNull($factory->getSchema()); diff --git a/test/unit/AclSchema.php b/test/unit/DefaultSchema.php similarity index 53% rename from test/unit/AclSchema.php rename to test/unit/DefaultSchema.php index 25aa86c..ce06548 100644 --- a/test/unit/AclSchema.php +++ b/test/unit/DefaultSchema.php @@ -6,7 +6,7 @@ use PhpDb\SchemaInterface; -enum AclSchema: string implements SchemaInterface +enum DefaultSchema: string implements SchemaInterface { - case Role = 'acl_role'; + case Session = 'session'; } diff --git a/test/unit/EmptySchema.php b/test/unit/EmptySchema.php index e5dcef6..cc1d3fa 100644 --- a/test/unit/EmptySchema.php +++ b/test/unit/EmptySchema.php @@ -8,5 +8,5 @@ enum EmptySchema: string implements SchemaInterface { - case Role = ''; + case Session = ''; } diff --git a/test/unit/IntSchema.php b/test/unit/IntSchema.php index 65fa93b..0f0374a 100644 --- a/test/unit/IntSchema.php +++ b/test/unit/IntSchema.php @@ -8,5 +8,5 @@ enum IntSchema: int implements SchemaInterface { - case Role = 1; + case Session = 1; } diff --git a/test/unit/PublicSchema.php b/test/unit/PublicSchema.php deleted file mode 100644 index 93f9151..0000000 --- a/test/unit/PublicSchema.php +++ /dev/null @@ -1,14 +0,0 @@ - 'bck', 'backup_schema' => 'backup', ]); - $identifier = $factory->backup(AclSchema::Role); + $identifier = $factory->backup(DefaultSchema::Session); - self::assertSame('bck_acl_role', $identifier->getTable()); + self::assertSame('bck_session', $identifier->getTable()); self::assertSame('backup', $identifier->getSchema()); } @@ -45,7 +45,7 @@ public function backupFallsBackToLiveSchemaWhenNoBackupSchema(): void 'schema' => 'public', 'backup_prefix' => 'bck', ]); - $identifier = $factory->backup(AclSchema::Role); + $identifier = $factory->backup(DefaultSchema::Session); self::assertSame('public', $identifier->getSchema()); } @@ -55,21 +55,21 @@ public function backupIgnoresLivePrefix(): void { $factory = new SchemaFactory([ 'prefix' => 'ww', - 'prefixes' => ['acl_role' => 'acl'], + 'prefixes' => ['session' => 'sess'], 'backup_prefix' => 'bck', ]); - $identifier = $factory->backup(AclSchema::Role); + $identifier = $factory->backup(DefaultSchema::Session); - self::assertSame('bck_acl_role', $identifier->getTable()); + self::assertSame('bck_session', $identifier->getTable()); } #[Test] public function backupPrefersCallTimePrefixOverBackupPrefix(): void { $factory = new SchemaFactory(['backup_prefix' => 'bck']); - $identifier = $factory->backup(AclSchema::Role, prefix: 'tmp'); + $identifier = $factory->backup(DefaultSchema::Session, prefix: 'tmp'); - self::assertSame('tmp_acl_role', $identifier->getTable()); + self::assertSame('tmp_session', $identifier->getTable()); } #[Test] @@ -77,9 +77,9 @@ public function backupPrefersCallTimeSchemaOverPerTableSchema(): void { $factory = new SchemaFactory([ 'backup_prefix' => 'bck', - 'schemas' => ['acl_role' => 'tenant'], + 'schemas' => ['session' => 'tenant'], ]); - $identifier = $factory->backup(AclSchema::Role, schemaName: 'archive'); + $identifier = $factory->backup(DefaultSchema::Session, schemaName: 'archive'); self::assertSame('archive', $identifier->getSchema()); } @@ -91,9 +91,9 @@ public function backupPrefersCallTimeSeparatorOverConfigured(): void 'backup_prefix' => 'bck', 'separator' => '__', ]); - $identifier = $factory->backup(AclSchema::Role, separator: '--'); + $identifier = $factory->backup(DefaultSchema::Session, separator: '--'); - self::assertSame('bck--acl_role', $identifier->getTable()); + self::assertSame('bck--session', $identifier->getTable()); } #[Test] @@ -102,9 +102,9 @@ public function backupPrefersPerTableSchemaOverBackupSchema(): void $factory = new SchemaFactory([ 'backup_prefix' => 'bck', 'backup_schema' => 'backup', - 'schemas' => ['acl_role' => 'tenant'], + 'schemas' => ['session' => 'tenant'], ]); - $identifier = $factory->backup(AclSchema::Role); + $identifier = $factory->backup(DefaultSchema::Session); self::assertSame('tenant', $identifier->getSchema()); } @@ -116,7 +116,7 @@ public function backupSupportsCallTimeSchemaRedirect(): void 'backup_prefix' => 'bck', 'backup_schema' => 'backup', ]); - $identifier = $factory->backup(AclSchema::Role, schemaName: 'archive'); + $identifier = $factory->backup(DefaultSchema::Session, schemaName: 'archive'); self::assertSame('archive', $identifier->getSchema()); } @@ -128,21 +128,21 @@ public function itAppliesCallTimeOverrides(): void 'prefix' => 'ww', 'schema' => 'public', ]); - $identifier = $factory(AclSchema::Role, schemaName: 'backup', prefix: 'tmp'); + $identifier = $factory(DefaultSchema::Session, schemaName: 'backup', prefix: 'tmp'); self::assertSame('backup', $identifier->getSchema()); self::assertSame('tmp', $identifier->getPrefix()); - self::assertSame('tmp_acl_role', $identifier->getTable()); + self::assertSame('tmp_session', $identifier->getTable()); } #[Test] public function itAppliesConfiguredPrefix(): void { $factory = new SchemaFactory(['prefix' => 'ww']); - $identifier = $factory(AclSchema::Role); + $identifier = $factory(DefaultSchema::Session); - self::assertSame('ww_acl_role', $identifier->getTable()); - self::assertSame('acl_role', $identifier->getUnprefixedTable()); + self::assertSame('ww_session', $identifier->getTable()); + self::assertSame('session', $identifier->getUnprefixedTable()); self::assertSame('ww', $identifier->getPrefix()); self::assertNull($identifier->getSchema()); } @@ -178,11 +178,11 @@ public function itPrefersPerTablePrefixOverAppWidePrefix(): void { $factory = new SchemaFactory([ 'prefix' => 'ww', - 'prefixes' => ['acl_role' => 'acl'], + 'prefixes' => ['session' => 'sess'], ]); - $identifier = $factory(AclSchema::Role); + $identifier = $factory(DefaultSchema::Session); - self::assertSame('acl_acl_role', $identifier->getTable()); + self::assertSame('sess_session', $identifier->getTable()); } #[Test] @@ -190,9 +190,9 @@ public function itPrefersPerTableSchemaOverAppWideSchema(): void { $factory = new SchemaFactory([ 'schema' => 'public', - 'schemas' => ['acl_role' => 'tenant'], + 'schemas' => ['session' => 'tenant'], ]); - $identifier = $factory(AclSchema::Role); + $identifier = $factory(DefaultSchema::Session); self::assertSame('tenant', $identifier->getSchema()); } @@ -201,9 +201,9 @@ public function itPrefersPerTableSchemaOverAppWideSchema(): void public function itPreservesSchemaFromEnumConstant(): void { $factory = new SchemaFactory(['prefix' => 'ww']); - $identifier = $factory(PublicSchema::Role); + $identifier = $factory(WebwareSchema::Session); - self::assertSame('public', $identifier->getSchema()); + self::assertSame('webware', $identifier->getSchema()); } #[Test] @@ -211,7 +211,7 @@ public function itRejectsEmptyEnumValue(): void { $this->expectException(AssertException::class); - (new SchemaFactory())(EmptySchema::Role); + new SchemaFactory()(EmptySchema::Session); } #[Test] @@ -219,7 +219,7 @@ public function itRejectsNonStringEnumValue(): void { $this->expectException(AssertException::class); - (new SchemaFactory())(IntSchema::Role); + new SchemaFactory()(IntSchema::Session); } #[Test] @@ -277,8 +277,8 @@ public function itUsesCustomSeparator(): void 'prefix' => 'ww', 'separator' => '__', ]); - $identifier = $factory(AclSchema::Role); + $identifier = $factory(DefaultSchema::Session); - self::assertSame('ww__acl_role', $identifier->getTable()); + self::assertSame('ww__session', $identifier->getTable()); } } diff --git a/test/unit/Session/Console/Container/InitDbCommandFactoryTest.php b/test/unit/Session/Console/Container/InitDbCommandFactoryTest.php index fdd988f..7d20423 100644 --- a/test/unit/Session/Console/Container/InitDbCommandFactoryTest.php +++ b/test/unit/Session/Console/Container/InitDbCommandFactoryTest.php @@ -32,7 +32,7 @@ public function invokeBuildsACommandForTheSessionTable(): void static fn(string $id): AdapterInterface => $adapter, ); - $command = (new InitDbCommandFactory())($container); + $command = new InitDbCommandFactory()($container); self::assertInstanceOf(InitDbCommand::class, $command); diff --git a/test/unit/Session/Container/SessionFactoriesTest.php b/test/unit/Session/Container/SessionFactoriesTest.php index e1bb957..3077985 100644 --- a/test/unit/Session/Container/SessionFactoriesTest.php +++ b/test/unit/Session/Container/SessionFactoriesTest.php @@ -28,7 +28,7 @@ public function handlerFactoryBuildsTheHandler(): void { self::assertInstanceOf( DbSessionHandler::class, - (new DbSessionHandlerFactory())($this->container(services: [])), + new DbSessionHandlerFactory()($this->container(services: [])), ); } @@ -37,7 +37,7 @@ public function persistenceFactoryBuildsThePersistenceFromTheConfigService(): vo { self::assertInstanceOf( PhpDbSessionPersistence::class, - (new PhpDbSessionPersistenceFactory())($this->container(services: [ + new PhpDbSessionPersistenceFactory()($this->container(services: [ 'config' => ['session' => ['name' => 'WEBWARE']], ])), ); @@ -48,7 +48,7 @@ public function persistenceFactoryBuildsThePersistenceWithoutAConfigService(): v { self::assertInstanceOf( PhpDbSessionPersistence::class, - (new PhpDbSessionPersistenceFactory())($this->container(services: [])), + new PhpDbSessionPersistenceFactory()($this->container(services: [])), ); } diff --git a/test/unit/Session/SessionProviderTest.php b/test/unit/Session/SessionProviderTest.php index 4d999db..21c6fea 100644 --- a/test/unit/Session/SessionProviderTest.php +++ b/test/unit/Session/SessionProviderTest.php @@ -56,7 +56,7 @@ public function invokeCarriesTheDependencies(): void #[Test] public function invokeRegistersTheInitDbCommandForConsoleDiscovery(): void { - $config = (new SessionProvider())(); + $config = new SessionProvider()(); self::assertSame( ['commands' => ['session:init-db' => InitDbCommand::class]], diff --git a/test/unit/TableGateway/Feature/EventDispatcher/TableGatewayEventTest.php b/test/unit/TableGateway/Feature/EventDispatcher/TableGatewayEventTest.php new file mode 100644 index 0000000..0ca74eb --- /dev/null +++ b/test/unit/TableGateway/Feature/EventDispatcher/TableGatewayEventTest.php @@ -0,0 +1,50 @@ + 'a select'], + ); + + self::assertSame('preSelect', $event->getName()); + self::assertSame($target, $event->getTarget()); + self::assertSame(['select' => 'a select'], $event->getParams()); + } + + #[Test] + public function itFallsBackToTheClassNameWhenNoNameIsGiven(): void + { + $event = new TableGatewayEvent(); + + self::assertSame(TableGatewayEvent::class, $event->getName()); + self::assertNull($event->getTarget()); + self::assertSame([], $event->getParams()); + } + + #[Test] + public function itIsAWebwareEvent(): void + { + self::assertInstanceOf(Event::class, new TableGatewayEvent()); + } +} diff --git a/test/unit/TableGateway/Feature/EventDispatcherFeatureTest.php b/test/unit/TableGateway/Feature/EventDispatcherFeatureTest.php new file mode 100644 index 0000000..5f922a3 --- /dev/null +++ b/test/unit/TableGateway/Feature/EventDispatcherFeatureTest.php @@ -0,0 +1,200 @@ + */ + private array $dispatched = []; + + #[Test] + public function itCarriesTheHooksArgumentsAsEventParams(): void + { + $select = new Select(); + $insert = new Insert(); + $update = new Update(); + $delete = new Delete(); + $statement = $this->createStub(StatementInterface::class); + $result = $this->createStub(ResultInterface::class); + $resultSet = $this->createStub(ResultSetInterface::class); + + $feature = $this->feature($this->createStub(AbstractTableGateway::class)); + + $feature->preInitialize(); + $feature->postInitialize(); + $feature->preSelect($select); + $feature->postSelect($statement, $result, $resultSet); + $feature->preInsert($insert); + $feature->postInsert($statement, $result); + $feature->preUpdate($update); + $feature->postUpdate($statement, $result); + $feature->preDelete($delete); + $feature->postDelete($statement, $result); + + self::assertSame( + [ + [], + [], + ['select' => $select], + ['statement' => $statement, 'result' => $result, 'result_set' => $resultSet], + ['insert' => $insert], + ['statement' => $statement, 'result' => $result], + ['update' => $update], + ['statement' => $statement, 'result' => $result], + ['delete' => $delete], + ['statement' => $statement, 'result' => $result], + ], + array_map(static fn(TableGatewayEvent $event): array => $event->getParams(), $this->dispatched), + ); + } + + #[Test] + public function itExposesTheComposedDispatcher(): void + { + $dispatcher = $this->createStub(EventDispatcherInterface::class); + + self::assertSame( + $dispatcher, + new EventDispatcherFeature(eventDispatcher: $dispatcher)->getEventDispatcher(), + ); + } + + #[Test] + public function itPublishesAFreshEventInstancePerHook(): void + { + $this->runEveryHook(); + + self::assertCount(10, $this->dispatched); + self::assertCount( + 10, + array_unique(array_map(spl_object_id(...), $this->dispatched)), + ); + } + + #[Test] + public function itPublishesOneEventPerLifecycleHookUnderThatHooksName(): void + { + $this->runEveryHook(); + + self::assertSame( + [ + EventFeatureEventsInterface::EVENT_PRE_INITIALIZE, + EventFeatureEventsInterface::EVENT_POST_INITIALIZE, + EventFeatureEventsInterface::EVENT_PRE_SELECT, + EventFeatureEventsInterface::EVENT_POST_SELECT, + EventFeatureEventsInterface::EVENT_PRE_INSERT, + EventFeatureEventsInterface::EVENT_POST_INSERT, + EventFeatureEventsInterface::EVENT_PRE_UPDATE, + EventFeatureEventsInterface::EVENT_POST_UPDATE, + EventFeatureEventsInterface::EVENT_PRE_DELETE, + EventFeatureEventsInterface::EVENT_POST_DELETE, + ], + array_map(static fn(TableGatewayEvent $event): string => $event->getName(), $this->dispatched), + ); + } + + #[Test] + public function itTargetsEveryPublishedEventAtTheTableGateway(): void + { + $gateway = $this->runEveryHook(); + + self::assertCount(10, $this->dispatched); + foreach ($this->dispatched as $event) { + self::assertSame($gateway, $event->getTarget()); + } + } + + /** + * Builds a feature whose dispatcher records every event it is handed. + */ + private function feature(AbstractTableGateway $gateway): EventDispatcherFeature + { + $dispatcher = $this->createStub(EventDispatcherInterface::class); + $dispatcher->method('dispatch') + ->willReturnCallback( + function (object $event): object { + if ($event instanceof TableGatewayEvent) { + $this->dispatched[] = $event; + } + + return $event; + }, + ); + + $feature = new EventDispatcherFeature(eventDispatcher: $dispatcher); + $feature->setTableGateway($gateway); + + return $feature; + } + + /** + * Drives every lifecycle hook, and returns the gateway they were driven against. + */ + private function runEveryHook(): AbstractTableGateway + { + $gateway = $this->createStub(AbstractTableGateway::class); + $feature = $this->feature($gateway); + + $feature->preInitialize(); + $feature->postInitialize(); + $feature->preSelect(new Select()); + $feature->postSelect( + $this->createStub(StatementInterface::class), + $this->createStub(ResultInterface::class), + $this->createStub(ResultSetInterface::class), + ); + $feature->preInsert(new Insert()); + $feature->postInsert( + $this->createStub(StatementInterface::class), + $this->createStub(ResultInterface::class), + ); + $feature->preUpdate(new Update()); + $feature->postUpdate( + $this->createStub(StatementInterface::class), + $this->createStub(ResultInterface::class), + ); + $feature->preDelete(new Delete()); + $feature->postDelete( + $this->createStub(StatementInterface::class), + $this->createStub(ResultInterface::class), + ); + + return $gateway; + } +} diff --git a/test/unit/WebwareSchema.php b/test/unit/WebwareSchema.php new file mode 100644 index 0000000..2124c8b --- /dev/null +++ b/test/unit/WebwareSchema.php @@ -0,0 +1,14 @@ +