diff --git a/src/Bolt/BoltUnmanagedTransaction.php b/src/Bolt/BoltUnmanagedTransaction.php index 3148e719..847ac257 100644 --- a/src/Bolt/BoltUnmanagedTransaction.php +++ b/src/Bolt/BoltUnmanagedTransaction.php @@ -59,6 +59,7 @@ public function __construct( private readonly TelemetryAPIEnum $telemetryApi, private readonly ?ConnectionPoolInterface $pool = null, bool $beginAlreadySent = false, + private readonly ?SessionBookmarkTracker $bookmarkTracker = null, ) { $this->beginSent = $beginAlreadySent; } @@ -173,6 +174,10 @@ public function runStatement(Statement $statement): SummarizedResult $this->connection->consumeResults(); } + if ($this->isInstantTransaction) { + $this->bookmarkTracker?->prepareForSend(true); + } + $this->ensureBeginSent(); try { @@ -183,7 +188,7 @@ public function runStatement(Statement $statement): SummarizedResult $this->tsxConfig->getTimeout(), $this->isInstantTransaction ? $this->bookmarkHolder : null, // let the begin transaction pass the bookmarks if it is a managed transaction null, // mode is never sent in RUN messages - it comes from session configuration - $this->tsxConfig->getMetaData(), + $this->isInstantTransaction ? $this->tsxConfig->getMetaData() : null, $this->telemetryApi, ); } catch (Throwable $e) { @@ -253,6 +258,7 @@ private function ensureBeginSent(): void return; } try { + $this->bookmarkTracker?->prepareForSend(true); $this->connection->sendTelemetryIfNeeded($this->telemetryApi); $this->connection->begin($this->database, $this->tsxConfig->getTimeout(), $this->bookmarkHolder, $this->tsxConfig->getMetaData()); $this->beginSent = true; diff --git a/src/Bolt/Messages/BoltCommitMessage.php b/src/Bolt/Messages/BoltCommitMessage.php index e554f51a..b65c61f0 100644 --- a/src/Bolt/Messages/BoltCommitMessage.php +++ b/src/Bolt/Messages/BoltCommitMessage.php @@ -56,7 +56,8 @@ public function getResponse(): Response $bookmark = $content['bookmark'] ?? ''; if (trim($bookmark) !== '') { - $this->bookmarks->setBookmark(new Bookmark([$bookmark])); + // Propagate the committed bookmark to the shared BookmarkManager, not only the session holder. + $this->bookmarks->setBookmarkFromServer(new Bookmark([$bookmark])); } $this->connection->protocol()->serverState = ServerState::READY; diff --git a/src/Bolt/Session.php b/src/Bolt/Session.php index 33dfefc6..1b9e5e0a 100644 --- a/src/Bolt/Session.php +++ b/src/Bolt/Session.php @@ -33,6 +33,7 @@ use Laudis\Neo4j\Exception\Neo4jException; use Laudis\Neo4j\Formatter\SummarizedResultFormatter; use Laudis\Neo4j\Neo4j\Neo4jConnectionPool; +use Laudis\Neo4j\NoOpBookmarkManager; use Laudis\Neo4j\Types\CypherList; use Psr\Log\LogLevel; use Throwable; @@ -46,23 +47,30 @@ final class Session implements SessionInterface /** @var list */ private array $usedConnections = []; - /** @psalm-readonly */ private readonly BookmarkHolder $bookmarkHolder; + private readonly SessionBookmarkTracker $bookmarkTracker; + private readonly SessionConfiguration $sessionConfig; /** * @param ConnectionPool|Neo4jConnectionPool $pool - * - * @psalm-mutation-free */ public function __construct( - private readonly SessionConfiguration $config, + SessionConfiguration $config, private readonly ConnectionPoolInterface $pool, /** * @psalm-readonly */ private readonly SummarizedResultFormatter $formatter, ) { + $bookmarkManager = $config->getBookmarkManager() ?? NoOpBookmarkManager::instance(); $this->bookmarkHolder = new BookmarkHolder(Bookmark::from($config->getBookmarks())); + $this->bookmarkTracker = new SessionBookmarkTracker( + $this->bookmarkHolder, + $bookmarkManager, + $config->getBookmarks(), + ); + $this->bookmarkHolder->onServerBookmark($this->bookmarkTracker->handleNewBookmark(...)); + $this->sessionConfig = $config->withRoutingBookmarks($this->bookmarkTracker->getRediscoveryBookmarkValues()); } /** @@ -117,7 +125,7 @@ static function (TransactionInterface $tx) use ($statement): SummarizedResult { return $result; }, $this->mergeTsxConfig(null), - $this->config, + $this->sessionConfig, TelemetryAPIEnum::EXECUTE_QUERY, ); } @@ -130,7 +138,7 @@ public function writeTransaction(callable $tsxHandler, ?TransactionConfiguration return $this->retry( $tsxHandler, $config, - $this->config->withAccessMode(AccessMode::WRITE()), + $this->sessionConfig->withAccessMode(AccessMode::WRITE()), TelemetryAPIEnum::TRANSACTION_FUNCTION ); } @@ -143,7 +151,7 @@ public function readTransaction(callable $tsxHandler, ?TransactionConfiguration return $this->retry( $tsxHandler, $config, - $this->config->withAccessMode(AccessMode::READ()), + $this->sessionConfig->withAccessMode(AccessMode::READ()), TelemetryAPIEnum::TRANSACTION_FUNCTION ); } @@ -186,7 +194,7 @@ private function retry( $transaction = null; if ($e->getTitle() === 'NotALeader' || $e->getNeo4jCode() === 'Neo.ClientError.Cluster.NotALeader') { if ($this->pool instanceof Neo4jConnectionPool) { - $this->pool->clearRoutingTable($this->config); + $this->pool->clearRoutingTable($this->sessionConfig); } $error = $e; @@ -284,7 +292,7 @@ private function executeStatementWithRetry(Statement $statement, TransactionConf while ($retries < $maxRetries) { try { - return $this->beginInstantTransaction($this->config, $config)->runStatement($statement); + return $this->beginInstantTransaction($this->sessionConfig, $config)->runStatement($statement); } catch (Neo4jException $e) { if (!$this->shouldClearRoutingTable($e)) { throw $e; @@ -332,7 +340,7 @@ public function beginTransaction(?iterable $statements = null, ?TransactionConfi { $this->getLogger()?->log(LogLevel::INFO, 'Beginning transaction', ['statements' => $statements, 'config' => $config]); $config = $this->mergeTsxConfig($config); - $tsx = $this->startTransaction($config, $this->config, TelemetryAPIEnum::UNMANAGED_TRANSACTION); + $tsx = $this->startTransaction($config, $this->sessionConfig, TelemetryAPIEnum::UNMANAGED_TRANSACTION); $tsx->runStatements($statements ?? []); @@ -343,14 +351,14 @@ public function beginReadTransaction(?TransactionConfiguration $config = null): { $config = $this->mergeTsxConfig($config); - return $this->startTransaction($config, $this->config->withAccessMode(AccessMode::READ()), TelemetryAPIEnum::TRANSACTION_FUNCTION); + return $this->startTransaction($config, $this->sessionConfig->withAccessMode(AccessMode::READ()), TelemetryAPIEnum::TRANSACTION_FUNCTION); } public function beginWriteTransaction(?TransactionConfiguration $config = null): UnmanagedTransactionInterface { $config = $this->mergeTsxConfig($config); - return $this->startTransaction($config, $this->config->withAccessMode(AccessMode::WRITE()), TelemetryAPIEnum::TRANSACTION_FUNCTION); + return $this->startTransaction($config, $this->sessionConfig->withAccessMode(AccessMode::WRITE()), TelemetryAPIEnum::TRANSACTION_FUNCTION); } /** @@ -367,16 +375,18 @@ private function beginInstantTransaction( $pool = $this->pool; return new BoltUnmanagedTransaction( - $this->config->getDatabase(), + $this->sessionConfig->getDatabase(), $this->formatter, $connection, - $this->config, + $this->sessionConfig, $tsxConfig, $this->bookmarkHolder, new BoltMessageFactory($connection, $this->getLogger()), true, TelemetryAPIEnum::SESSION_RUN, $pool, + false, + $this->bookmarkTracker, ); } @@ -419,17 +429,18 @@ private function startTransaction(TransactionConfiguration $config, SessionConfi $pool = $this->pool; return new BoltUnmanagedTransaction( - $this->config->getDatabase(), + $this->sessionConfig->getDatabase(), $this->formatter, $connection, - $this->config, + $this->sessionConfig, $config, $this->bookmarkHolder, new BoltMessageFactory($connection, $this->getLogger()), false, $telemetryApi, $pool, - false, // BEGIN sent on first run/commit/rollback + false, + $this->bookmarkTracker, ); } diff --git a/src/Bolt/SessionBookmarkTracker.php b/src/Bolt/SessionBookmarkTracker.php new file mode 100644 index 00000000..307aa78e --- /dev/null +++ b/src/Bolt/SessionBookmarkTracker.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\Bolt; + +use Laudis\Neo4j\Contracts\BookmarkManagerInterface; +use Laudis\Neo4j\Databags\Bookmark; +use Laudis\Neo4j\Databags\BookmarkHolder; + +/** + * Tracks bookmark manager state for a session, matching Java NetworkSession bookmark behaviour. + */ +final class SessionBookmarkTracker +{ + /** @var list */ + private array $lastUsedBookmarks = []; + + /** @var list */ + private array $lastReceivedBookmarks; + + /** + * @param list $sessionBookmarks + */ + public function __construct( + private readonly BookmarkHolder $bookmarkHolder, + private readonly BookmarkManagerInterface $bookmarkManager, + array $sessionBookmarks, + ) { + $this->lastReceivedBookmarks = $sessionBookmarks; + $this->syncHolder(false); + } + + public function prepareForSend(bool $updateLastUsed): void + { + $this->syncHolder($updateLastUsed); + } + + public function handleNewBookmark(Bookmark $bookmark): void + { + if ($bookmark->isEmpty()) { + return; + } + + $newBookmarks = [new Bookmark($bookmark->values())]; + $this->lastReceivedBookmarks = $newBookmarks; + $this->bookmarkManager->updateBookmarks($this->lastUsedBookmarks, $newBookmarks); + } + + /** + * @return list + */ + public function getRediscoveryBookmarkValues(): array + { + return $this->determineBookmarks(false)->values(); + } + + private function syncHolder(bool $updateLastUsed): void + { + $this->bookmarkHolder->setBookmark($this->determineBookmarks($updateLastUsed)); + } + + private function determineBookmarks(bool $updateLastUsed): Bookmark + { + $bookmarks = $this->bookmarkManager->getBookmarks(); + if ($updateLastUsed) { + $this->lastUsedBookmarks = $bookmarks; + } + + return Bookmark::from([...$bookmarks, ...$this->lastReceivedBookmarks]); + } +} diff --git a/src/BookmarkManagers.php b/src/BookmarkManagers.php new file mode 100644 index 00000000..2c75e98c --- /dev/null +++ b/src/BookmarkManagers.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j; + +use Laudis\Neo4j\Contracts\BookmarkManagerInterface; +use Laudis\Neo4j\Databags\BookmarkManagerConfig; + +/** + * Creates new instances of {@see BookmarkManagerInterface}. + */ +final class BookmarkManagers +{ + private function __construct() + { + } + + public static function defaultManager(BookmarkManagerConfig $config): BookmarkManagerInterface + { + return new Neo4jBookmarkManager( + $config->getInitialBookmarks(), + $config->getBookmarksConsumer(), + $config->getBookmarksSupplier(), + ); + } +} diff --git a/src/Contracts/BookmarkManagerInterface.php b/src/Contracts/BookmarkManagerInterface.php new file mode 100644 index 00000000..89ca19b6 --- /dev/null +++ b/src/Contracts/BookmarkManagerInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\Contracts; + +use Laudis\Neo4j\Databags\Bookmark; + +/** + * Keeps track of bookmarks and is used by the driver to ensure causal consistency between sessions and query executions. + * + * @see SessionConfiguration::withBookmarkManager() + */ +interface BookmarkManagerInterface +{ + /** + * Updates bookmarks by deleting the given previous bookmarks and adding the new bookmarks. + * + * @param list $previousBookmarks + * @param list $newBookmarks + */ + public function updateBookmarks(array $previousBookmarks, array $newBookmarks): void; + + /** + * Gets an immutable set of bookmarks. + * + * @return list + */ + public function getBookmarks(): array; +} diff --git a/src/Databags/BookmarkHolder.php b/src/Databags/BookmarkHolder.php index 9789a054..10921399 100644 --- a/src/Databags/BookmarkHolder.php +++ b/src/Databags/BookmarkHolder.php @@ -15,6 +15,9 @@ final class BookmarkHolder { + /** @var callable(Bookmark): void|null */ + private $serverBookmarkListener; + public function __construct( private Bookmark $bookmark, ) { @@ -25,8 +28,31 @@ public function getBookmark(): Bookmark return $this->bookmark; } + /** + * Sets the outgoing bookmark to send on the next BEGIN or RUN. + */ public function setBookmark(Bookmark $bookmark): void { $this->bookmark = $bookmark; } + + /** + * @param callable(Bookmark): void|null $listener + */ + public function onServerBookmark(?callable $listener): void + { + $this->serverBookmarkListener = $listener; + } + + /** + * Handles an incoming bookmark from the server and propagates it to the BookmarkManager via the registered listener. + */ + public function setBookmarkFromServer(Bookmark $bookmark): void + { + $this->bookmark = $bookmark; + + if ($this->serverBookmarkListener !== null && !$bookmark->isEmpty()) { + ($this->serverBookmarkListener)($bookmark); + } + } } diff --git a/src/Databags/BookmarkManagerConfig.php b/src/Databags/BookmarkManagerConfig.php new file mode 100644 index 00000000..133476c8 --- /dev/null +++ b/src/Databags/BookmarkManagerConfig.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\Databags; + +/** + * Configuration used to create a bookmark manager via {@see \Laudis\Neo4j\BookmarkManagers::defaultManager()}. + * + * @psalm-immutable + */ +final class BookmarkManagerConfig +{ + /** + * @param list $initialBookmarks + * @param callable(list): void|null $bookmarksConsumer + * @param callable(): list|null $bookmarksSupplier + */ + public function __construct( + private readonly array $initialBookmarks = [], + private readonly mixed $bookmarksConsumer = null, + private readonly mixed $bookmarksSupplier = null, + ) { + } + + public static function default(): self + { + return new self(); + } + + /** + * @param list $initialBookmarks + */ + public function withInitialBookmarks(array $initialBookmarks): self + { + return new self($initialBookmarks, $this->bookmarksConsumer, $this->bookmarksSupplier); + } + + /** + * @param callable(list): void $bookmarksConsumer + */ + public function withBookmarksConsumer(callable $bookmarksConsumer): self + { + return new self($this->initialBookmarks, $bookmarksConsumer, $this->bookmarksSupplier); + } + + /** + * @param callable(): list $bookmarksSupplier + */ + public function withBookmarksSupplier(callable $bookmarksSupplier): self + { + return new self($this->initialBookmarks, $this->bookmarksConsumer, $bookmarksSupplier); + } + + /** + * @return list + */ + public function getInitialBookmarks(): array + { + return $this->initialBookmarks; + } + + /** + * @return callable(list): void|null + */ + public function getBookmarksConsumer(): ?callable + { + return $this->bookmarksConsumer; + } + + /** + * @return callable(): list|null + */ + public function getBookmarksSupplier(): ?callable + { + return $this->bookmarksSupplier; + } +} diff --git a/src/Databags/SessionConfiguration.php b/src/Databags/SessionConfiguration.php index da8e233a..d4ac3f3a 100644 --- a/src/Databags/SessionConfiguration.php +++ b/src/Databags/SessionConfiguration.php @@ -14,6 +14,7 @@ namespace Laudis\Neo4j\Databags; use Laudis\Neo4j\Common\Neo4jLogger; +use Laudis\Neo4j\Contracts\BookmarkManagerInterface; use Laudis\Neo4j\Enum\AccessMode; use function parse_str; @@ -33,6 +34,7 @@ final class SessionConfiguration /** * @param list|null $bookmarks + * @param list|null $routingBookmarks */ public function __construct( private readonly ?string $database = null, @@ -40,6 +42,8 @@ public function __construct( private readonly ?AccessMode $accessMode = null, private readonly ?array $bookmarks = null, private readonly ?Neo4jLogger $logger = null, + private readonly ?BookmarkManagerInterface $bookmarkManager = null, + private readonly ?array $routingBookmarks = null, ) { } @@ -66,7 +70,7 @@ public static function default(): self */ public function withDatabase(?string $database): self { - return new self($database, $this->fetchSize, $this->accessMode, $this->bookmarks, $this->logger); + return new self($database, $this->fetchSize, $this->accessMode, $this->bookmarks, $this->logger, $this->bookmarkManager, $this->routingBookmarks); } /** @@ -74,7 +78,7 @@ public function withDatabase(?string $database): self */ public function withFetchSize(?int $size): self { - return new self($this->database, $size, $this->accessMode, $this->bookmarks, $this->logger); + return new self($this->database, $size, $this->accessMode, $this->bookmarks, $this->logger, $this->bookmarkManager, $this->routingBookmarks); } /** @@ -82,7 +86,7 @@ public function withFetchSize(?int $size): self */ public function withAccessMode(?AccessMode $defaultAccessMode): self { - return new self($this->database, $this->fetchSize, $defaultAccessMode, $this->bookmarks, $this->logger); + return new self($this->database, $this->fetchSize, $defaultAccessMode, $this->bookmarks, $this->logger, $this->bookmarkManager, $this->routingBookmarks); } /** @@ -92,7 +96,7 @@ public function withAccessMode(?AccessMode $defaultAccessMode): self */ public function withBookmarks(?array $bookmarks): self { - return new self($this->database, $this->fetchSize, $this->accessMode, $bookmarks, $this->logger); + return new self($this->database, $this->fetchSize, $this->accessMode, $bookmarks, $this->logger, $this->bookmarkManager, $this->routingBookmarks); } /** @@ -100,7 +104,23 @@ public function withBookmarks(?array $bookmarks): self */ public function withLogger(?Neo4jLogger $logger): self { - return new self($this->database, $this->fetchSize, $this->accessMode, $this->bookmarks, $logger); + return new self($this->database, $this->fetchSize, $this->accessMode, $this->bookmarks, $logger, $this->bookmarkManager, $this->routingBookmarks); + } + + /** + * Creates a new session with the provided bookmark manager. + */ + public function withBookmarkManager(?BookmarkManagerInterface $bookmarkManager): self + { + return new self($this->database, $this->fetchSize, $this->accessMode, $this->bookmarks, $this->logger, $bookmarkManager, $this->routingBookmarks); + } + + /** + * @param list|null $routingBookmarks + */ + public function withRoutingBookmarks(?array $routingBookmarks): self + { + return new self($this->database, $this->fetchSize, $this->accessMode, $this->bookmarks, $this->logger, $this->bookmarkManager, $routingBookmarks); } /** @@ -144,6 +164,19 @@ public function getLogger(): ?Neo4jLogger return $this->logger; } + public function getBookmarkManager(): ?BookmarkManagerInterface + { + return $this->bookmarkManager; + } + + /** + * @return list + */ + public function getRoutingBookmarks(): array + { + return $this->routingBookmarks ?? []; + } + /** * Creates a new configuration by merging the provided configuration with the current one. * The set values of the provided configuration will override the values of this configuration. @@ -154,7 +187,10 @@ public function merge(SessionConfiguration $config): self $config->database ?? $this->database, $config->fetchSize ?? $this->fetchSize, $config->accessMode ?? $this->accessMode, - $config->bookmarks ?? $this->bookmarks + $config->bookmarks ?? $this->bookmarks, + $config->logger ?? $this->logger, + $config->bookmarkManager ?? $this->bookmarkManager, + $config->routingBookmarks ?? $this->routingBookmarks, ); } diff --git a/src/Formatter/SummarizedResultFormatter.php b/src/Formatter/SummarizedResultFormatter.php index 9712fd13..63135475 100644 --- a/src/Formatter/SummarizedResultFormatter.php +++ b/src/Formatter/SummarizedResultFormatter.php @@ -293,8 +293,8 @@ private function processBoltResult(array $meta, BoltResult $result, BoltConnecti $connection->subscribeResult($tbr); $result->addFinishedCallback(function (array $response) use ($holder) { - if (array_key_exists('bookmark', $response) && is_string($response['bookmark'])) { - $holder->setBookmark(new Bookmark([$response['bookmark']])); + if (array_key_exists('bookmark', $response) && is_string($response['bookmark']) && trim($response['bookmark']) !== '') { + $holder->setBookmarkFromServer(new Bookmark([$response['bookmark']])); } }); diff --git a/src/Neo4j/Neo4jConnectionPool.php b/src/Neo4j/Neo4jConnectionPool.php index 22479383..4e874bdc 100644 --- a/src/Neo4j/Neo4jConnectionPool.php +++ b/src/Neo4j/Neo4jConnectionPool.php @@ -336,16 +336,22 @@ private function routingTable(BoltConnection $connection, SessionConfiguration $ } /** - * @return array{db?: string} + * @return array{db?: string, bookmarks?: list} */ private function buildRouteExtra(SessionConfiguration $config): array { + $extra = []; $database = $config->getDatabase(); - if ($database === null) { - return []; + if ($database !== null) { + $extra['db'] = $database; } - return ['db' => $database]; + $bookmarks = $config->getRoutingBookmarks(); + if ($bookmarks !== []) { + $extra['bookmarks'] = array_values(array_unique($bookmarks)); + } + + return $extra; } public function release(ConnectionInterface $connection): void diff --git a/src/Neo4jBookmarkManager.php b/src/Neo4jBookmarkManager.php new file mode 100644 index 00000000..14d3ac3a --- /dev/null +++ b/src/Neo4jBookmarkManager.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j; + +use function array_keys; + +use Laudis\Neo4j\Contracts\BookmarkManagerInterface; +use Laudis\Neo4j\Databags\Bookmark; + +use function sort; + +/** + * A basic {@see BookmarkManagerInterface} implementation. + */ +final class Neo4jBookmarkManager implements BookmarkManagerInterface +{ + /** @var array */ + private array $bookmarks; + + /** + * @param list $initialBookmarks + * @param callable(list): void|null $updateListener + * @param callable(): list|null $bookmarksSupplier + */ + public function __construct( + array $initialBookmarks, + private readonly mixed $updateListener = null, + private readonly mixed $bookmarksSupplier = null, + ) { + $this->bookmarks = self::toValueSet($initialBookmarks); + } + + public function updateBookmarks(array $previousBookmarks, array $newBookmarks): void + { + foreach (self::bookmarkValues($previousBookmarks) as $value) { + unset($this->bookmarks[$value]); + } + + foreach (self::bookmarkValues($newBookmarks) as $value) { + $this->bookmarks[$value] = true; + } + + if ($this->updateListener !== null) { + ($this->updateListener)($this->toBookmarkList($this->bookmarks)); + } + } + + public function getBookmarks(): array + { + $bookmarks = $this->bookmarks; + + if ($this->bookmarksSupplier !== null) { + foreach (self::bookmarkValues(($this->bookmarksSupplier)()) as $value) { + $bookmarks[$value] = true; + } + } + + return $this->toBookmarkList($bookmarks); + } + + /** + * @param list $bookmarks + * + * @return array + */ + private static function toValueSet(array $bookmarks): array + { + $set = []; + foreach (self::bookmarkValues($bookmarks) as $value) { + $set[$value] = true; + } + + return $set; + } + + /** + * @param list $bookmarks + * + * @return list + */ + private static function bookmarkValues(array $bookmarks): array + { + return Bookmark::from($bookmarks)->values(); + } + + /** + * @param array $values + * + * @return list + */ + private function toBookmarkList(array $values): array + { + $strings = array_keys($values); + sort($strings); + + return array_map(static fn (string $value): Bookmark => new Bookmark([$value]), $strings); + } +} diff --git a/src/NoOpBookmarkManager.php b/src/NoOpBookmarkManager.php new file mode 100644 index 00000000..26fbdb0a --- /dev/null +++ b/src/NoOpBookmarkManager.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j; + +use Laudis\Neo4j\Contracts\BookmarkManagerInterface; + +/** + * A no-op {@see BookmarkManagerInterface} implementation. + */ +final class NoOpBookmarkManager implements BookmarkManagerInterface +{ + private static ?self $instance = null; + + private function __construct() + { + } + + public static function instance(): self + { + return self::$instance ??= new self(); + } + + public function updateBookmarks(array $previousBookmarks, array $newBookmarks): void + { + } + + public function getBookmarks(): array + { + return []; + } +} diff --git a/testkit-backend/register.php b/testkit-backend/register.php index ddf649ac..cae89994 100644 --- a/testkit-backend/register.php +++ b/testkit-backend/register.php @@ -11,9 +11,14 @@ * file that was distributed with this source code. */ +use Laudis\Neo4j\TestkitBackend\CallbackRegistry; use Laudis\Neo4j\TestkitBackend\Handlers\GetFeatures; use Laudis\Neo4j\TestkitBackend\Handlers\StartTest; +use Laudis\Neo4j\TestkitBackend\IdGenerator; use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\RequestFactory; +use Laudis\Neo4j\TestkitBackend\Socket; +use Laudis\Neo4j\TestkitBackend\TestkitCallbackDispatcher; use Monolog\Handler\StreamHandler; use Monolog\Logger; use Psr\Log\LoggerInterface; @@ -46,4 +51,30 @@ [], ); }, + + IdGenerator::class => static function () { + return new IdGenerator(); + }, + + CallbackRegistry::class => static function () { + return new CallbackRegistry(); + }, + + Socket::class => static function () { + return Socket::fromEnvironment(); + }, + + RequestFactory::class => static function () { + return new RequestFactory(); + }, + + TestkitCallbackDispatcher::class => static function (Psr\Container\ContainerInterface $c) { + return new TestkitCallbackDispatcher( + $c->get(Socket::class), + $c->get(LoggerInterface::class), + $c->get(CallbackRegistry::class), + $c, + $c->get(RequestFactory::class), + ); + }, ]; diff --git a/testkit-backend/src/Backend.php b/testkit-backend/src/Backend.php index 08f36702..889dfff5 100644 --- a/testkit-backend/src/Backend.php +++ b/testkit-backend/src/Backend.php @@ -24,6 +24,8 @@ use JsonException; use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitCallbackResponseInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitCallbackResultInterface; use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; use Laudis\Neo4j\TestkitBackend\Responses\BackendErrorResponse; @@ -40,17 +42,20 @@ final class Backend private LoggerInterface $logger; private ContainerInterface $container; private RequestFactory $factory; + private TestkitCallbackDispatcher $callbackDispatcher; public function __construct( Socket $socket, LoggerInterface $logger, ContainerInterface $container, RequestFactory $factory, + TestkitCallbackDispatcher $callbackDispatcher, ) { $this->socket = $socket; $this->logger = $logger; $this->container = $container; $this->factory = $factory; + $this->callbackDispatcher = $callbackDispatcher; } /** @@ -66,7 +71,13 @@ public static function boot(): self $logger = $container->get(LoggerInterface::class); $logger->info('Booting testkit backend ...'); Socket::setupEnvironment(); - $tbr = new self(Socket::fromEnvironment(), $logger, $container, new RequestFactory()); + $tbr = new self( + $container->get(Socket::class), + $logger, + $container, + $container->get(RequestFactory::class), + $container->get(TestkitCallbackDispatcher::class), + ); $logger->info('Testkit booted'); return $tbr; @@ -87,7 +98,7 @@ public function handle(): void [$handler, $request] = $this->extractRequest($message); try { - $this->properSendoff($handler->handle($request)); + $this->sendHandlerResponse($handler->handle($request)); } catch (Throwable $e) { $this->logger->error($e->__toString()); @@ -96,6 +107,21 @@ public function handle(): void } } + /** + * Sends a callback response to the frontend and blocks until the matching completion request arrives. + */ + public function dispatchCallback(TestkitCallbackResponseInterface $callbackResponse): TestkitCallbackResultInterface + { + return $this->callbackDispatcher->dispatch($callbackResponse); + } + + private function sendHandlerResponse(?TestkitResponseInterface $response): void + { + if ($response !== null) { + $this->properSendoff($response); + } + } + private function loadRequestHandler(string $name): RequestHandlerInterface { $action = $this->container->get('Laudis\\Neo4j\\TestkitBackend\\Handlers\\'.$name); diff --git a/testkit-backend/src/CallbackRegistry.php b/testkit-backend/src/CallbackRegistry.php new file mode 100644 index 00000000..5f2f3c6a --- /dev/null +++ b/testkit-backend/src/CallbackRegistry.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitCallbackResultInterface; +use RuntimeException; + +/** + * Tracks in-flight TestKit callback round-trips between backend and frontend. + */ +final class CallbackRegistry +{ + /** @var array */ + private array $pending = []; + + /** @var array */ + private array $completed = []; + + public function registerPending(string $callbackId): void + { + $this->pending[$callbackId] = true; + } + + public function complete(string $callbackId, TestkitCallbackResultInterface $result): void + { + if (!array_key_exists($callbackId, $this->pending)) { + throw new RuntimeException('No pending callback for id: '.$callbackId); + } + + unset($this->pending[$callbackId]); + $this->completed[$callbackId] = $result; + } + + public function hasCompleted(string $callbackId): bool + { + return array_key_exists($callbackId, $this->completed); + } + + public function takeCompleted(string $callbackId): TestkitCallbackResultInterface + { + if (!array_key_exists($callbackId, $this->completed)) { + throw new RuntimeException('Callback not completed for id: '.$callbackId); + } + + $result = $this->completed[$callbackId]; + unset($this->completed[$callbackId]); + + return $result; + } +} diff --git a/testkit-backend/src/Contracts/RequestHandlerInterface.php b/testkit-backend/src/Contracts/RequestHandlerInterface.php index c42ec672..56172fad 100644 --- a/testkit-backend/src/Contracts/RequestHandlerInterface.php +++ b/testkit-backend/src/Contracts/RequestHandlerInterface.php @@ -21,5 +21,5 @@ interface RequestHandlerInterface /** * @param T $request */ - public function handle($request): TestkitResponseInterface; + public function handle($request): ?TestkitResponseInterface; } diff --git a/testkit-backend/src/Contracts/TestkitCallbackResponseInterface.php b/testkit-backend/src/Contracts/TestkitCallbackResponseInterface.php new file mode 100644 index 00000000..a05a53f6 --- /dev/null +++ b/testkit-backend/src/Contracts/TestkitCallbackResponseInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Contracts; + +/** + * Response that instructs the TestKit frontend to run a callback and reply with a completion request. + */ +interface TestkitCallbackResponseInterface extends TestkitResponseInterface +{ + public function getCallbackId(): string; +} diff --git a/testkit-backend/src/Contracts/TestkitCallbackResultInterface.php b/testkit-backend/src/Contracts/TestkitCallbackResultInterface.php new file mode 100644 index 00000000..a6287a25 --- /dev/null +++ b/testkit-backend/src/Contracts/TestkitCallbackResultInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Contracts; + +/** + * Request sent by the TestKit frontend in response to a {@see TestkitCallbackResponseInterface}. + */ +interface TestkitCallbackResultInterface +{ + public function getRequestId(): string; +} diff --git a/testkit-backend/src/Handlers/BookmarkManagerClose.php b/testkit-backend/src/Handlers/BookmarkManagerClose.php new file mode 100644 index 00000000..3aa1d7a2 --- /dev/null +++ b/testkit-backend/src/Handlers/BookmarkManagerClose.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Handlers; + +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\BookmarkManagerCloseRequest; +use Laudis\Neo4j\TestkitBackend\Responses\BookmarkManagerResponse; + +/** + * @implements RequestHandlerInterface + */ +final class BookmarkManagerClose implements RequestHandlerInterface +{ + public function __construct( + private readonly MainRepository $repository, + ) { + } + + /** + * @param BookmarkManagerCloseRequest $request + */ + public function handle($request): TestkitResponseInterface + { + $this->repository->removeBookmarkManager($request->id); + + return new BookmarkManagerResponse($request->id); + } +} diff --git a/testkit-backend/src/Handlers/BookmarksConsumerCompleted.php b/testkit-backend/src/Handlers/BookmarksConsumerCompleted.php new file mode 100644 index 00000000..61370e4e --- /dev/null +++ b/testkit-backend/src/Handlers/BookmarksConsumerCompleted.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Handlers; + +use Laudis\Neo4j\TestkitBackend\CallbackRegistry; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\Requests\BookmarksConsumerCompletedRequest; + +/** + * @implements RequestHandlerInterface + */ +final class BookmarksConsumerCompleted implements RequestHandlerInterface +{ + public function __construct( + private readonly CallbackRegistry $callbackRegistry, + ) { + } + + /** + * @param BookmarksConsumerCompletedRequest $request + */ + public function handle($request): ?TestkitResponseInterface + { + $this->callbackRegistry->complete($request->getRequestId(), $request); + + return null; + } +} diff --git a/testkit-backend/src/Handlers/BookmarksSupplierCompleted.php b/testkit-backend/src/Handlers/BookmarksSupplierCompleted.php new file mode 100644 index 00000000..36b3341f --- /dev/null +++ b/testkit-backend/src/Handlers/BookmarksSupplierCompleted.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Handlers; + +use Laudis\Neo4j\TestkitBackend\CallbackRegistry; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\Requests\BookmarksSupplierCompletedRequest; + +/** + * @implements RequestHandlerInterface + */ +final class BookmarksSupplierCompleted implements RequestHandlerInterface +{ + public function __construct( + private readonly CallbackRegistry $callbackRegistry, + ) { + } + + /** + * @param BookmarksSupplierCompletedRequest $request + */ + public function handle($request): ?TestkitResponseInterface + { + $this->callbackRegistry->complete($request->getRequestId(), $request); + + return null; + } +} diff --git a/testkit-backend/src/Handlers/NewBookmarkManager.php b/testkit-backend/src/Handlers/NewBookmarkManager.php new file mode 100644 index 00000000..7d2e004b --- /dev/null +++ b/testkit-backend/src/Handlers/NewBookmarkManager.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Handlers; + +use Laudis\Neo4j\BookmarkManagers; +use Laudis\Neo4j\Databags\Bookmark; +use Laudis\Neo4j\Databags\BookmarkManagerConfig; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\IdGenerator; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\NewBookmarkManagerRequest; +use Laudis\Neo4j\TestkitBackend\Responses\BookmarkManagerResponse; +use Laudis\Neo4j\TestkitBackend\TestkitBookmarksConsumer; +use Laudis\Neo4j\TestkitBackend\TestkitBookmarksSupplier; +use Laudis\Neo4j\TestkitBackend\TestkitCallbackDispatcher; + +/** + * @implements RequestHandlerInterface + */ +final class NewBookmarkManager implements RequestHandlerInterface +{ + public function __construct( + private readonly MainRepository $repository, + private readonly IdGenerator $idGenerator, + private readonly TestkitCallbackDispatcher $callbackDispatcher, + ) { + } + + /** + * @param NewBookmarkManagerRequest $request + */ + public function handle($request): TestkitResponseInterface + { + $id = $this->idGenerator->newId(); + $initialBookmarks = array_map( + static fn (string $value): Bookmark => new Bookmark([$value]), + $request->initialBookmarks ?? [], + ); + + $config = BookmarkManagerConfig::default()->withInitialBookmarks($initialBookmarks); + + if ($request->bookmarksSupplierRegistered) { + $supplier = new TestkitBookmarksSupplier($id, $this->callbackDispatcher, $this->idGenerator); + $config = $config->withBookmarksSupplier(static fn (): array => $supplier->get()); + } + + if ($request->bookmarksConsumerRegistered) { + $consumer = new TestkitBookmarksConsumer($id, $this->callbackDispatcher, $this->idGenerator); + $config = $config->withBookmarksConsumer(static fn (array $bookmarks): mixed => $consumer->accept($bookmarks)); + } + + $manager = BookmarkManagers::defaultManager($config); + $this->repository->addBookmarkManager($id, $manager); + + return new BookmarkManagerResponse($id); + } +} diff --git a/testkit-backend/src/Handlers/NewSession.php b/testkit-backend/src/Handlers/NewSession.php index 88ea8162..26a6a934 100644 --- a/testkit-backend/src/Handlers/NewSession.php +++ b/testkit-backend/src/Handlers/NewSession.php @@ -61,6 +61,12 @@ public function handle($request): SessionResponse $config = $config->withFetchSize($request->fetchSize); } + if ($request->bookmarkManagerId !== null) { + $config = $config->withBookmarkManager( + $this->repository->getBookmarkManager($request->bookmarkManagerId), + ); + } + $session = $driver->createSession($config); $id = Uuid::v4(); $this->repository->addSession($id, $session); diff --git a/testkit-backend/src/IdGenerator.php b/testkit-backend/src/IdGenerator.php new file mode 100644 index 00000000..6db6d99f --- /dev/null +++ b/testkit-backend/src/IdGenerator.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend; + +/** + * Generates monotonically increasing string identifiers for TestKit objects and callbacks. + * + * Matches the Java TestkitState#newId() behaviour. + */ +final class IdGenerator +{ + private int $next = 0; + + public function newId(): string + { + return (string) $this->next++; + } +} diff --git a/testkit-backend/src/MainRepository.php b/testkit-backend/src/MainRepository.php index e4fd7db9..d6bf98b9 100644 --- a/testkit-backend/src/MainRepository.php +++ b/testkit-backend/src/MainRepository.php @@ -14,6 +14,7 @@ namespace Laudis\Neo4j\TestkitBackend; use Iterator; +use Laudis\Neo4j\Contracts\BookmarkManagerInterface; use Laudis\Neo4j\Contracts\DriverInterface; use Laudis\Neo4j\Contracts\SessionInterface; use Laudis\Neo4j\Contracts\TransactionInterface; @@ -23,6 +24,7 @@ use Laudis\Neo4j\Formatter\SummarizedResultFormatter; use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; use Laudis\Neo4j\Types\CypherMap; +use RuntimeException; use Symfony\Component\Uid\Uuid; /** @@ -68,6 +70,9 @@ final class MainRepository */ private array $transactionConfigs = []; + /** @var array */ + private array $bookmarkManagers = []; + /** * @param array>>> $drivers * @param array>>> $sessions @@ -286,4 +291,27 @@ public function tryGetTsxIdFromSession(Uuid $sessionId): ?Uuid { return $this->sessionToTransactions[$sessionId->toRfc4122()] ?? null; } + + public function addBookmarkManager(string $id, BookmarkManagerInterface $bookmarkManager): void + { + $this->bookmarkManagers[$id] = $bookmarkManager; + } + + public function getBookmarkManager(string $id): BookmarkManagerInterface + { + if (!array_key_exists($id, $this->bookmarkManagers)) { + throw new RuntimeException('Could not find bookmark manager'); + } + + return $this->bookmarkManagers[$id]; + } + + public function removeBookmarkManager(string $id): void + { + if (!array_key_exists($id, $this->bookmarkManagers)) { + throw new RuntimeException('Could not find bookmark manager'); + } + + unset($this->bookmarkManagers[$id]); + } } diff --git a/testkit-backend/src/RequestFactory.php b/testkit-backend/src/RequestFactory.php index 53703811..b3ddba9b 100644 --- a/testkit-backend/src/RequestFactory.php +++ b/testkit-backend/src/RequestFactory.php @@ -16,6 +16,9 @@ use function is_string; use Laudis\Neo4j\TestkitBackend\Requests\AuthorizationTokenRequest; +use Laudis\Neo4j\TestkitBackend\Requests\BookmarkManagerCloseRequest; +use Laudis\Neo4j\TestkitBackend\Requests\BookmarksConsumerCompletedRequest; +use Laudis\Neo4j\TestkitBackend\Requests\BookmarksSupplierCompletedRequest; use Laudis\Neo4j\TestkitBackend\Requests\CheckMultiDBSupportRequest; use Laudis\Neo4j\TestkitBackend\Requests\DomainNameResolutionCompletedRequest; use Laudis\Neo4j\TestkitBackend\Requests\DriverCloseRequest; @@ -24,6 +27,7 @@ use Laudis\Neo4j\TestkitBackend\Requests\GetFeaturesRequest; use Laudis\Neo4j\TestkitBackend\Requests\GetRoutingTableRequest; use Laudis\Neo4j\TestkitBackend\Requests\GetServerInfoRequest; +use Laudis\Neo4j\TestkitBackend\Requests\NewBookmarkManagerRequest; use Laudis\Neo4j\TestkitBackend\Requests\NewDriverRequest; use Laudis\Neo4j\TestkitBackend\Requests\NewSessionRequest; use Laudis\Neo4j\TestkitBackend\Requests\ResolverResolutionCompletedRequest; @@ -55,6 +59,8 @@ final class RequestFactory 'StartTest' => StartTestRequest::class, 'GetFeatures' => GetFeaturesRequest::class, 'NewDriver' => NewDriverRequest::class, + 'NewBookmarkManager' => NewBookmarkManagerRequest::class, + 'BookmarkManagerClose' => BookmarkManagerCloseRequest::class, 'AuthorizationToken' => AuthorizationTokenRequest::class, 'VerifyConnectivity' => VerifyConnectivityRequest::class, 'CheckMultiDBSupport' => CheckMultiDBSupportRequest::class, @@ -84,6 +90,8 @@ final class RequestFactory 'GetRoutingTable' => GetRoutingTableRequest::class, 'GetServerInfo' => GetServerInfoRequest::class, 'ExecuteQuery' => ExecuteQueryRequest::class, + 'BookmarksSupplierCompleted' => BookmarksSupplierCompletedRequest::class, + 'BookmarksConsumerCompleted' => BookmarksConsumerCompletedRequest::class, ]; /** @@ -102,6 +110,45 @@ public function create(string $name, iterable $data): object ); } + if ($name === 'BookmarksSupplierCompleted') { + return new BookmarksSupplierCompletedRequest( + (string) $data['requestId'], + $data['bookmarks'] ?? [], + ); + } + + if ($name === 'BookmarksConsumerCompleted') { + return new BookmarksConsumerCompletedRequest( + (string) $data['requestId'], + ); + } + + if ($name === 'NewBookmarkManager') { + return new NewBookmarkManagerRequest( + $data['initialBookmarks'] ?? null, + (bool) ($data['bookmarksSupplierRegistered'] ?? false), + (bool) ($data['bookmarksConsumerRegistered'] ?? false), + ); + } + + if ($name === 'BookmarkManagerClose') { + return new BookmarkManagerCloseRequest( + (string) $data['id'], + ); + } + + if ($name === 'NewSession') { + return new NewSessionRequest( + Uuid::fromString((string) $data['driverId']), + (string) $data['accessMode'], + $data['bookmarks'] ?? null, + $data['database'] ?? null, + $data['fetchSize'] ?? null, + $data['impersonatedUser'] ?? null, + array_key_exists('bookmarkManagerId', $data) ? (string) $data['bookmarkManagerId'] : null, + ); + } + if ($name === 'NewDriver') { return new NewDriverRequest( uri: $data['uri'], diff --git a/testkit-backend/src/Requests/BookmarkManagerCloseRequest.php b/testkit-backend/src/Requests/BookmarkManagerCloseRequest.php new file mode 100644 index 00000000..cfe6e793 --- /dev/null +++ b/testkit-backend/src/Requests/BookmarkManagerCloseRequest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Requests; + +final class BookmarkManagerCloseRequest +{ + public function __construct( + public string $id, + ) { + } +} diff --git a/testkit-backend/src/Requests/BookmarksConsumerCompletedRequest.php b/testkit-backend/src/Requests/BookmarksConsumerCompletedRequest.php new file mode 100644 index 00000000..a8f2b032 --- /dev/null +++ b/testkit-backend/src/Requests/BookmarksConsumerCompletedRequest.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Requests; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitCallbackResultInterface; + +final class BookmarksConsumerCompletedRequest implements TestkitCallbackResultInterface +{ + public function __construct( + private readonly string $requestId, + ) { + } + + public function getRequestId(): string + { + return $this->requestId; + } +} diff --git a/testkit-backend/src/Requests/BookmarksSupplierCompletedRequest.php b/testkit-backend/src/Requests/BookmarksSupplierCompletedRequest.php new file mode 100644 index 00000000..7c1a1168 --- /dev/null +++ b/testkit-backend/src/Requests/BookmarksSupplierCompletedRequest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Requests; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitCallbackResultInterface; + +final class BookmarksSupplierCompletedRequest implements TestkitCallbackResultInterface +{ + /** + * @param list $bookmarks + */ + public function __construct( + private readonly string $requestId, + private readonly array $bookmarks, + ) { + } + + public function getRequestId(): string + { + return $this->requestId; + } + + /** + * @return list + */ + public function getBookmarks(): array + { + return $this->bookmarks; + } +} diff --git a/testkit-backend/src/Requests/NewBookmarkManagerRequest.php b/testkit-backend/src/Requests/NewBookmarkManagerRequest.php new file mode 100644 index 00000000..64071904 --- /dev/null +++ b/testkit-backend/src/Requests/NewBookmarkManagerRequest.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Requests; + +final class NewBookmarkManagerRequest +{ + /** + * @param list|null $initialBookmarks + */ + public function __construct( + public ?array $initialBookmarks, + public bool $bookmarksSupplierRegistered, + public bool $bookmarksConsumerRegistered, + ) { + } +} diff --git a/testkit-backend/src/Requests/NewSessionRequest.php b/testkit-backend/src/Requests/NewSessionRequest.php index c0b8d63c..0255a1a6 100644 --- a/testkit-backend/src/Requests/NewSessionRequest.php +++ b/testkit-backend/src/Requests/NewSessionRequest.php @@ -27,6 +27,7 @@ public function __construct( public ?string $database, public ?int $fetchSize, public ?string $impersonatedUser, + public ?string $bookmarkManagerId = null, ) { } } diff --git a/testkit-backend/src/Responses/BookmarkManagerResponse.php b/testkit-backend/src/Responses/BookmarkManagerResponse.php new file mode 100644 index 00000000..17bcc21e --- /dev/null +++ b/testkit-backend/src/Responses/BookmarkManagerResponse.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Responses; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; + +final class BookmarkManagerResponse implements TestkitResponseInterface +{ + public function __construct( + private readonly string $id, + ) { + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'BookmarkManager', + 'data' => [ + 'id' => $this->id, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/BookmarksConsumerRequestResponse.php b/testkit-backend/src/Responses/BookmarksConsumerRequestResponse.php new file mode 100644 index 00000000..76b10a35 --- /dev/null +++ b/testkit-backend/src/Responses/BookmarksConsumerRequestResponse.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Responses; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitCallbackResponseInterface; + +final class BookmarksConsumerRequestResponse implements TestkitCallbackResponseInterface +{ + /** + * @param list $bookmarks + */ + public function __construct( + private readonly string $id, + private readonly string $bookmarkManagerId, + private readonly array $bookmarks, + ) { + } + + public function getCallbackId(): string + { + return $this->id; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'BookmarksConsumerRequest', + 'data' => [ + 'id' => $this->id, + 'bookmarkManagerId' => $this->bookmarkManagerId, + 'bookmarks' => $this->bookmarks, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/BookmarksSupplierRequestResponse.php b/testkit-backend/src/Responses/BookmarksSupplierRequestResponse.php new file mode 100644 index 00000000..47048270 --- /dev/null +++ b/testkit-backend/src/Responses/BookmarksSupplierRequestResponse.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Responses; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitCallbackResponseInterface; + +final class BookmarksSupplierRequestResponse implements TestkitCallbackResponseInterface +{ + public function __construct( + private readonly string $id, + private readonly string $bookmarkManagerId, + ) { + } + + public function getCallbackId(): string + { + return $this->id; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'BookmarksSupplierRequest', + 'data' => [ + 'id' => $this->id, + 'bookmarkManagerId' => $this->bookmarkManagerId, + ], + ]; + } +} diff --git a/testkit-backend/src/TestkitBookmarksConsumer.php b/testkit-backend/src/TestkitBookmarksConsumer.php new file mode 100644 index 00000000..a37abc48 --- /dev/null +++ b/testkit-backend/src/TestkitBookmarksConsumer.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend; + +use Laudis\Neo4j\Databags\Bookmark; +use Laudis\Neo4j\TestkitBackend\Responses\BookmarksConsumerRequestResponse; + +final class TestkitBookmarksConsumer +{ + public function __construct( + private readonly string $bookmarkManagerId, + private readonly TestkitCallbackDispatcher $dispatcher, + private readonly IdGenerator $idGenerator, + ) { + } + + /** + * @param list $bookmarks + */ + public function accept(array $bookmarks): void + { + $callbackId = $this->idGenerator->newId(); + $values = Bookmark::from($bookmarks)->values(); + + $this->dispatcher->dispatch( + new BookmarksConsumerRequestResponse($callbackId, $this->bookmarkManagerId, $values), + ); + } +} diff --git a/testkit-backend/src/TestkitBookmarksSupplier.php b/testkit-backend/src/TestkitBookmarksSupplier.php new file mode 100644 index 00000000..e35d9cf4 --- /dev/null +++ b/testkit-backend/src/TestkitBookmarksSupplier.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend; + +use Laudis\Neo4j\Databags\Bookmark; +use Laudis\Neo4j\TestkitBackend\Requests\BookmarksSupplierCompletedRequest; +use Laudis\Neo4j\TestkitBackend\Responses\BookmarksSupplierRequestResponse; +use RuntimeException; + +final class TestkitBookmarksSupplier +{ + public function __construct( + private readonly string $bookmarkManagerId, + private readonly TestkitCallbackDispatcher $dispatcher, + private readonly IdGenerator $idGenerator, + ) { + } + + /** + * @return list + */ + public function get(): array + { + $callbackId = $this->idGenerator->newId(); + $result = $this->dispatcher->dispatch( + new BookmarksSupplierRequestResponse($callbackId, $this->bookmarkManagerId), + ); + + if (!$result instanceof BookmarksSupplierCompletedRequest) { + throw new RuntimeException('Expected BookmarksSupplierCompleted but got '.get_debug_type($result)); + } + + return array_map(static fn (string $value): Bookmark => new Bookmark([$value]), $result->getBookmarks()); + } +} diff --git a/testkit-backend/src/TestkitCallbackDispatcher.php b/testkit-backend/src/TestkitCallbackDispatcher.php new file mode 100644 index 00000000..24f63590 --- /dev/null +++ b/testkit-backend/src/TestkitCallbackDispatcher.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend; + +use function get_debug_type; +use function json_decode; +use function json_encode; + +use const JSON_THROW_ON_ERROR; + +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitCallbackResponseInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitCallbackResultInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; + +use const PHP_EOL; + +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; +use UnexpectedValueException; + +/** + * Sends callback responses to the TestKit frontend and waits for completion requests. + */ +final class TestkitCallbackDispatcher +{ + public function __construct( + private readonly Socket $socket, + private readonly LoggerInterface $logger, + private readonly CallbackRegistry $callbackRegistry, + private readonly ContainerInterface $container, + private readonly RequestFactory $factory, + ) { + } + + public function dispatch(TestkitCallbackResponseInterface $callbackResponse): TestkitCallbackResultInterface + { + $callbackId = $callbackResponse->getCallbackId(); + $this->callbackRegistry->registerPending($callbackId); + $this->sendResponse($callbackResponse); + + while (true) { + $message = $this->socket->readMessage(); + + if ($message === null) { + throw new RuntimeException('Unexpected end of stream while waiting for callback '.$callbackId); + } + + [$handler, $request] = $this->extractRequest($message); + $response = $handler->handle($request); + if ($response !== null) { + $this->sendResponse($response); + } + + if ($this->callbackRegistry->hasCompleted($callbackId)) { + return $this->callbackRegistry->takeCompleted($callbackId); + } + } + } + + private function sendResponse(TestkitResponseInterface $response): void + { + $message = json_encode($response, JSON_THROW_ON_ERROR); + + $this->logger->debug('Sending: '.$this->cutoffStringForLogging($message)); + $this->socket->write('#response begin'.PHP_EOL); + $this->socket->write($message.PHP_EOL); + $this->socket->write('#response end'.PHP_EOL); + } + + /** + * @return array{0: RequestHandlerInterface, 1: object} + */ + private function extractRequest(string $message): array + { + $this->logger->debug('Received: '.$this->cutoffStringForLogging($message)); + /** @var array{name: string, data: iterable} $response */ + $response = json_decode($message, true, 512, JSON_THROW_ON_ERROR); + + $handler = $this->loadRequestHandler($response['name']); + $request = $this->factory->create($response['name'], $response['data']); + + return [$handler, $request]; + } + + private function loadRequestHandler(string $name): RequestHandlerInterface + { + $action = $this->container->get('Laudis\\Neo4j\\TestkitBackend\\Handlers\\'.$name); + if (!$action instanceof RequestHandlerInterface) { + throw new UnexpectedValueException(sprintf('Expected action to be an instance of %s, received %s instead', RequestHandlerInterface::class, get_debug_type($action))); + } + + return $action; + } + + private function cutoffStringForLogging(string $message): string + { + if (mb_strlen($message) > 1000) { + return substr($message, 0, 1000).'### Long message cut for brevity'; + } + + return $message; + } +} diff --git a/testkit-backend/testkit.sh b/testkit-backend/testkit.sh index 20fad4bd..106c0a6c 100755 --- a/testkit-backend/testkit.sh +++ b/testkit-backend/testkit.sh @@ -43,6 +43,7 @@ echo "" ### Failing/error tests #python3 -m unittest -vvv \ +# tests.stub.driver_parameters.test_bookmark_manager.TestNeo4jBookmarkManager.test_should_keep_track_of_session_run \ # tests.stub.datatypes.test_vector_types.TestVectorTypes.test_vector \ # tests.stub.datatypes.test_unsupported_type.TestUnsupportedTypes.test_unsupported_type \ # tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_date_time_with_patch \ @@ -71,7 +72,7 @@ echo "" # # Passing tests (commented out) # python3 -m unittest -vvv \ - +# python3 -m unittest -vvv \ tests.neo4j.test_authentication.TestAuthenticationBasic.test_error_on_incorrect_credentials \ tests.neo4j.test_authentication.TestAuthenticationBasic.test_success_on_basic_token \ @@ -194,20 +195,26 @@ python3 -m unittest -vvv \ tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetry.test_execute_read_retry \ tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetry.test_execute_write_retry \ tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetry.test_execute_query_retry \ - tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_query_retry \ - tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_read \ - tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_write \ - tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_begin_transaction \ - tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_session_run \ - tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_query \ - tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_read_retry \ - tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_write_retry \ - tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_query_retry \ + tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_query_retry \ + tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_read \ + tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_write \ + tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_begin_transaction \ + tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_session_run \ + tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_query \ + tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_read_retry \ + tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_write_retry \ + tests.stub.driver_parameters.telemetry.test_telemetry.TestTelemetryRouting.test_execute_query_retry \ tests.stub.datatypes.test_vector_types.TestVectorTypes.test_vector \ tests.stub.datatypes.test_unsupported_type.TestUnsupportedTypes.test_unsupported_type \ tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_date_time_with_patch \ tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_date_time_with_patch \ - tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_zoned_date_time + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_zoned_date_time \ + tests.stub.driver_parameters.test_client_agent_strings.TestClientAgentStringsV5x2.test_default_user_agent \ + tests.stub.driver_parameters.test_client_agent_strings.TestClientAgentStringsV5x2.test_custom_user_agent \ + tests.stub.driver_parameters.test_client_agent_strings.TestClientAgentStringsV5x3.test_default_user_agent \ + tests.stub.driver_parameters.test_client_agent_strings.TestClientAgentStringsV5x3.test_custom_user_agent \ + tests.stub.driver_parameters.test_client_agent_strings.TestClientAgentStringsV5x3.test_bolt_agent \ + tests.stub.driver_parameters.test_bookmark_manager.TestNeo4jBookmarkManager.test_should_keep_track_of_session_run EXIT_CODE="$?" diff --git a/tests/Unit/Neo4jBookmarkManagerTest.php b/tests/Unit/Neo4jBookmarkManagerTest.php new file mode 100644 index 00000000..1272257e --- /dev/null +++ b/tests/Unit/Neo4jBookmarkManagerTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\Tests\Unit; + +use Laudis\Neo4j\BookmarkManagers; +use Laudis\Neo4j\Databags\Bookmark; +use Laudis\Neo4j\Databags\BookmarkManagerConfig; +use Laudis\Neo4j\Neo4jBookmarkManager; +use Laudis\Neo4j\NoOpBookmarkManager; +use PHPUnit\Framework\TestCase; + +final class Neo4jBookmarkManagerTest extends TestCase +{ + public function testShouldAddInitialBookmarks(): void + { + $initialBookmarks = [new Bookmark(['SY:000001'])]; + $manager = new Neo4jBookmarkManager($initialBookmarks); + + self::assertEquals($initialBookmarks, $manager->getBookmarks()); + } + + public function testShouldNotifyUpdateListener(): void + { + $notified = null; + $manager = new Neo4jBookmarkManager([], static function (array $bookmarks) use (&$notified): void { + $notified = $bookmarks; + }); + $bookmark = new Bookmark(['SY:000001']); + + $manager->updateBookmarks([], [$bookmark]); + + self::assertEquals([$bookmark], $notified); + } + + public function testShouldUpdateBookmarks(): void + { + $initialBookmarks = [ + new Bookmark(['SY:000001']), + new Bookmark(['SY:000002']), + new Bookmark(['SY:000003']), + new Bookmark(['SY:000004']), + new Bookmark(['SY:000005']), + ]; + $manager = new Neo4jBookmarkManager($initialBookmarks); + $newBookmark = new Bookmark(['SY:000007']); + + $manager->updateBookmarks( + [new Bookmark(['SY:000003']), new Bookmark(['SY:000004'])], + [$newBookmark], + ); + + self::assertEquals( + [ + new Bookmark(['SY:000001']), + new Bookmark(['SY:000002']), + new Bookmark(['SY:000005']), + $newBookmark, + ], + $manager->getBookmarks(), + ); + } + + public function testShouldGetBookmarksFromBookmarkSupplier(): void + { + $initialBookmark = new Bookmark(['SY:000001']); + $supplierBookmark = new Bookmark(['SY:000002']); + $supplierCalls = 0; + $manager = new Neo4jBookmarkManager( + [$initialBookmark], + null, + static function () use (&$supplierCalls, $supplierBookmark): array { + ++$supplierCalls; + + return [$supplierBookmark]; + }, + ); + + self::assertEquals( + [$initialBookmark, $supplierBookmark], + $manager->getBookmarks(), + ); + self::assertSame(1, $supplierCalls); + } + + public function testDefaultManagerUsesConfig(): void + { + $manager = BookmarkManagers::defaultManager( + BookmarkManagerConfig::default()->withInitialBookmarks([new Bookmark(['bm1'])]), + ); + + self::assertEquals([new Bookmark(['bm1'])], $manager->getBookmarks()); + } + + public function testNoOpBookmarkManagerReturnsEmptyBookmarks(): void + { + $manager = NoOpBookmarkManager::instance(); + + $manager->updateBookmarks([new Bookmark(['bm1'])], [new Bookmark(['bm2'])]); + + self::assertSame([], $manager->getBookmarks()); + } +}