From 2f6728b560c22a6a060d74e5f2c35f775665dff2 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Thu, 8 Oct 2026 21:08:49 -0500 Subject: [PATCH 1/5] feat: add the PSR-14 table gateway event dispatcher feature Migrates the EventDispatcher feature from webinertia/phpdb-feature (0.1.x) into the PhpDb bridge, placed under the `PhpDb\` root this package shadows and named beside php-db's own laminas-based EventFeature: - `PhpDb\TableGateway\Feature\EventDispatcherFeature` implements php-db's EventFeatureEventsInterface and publishes each lifecycle hook through a PSR-14 dispatcher instead of a laminas event manager. - `PhpDb\TableGateway\Feature\EventDispatcher\TableGatewayEvent` is the event each hook is published as: the hook name, the table gateway as target, and the hook's arguments keyed as EventFeature keys them. `webware/webware-event` is require-dev plus a suggestion, not a runtime require: this package's own tests exercise the feature, and a consumer that uses it supplies its own event dispatcher. The lock moves to webware/webware-tools 1.0.0-rc.2 in the same update, which resets the tool pin this repo extends to Mago 1.54.0; the test files the formatter reshapes under that version are included. One analyzer baseline entry covers php-db's `AbstractFeature::$tableGateway`, which FeatureSet assigns after construction. Signed-off-by: Joey Smith Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- analysis-baseline.toml | 7 +- composer.json | 4 +- composer.lock | 185 +++++++++++++++- .../EventDispatcher/TableGatewayEvent.php | 45 ++++ .../Feature/EventDispatcherFeature.php | 203 ++++++++++++++++++ .../Session/PhpDbSessionPersistenceTest.php | 2 +- .../Container/SchemaFactoryFactoryTest.php | 6 +- test/unit/SchemaFactoryTest.php | 4 +- .../Container/InitDbCommandFactoryTest.php | 2 +- .../Container/SessionFactoriesTest.php | 6 +- test/unit/Session/SessionProviderTest.php | 2 +- .../EventDispatcher/TableGatewayEventTest.php | 50 +++++ .../Feature/EventDispatcherFeatureTest.php | 200 +++++++++++++++++ 13 files changed, 696 insertions(+), 20 deletions(-) create mode 100644 src/TableGateway/Feature/EventDispatcher/TableGatewayEvent.php create mode 100644 src/TableGateway/Feature/EventDispatcherFeature.php create mode 100644 test/unit/TableGateway/Feature/EventDispatcher/TableGatewayEventTest.php create mode 100644 test/unit/TableGateway/Feature/EventDispatcherFeatureTest.php 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/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/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..7cca264 100644 --- a/test/unit/Container/SchemaFactoryFactoryTest.php +++ b/test/unit/Container/SchemaFactoryFactoryTest.php @@ -20,7 +20,7 @@ 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' => '__', @@ -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/SchemaFactoryTest.php b/test/unit/SchemaFactoryTest.php index ee09294..8b957aa 100644 --- a/test/unit/SchemaFactoryTest.php +++ b/test/unit/SchemaFactoryTest.php @@ -211,7 +211,7 @@ public function itRejectsEmptyEnumValue(): void { $this->expectException(AssertException::class); - (new SchemaFactory())(EmptySchema::Role); + new SchemaFactory()(EmptySchema::Role); } #[Test] @@ -219,7 +219,7 @@ public function itRejectsNonStringEnumValue(): void { $this->expectException(AssertException::class); - (new SchemaFactory())(IntSchema::Role); + new SchemaFactory()(IntSchema::Role); } #[Test] 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; + } +} From 92802eac0b481750dc2f290aa4e56818949ecd0a Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Thu, 8 Oct 2026 21:30:22 -0500 Subject: [PATCH 2/5] docs: add the v1 documentation set Six topics under docs/v1: the shadow and what ships, installation, the schema factory, the session persistence and its table, the PSR-14 table gateway event feature, and development. Every statement was checked against the code, including the SchemaFactory exception paths and the backup prefix fallbacks, which were measured rather than inferred. Signed-off-by: Joey Smith Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/v1/development.md | 93 ++++++++++++++++++++ docs/v1/event-dispatcher.md | 99 ++++++++++++++++++++++ docs/v1/index.md | 65 ++++++++++++++ docs/v1/installation.md | 79 +++++++++++++++++ docs/v1/schema-factory.md | 164 ++++++++++++++++++++++++++++++++++++ docs/v1/session.md | 134 +++++++++++++++++++++++++++++ 6 files changed, 634 insertions(+) create mode 100644 docs/v1/development.md create mode 100644 docs/v1/event-dispatcher.md create mode 100644 docs/v1/index.md create mode 100644 docs/v1/installation.md create mode 100644 docs/v1/schema-factory.md create mode 100644 docs/v1/session.md 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..9775c02 --- /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 : 'acl_role', + 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 RoleChangeLogger +{ + 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..1755ea1 --- /dev/null +++ b/docs/v1/schema-factory.md @@ -0,0 +1,164 @@ +# 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 AclSchema: string implements SchemaInterface +{ + public const string NAME = 'acl'; + + case Role = 'acl_role'; +} +``` + +An enum that leaves `NAME` alone produces identifiers with a `null` schema, leaving the choice to +the connection: + +```php +enum TestSchema: string implements SchemaInterface +{ + case Roles = 'core_role'; +} +``` + +## Producing an identifier + +```php +use PhpDb\SchemaFactory; + +$factory = new SchemaFactory([ + 'prefix' => 'ww', + 'schema' => 'public', +]); + +$identifier = $factory(AclSchema::Role); + +$identifier->getTable(); // 'ww_acl_role' +$identifier->getSchema(); // 'public' +$identifier->getPrefix(); // 'ww' +$identifier->getSeparator(); // '_' +$identifier->getUnprefixedTable(); // 'acl_role' +$identifier->getTableAndSchema(); // ['ww_acl_role', 'public'] +``` + +`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' => ['acl_role' => 'acl'], + 'schemas' => ['acl_role' => 'authz'], +]); + +$factory(AclSchema::Role)->getTable(); // 'acl_acl_role' (per-table prefix wins) +$factory(AclSchema::Role)->getSchema(); // 'authz' (per-table schema wins) +$factory(AclSchema::Role, prefix: 'x')->getTable(); // 'x_acl_role' (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', + 'schema' => 'public', + 'backup_prefix' => 'bck', + 'backup_schema' => 'backup', +]); + +$factory->backup(AclSchema::Role)->getTable(); // 'bck_acl_role' +$factory->backup(AclSchema::Role)->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_acl_role`, not `bck_ww_acl_role`. +- 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. + +## 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 Role = '';` | `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. 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. From b97917df14c0377b1c41086b502b144846048df3 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Thu, 8 Oct 2026 21:39:51 -0500 Subject: [PATCH 3/5] docs: add a quickstart and link the versioned docs from the README The README now carries the quickstart: install, merge the providers, build an identifier from a schema enum, create the session table. It then links the six pages under docs/v1. The old Installation section claimed WebwareProvider must be registered after PhpDb\\ConfigProvider for its rules to win. The two share no config keys, so that ordering requirement was not real; the README now says so, and the ordering note it keeps is the one that does exist, SessionProvider after the Mezzio session providers. The seven em dashes in the README are replaced, matching the rule applied to docs/v1. Each quickstart step was executed before committing: the merged aggregator resolves SchemaFactory through the container, the enum yields 'acl_role' with no prefix configured and 'acl' as its schema, and SessionProvider registers both the SessionPersistenceInterface alias and the session:init-db command. Signed-off-by: Joey Smith Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index af1ed89..a9f64d3 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 AclSchema: string implements SchemaInterface +{ + public const string NAME = 'acl'; + + case Role = 'acl_role'; +} + +$factory = $container->get(SchemaFactory::class); +$identifier = $factory(AclSchema::Role); + +$identifier->getTable(); // 'acl_role' until a prefix is configured +$identifier->getSchema(); // 'acl' +``` + +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 From 53c8a69079558be2299dd67a60493766ab5118c3 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Thu, 8 Oct 2026 21:55:52 -0500 Subject: [PATCH 4/5] docs: use this package's own tables as the examples The schema and event examples borrowed webware-acl's table name, and one borrowed webware-core's. Neither component is a collaborator of this bridge, so the examples now use the tables this package owns: WebwareSchema with a 'webware' schema and a 'session' case, and PhpDb\\Session\\SessionTable for the enum that leaves NAME at its default. The SchemaFactory docblock carried the same borrowed name and is updated with it. Every example was executed against the working tree before committing: with prefix 'ww' the enum yields 'ww_session' and schema 'webware'; per-table overrides yield 'sess_session' and 'tenant'; a call-time prefix yields 'x_session'; backup with 'bck' yields 'bck_session' and schema 'backup'; a backup with no backup_prefix yields 'session' with a null prefix; the shipped SessionTable yields a null schema; and an empty case value still raises Psl\\Type\\Exception\\AssertException. Signed-off-by: Joey Smith Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 12 ++++----- docs/v1/event-dispatcher.md | 4 +-- docs/v1/schema-factory.md | 51 +++++++++++++++++-------------------- src/SchemaFactory.php | 2 +- 4 files changed, 33 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index a9f64d3..c3f0181 100644 --- a/README.md +++ b/README.md @@ -62,18 +62,18 @@ layer: use PhpDb\SchemaFactory; use PhpDb\SchemaInterface; -enum AclSchema: string implements SchemaInterface +enum WebwareSchema: string implements SchemaInterface { - public const string NAME = 'acl'; + public const string NAME = 'webware'; - case Role = 'acl_role'; + case Session = 'session'; } $factory = $container->get(SchemaFactory::class); -$identifier = $factory(AclSchema::Role); +$identifier = $factory(WebwareSchema::Session); -$identifier->getTable(); // 'acl_role' until a prefix is configured -$identifier->getSchema(); // 'acl' +$identifier->getTable(); // 'session' until a prefix is configured +$identifier->getSchema(); // 'webware' ``` 4. Create the session table, and let Mezzio session middleware use it: diff --git a/docs/v1/event-dispatcher.md b/docs/v1/event-dispatcher.md index 9775c02..75eb1d8 100644 --- a/docs/v1/event-dispatcher.md +++ b/docs/v1/event-dispatcher.md @@ -29,7 +29,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; $feature = new EventDispatcherFeature(eventDispatcher: $dispatcher); $gateway = new TableGateway( - table : 'acl_role', + table : 'session', adapter : $adapter, features: $feature, ); @@ -47,7 +47,7 @@ Every hook publishes a new `PhpDb\TableGateway\Feature\EventDispatcher\TableGate ```php use PhpDb\TableGateway\Feature\EventDispatcher\TableGatewayEvent; -final class RoleChangeLogger +final class SessionWriteLogger { public function __invoke(TableGatewayEvent $event): void { diff --git a/docs/v1/schema-factory.md b/docs/v1/schema-factory.md index 1755ea1..b91c798 100644 --- a/docs/v1/schema-factory.md +++ b/docs/v1/schema-factory.md @@ -13,21 +13,22 @@ string, which means no explicit schema. ```php use PhpDb\SchemaInterface; -enum AclSchema: string implements SchemaInterface +enum WebwareSchema: string implements SchemaInterface { - public const string NAME = 'acl'; + public const string NAME = 'webware'; - case Role = 'acl_role'; + case Session = 'session'; } ``` An enum that leaves `NAME` alone produces identifiers with a `null` schema, leaving the choice to -the connection: +the connection. The session table ships that way: ```php -enum TestSchema: string implements SchemaInterface +// PhpDb\Session\SessionTable +enum SessionTable: string implements SchemaInterface { - case Roles = 'core_role'; + case Session = 'session'; } ``` @@ -36,19 +37,16 @@ enum TestSchema: string implements SchemaInterface ```php use PhpDb\SchemaFactory; -$factory = new SchemaFactory([ - 'prefix' => 'ww', - 'schema' => 'public', -]); +$factory = new SchemaFactory(['prefix' => 'ww']); -$identifier = $factory(AclSchema::Role); +$identifier = $factory(WebwareSchema::Session); -$identifier->getTable(); // 'ww_acl_role' -$identifier->getSchema(); // 'public' +$identifier->getTable(); // 'ww_session' +$identifier->getSchema(); // 'webware', from the enum NAME $identifier->getPrefix(); // 'ww' $identifier->getSeparator(); // '_' -$identifier->getUnprefixedTable(); // 'acl_role' -$identifier->getTableAndSchema(); // ['ww_acl_role', 'public'] +$identifier->getUnprefixedTable(); // 'session' +$identifier->getTableAndSchema(); // ['ww_session', 'webware'] ``` `TableIdentifier::getTable()` applies the prefix and separator; `getUnprefixedTable()` returns the @@ -70,13 +68,13 @@ What the values look like: ```php $factory = new SchemaFactory([ 'prefix' => 'ww', - 'prefixes' => ['acl_role' => 'acl'], - 'schemas' => ['acl_role' => 'authz'], + 'prefixes' => ['session' => 'sess'], + 'schemas' => ['session' => 'tenant'], ]); -$factory(AclSchema::Role)->getTable(); // 'acl_acl_role' (per-table prefix wins) -$factory(AclSchema::Role)->getSchema(); // 'authz' (per-table schema wins) -$factory(AclSchema::Role, prefix: 'x')->getTable(); // 'x_acl_role' (call time wins) +$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 @@ -93,22 +91,21 @@ the live table. It resolves each part in this order: ```php $factory = new SchemaFactory([ 'prefix' => 'ww', - 'schema' => 'public', 'backup_prefix' => 'bck', 'backup_schema' => 'backup', ]); -$factory->backup(AclSchema::Role)->getTable(); // 'bck_acl_role' -$factory->backup(AclSchema::Role)->getSchema(); // '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_acl_role`, not `bck_ww_acl_role`. + 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. + the live lookup resolves, here the enum `NAME`. ## Configuration @@ -152,7 +149,7 @@ service, or an absent key, produces a factory with defaults. |---|---| | 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 Role = '';` | `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 @@ -161,4 +158,4 @@ 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. +such as `prefx` fails at construction rather than being ignored. \ No newline at end of file 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. From 83f629ba0d6638ec6ce7ead11e0fdfea3fd4365a Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Thu, 8 Oct 2026 22:31:54 -0500 Subject: [PATCH 5/5] test: replace the borrowed schema fixtures with this package's own tables The fixtures carried webware-acl's and webware-core's names, neither of which is a collaborator of this bridge, and they were the source the docs had copied. They now use the table this package owns: - AclSchema, no NAME, becomes DefaultSchema with case Session = 'session'. - PublicSchema, which exercises the NAME constant, becomes WebwareSchema with NAME = 'webware', matching the example in docs/v1. - EmptySchema and IntSchema keep their purpose names and take case Session, so their values stay empty and int-backed. - The integration fixture becomes IntegrationSchema with case Session, and its DDL assertions move from ww_core_role to ww_session. SchemaFactoryTest and SchemaFactoryFactoryTest follow: per-table overrides are keyed 'session', and the NAME assertion now expects 'webware'. The schema fixture that leaves NAME alone produces a null schema, which is what the shipped PhpDb\\Session\\SessionTable does, so the pair now mirrors the package rather than an unrelated component. Verified locally: four Mago gates clean, unit suite OK (67 tests), integration suite OK (28 tests, 2 skipped for the disabled adapters), Infection Coverage 100% and Covered MSI 99% unchanged. The MySQL integration round-trip created and dropped ww_session, leaving the database empty. Signed-off-by: Joey Smith Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../{TestSchema.php => IntegrationSchema.php} | 4 +- .../SchemaFactoryIntegrationTest.php | 10 +-- .../Container/SchemaFactoryFactoryTest.php | 4 +- .../unit/{AclSchema.php => DefaultSchema.php} | 4 +- test/unit/EmptySchema.php | 2 +- test/unit/IntSchema.php | 2 +- test/unit/PublicSchema.php | 14 ----- test/unit/SchemaFactoryTest.php | 62 +++++++++---------- test/unit/WebwareSchema.php | 14 +++++ 9 files changed, 58 insertions(+), 58 deletions(-) rename test/integration/{TestSchema.php => IntegrationSchema.php} (54%) rename test/unit/{AclSchema.php => DefaultSchema.php} (53%) delete mode 100644 test/unit/PublicSchema.php create mode 100644 test/unit/WebwareSchema.php 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/unit/Container/SchemaFactoryFactoryTest.php b/test/unit/Container/SchemaFactoryFactoryTest.php index 7cca264..2608e11 100644 --- a/test/unit/Container/SchemaFactoryFactoryTest.php +++ b/test/unit/Container/SchemaFactoryFactoryTest.php @@ -25,8 +25,8 @@ public function itBuildsFactoryFromConfig(): void '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', ], 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/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 @@ +