From 8479fb3a235471fd29d0e3f5a5d6fa9a0cfccb4b Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 19:56:46 -0400 Subject: [PATCH] Add WordPress operation provider factory --- composer.json | 2 +- composer.lock | 10 +- lib/Strategies/CoordinatedQueryStrategy.php | 38 +++ lib/Strategies/WordPressInitializer.php | 2 + ...dPressOperationDatabaseProviderFactory.php | 67 ++++ ...WpdbOperationDatabaseHandlerBridgeTest.php | 320 ++++++++++++++++++ 6 files changed, 433 insertions(+), 6 deletions(-) create mode 100644 lib/Strategies/WordPressOperationDatabaseProviderFactory.php create mode 100644 tests/Integration/Database/RealWpdbOperationDatabaseHandlerBridgeTest.php diff --git a/composer.json b/composer.json index e07d25f..9c143d0 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "php": ">=8.0", "phpnomad/auth": "^1.0", "phpnomad/asset": "^1.0", - "phpnomad/db": "dev-release/2.2 as 2.2.x-dev", + "phpnomad/db": "dev-codex/operation-local-handler-bridge as 2.2.x-dev", "phpnomad/datastore": "^2.0", "phpnomad/event": "^1.0", "phpnomad/email": "^1.0", diff --git a/composer.lock b/composer.lock index 8f656ad..1384208 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": "2a88635225488e950fdb4e7ca403fb02", + "content-hash": "37faa428f4f7ff1eeb826752e48d0bff", "packages": [ { "name": "phpnomad/asset", @@ -270,11 +270,11 @@ }, { "name": "phpnomad/db", - "version": "dev-release/2.2", + "version": "dev-codex/operation-local-handler-bridge", "source": { "type": "git", "url": "https://github.com/phpnomad/db.git", - "reference": "17fb84703d260a66de874704ae37cf924b9d8c2b" + "reference": "4b0b7709e5945a4290b04c2e3562ef919923ed08" }, "require": { "phpnomad/cache": "^1.0", @@ -313,7 +313,7 @@ } ], "homepage": "https://github.com/phpnomad/core", - "time": "2026-09-20T15:42:00+00:00" + "time": "2026-09-20T23:21:17+00:00" }, { "name": "phpnomad/di", @@ -6125,7 +6125,7 @@ "aliases": [ { "package": "phpnomad/db", - "version": "dev-release/2.2", + "version": "dev-codex/operation-local-handler-bridge", "alias": "2.2.x-dev", "alias_normalized": "2.2.9999999.9999999-dev" } diff --git a/lib/Strategies/CoordinatedQueryStrategy.php b/lib/Strategies/CoordinatedQueryStrategy.php index cb93e2c..25bc8e5 100644 --- a/lib/Strategies/CoordinatedQueryStrategy.php +++ b/lib/Strategies/CoordinatedQueryStrategy.php @@ -9,6 +9,8 @@ use PHPNomad\Database\Exceptions\CoordinatedOperationReportingFailedException; use PHPNomad\Database\Exceptions\UnsupportedCoordinationException; use PHPNomad\Database\Interfaces\CoordinatedQueryStrategy as CoordinatedQueryStrategyInterface; +use PHPNomad\Database\Interfaces\QueryBuilder as CoreQueryBuilder; +use PHPNomad\Database\Interfaces\QueryStrategy as CoreQueryStrategy; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Strategies\OperationQueryStrategy; use PHPNomad\Datastore\Exceptions\DatastoreErrorException; @@ -30,6 +32,8 @@ class CoordinatedQueryStrategy extends QueryStrategy implements CoordinatedQuery private ?mysqli $connection = null; private int $connectionId = 0; private bool $transactionActive = false; + private ?CoreQueryStrategy $activeOperationStrategy = null; + private ?PinnedQueryStrategy $activePinnedStrategy = null; public function __construct(private ?LoggerStrategy $logger = null) { @@ -95,6 +99,8 @@ function (string $table) use ($schema): array { } ); $operationStrategy = new OperationQueryStrategy($delegate, $participants); + $this->activeOperationStrategy = $operationStrategy; + $this->activePinnedStrategy = $delegate; $result = $operation($operationStrategy); } catch (Throwable $failure) { $this->abort($names, 'callback', $failure); @@ -122,10 +128,42 @@ function (string $table) use ($schema): array { if ($operationStrategy instanceof OperationQueryStrategy) { $operationStrategy->close(); } + $this->activeOperationStrategy = null; + $this->activePinnedStrategy = null; $this->clearAttempt(); } } + /** + * Create a fresh builder for the currently active coordinated callback. + * + * The operation provider factory uses this seam before handlers prepare + * clauses. It never exposes the wpdb object or permits a stale callback. + */ + public function createOperationQueryBuilder(CoreQueryStrategy $operation): CoreQueryBuilder + { + $pinned = $this->assertActiveOperation($operation); + + return $pinned->createQueryBuilder(); + } + + /** Create a fresh operation-local clause builder bound to the pinned wpdb. */ + public function createOperationClauseBuilder(CoreQueryStrategy $operation): \PHPNomad\Database\Interfaces\ClauseBuilder + { + $pinned = $this->assertActiveOperation($operation); + + return $pinned->createClauseBuilder(); + } + + private function assertActiveOperation(CoreQueryStrategy $operation): PinnedQueryStrategy + { + if ($this->activeOperationStrategy === null || $operation !== $this->activeOperationStrategy || $this->activePinnedStrategy === null) { + throw new UnsupportedCoordinationException('Operation-local WordPress builders are available only inside the active coordinated callback.'); + } + + return $this->activePinnedStrategy; + } + private function clearAttempt(): void { $this->database = null; diff --git a/lib/Strategies/WordPressInitializer.php b/lib/Strategies/WordPressInitializer.php index 2eb9e56..6af8235 100644 --- a/lib/Strategies/WordPressInitializer.php +++ b/lib/Strategies/WordPressInitializer.php @@ -30,6 +30,7 @@ use PHPNomad\Database\Interfaces\HasCharsetProvider; use PHPNomad\Database\Interfaces\HasCollateProvider; use PHPNomad\Database\Interfaces\HasGlobalDatabasePrefix; +use PHPNomad\Database\Interfaces\OperationDatabaseProviderFactory as CoreOperationDatabaseProviderFactory; use PHPNomad\Database\Interfaces\QueryBuilder as CoreQueryBuilder; use PHPNomad\Database\Interfaces\QueryStrategy as CoreQueryStrategy; use PHPNomad\Database\Interfaces\TableCreateStrategy as CoreTableCreateStrategyAlias; @@ -99,6 +100,7 @@ public function getClassDefinitions(): array // one resource-owning instance, while inherited CRUD remains the // ordinary WordPress implementation. CoordinatedQueryStrategy::class => [CoreQueryStrategy::class, CoreCoordinatedQueryStrategy::class], + WordPressOperationDatabaseProviderFactory::class => CoreOperationDatabaseProviderFactory::class, DefaultCacheTtlProvider::class => HasDefaultTtl::class, TableCreateStrategy::class => CoreTableCreateStrategyAlias::class, TableUpdateStrategy::class => CoreTableUpdateStrategy::class, diff --git a/lib/Strategies/WordPressOperationDatabaseProviderFactory.php b/lib/Strategies/WordPressOperationDatabaseProviderFactory.php new file mode 100644 index 0000000..2249fa7 --- /dev/null +++ b/lib/Strategies/WordPressOperationDatabaseProviderFactory.php @@ -0,0 +1,67 @@ +coordinator = $coordinator; + } + + public function create( + DatabaseHandler $handler, + QueryStrategy $queryStrategy, + OperationCacheableService $cache, + OperationEventStrategy $events + ): DatabaseServiceProvider { + $source = $handler->getDatabaseServiceProvider(); + // Ask the coordinator first. This refuses stale or out-of-callback + // factory use before inspecting or cloning any provider state. + $queryBuilder = $this->coordinator->createOperationQueryBuilder($queryStrategy); + $clauseBuilder = $this->coordinator->createOperationClauseBuilder($queryStrategy); + if ($source->queryStrategy !== $this->coordinator) { + throw new InvalidArgumentException('The WordPress handler must use the coordinating query strategy.'); + } + + $provider = $source->forOperation( + $queryStrategy, + $queryBuilder, + $clauseBuilder, + $cache, + $events + ); + + if ($provider->queryStrategy !== $queryStrategy + || $provider->cacheableService !== $cache + || $provider->eventStrategy !== $events + || $provider->loggerStrategy !== $source->loggerStrategy + || $provider->queryBuilder === $source->queryBuilder + || $provider->clauseBuilder === $source->clauseBuilder) { + throw new InvalidArgumentException('The WordPress operation provider did not preserve operation resources.'); + } + + return $provider; + } +} diff --git a/tests/Integration/Database/RealWpdbOperationDatabaseHandlerBridgeTest.php b/tests/Integration/Database/RealWpdbOperationDatabaseHandlerBridgeTest.php new file mode 100644 index 0000000..cc9b048 --- /dev/null +++ b/tests/Integration/Database/RealWpdbOperationDatabaseHandlerBridgeTest.php @@ -0,0 +1,320 @@ + */ + private array $items = []; + + public function get(string $key) + { + if (!array_key_exists($key, $this->items)) { + throw new RuntimeException('Cache item missing.'); + } + return $this->items[$key]; + } + + public function set(string $key, $value, ?int $ttl): void + { + $this->items[$key] = $value; + } + + public function delete(string $key): void + { + unset($this->items[$key]); + } + + public function exists(string $key): bool + { + return array_key_exists($key, $this->items); + } + + public function clear(): void + { + $this->items = []; + } +} + +final class BridgeCachePolicy implements CachePolicy +{ + public function getCacheKey(array $context): string + { + return json_encode($context, JSON_THROW_ON_ERROR); + } + + public function shouldCache(string $operation, array $context = []): bool + { + return true; + } + + public function getTtl(array $context = []): ?int + { + return null; + } + + public function shouldInvalidate(string $operation, array $context = []): bool + { + return true; + } +} + +final class BridgeEvent implements Event +{ + public static function getId(): string + { + return 'bridge.event'; + } +} + +final class BridgeEventStrategy implements EventStrategy +{ + /** @var list */ + public array $events = []; + + public function broadcast(Event $event): void + { + $this->events[] = $event; + } + + public function attach(string $event, callable $action, ?int $priority = null): void + { + } + + public function detach(string $event, callable $action, ?int $priority = null): void + { + } +} + +final class BridgeLogger implements LoggerStrategy +{ + public function emergency(string $message, array $context = []): void {} + public function alert(string $message, array $context = []): void {} + public function critical(string $message, array $context = []): void {} + public function error(string $message, array $context = []): void {} + public function warning(string $message, array $context = []): void {} + public function notice(string $message, array $context = []): void {} + public function info(string $message, array $context = []): void {} + public function debug(string $message, array $context = []): void {} + public function logException(\Exception $e, string $message = '', array $context = [], string $level = null) {} +} + +final class BridgeHandler implements DatabaseHandler +{ + public function __construct(private Table $table, private DatabaseServiceProvider $provider) + { + } + + public function getDatabaseTable(): Table + { + return $this->table; + } + + public function getDatabaseServiceProvider(): DatabaseServiceProvider + { + return $this->provider; + } + + public function cloneForOperation(DatabaseServiceProvider $serviceProvider): DatabaseHandler + { + return new self($this->table, $serviceProvider); + } +} + +final class RealWpdbOperationDatabaseHandlerBridgeTest extends TestCase +{ + private const PARENT = 'nomad_wpdb_bridge_parent'; + private const CHILD = 'nomad_wpdb_bridge_child'; + + private static wpdb $wpdb; + private static ContractTable $parent; + private static ContractTable $child; + private static BridgeCacheStrategy $cacheStrategy; + private static BridgeEventStrategy $eventStrategy; + private static DatabaseServiceProvider $sourceProvider; + private static BridgeHandler $handler; + + public static function setUpBeforeClass(): void + { + if (!class_exists(wpdb::class) || !extension_loaded('mysqli')) { + self::markTestSkipped('The operation bridge suite requires official WordPress and ext-mysqli.'); + } + $host = getenv('MYSQL_HOST') ?: '127.0.0.1'; + $port = getenv('MYSQL_PORT') ?: '3306'; + $user = getenv('MYSQL_USER') ?: 'root'; + $password = getenv('MYSQL_PASSWORD') ?: ''; + $database = getenv('MYSQL_DATABASE') ?: 'phpnomad_wordpress_integration_test'; + $probe = mysqli_init(); + mysqli_options($probe, MYSQLI_OPT_CONNECT_TIMEOUT, 2); + if (!@mysqli_real_connect($probe, $host, $user, $password, $database, (int) $port)) { + self::markTestSkipped('The real MySQL 8.0 operation bridge fixture is unavailable.'); + } + mysqli_close($probe); + + self::$wpdb = new wpdb($user, $password, $database, $host . ':' . $port); + self::$wpdb->suppress_errors(false); + $GLOBALS['wpdb'] = self::$wpdb; + self::$parent = new ContractTable(self::PARENT, 'bridge_parent', [new Column('id', 'INT', null, 'PRIMARY KEY')], ['id']); + self::$child = new ContractTable(self::CHILD, 'bridge_child', [new Column('id', 'INT', null, 'PRIMARY KEY'), new Column('value', 'VARCHAR', [64])], ['id']); + self::query('DROP TABLE IF EXISTS ' . self::CHILD); + self::query('DROP TABLE IF EXISTS ' . self::PARENT); + self::query('CREATE TABLE ' . self::PARENT . ' (id INT PRIMARY KEY) ENGINE=InnoDB'); + self::query('CREATE TABLE ' . self::CHILD . ' (id INT PRIMARY KEY, value VARCHAR(64) NULL) ENGINE=InnoDB'); + + self::$cacheStrategy = new BridgeCacheStrategy(); + self::$eventStrategy = new BridgeEventStrategy(); + self::$sourceProvider = new DatabaseServiceProvider( + new BridgeLogger(), + new CoordinatedQueryStrategy(), + new QueryBuilder(), + new ClauseBuilder(), + new \PHPNomad\Cache\Services\CacheableService( + self::$eventStrategy, + self::$cacheStrategy, + new BridgeCachePolicy() + ), + self::$eventStrategy + ); + self::$handler = new BridgeHandler(self::$child, self::$sourceProvider); + } + + protected function setUp(): void + { + self::query('DELETE FROM ' . self::CHILD); + self::query('DELETE FROM ' . self::PARENT); + self::query('INSERT INTO ' . self::PARENT . ' (id) VALUES (1)'); + self::$cacheStrategy->clear(); + self::$eventStrategy->events = []; + } + + public static function tearDownAfterClass(): void + { + if (isset(self::$wpdb)) { + self::query('DROP TABLE IF EXISTS ' . self::CHILD); + self::query('DROP TABLE IF EXISTS ' . self::PARENT); + unset($GLOBALS['wpdb']); + } + } + + public function testInitializerBindsFactoryAndFactoryRefusesOutsideCallback(): void + { + $container = new \PHPNomad\Di\Container\Container(); + (new Bootstrapper($container, new WordPressInitializer()))->load(); + $factory = $container->get(OperationDatabaseProviderFactory::class); + self::assertInstanceOf(WordPressOperationDatabaseProviderFactory::class, $factory); + $ordinary = $container->get(\PHPNomad\Database\Interfaces\QueryStrategy::class); + $coordinated = $container->get(\PHPNomad\Database\Interfaces\CoordinatedQueryStrategy::class); + self::assertSame($ordinary, $coordinated); + $factoryCoordinator = (new \ReflectionProperty($factory, 'coordinator'))->getValue($factory); + self::assertSame($coordinated, $factoryCoordinator); + self::assertInstanceOf(\PHPNomad\Integrations\WordPress\Providers\DatabaseProvider::class, $container->get(\PHPNomad\Integrations\WordPress\Providers\DatabaseProvider::class)); + self::assertInstanceOf(\PHPNomad\Cache\Interfaces\HasDefaultTtl::class, $container->get(\PHPNomad\Cache\Interfaces\HasDefaultTtl::class)); + + $cache = new \PHPNomad\Database\Services\OperationCacheableService(self::$sourceProvider->cacheableService); + $events = new \PHPNomad\Database\Services\OperationEventStrategy(self::$eventStrategy); + self::expectException(\PHPNomad\Database\Exceptions\UnsupportedCoordinationException::class); + $factory->create(self::$handler, self::$sourceProvider->queryStrategy, $cache, $events); + } + + public function testBridgeUsesBoundBuildersSuppressesPublicationOnRollback(): void + { + $factory = new WordPressOperationDatabaseProviderFactory(self::$sourceProvider->queryStrategy); + $bridge = new OperationDatabaseHandlerBridge($factory); + $coordinator = self::$sourceProvider->queryStrategy; + $this->assertInstanceOf(CoordinatedQueryStrategy::class, $coordinator); + + try { + $bridge->coordinate($coordinator, self::$parent, ['id' => 1], [self::$parent, self::$child], ['child' => self::$handler], function (array $handlers): void { + $provider = $handlers['child']->getDatabaseServiceProvider(); + $queryDatabase = $this->builderDatabase($provider->queryBuilder); + $clauseDatabase = $this->builderDatabase($provider->clauseBuilder); + self::assertSame(self::$wpdb, $queryDatabase); + self::assertSame(self::$wpdb, $clauseDatabase); + self::assertNotSame(self::$sourceProvider->queryBuilder, $provider->queryBuilder); + self::assertNotSame(self::$sourceProvider->clauseBuilder, $provider->clauseBuilder); + + $provider->queryStrategy->insert(self::$child, ['id' => 21, 'value' => 'rolled-back']); + $clause = $provider->clauseBuilder->reset()->useTable(self::$child)->where('id', '=', 21); + $builder = $provider->queryBuilder->reset()->select('*')->from(self::$child)->where($clause); + self::assertSame('rolled-back', $provider->queryStrategy->query($builder)[0]['value']); + $provider->cacheableService->set(['id' => 21], ['value' => 'rolled-back']); + $provider->eventStrategy->broadcast(new BridgeEvent()); + throw new RuntimeException('bridge rollback'); + }); + self::fail('Expected bridge rollback.'); + } catch (RuntimeException $failure) { + self::assertSame('bridge rollback', $failure->getMessage()); + } + + self::assertFalse((bool) self::$wpdb->get_var('SELECT 1 FROM ' . self::CHILD . ' WHERE id = 21')); + self::assertFalse(self::$cacheStrategy->exists(json_encode(['id' => 21], JSON_THROW_ON_ERROR))); + self::assertCount(0, self::$eventStrategy->events); + } + + public function testBridgePublishesInvalidationAndEventsAfterConfirmedCommit(): void + { + $factory = new WordPressOperationDatabaseProviderFactory(self::$sourceProvider->queryStrategy); + $bridge = new OperationDatabaseHandlerBridge($factory); + $result = $bridge->coordinate(self::$sourceProvider->queryStrategy, self::$parent, ['id' => 1], [self::$parent, self::$child], ['child' => self::$handler], function (array $handlers): string { + $provider = $handlers['child']->getDatabaseServiceProvider(); + $provider->queryStrategy->insert(self::$child, ['id' => 22, 'value' => 'committed']); + $clause = $provider->clauseBuilder->reset()->useTable(self::$child)->where('id', '=', 22); + $builder = $provider->queryBuilder->reset()->select('*')->from(self::$child)->where($clause); + if ($provider->queryStrategy->query($builder)[0]['value'] !== 'committed') { + throw new RuntimeException('The operation query did not read its own write.'); + } + $provider->cacheableService->set(['id' => 22], ['value' => 'committed']); + $provider->eventStrategy->broadcast(new BridgeEvent()); + return 'done'; + }); + + self::assertSame('done', $result->getValue()); + self::assertSame('committed', self::$wpdb->get_var('SELECT value FROM ' . self::CHILD . ' WHERE id = 22')); + self::assertFalse(self::$cacheStrategy->exists(json_encode(['id' => 22], JSON_THROW_ON_ERROR))); + self::assertCount(1, self::$eventStrategy->events); + self::assertInstanceOf(BridgeEvent::class, self::$eventStrategy->events[0]); + } + + private function builderDatabase(object $builder): wpdb + { + $property = new \ReflectionProperty($builder, 'database'); + $property->setAccessible(true); + $database = $property->getValue($builder); + self::assertInstanceOf(wpdb::class, $database); + return $database; + } + + private static function query(string $sql): void + { + if (self::$wpdb->query($sql) === false) { + throw new RuntimeException(self::$wpdb->last_error); + } + } +}