diff --git a/composer.json b/composer.json index 408a603..71a7680 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ } ], "require": { - "phpnomad/db": "dev-release/2.2 as 2.2.x-dev", + "phpnomad/db": "dev-codex/operation-local-handler-bridge#4b0b7709e5945a4290b04c2e3562ef919923ed08 as 2.2.x-dev", "phpnomad/loader": "^1.0 || ^2.0", "phpnomad/logger": "^1.0", "ext-pdo": "*" diff --git a/composer.lock b/composer.lock index 5697f93..30e2e67 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": "05bf4d123c4107d35ba7155103271611", + "content-hash": "65f1a0ce072b6923bec7c83fa5a20df0", "packages": [ { "name": "phpnomad/cache", @@ -93,11 +93,11 @@ }, { "name": "phpnomad/db", - "version": "dev-release/2.2", + "version": "dev-codex/operation-local-handler-bridge", "source": { "type": "git", "url": "git@github.com:phpnomad/db.git", - "reference": "17fb84703d260a66de874704ae37cf924b9d8c2b" + "reference": "4b0b7709e5945a4290b04c2e3562ef919923ed08" }, "require": { "phpnomad/cache": "^1.0", @@ -136,7 +136,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", @@ -5365,7 +5365,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/Interfaces/ProvidesOperationBuilders.php b/lib/Interfaces/ProvidesOperationBuilders.php new file mode 100644 index 0000000..90ac269 --- /dev/null +++ b/lib/Interfaces/ProvidesOperationBuilders.php @@ -0,0 +1,14 @@ + [ + DatabaseStrategy::class, + CoordinatedDatabaseStrategy::class, + ], + CoordinatedQueryStrategy::class => [ + CoreQueryStrategy::class, + CoreCoordinatedQueryStrategy::class, + ProvidesOperationBuilders::class, + ], + MySqlOperationDatabaseProviderFactory::class => OperationDatabaseProviderFactory::class, + ]; + } +} diff --git a/lib/Services/MySqlOperationDatabaseProviderFactory.php b/lib/Services/MySqlOperationDatabaseProviderFactory.php new file mode 100644 index 0000000..f5ab302 --- /dev/null +++ b/lib/Services/MySqlOperationDatabaseProviderFactory.php @@ -0,0 +1,39 @@ +coordinator = $coordinator; + } + + public function create( + DatabaseHandler $handler, + QueryStrategy $queryStrategy, + OperationCacheableService $cache, + OperationEventStrategy $events + ): DatabaseServiceProvider { + [$queryBuilder, $clauseBuilder] = $this->coordinator->createOperationBuilders($queryStrategy); + + return $handler->getDatabaseServiceProvider()->forOperation( + $queryStrategy, + $queryBuilder, + $clauseBuilder, + $cache, + $events + ); + } +} diff --git a/lib/Strategies/CoordinatedQueryStrategy.php b/lib/Strategies/CoordinatedQueryStrategy.php new file mode 100644 index 0000000..21bbc10 --- /dev/null +++ b/lib/Strategies/CoordinatedQueryStrategy.php @@ -0,0 +1,87 @@ +coordinatedDatabaseStrategy = $db; + } + + /** + * @template TResult + * @param non-empty-array $identity + * @param non-empty-list $participants + * @param callable(CoreQueryStrategy): TResult $operation + * @return TResult + */ + public function coordinate( + Table $coordinationTable, + array $identity, + array $participants, + callable $operation + ) { + return $this->coordinatedDatabaseStrategy->coordinate( + $coordinationTable, + $identity, + $participants, + function (DatabaseStrategy $database) use ($participants, $operation) { + $delegate = new OperationBoundQueryStrategy( + $database, + $this->tableSchemaService, + new MySqlClauseBuilder() + ); + $queryStrategy = new OperationQueryStrategy($delegate, $participants); + $this->activeQueryStrategy = $queryStrategy; + $this->activeDatabaseStrategy = $database; + + try { + return $operation($queryStrategy); + } finally { + $this->activeQueryStrategy = null; + $this->activeDatabaseStrategy = null; + $queryStrategy->close(); + } + } + ); + } + + /** @return array{\PHPNomad\Database\Interfaces\QueryBuilder, \PHPNomad\Database\Interfaces\ClauseBuilder} */ + public function createOperationBuilders(CoreQueryStrategy $queryStrategy): array + { + if ( + $queryStrategy !== $this->activeQueryStrategy + || $this->activeDatabaseStrategy === null + ) { + throw new UnsupportedCoordinationException( + 'Operation builders require the exact active coordinated query strategy.' + ); + } + + return [new QueryBuilder(), new MySqlClauseBuilder()]; + } +} diff --git a/lib/Strategies/OperationBoundQueryStrategy.php b/lib/Strategies/OperationBoundQueryStrategy.php new file mode 100644 index 0000000..66d879f --- /dev/null +++ b/lib/Strategies/OperationBoundQueryStrategy.php @@ -0,0 +1,68 @@ + */ + public function query(QueryBuilder $builder): array + { + if (!$builder instanceof CanBuildWithDatabaseStrategy) { + throw new UnsupportedCoordinationException('Coordinated queries need a resource-bound query builder.'); + } + + try { + $query = $builder->buildWithDatabaseStrategy($this->db); + /** @var array $result */ + $result = $this->db->query($query); + } catch (QueryBuilderException $e) { + throw new DatastoreErrorException('Get results failed. Invalid query: ' . $e->getMessage(), 500, $e); + } + + if (empty($result)) { + throw new RecordNotFoundException(); + } + + return $result; + } + + /** + * Insert through the owned backend without starting or ending a transaction. + * + * @param array $data + * @return array + */ + public function insert(Table $table, array $data): array + { + $columns = Arr::process($data) + ->keys() + ->map(fn (string $column): string => $this->db->parse('?n', $column)) + ->setSeparator(',') + ->toString(); + + $placeholders = Arr::process($data) + ->map(fn () => '?s') + ->setSeparator(',') + ->toString(); + + $query = $this->db->parse( + "INSERT INTO ?n ($columns) VALUES ($placeholders)", + $table->getName(), + ...Arr::values($data) + ); + + $this->db->query($query); + + return $this->resolveInsertIdentity($table, $data); + } +} diff --git a/lib/Strategies/QueryStrategy.php b/lib/Strategies/QueryStrategy.php index 4e76be9..35508d3 100644 --- a/lib/Strategies/QueryStrategy.php +++ b/lib/Strategies/QueryStrategy.php @@ -81,7 +81,7 @@ protected function resolveInsertIdentity(Table $table, array $data) { /** @var array $identity */ $identity = []; - $primaryColumns = $this->tableSchemaService->getPrimaryColumnsForTable($table); + $primaryColumns = $this->tableSchemaService->getPrimaryColumnsForTableUncached($table); foreach ($primaryColumns as $column) { $name = $column->getName(); diff --git a/lib/Strategies/TableExistsStrategy.php b/lib/Strategies/TableExistsStrategy.php index 48e5f28..07550b6 100644 --- a/lib/Strategies/TableExistsStrategy.php +++ b/lib/Strategies/TableExistsStrategy.php @@ -24,11 +24,30 @@ public function __construct(DatabaseStrategy $db) public function exists(string $tableName): bool { try { - $query = $this->db->parse("SHOW TABLES LIKE ?s", $tableName); - return $this->db->query($query)->fetchColumn() === $tableName; - + $query = $this->db->parse( + 'SELECT TABLE_NAME AS table_name FROM information_schema.TABLES' + . ' WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?s', + $tableName + ); + $rows = $this->db->query($query); } catch (DatastoreErrorException $e) { return false; } + + if (!is_array($rows)) { + throw new \UnexpectedValueException('The table metadata query must return an array.'); + } + + if ($rows === []) { + return false; + } + + $firstRow = reset($rows); + if (count($rows) !== 1 || !is_array($firstRow) || !array_key_exists('table_name', $firstRow) + || !is_string($firstRow['table_name'])) { + throw new \UnexpectedValueException('The table metadata query returned an invalid row.'); + } + + return $firstRow['table_name'] === $tableName; } } diff --git a/tests/Integration/OperationDatabaseHandlerBridgeContractTest.php b/tests/Integration/OperationDatabaseHandlerBridgeContractTest.php new file mode 100644 index 0000000..f570e11 --- /dev/null +++ b/tests/Integration/OperationDatabaseHandlerBridgeContractTest.php @@ -0,0 +1,505 @@ +pdo = $this->connection(); + $this->observer = $this->connection(); + $this->pdo->exec('DROP TABLE IF EXISTS nomad_query_coordination_child'); + $this->pdo->exec('DROP TABLE IF EXISTS nomad_query_coordination_parent'); + $this->pdo->exec( + 'CREATE TABLE nomad_query_coordination_parent (' + . 'id INT NOT NULL PRIMARY KEY, value INT NOT NULL' + . ') ENGINE=InnoDB' + ); + $this->pdo->exec( + 'CREATE TABLE nomad_query_coordination_child (' + . 'id INT NOT NULL PRIMARY KEY, value INT NOT NULL' + . ') ENGINE=InnoDB' + ); + $this->pdo->exec('INSERT INTO nomad_query_coordination_parent VALUES (1, 10)'); + $this->pdo->exec('INSERT INTO nomad_query_coordination_child VALUES (1, 20)'); + + $this->host = new BridgeHostServices(); + $this->container = new Container(); + $connection = PdoConnection::fromPdo($this->pdo); + $logger = new RecordingLogger(); + $this->container->bindSingletonFromFactory(PdoConnection::class, static fn (): PdoConnection => $connection); + $this->container->bindSingletonFromFactory(LoggerStrategy::class, static fn (): LoggerStrategy => $logger); + foreach ([CachePolicy::class, CacheStrategy::class, EventStrategy::class] as $contract) { + $host = $this->host; + $this->container->bindSingletonFromFactory($contract, static fn (): BridgeHostServices => $host); + } + (new Bootstrapper( + $this->container, + new MySqlInitializer(), + new PdoCoordinationInitializer() + ))->load(); + + $this->provider = $this->container->get(DatabaseServiceProvider::class); + $this->coordinator = $this->container->get(CoordinatedQueryStrategy::class); + $this->bridge = $this->container->get(OperationDatabaseHandlerBridge::class); + $this->parentTable = new BridgeTable('nomad_query_coordination_parent', 'parent'); + $this->childTable = new BridgeTable('nomad_query_coordination_child', 'child'); + + $poison = $this->createMock(DatabaseStrategy::class); + $poison->expects(self::never())->method('parse'); + $poison->expects(self::never())->method('query'); + $poisonContainer = new Container(); + $poisonContainer->bindSingletonFromFactory(DatabaseStrategy::class, static fn (): DatabaseStrategy => $poison); + $this->facadeInstance = new ReflectionProperty(Database::class, 'instance'); + $this->facadeInstance->setAccessible(true); + $this->previousFacade = $this->facadeInstance->getValue(); + $this->facadeInstance->setValue(null, new Database()); + Database::instance()->setContainer($poisonContainer); + } + + protected function tearDown(): void + { + if (isset($this->facadeInstance)) { + $this->facadeInstance->setValue(null, $this->previousFacade); + } + if (isset($this->pdo)) { + $this->pdo->exec('DROP TABLE IF EXISTS nomad_query_coordination_child'); + $this->pdo->exec('DROP TABLE IF EXISTS nomad_query_coordination_parent'); + } + parent::tearDown(); + } + + public function testCommitUsesFreshIndependentBuildersAndPublishesAfterCommit(): void + { + $parent = new BridgeDatabaseHandler($this->provider, $this->parentTable); + $child = new BridgeDatabaseHandler($this->provider, $this->childTable); + $this->observer->exec('UPDATE nomad_query_coordination_child SET value = 24 WHERE id = 1'); + $this->host->set($this->host->getCacheKey(['table' => $this->childTable->getName(), 'id' => 1]), 999, null); + $this->host->resetObservations(); + $callbackCount = 0; + + $result = $this->bridge->coordinate( + $this->coordinator, + $this->parentTable, + ['id' => 1], + [$this->parentTable, $this->childTable], + ['parent' => $parent, 'child' => $child], + function (array $handlers, QueryStrategy $queryStrategy) use (&$callbackCount, $parent, $child): string { + $callbackCount++; + /** @var BridgeDatabaseHandler $localParent */ + $localParent = $handlers['parent']; + /** @var BridgeDatabaseHandler $localChild */ + $localChild = $handlers['child']; + self::assertNotSame($parent, $localParent); + self::assertNotSame($child, $localChild); + self::assertSame($queryStrategy, $localParent->getDatabaseServiceProvider()->queryStrategy); + self::assertSame($queryStrategy, $localChild->getDatabaseServiceProvider()->queryStrategy); + self::assertNotSame( + $parent->getDatabaseServiceProvider()->queryBuilder, + $localParent->getDatabaseServiceProvider()->queryBuilder + ); + self::assertNotSame( + $localParent->getDatabaseServiceProvider()->queryBuilder, + $localChild->getDatabaseServiceProvider()->queryBuilder + ); + self::assertNotSame( + $localParent->getDatabaseServiceProvider()->clauseBuilder, + $localChild->getDatabaseServiceProvider()->clauseBuilder + ); + self::assertSame(24, $localChild->cachedValue(1)); + $localParent->updateValue(1, 11); + $localChild->updateValue(1, 25); + self::assertSame(11, $localParent->value(1)); + self::assertSame(25, $localChild->value(1)); + self::assertSame([], $this->host->deletedKeys); + self::assertSame([], $this->host->broadcastEvents); + + return 'committed'; + } + ); + + self::assertInstanceOf(CoordinatedOperationResult::class, $result); + self::assertSame('committed', $result->getValue()); + self::assertSame([], $result->getPublicationFailures()); + self::assertSame(1, $callbackCount); + self::assertSame(11, $this->storedValue('nomad_query_coordination_parent')); + self::assertSame(25, $this->storedValue('nomad_query_coordination_child')); + self::assertCount(2, $this->host->deletedKeys); + self::assertCount(2, $this->host->broadcastEvents); + } + + public function testMultipleWritesAndReadAfterWriteRollBackWithoutReplayOrPublication(): void + { + $parent = new BridgeDatabaseHandler($this->provider, $this->parentTable); + $child = new BridgeDatabaseHandler($this->provider, $this->childTable); + $callbackCount = 0; + + try { + $this->bridge->coordinate( + $this->coordinator, + $this->parentTable, + ['id' => 1], + [$this->parentTable, $this->childTable], + [$parent, $child], + function (array $handlers) use (&$callbackCount): void { + $callbackCount++; + /** @var BridgeDatabaseHandler $localParent */ + $localParent = $handlers[0]; + /** @var BridgeDatabaseHandler $localChild */ + $localChild = $handlers[1]; + $localParent->updateValue(1, 31); + $localChild->updateValue(1, 41); + $localChild->insertValue(2, 42); + self::assertSame(31, $localParent->value(1)); + self::assertSame(41, $localChild->value(1)); + self::assertSame(42, $localChild->value(2)); + self::assertSame([], $this->host->deletedKeys); + self::assertSame([], $this->host->broadcastEvents); + throw new RuntimeException('roll back all writes'); + } + ); + self::fail('The callback failure must propagate.'); + } catch (RuntimeException $failure) { + self::assertSame('roll back all writes', $failure->getMessage()); + } + + self::assertSame(1, $callbackCount); + self::assertSame(10, $this->storedValue('nomad_query_coordination_parent')); + self::assertSame(20, $this->storedValue('nomad_query_coordination_child')); + self::assertSame(0, $this->storedCount('nomad_query_coordination_child', 2)); + self::assertSame([], $this->host->deletedKeys); + self::assertSame([], $this->host->broadcastEvents); + } + + public function testUndeclaredAndExpiredHandlesRefuseBeforeExecution(): void + { + $handler = new BridgeDatabaseHandler($this->provider, $this->parentTable); + $undeclared = new BridgeTable('nomad_query_coordination_child', 'child'); + $callbackCount = 0; + + try { + $this->bridge->coordinate( + $this->coordinator, + $this->parentTable, + ['id' => 1], + [$this->parentTable], + [$handler], + function (array $handlers, QueryStrategy $queryStrategy) use (&$callbackCount, $undeclared): void { + $callbackCount++; + /** @var BridgeDatabaseHandler $localHandler */ + $localHandler = $handlers[0]; + $localHandler->updateValue(1, 99); + $queryStrategy->estimatedCount($undeclared); + } + ); + self::fail('An undeclared table must be rejected.'); + } catch (\InvalidArgumentException $expected) { + } + + self::assertSame(1, $callbackCount); + self::assertSame(10, $this->storedValue('nomad_query_coordination_parent')); + + $escaped = null; + $this->bridge->coordinate( + $this->coordinator, + $this->parentTable, + ['id' => 1], + [$this->parentTable], + [$handler], + static function (array $handlers, QueryStrategy $queryStrategy) use (&$escaped): void { + $escaped = $queryStrategy; + } + ); + + $this->expectException(\PHPNomad\Database\Exceptions\InactiveDatabaseOperationException::class); + $escaped->estimatedCount($this->parentTable); + } + + private function connection(): PDO + { + $dsn = getenv('TEST_MYSQL_COORDINATION_DSN'); + if (!$dsn) { + $this->markTestSkipped('An explicit isolated MySQL coordination DSN is required.'); + } + + return new PDO($dsn, getenv('TEST_MYSQL_USER') ?: 'root', getenv('TEST_MYSQL_PASS') ?: '', [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_STRINGIFY_FETCHES => true, + PDO::ATTR_PERSISTENT => false, + ]); + } + + private function storedValue(string $table): int + { + $statement = $this->observer->query("SELECT value FROM `$table` WHERE id = 1"); + self::assertNotFalse($statement); + + return (int) $statement->fetchColumn(); + } + + private function storedCount(string $table, int $id): int + { + $statement = $this->observer->query("SELECT COUNT(*) FROM `$table` WHERE id = $id"); + self::assertNotFalse($statement); + + return (int) $statement->fetchColumn(); + } +} + +final class BridgeDatabaseHandler implements DatabaseHandler +{ + private DatabaseServiceProvider $provider; + private Table $table; + + public function __construct(DatabaseServiceProvider $provider, Table $table) + { + $this->provider = $provider; + $this->table = $table; + } + + public function getDatabaseTable(): Table + { + return $this->table; + } + + public function getDatabaseServiceProvider(): DatabaseServiceProvider + { + return $this->provider; + } + + public function cloneForOperation(DatabaseServiceProvider $serviceProvider): DatabaseHandler + { + $clone = clone $this; + $clone->provider = $serviceProvider; + + return $clone; + } + + public function value(int $id): int + { + $clause = (clone $this->provider->clauseBuilder) + ->reset() + ->useTable($this->table) + ->where('id', '=', $id); + $builder = $this->provider->queryBuilder + ->reset() + ->from($this->table) + ->select('*') + ->where($clause); + $rows = $this->provider->queryStrategy->query($builder); + + return (int) $rows[0]['value']; + } + + public function cachedValue(int $id): int + { + return (int) $this->provider->cacheableService->getWithCache( + 'read', + ['table' => $this->table->getName(), 'id' => $id], + fn (): int => $this->value($id) + ); + } + + public function updateValue(int $id, int $value): void + { + $this->provider->queryStrategy->update($this->table, ['id' => $id], ['value' => $value]); + $this->provider->cacheableService->delete(['table' => $this->table->getName(), 'id' => $id]); + $this->provider->eventStrategy->broadcast(new BridgeRecordChanged($this->table->getName(), $id)); + } + + public function insertValue(int $id, int $value): void + { + $this->provider->queryStrategy->insert($this->table, ['id' => $id, 'value' => $value]); + $this->provider->cacheableService->delete(['table' => $this->table->getName(), 'id' => $id]); + $this->provider->eventStrategy->broadcast(new BridgeRecordChanged($this->table->getName(), $id)); + } +} + +final class BridgeTable implements Table +{ + private string $name; + private string $alias; + + public function __construct(string $name, string $alias) + { + $this->name = $name; + $this->alias = $alias; + } + + public function getName(): string + { + return $this->name; + } + public function getAlias(): string + { + return $this->alias; + } + public function getTableVersion(): string + { + return '1'; + } + /** @return list */ + public function getColumns(): array + { + return [new Column('id', 'INT', null, 'PRIMARY KEY'), new Column('value', 'INT')]; + } + /** @return list */ + public function getIndices(): array + { + return []; + } + public function getCharset(): ?string + { + return 'utf8mb4'; + } + public function getCollation(): ?string + { + return 'utf8mb4_bin'; + } + /** @return non-empty-list */ + public function getFieldsForIdentity(): array + { + return ['id']; + } + public function getUnprefixedName(): string + { + return $this->name; + } + public function getSingularUnprefixedName(): string + { + return $this->name; + } +} + +final class BridgeRecordChanged implements Event +{ + public string $table; + public int $id; + + public function __construct(string $table, int $id) + { + $this->table = $table; + $this->id = $id; + } + + public static function getId(): string + { + return 'mysql.operation.record.changed'; + } +} + +final class BridgeHostServices implements CachePolicy, CacheStrategy, EventStrategy +{ + /** @var array */ + private array $cache = []; + /** @var list */ + public array $deletedKeys = []; + /** @var list */ + public array $broadcastEvents = []; + + public function resetObservations(): void + { + $this->deletedKeys = []; + $this->broadcastEvents = []; + } + + public function get(string $key): mixed + { + if (!$this->exists($key)) { + throw new CachedItemNotFoundException(); + } + + return $this->cache[$key]; + } + + public function set(string $key, mixed $value, ?int $ttl): void + { + $this->cache[$key] = $value; + } + public function delete(string $key): void + { + $this->deletedKeys[] = $key; + unset($this->cache[$key]); + } + public function clear(): void + { + $this->cache = []; + } + public function exists(string $key): bool + { + return array_key_exists($key, $this->cache); + } + /** @param array $context */ + public function getCacheKey(array $context): string + { + return serialize($context); + } + /** @param array $context */ + public function shouldCache(string $operation, array $context = []): bool + { + return true; + } + /** @param array $context */ + public function getTtl(array $context = []): ?int + { + return null; + } + /** @param array $context */ + public function shouldInvalidate(string $operation, array $context = []): bool + { + return true; + } + public function broadcast(Event $event): void + { + $this->broadcastEvents[] = $event; + } + public function attach(string $event, callable $action, ?int $priority = null): void + { + } + public function detach(string $event, callable $action, ?int $priority = null): void + { + } +} diff --git a/tests/Integration/TableExistsStrategyContractTest.php b/tests/Integration/TableExistsStrategyContractTest.php new file mode 100644 index 0000000..e7c4818 --- /dev/null +++ b/tests/Integration/TableExistsStrategyContractTest.php @@ -0,0 +1,140 @@ +markTestSkipped('An explicit isolated MySQL test DSN is required.'); + } + $this->pdo = new PDO($dsn, getenv('TEST_MYSQL_USER') ?: 'root', getenv('TEST_MYSQL_PASS') ?: '', [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_STRINGIFY_FETCHES => true, + PDO::ATTR_PERSISTENT => false, + ]); + $this->dropFixtureTables(); + foreach (self::TABLE_NAMES as $tableName) { + $this->pdo->exec("CREATE TABLE `{$tableName}` (id INT PRIMARY KEY) ENGINE=InnoDB"); + } + $this->strategy = new MySqlTableExistsStrategy( + new PdoDatabaseStrategy(PdoConnection::fromPdo($this->pdo)) + ); + } + + protected function tearDown(): void + { + if (isset($this->pdo)) { + $this->dropFixtureTables(); + } + parent::tearDown(); + } + + public function testItReadsTheDatabaseStrategyArrayResult(): void + { + self::assertTrue($this->strategy->exists('nomad_table_exists_array_contract')); + self::assertFalse($this->strategy->exists('nomad_table_exists_array_contract_missing')); + } + + public function testItTreatsLikeWildcardsAsLiteralTableNameCharacters(): void + { + self::assertTrue($this->strategy->exists('nomad_table_exists_literal%name')); + self::assertTrue($this->strategy->exists('nomad_table_exists_literal_name')); + self::assertFalse($this->strategy->exists('nomad_table_exists_percent%wildcard')); + self::assertFalse($this->strategy->exists('nomad_table_exists_under_wildcard')); + self::assertFalse($this->strategy->exists('nomad_table_exists_literal_name_missing')); + } + + public function testItReportsQueryFailureAsAbsence(): void + { + $strategy = new MySqlTableExistsStrategy($this->databaseStrategyReturning( + new DatastoreErrorException('Metadata is unavailable.') + )); + + self::assertFalse($strategy->exists('nomad_table_exists_array_contract')); + } + + public function testItRejectsMalformedMetadataResults(): void + { + $strategy = new MySqlTableExistsStrategy($this->databaseStrategyReturning(1)); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The table metadata query must return an array.'); + + $strategy->exists('nomad_table_exists_array_contract'); + } + + public function testItRejectsMalformedMetadataRows(): void + { + $strategy = new MySqlTableExistsStrategy($this->databaseStrategyReturning([ + ['unexpected_column' => 'nomad_table_exists_array_contract'], + ])); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The table metadata query returned an invalid row.'); + + $strategy->exists('nomad_table_exists_array_contract'); + } + + /** + * @param mixed $result + */ + private function databaseStrategyReturning($result): DatabaseStrategy + { + return new class ($result) implements DatabaseStrategy { + /** @var mixed */ + private $result; + + /** @param mixed $result */ + public function __construct($result) + { + $this->result = $result; + } + + public function parse(string $query, ...$args): string + { + return $query; + } + + public function query(string $query) + { + if ($this->result instanceof DatastoreErrorException) { + throw $this->result; + } + + return $this->result; + } + }; + } + + private function dropFixtureTables(): void + { + foreach (self::TABLE_NAMES as $tableName) { + $this->pdo->exec("DROP TABLE IF EXISTS `{$tableName}`"); + } + } +} diff --git a/tests/Unit/Fixtures/BoundQueryBuilder.php b/tests/Unit/Fixtures/BoundQueryBuilder.php index 288fde2..84ff157 100644 --- a/tests/Unit/Fixtures/BoundQueryBuilder.php +++ b/tests/Unit/Fixtures/BoundQueryBuilder.php @@ -3,9 +3,10 @@ namespace PHPNomad\MySql\Integration\Tests\Unit\Fixtures; use PHPNomad\Database\Interfaces\QueryBuilder; +use PHPNomad\Database\Interfaces\HasQueryTables; use PHPNomad\MySql\Integration\Interfaces\CanBuildWithDatabaseStrategy; /** Custom extension boundary independent of the built-in concrete builder. */ -interface BoundQueryBuilder extends QueryBuilder, CanBuildWithDatabaseStrategy +interface BoundQueryBuilder extends QueryBuilder, HasQueryTables, CanBuildWithDatabaseStrategy { } diff --git a/tests/Unit/PdoCoordinationInitializerTest.php b/tests/Unit/PdoCoordinationInitializerTest.php new file mode 100644 index 0000000..2b6ee94 --- /dev/null +++ b/tests/Unit/PdoCoordinationInitializerTest.php @@ -0,0 +1,57 @@ +bindSingletonFromFactory(PdoConnection::class, static fn (): PdoConnection => new PdoConnection()); + $container->bindSingletonFromFactory(LoggerStrategy::class, static fn () => Mockery::mock(LoggerStrategy::class)); + $container->bindSingletonFromFactory(CachePolicy::class, static fn () => Mockery::mock(CachePolicy::class)); + $container->bindSingletonFromFactory(CacheStrategy::class, static fn () => Mockery::mock(CacheStrategy::class)); + $container->bindSingletonFromFactory(EventStrategy::class, static fn () => Mockery::mock(EventStrategy::class)); + + (new Bootstrapper( + $container, + new MySqlInitializer(), + new PdoCoordinationInitializer() + ))->load(); + + self::assertSame( + $container->get(DatabaseStrategy::class), + $container->get(CoordinatedDatabaseStrategy::class) + ); + self::assertSame( + $container->get(CoreQueryStrategy::class), + $container->get(CoreCoordinatedQueryStrategy::class) + ); + self::assertSame( + $container->get(CoreCoordinatedQueryStrategy::class), + $container->get(ProvidesOperationBuilders::class) + ); + self::assertInstanceOf( + OperationDatabaseProviderFactory::class, + $container->get(OperationDatabaseProviderFactory::class) + ); + } +} diff --git a/tests/Unit/Strategies/CoordinatedQueryStrategyTest.php b/tests/Unit/Strategies/CoordinatedQueryStrategyTest.php new file mode 100644 index 0000000..ad8d1de --- /dev/null +++ b/tests/Unit/Strategies/CoordinatedQueryStrategyTest.php @@ -0,0 +1,105 @@ +shouldReceive('getName')->andReturn('records'); + $builder = Mockery::mock(BoundQueryBuilder::class); + $builder->shouldReceive('getReferencedTables')->once()->andReturn([$table]); + $builder->shouldReceive('buildWithDatabaseStrategy') + ->once() + ->with($operationBackend) + ->andReturn('SELECT operation_resource'); + $operationBackend->shouldReceive('query') + ->once() + ->with('SELECT operation_resource') + ->andReturn([['value' => 17]]); + + $callbackCount = 0; + $escaped = null; + $strategy = new CoordinatedQueryStrategy( + $backend, + Mockery::mock(TableSchemaService::class), + Mockery::mock(ClauseBuilder::class) + ); + $backend->shouldReceive('coordinate') + ->once() + ->with($table, ['id' => 1], [$table], Mockery::type('callable')) + ->andReturnUsing(static function ($coordinationTable, $identity, $participants, $operation) use ($operationBackend) { + return $operation($operationBackend); + }); + + $result = $strategy->coordinate( + $table, + ['id' => 1], + [$table], + function (CoreQueryStrategy $operation) use (&$callbackCount, &$escaped, $strategy, $builder): array { + $callbackCount++; + $escaped = $operation; + [$firstQuery, $firstClause] = $strategy->createOperationBuilders($operation); + [$secondQuery, $secondClause] = $strategy->createOperationBuilders($operation); + self::assertInstanceOf(QueryBuilder::class, $firstQuery); + self::assertInstanceOf(MySqlClauseBuilder::class, $firstClause); + self::assertNotSame($firstQuery, $secondQuery); + self::assertNotSame($firstClause, $secondClause); + + return $operation->query($builder); + } + ); + + self::assertSame(1, $callbackCount); + self::assertSame([['value' => 17]], $result); + self::assertInstanceOf(CoreQueryStrategy::class, $escaped); + + try { + $strategy->createOperationBuilders($escaped); + self::fail('Inactive handles must not mint operation builders.'); + } catch (UnsupportedCoordinationException $expected) { + } + + $this->expectException(InactiveDatabaseOperationException::class); + $escaped->estimatedCount($table); + } + + public function testAnotherHandleCannotMintBuildersDuringTheAttempt(): void + { + $backend = Mockery::mock(CoordinatedDatabaseStrategy::class); + $operationBackend = Mockery::mock(DatabaseStrategy::class); + $table = Mockery::mock(Table::class); + $table->shouldReceive('getName')->andReturn('records'); + $strategy = new CoordinatedQueryStrategy( + $backend, + Mockery::mock(TableSchemaService::class), + Mockery::mock(ClauseBuilder::class) + ); + $backend->shouldReceive('coordinate')->once()->andReturnUsing( + static fn ($coordinationTable, $identity, $participants, $operation) => $operation($operationBackend) + ); + + $this->expectException(UnsupportedCoordinationException::class); + $strategy->coordinate($table, ['id' => 1], [$table], function () use ($strategy): void { + $strategy->createOperationBuilders(Mockery::mock(CoreQueryStrategy::class)); + }); + } +}