diff --git a/.github/workflows/testkit.yml b/.github/workflows/testkit.yml new file mode 100644 index 00000000..a36ce962 --- /dev/null +++ b/.github/workflows/testkit.yml @@ -0,0 +1,80 @@ +name: Testkit Tests + +on: + push: + branches: [ '**' ] + pull_request: + branches: ['**'] + +jobs: + tests: + runs-on: ubuntu-latest + name: "Running Testkit tests for PHP ${{matrix.php-version}} on Neo4j and testkit ${{ matrix.neo4j-version }} with simple config" + strategy: + fail-fast: false + matrix: + neo4j-version: ["3.5", "4.0", "4.1", "4.2", "4.3"] + php-version: ["7.4", "8.0", "8.1"] + + services: + neo4j: + image: neo4j:${{ matrix.neo4j-version }} + env: + NEO4J_AUTH: neo4j/test + NEO4JLABS_PLUGINS: '["apoc"]' + ports: + - 7687:7687 + - 7474:7474 + options: >- + --health-cmd "wget http://localhost:7474 || exit 1" + + steps: + - name: Checkout driver + uses: actions/checkout@v2 + + - uses: php-actions/composer@v6 + with: + progress: yes + php_version: ${{ matrix.php-version }} + version: 2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + + - name: Checkout TestKit (testing tool) + uses: actions/checkout@v2 + with: + repository: neo4j-drivers/testkit + path: testkit + ref: '4.3' + + - name: Install dependencies + run: | + sudo apt-get update + # install docker + sudo apt-get install \ + apt-transport-https \ + ca-certificates \ + curl \ + gnupg \ + lsb-release + # Python (needed for dummy driver and TestKit) + sudo apt-get install python3 python3-pip + git clone https://github.com/pyenv/pyenv.git .pyenv + python -m pip install --upgrade pip + cd testkit + python -m pip install -r requirements.txt + + - name: Run TestKit + env: + TEST_NEO4J_HOST: localhost + TEST_NEO4J_USER: neo4j + TEST_NEO4J_PASS: test + TEST_DRIVER_NAME: php + run: | + php testkit-backend/index.php & + cd testkit + sleep 2 + python3 -m unittest -v "tests.neo4j.test_authentication.TestAuthenticationBasic" diff --git a/README.md b/README.md index 323ab80e..c03255f4 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Auto committed queries are the most straightforward and most intuitive but have ```php $client->run( 'MERGE (user {email: $email})', //The query is a required parameter - ['email' => 'abc@hotmail.com'], //Parameters can be optionally added + ['email' => 'abc@hotmail.com'], //Requests can be optionally added 'backup' //The default connection can be overridden ); ``` diff --git a/composer.json b/composer.json index 6e8e6663..d8d886e1 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "psr/http-client": "^1.0", "php-http/message": "^1.0", "php-http/message-factory": "^1.0", - "stefanak-michal/bolt": "^2.5.2", + "stefanak-michal/bolt": "^2.5.3", "symfony/polyfill-php80": "^1.2", "ext-json": "*" }, @@ -47,7 +47,13 @@ "vimeo/psalm": "^4.13.0", "friendsofphp/php-cs-fixer": "3.0.2", "psalm/plugin-phpunit": "^0.15.1", - "vlucas/phpdotenv": "^5.0" + "monolog/monolog": "^2.2", + "psr/log": "^1.1", + "php-di/php-di": "^6.3", + "vlucas/phpdotenv": "^5.0", + "psr/container": "^1.1", + "lctrs/psalm-psr-container-plugin": "^1.3", + "symfony/uid": "^5.0" }, "autoload": { "psr-4": { @@ -56,7 +62,8 @@ }, "autoload-dev": { "psr-4": { - "Laudis\\Neo4j\\Tests\\": "tests/" + "Laudis\\Neo4j\\Tests\\": "tests/", + "Laudis\\Neo4j\\TestkitBackend\\": "testkit-backend/src" } }, "minimum-stability": "stable" diff --git a/docker-compose.yml b/docker-compose.yml index bf69626f..5eb2f183 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,22 @@ services: - 9000 env_file: - .env + testkit-backend: + build: + context: . + dockerfile: Dockerfile + args: + - WITH_XDEBUG=true + working_dir: /opt/project + volumes: + - .:/opt/project + command: php /opt/project/testkit-backend/index.php + networks: + - neo4j + depends_on: + - neo4j + ports: + - "9876:9876" neo4j: networks: - neo4j diff --git a/psalm.xml b/psalm.xml index e66752cc..7fe61814 100755 --- a/psalm.xml +++ b/psalm.xml @@ -12,7 +12,8 @@ > - + + @@ -52,11 +53,17 @@ + + + + + + diff --git a/src/Authentication/BasicAuth.php b/src/Authentication/BasicAuth.php index 2e91ca94..b4280029 100644 --- a/src/Authentication/BasicAuth.php +++ b/src/Authentication/BasicAuth.php @@ -15,8 +15,12 @@ use function base64_encode; use Bolt\Bolt; +use Bolt\error\MessageException; use Exception; +use Laudis\Neo4j\Common\TransactionHelper; use Laudis\Neo4j\Contracts\AuthenticateInterface; +use Laudis\Neo4j\Databags\Neo4jError; +use Laudis\Neo4j\Exception\Neo4jException; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\UriInterface; @@ -58,7 +62,12 @@ public function authenticateHttp(RequestInterface $request, UriInterface $uri, s */ public function authenticateBolt(Bolt $bolt, UriInterface $uri, string $userAgent): void { - $bolt->init($userAgent, $this->username, $this->password); + try { + $bolt->init($userAgent, $this->username, $this->password); + } catch (MessageException $e) { + $code = TransactionHelper::extractCode($e) ?? ''; + throw new Neo4jException([new Neo4jError($code, $e->getMessage())]); + } } /** diff --git a/src/Bolt/BoltConfiguration.php b/src/Bolt/BoltConfiguration.php index 31ef499f..e5a216e3 100644 --- a/src/Bolt/BoltConfiguration.php +++ b/src/Bolt/BoltConfiguration.php @@ -15,6 +15,7 @@ use function call_user_func; use function is_callable; +use function is_string; use Laudis\Neo4j\Client; use Laudis\Neo4j\Contracts\ConfigInterface; diff --git a/src/Bolt/BoltConnectionPool.php b/src/Bolt/BoltConnectionPool.php index d47ad691..f6f83380 100644 --- a/src/Bolt/BoltConnectionPool.php +++ b/src/Bolt/BoltConnectionPool.php @@ -64,6 +64,8 @@ public function acquire( if (!$connection->isOpen()) { $connection->open(); + $authenticate->authenticateBolt($connection->getImplementation(), $connectingTo, $userAgent); + return $connection; } } diff --git a/src/Client.php b/src/Client.php index e17ee5db..88d4507d 100644 --- a/src/Client.php +++ b/src/Client.php @@ -140,15 +140,7 @@ public function verifyConnectivity(?string $driver = null): bool */ private function decideAlias(?string $alias): string { - if ($alias !== null) { - return $alias; - } - - if ($this->default !== null) { - return $this->default; - } - - return array_key_first($this->drivers); + return $alias ?? $this->default ?? array_key_first($this->drivers); } /** diff --git a/src/Contracts/SessionInterface.php b/src/Contracts/SessionInterface.php index 22f7980e..7bfd3541 100644 --- a/src/Contracts/SessionInterface.php +++ b/src/Contracts/SessionInterface.php @@ -13,6 +13,7 @@ namespace Laudis\Neo4j\Contracts; +use Laudis\Neo4j\Databags\Bookmark; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Databags\TransactionConfiguration; use Laudis\Neo4j\Exception\Neo4jException; @@ -95,4 +96,6 @@ public function readTransaction(callable $tsxHandler, ?TransactionConfiguration * @return HandlerResult */ public function transaction(callable $tsxHandler, ?TransactionConfiguration $config = null); + +// public function getLastBookmark(): Bookmark; } diff --git a/src/Databags/Bookmark.php b/src/Databags/Bookmark.php new file mode 100644 index 00000000..f4503ecc --- /dev/null +++ b/src/Databags/Bookmark.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\Databags; + +use function bin2hex; +use Exception; +use function random_bytes; +use function substr; + +final class Bookmark +{ + /** @var list */ + private array $bookmarks; + + /** + * @param list $bookmarks + */ + public function __construct(?array $bookmarks = null) + { + $this->bookmarks = $bookmarks ?? []; + } + + public function isEmpty(): bool + { + return count($this->bookmarks) === 0; + } + + /** + * @return list + */ + public function values(): array + { + return $this->bookmarks; + } + + /** + * @throws Exception + */ + public function withIncrement(?string $bookmark = null): self + { + $copy = $this->bookmarks; + if ($bookmark === null) { + $bookmark = $this->generateUuidV4(); + } + $copy[] = $bookmark; + + return new self($copy); + } + + /** + * @throws Exception + */ + private function generateUuidV4(): string + { + $uuid = random_bytes(16); + $uuid[6] = ((int) $uuid[6]) & 0x0F | 0x40; + $uuid[8] = ((int) $uuid[8]) & 0x3F | 0x80; + $uuid = bin2hex($uuid); + + return substr($uuid, 0, 8).'-' + .substr($uuid, 8, 4).'-' + .substr($uuid, 12, 4).'-' + .substr($uuid, 16, 4).'-' + .substr($uuid, 20, 12); + } +} diff --git a/src/Databags/BookmarkHolder.php b/src/Databags/BookmarkHolder.php new file mode 100644 index 00000000..8f8fb7a6 --- /dev/null +++ b/src/Databags/BookmarkHolder.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\Databags; + +final class BookmarkHolder +{ + private Bookmark $bookmark; + + public function __construct(?Bookmark $bookmark = null) + { + $this->bookmark = $bookmark ?? new Bookmark(); + } + + public function getBookmark(): Bookmark + { + return $this->bookmark; + } + + public function setBookmark(Bookmark $bookmark): void + { + $this->bookmark = $bookmark; + } +} diff --git a/src/Databags/SessionConfiguration.php b/src/Databags/SessionConfiguration.php index 0a478f7e..72c477dd 100644 --- a/src/Databags/SessionConfiguration.php +++ b/src/Databags/SessionConfiguration.php @@ -138,6 +138,7 @@ public function getAccessMode(): AccessMode { $accessMode = is_callable($this->accessMode) ? call_user_func($this->accessMode) : $this->accessMode; + /** @psalm-suppress ImpureMethodCall */ return $accessMode ?? AccessMode::WRITE(); } diff --git a/src/Databags/Statement.php b/src/Databags/Statement.php index acd8ce60..5f1de949 100644 --- a/src/Databags/Statement.php +++ b/src/Databags/Statement.php @@ -13,6 +13,8 @@ namespace Laudis\Neo4j\Databags; +use Laudis\Neo4j\Types\AbstractCypherObject; + /** * The components of a Cypher query, containing the query text and parameter mapping. * @@ -20,7 +22,7 @@ * * @psalm-immutable */ -final class Statement +final class Statement extends AbstractCypherObject { private string $text; /** @var iterable */ @@ -62,4 +64,12 @@ public function getParameters(): iterable { return $this->parameters; } + + public function toArray(): array + { + return [ + 'text' => $this->text, + 'parameters' => $this->parameters, + ]; + } } diff --git a/src/Databags/SummaryCounters.php b/src/Databags/SummaryCounters.php index 337e55c4..1b101fd6 100644 --- a/src/Databags/SummaryCounters.php +++ b/src/Databags/SummaryCounters.php @@ -13,17 +13,14 @@ namespace Laudis\Neo4j\Databags; -use ArrayIterator; -use IteratorAggregate; +use Laudis\Neo4j\Types\AbstractCypherObject; /** * Contains counters for various operations that a query triggered. * * @psalm-immutable - * - * @implements IteratorAggregate */ -final class SummaryCounters implements IteratorAggregate +final class SummaryCounters extends AbstractCypherObject { private int $nodesCreated; @@ -236,9 +233,9 @@ public static function aggregate(iterable $stats): SummaryCounters return $tbr; } - public function getIterator(): ArrayIterator + public function toArray(): array { - return new ArrayIterator([ + return [ 'nodesCreated' => $this->nodesCreated, 'nodesDeleted' => $this->nodesDeleted, 'relationshipsCreated' => $this->relationshipsCreated, @@ -253,6 +250,6 @@ public function getIterator(): ArrayIterator 'containsUpdates' => $this->containsUpdates, 'containsSystemUpdates' => $this->containsSystemUpdates, 'systemUpdates' => $this->systemUpdates, - ]); + ]; } } diff --git a/src/Enum/AccessMode.php b/src/Enum/AccessMode.php index a1b3a03a..a2ff3bf3 100644 --- a/src/Enum/AccessMode.php +++ b/src/Enum/AccessMode.php @@ -13,6 +13,7 @@ namespace Laudis\Neo4j\Enum; +use JsonSerializable; use Laudis\TypedEnum\TypedEnum; /** @@ -27,8 +28,13 @@ * * @psalm-suppress MutableDependency */ -final class AccessMode extends TypedEnum +final class AccessMode extends TypedEnum implements JsonSerializable { private const READ = 'read'; private const WRITE = 'write'; + + public function jsonSerialize() + { + return $this->getValue(); + } } diff --git a/src/Enum/QueryTypeEnum.php b/src/Enum/QueryTypeEnum.php index d9863ef4..7bc4105a 100644 --- a/src/Enum/QueryTypeEnum.php +++ b/src/Enum/QueryTypeEnum.php @@ -13,6 +13,7 @@ namespace Laudis\Neo4j\Enum; +use JsonSerializable; use Laudis\Neo4j\Databags\SummaryCounters; use Laudis\TypedEnum\TypedEnum; @@ -28,7 +29,7 @@ * * @psalm-suppress MutableDependency */ -final class QueryTypeEnum extends TypedEnum +final class QueryTypeEnum extends TypedEnum implements JsonSerializable { private const READ_ONLY = 'read_only'; private const READ_WRITE = 'read_write'; @@ -54,4 +55,15 @@ public static function fromCounters(SummaryCounters $counters): self return self::READ_ONLY(); } + + public function __toString() + { + /** @noinspection MagicMethodsValidityInspection */ + return $this->getValue(); + } + + public function jsonSerialize() + { + return $this->getValue(); + } } diff --git a/src/Enum/RoutingRoles.php b/src/Enum/RoutingRoles.php index ccf0de99..0bfb717e 100644 --- a/src/Enum/RoutingRoles.php +++ b/src/Enum/RoutingRoles.php @@ -13,6 +13,7 @@ namespace Laudis\Neo4j\Enum; +use JsonSerializable; use Laudis\TypedEnum\TypedEnum; /** @@ -28,9 +29,25 @@ * * @psalm-suppress MutableDependency */ -final class RoutingRoles extends TypedEnum +final class RoutingRoles extends TypedEnum implements JsonSerializable { private const LEADER = ['WRITE', 'LEADER']; private const FOLLOWER = ['READ', 'FOLLOWER']; private const ROUTE = ['ROUTE']; + + /** + * @psalm-suppress ImpureMethodCall + */ + public function jsonSerialize(): string + { + if ($this === self::LEADER()) { + return 'LEADER'; + } + + if ($this === self::FOLLOWER()) { + return 'FOLLOWER'; + } + + return 'ROUTE'; + } } diff --git a/src/Exception/InvalidTransactionStateException.php b/src/Exception/InvalidTransactionStateException.php new file mode 100644 index 00000000..323b8cb7 --- /dev/null +++ b/src/Exception/InvalidTransactionStateException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\Exception; + +use RuntimeException; + +final class InvalidTransactionStateException extends RuntimeException +{ +} diff --git a/src/Formatter/BasicFormatter.php b/src/Formatter/BasicFormatter.php index 615a624b..de3d3cc6 100644 --- a/src/Formatter/BasicFormatter.php +++ b/src/Formatter/BasicFormatter.php @@ -129,7 +129,7 @@ private function formatRow(array $meta, array $result): CypherMap private function mapPath(Path $path): array { - $rels = $path->rels(); + $relationships = $path->rels(); $nodes = $path->nodes(); $tbr = []; /** @@ -138,9 +138,9 @@ private function mapPath(Path $path): array foreach ($nodes as $i => $node) { /** @var mixed */ $tbr[] = $node; - if (isset($rels[$i])) { + if (isset($relationships[$i])) { /** @var mixed */ - $tbr[] = $rels[$i]; + $tbr[] = $relationships[$i]; } } diff --git a/src/Formatter/Specialised/BoltOGMTranslator.php b/src/Formatter/Specialised/BoltOGMTranslator.php index be0d09fb..30dfeb5d 100644 --- a/src/Formatter/Specialised/BoltOGMTranslator.php +++ b/src/Formatter/Specialised/BoltOGMTranslator.php @@ -216,7 +216,7 @@ private function makeFromBoltPath(BoltPath $path): Path */ private function mapArray(array $value) { - if (isset($value[0])) { + if (array_key_exists(0, $value)) { /** @var array $vector */ $vector = []; /** @var mixed $x */ diff --git a/src/Http/HttpConfig.php b/src/Http/HttpConfig.php index da0c22c2..c3bca82b 100644 --- a/src/Http/HttpConfig.php +++ b/src/Http/HttpConfig.php @@ -17,6 +17,7 @@ use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; use function is_callable; +use function is_string; use Laudis\Neo4j\Contracts\ConfigInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; diff --git a/src/ParameterHelper.php b/src/ParameterHelper.php index 78bf45e5..f7fa1e90 100644 --- a/src/ParameterHelper.php +++ b/src/ParameterHelper.php @@ -69,7 +69,7 @@ public static function asMap(iterable $iterable): CypherMap */ public static function asParameter($value) { - return self::emptyDictionaryToStdClass($value) ?? + return self::cypherMapToStdClass($value) ?? self::emptySequenceToArray($value) ?? self::filledIterableToArray($value) ?? self::stringAbleToString($value) ?? @@ -100,7 +100,7 @@ private static function stringAbleToString($value): ?string private static function filterInvalidType($value) { if ($value !== null && !is_scalar($value)) { - throw new InvalidArgumentException('Parameters must be iterable, scalar, null or stringable'); + throw new InvalidArgumentException('Requests must be iterable, scalar, null or string able'); } return $value; @@ -125,11 +125,19 @@ private static function emptySequenceToArray($value): ?array * @param mixed $value * * @pure + * + * @psalm-suppress ImpureMethodCall + * @psalm-suppress ImpurePropertyAssignment */ - private static function emptyDictionaryToStdClass($value): ?stdClass + private static function cypherMapToStdClass($value): ?stdClass { - if (($value instanceof CypherMap) && $value->count() === 0) { - return new stdClass(); + if ($value instanceof CypherMap) { + $tbr = new stdClass(); + foreach ($value as $key => $val) { + $tbr->$key = $val; + } + + return $tbr; } return null; diff --git a/testkit-backend/blacklist.php b/testkit-backend/blacklist.php new file mode 100644 index 00000000..29a6ed47 --- /dev/null +++ b/testkit-backend/blacklist.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + 'neo4j' => [ + 'datatypes' => [ + 'TestDataTypes' => [ + 'test_should_echo_very_long_map' => 'Work in progress on testkit frontend', + ], + ], + 'sessionrun' => [ + 'TestSessionRun' => [ + 'test_autocommit_transactions_should_support_metadata' => 'Meta data isn\'t supported yet', + 'test_autocommit_transactions_should_support_timeout' => 'Waiting on bookmarks isn\'t supported yet', + ], + ], + 'test_direct_driver' => [ + 'TestDirectDriver' => [ + 'test_custom_resolver' => 'No custom resolver implemented', + 'test_fail_nicely_when_using_http_port' => 'Not implemented yet', + ], + ], + 'test_summary' => [ + 'TestDirectDriver' => [ + 'test_agent_string' => 'This is not an official driver yet', + ], + ], + 'txrun' => [ + 'TestTxRun' => [ + 'test_should_fail_to_run_query_for_invalid_bookmark' => 'Waiting on bookmarks isn\'t supported yet', + ], + ], + 'txfuncrun' => [ + 'TestTxFuncRun' => [ + 'test_iteration_nested' => 'Buffers not supported yet', + 'test_updates_last_bookmark_on_commit' => 'Waiting on bookmarks isn\'t supported yet', + ], + ], + ], +]; diff --git a/testkit-backend/features.php b/testkit-backend/features.php new file mode 100644 index 00000000..2736fbab --- /dev/null +++ b/testkit-backend/features.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + // === OPTIMIZATIONS === + // On receiving Neo.ClientError.Security.AuthorizationExpired, the driver + // shouldn't reuse any open connections for anything other than finishing + // a started job. All other connections should be re-established before + // running the next job with them. + 'AuthorizationExpiredTreatment' => false, + + // Driver doesn't explicitly send message data that is the default value. + // This conserves bandwidth. + 'Optimization:ImplicitDefaultArguments' => false, + + // The driver sends no more than the strictly necessary RESET messages. + 'Optimization:MinimalResets' => false, + + // The driver caches connections (e.g., in a pool) and doesn't start a new + // one (with hand-shake, HELLO, etc.) for each query. + 'Optimization:ConnectionReuse' => false, + + // The driver doesn't wait for a SUCCESS after calling RUN but pipelines a + // PULL right afterwards and consumes two messages after that. This saves a + // full round-trip. + 'Optimization:PullPipelining' => false, + + // === CONFIGURATION HINTS (BOLT 4.3+) === + // The driver understands and follow the connection hint + // connection.recv_timeout_seconds which tells it to close the connection + // after not receiving an answer on any request for longer than the given + // time period. On timout, the driver should remove the server from its + // routing table and assume all other connections to the server are dead + // as well. + 'ConfHint:connection.recv_timeout_seconds' => false, + + // Temporary driver feature that will be removed when all official drivers + // have been unified in their behaviour of when they return a Result object. + // We aim for drivers to not providing a Result until the server replied with + // SUCCESS so that the result keys are already known and attached to the + // Result object without further waiting or communication with the server. + 'Temporary:ResultKeys' => false, + + // Temporary driver feature that will be removed when all official driver + // backends have implemented all summary response fields. + 'Temporary:FullSummary' => false, + + // Temporary driver feature that will be removed when all official driver + // backends have implemented path and relationship types + 'Temporary:CypherPathAndRelationship' => true, +]; diff --git a/testkit-backend/index.php b/testkit-backend/index.php new file mode 100644 index 00000000..456a32b2 --- /dev/null +++ b/testkit-backend/index.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Laudis\Neo4j\TestkitBackend\Backend; + +require_once __DIR__.'/../vendor/autoload.php'; + +$backend = Backend::boot(); +do { + $backend->handle(); +} while (true); diff --git a/testkit-backend/register.php b/testkit-backend/register.php new file mode 100644 index 00000000..b45c13b8 --- /dev/null +++ b/testkit-backend/register.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Laudis\Neo4j\TestkitBackend\Handlers\GetFeatures; +use Laudis\Neo4j\TestkitBackend\Handlers\StartTest; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Monolog\Handler\StreamHandler; +use Monolog\Logger; +use Psr\Log\LoggerInterface; + +return [ + LoggerInterface::class => static function () { + $logger = new Logger('testkit-backend'); + $logger->pushHandler(new StreamHandler('php://stdout', Logger::DEBUG)); + + return $logger; + }, + + GetFeatures::class => static function () { + $featuresConfig = require __DIR__.'/features.php'; + + return new GetFeatures($featuresConfig); + }, + + StartTest::class => static function () { + $acceptedTests = require __DIR__.'/blacklist.php'; + + return new StartTest($acceptedTests); + }, + + MainRepository::class => static function () { + return new MainRepository( + [], + [], + [], + [], + ); + }, +]; diff --git a/testkit-backend/src/Backend.php b/testkit-backend/src/Backend.php new file mode 100644 index 00000000..430effcb --- /dev/null +++ b/testkit-backend/src/Backend.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend; + +use DI\ContainerBuilder; +use Exception; +use function get_debug_type; +use function json_decode; +use function json_encode; +use const JSON_THROW_ON_ERROR; +use JsonException; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\Responses\BackendErrorResponse; +use const PHP_EOL; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; +use UnexpectedValueException; + +final class Backend +{ + private Socket $socket; + private LoggerInterface $logger; + private ContainerInterface $container; + private RequestFactory $factory; + + public function __construct( + Socket $socket, + LoggerInterface $logger, + ContainerInterface $container, + RequestFactory $factory + ) { + $this->socket = $socket; + $this->logger = $logger; + $this->container = $container; + $this->factory = $factory; + } + + /** + * @throws Exception + */ + public static function boot(): self + { + $builder = new ContainerBuilder(); + $builder->addDefinitions(__DIR__.'/../register.php'); + $builder->useAutowiring(true); + $container = $builder->build(); + + $logger = $container->get(LoggerInterface::class); + $logger->info('Booting testkit backend ...'); + Socket::setupEnvironment(); + $tbr = new self(Socket::fromEnvironment(), $logger, $container, new RequestFactory()); + $logger->info('Testkit booted'); + + return $tbr; + } + + /** + * @throws JsonException + */ + public function handle(): void + { + while (true) { + $message = $this->socket->readMessage(); + if ($message === null) { + $this->socket->reset(); + continue; + } + + [$handler, $request] = $this->extractRequest($message); + + try { + $this->properSendoff($handler->handle($request)); + } catch (Throwable $e) { + $this->logger->error($e->__toString()); + $this->properSendoff(new BackendErrorResponse($e->getMessage())); + } + } + } + + private function loadRequestHandler(string $name): RequestHandlerInterface + { + $action = $this->container->get('Laudis\\Neo4j\\TestkitBackend\\Handlers\\'.$name); + if (!$action instanceof RequestHandlerInterface) { + $str = printf( + 'Expected action to be an instance of %s, received %s instead', + RequestHandlerInterface::class, + get_debug_type($action) + ); + throw new UnexpectedValueException($str); + } + + return $action; + } + + /** + * @param string $message + */ + private function properSendoff(TestkitResponseInterface $response): void + { + $message = json_encode($response, JSON_THROW_ON_ERROR); + + $this->logger->debug('Sending: '.$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: '.$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]; + } +} diff --git a/testkit-backend/src/Contracts/RequestHandlerInterface.php b/testkit-backend/src/Contracts/RequestHandlerInterface.php new file mode 100644 index 00000000..b89170d8 --- /dev/null +++ b/testkit-backend/src/Contracts/RequestHandlerInterface.php @@ -0,0 +1,16 @@ + + * + * 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\Contracts\SessionInterface; +use Laudis\Neo4j\Contracts\TransactionInterface; +use Laudis\Neo4j\Databags\SummarizedResult; +use Laudis\Neo4j\Exception\InvalidTransactionStateException; +use Laudis\Neo4j\Exception\Neo4jException; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Responses\DriverErrorResponse; +use Laudis\Neo4j\TestkitBackend\Responses\FrontendErrorResponse; +use Laudis\Neo4j\TestkitBackend\Responses\ResultResponse; +use Laudis\Neo4j\Types\AbstractCypherObject; +use Laudis\Neo4j\Types\CypherMap; +use Psr\Log\LoggerInterface; +use Symfony\Component\Uid\Uuid; + +/** + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Formatter\OGMFormatter + * + * @template T of \Laudis\Neo4j\TestkitBackend\Requests\SessionRunRequest|\Laudis\Neo4j\TestkitBackend\Requests\TransactionRunRequest + * + * @implements RequestHandlerInterface + */ +abstract class AbstractRunner implements RequestHandlerInterface +{ + protected MainRepository $repository; + private LoggerInterface $logger; + + public function __construct(MainRepository $repository, LoggerInterface $logger) + { + $this->repository = $repository; + $this->logger = $logger; + } + + public function handle($request): ResultResponse + { + $session = $this->getRunner($request); + $id = Uuid::v4(); + + try { + $params = []; + foreach ($request->getParams() as $key => $value) { + $params[$key] = $this->decodeToValue($value); + } + $result = $session->run($request->getCypher(), $params); + } catch (Neo4jException|InvalidTransactionStateException $exception) { + $this->logger->debug($exception->__toString()); + if ($exception instanceof InvalidTransactionStateException || + str_contains($exception->getMessage(), 'Neo.ClientError.Security.Unauthorized') || + str_contains($exception->getMessage(), 'ClientError') + ) { + $this->repository->addRecords($id, new DriverErrorResponse( + $this->getId($request), + $exception instanceof Neo4jException ? $exception->getNeo4jCode() : 'n/a', + $exception->getMessage(), + )); + } else { + $this->repository->addRecords($id, new FrontendErrorResponse( + $exception->getMessage() + )); + } + + return new ResultResponse($id, []); + } + $this->repository->addRecords($id, $result); + + return new ResultResponse($id, $result->isEmpty() ? [] : $result->first()->keys()); + } + + /** + * @param array{name: string, data: array{value: iterable|scalar|null}} $param + * + * @return scalar|AbstractCypherObject|iterable|null + */ + private function decodeToValue(array $param) + { + $value = $param['data']['value']; + if (is_iterable($value)) { + if ($param['name'] === 'CypherMap') { + /** @psalm-suppress MixedArgumentTypeCoercion */ + $map = []; + /** + * @var numeric $k + * @var mixed $v + */ + foreach ($value as $k => $v) { + /** @psalm-suppress MixedArgument */ + $map[(string) $k] = $this->decodeToValue($v); + } + + return new CypherMap($map); + } + + if ($param['name'] === 'CypherList') { + $list = []; + /** + * @var mixed $v + */ + foreach ($value as $v) { + /** @psalm-suppress MixedArgument */ + $list[] = $this->decodeToValue($v); + } + + return new CypherMap($list); + } + } + + return $value; + } + + /** + * @param T $request + * + * @return SessionInterface>>|TransactionInterface>> + */ + abstract protected function getRunner($request); + + /** + * @param T $request + */ + abstract protected function getId($request): Uuid; +} diff --git a/testkit-backend/src/Handlers/CheckMultiDBSupport.php b/testkit-backend/src/Handlers/CheckMultiDBSupport.php new file mode 100644 index 00000000..284e38d5 --- /dev/null +++ b/testkit-backend/src/Handlers/CheckMultiDBSupport.php @@ -0,0 +1,44 @@ + + */ +final class CheckMultiDBSupport implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param CheckMultiDBSupportRequest $request + */ + public function handle($request): TestkitResponseInterface + { + $driver = $this->repository->getDriver($request->getDriverId()); + + try { + $session = $driver->createSession(SessionConfiguration::default()->withDatabase('system')); + $session->run('SHOW databases'); + } catch (Exception $e) { + return new MultiDBSupportResponse($request->getDriverId(), false); + } + + return new MultiDBSupportResponse($request->getDriverId(), true); + } +} diff --git a/testkit-backend/src/Handlers/DomainNameResolutionCompleted.php b/testkit-backend/src/Handlers/DomainNameResolutionCompleted.php new file mode 100644 index 00000000..0df4c567 --- /dev/null +++ b/testkit-backend/src/Handlers/DomainNameResolutionCompleted.php @@ -0,0 +1,33 @@ + + * + * 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\Requests\DomainNameResolutionCompletedRequest; +use Laudis\Neo4j\TestkitBackend\Responses\BackendErrorResponse; + +/** + * @implements RequestHandlerInterface + */ +final class DomainNameResolutionCompleted implements RequestHandlerInterface +{ + /** + * @param DomainNameResolutionCompletedRequest $request + */ + public function handle($request): TestkitResponseInterface + { + return new BackendErrorResponse('Domain name resolution not implemented yet'); // TODO + } +} diff --git a/testkit-backend/src/Handlers/DriverClose.php b/testkit-backend/src/Handlers/DriverClose.php new file mode 100644 index 00000000..dd4c60c7 --- /dev/null +++ b/testkit-backend/src/Handlers/DriverClose.php @@ -0,0 +1,35 @@ + + */ +final class DriverClose implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param DriverCloseRequest $request + */ + public function handle($request): DriverResponse + { + $this->repository->removeDriver($request->getDriverId()); + + return new DriverResponse($request->getDriverId()); + } +} diff --git a/testkit-backend/src/Handlers/ForcedRoutingTableUpdate.php b/testkit-backend/src/Handlers/ForcedRoutingTableUpdate.php new file mode 100644 index 00000000..bde81633 --- /dev/null +++ b/testkit-backend/src/Handlers/ForcedRoutingTableUpdate.php @@ -0,0 +1,64 @@ + + * + * 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 Exception; +use Laudis\Neo4j\Contracts\ConnectionPoolInterface; +use Laudis\Neo4j\Neo4j\Neo4jConnectionPool; +use Laudis\Neo4j\Neo4j\Neo4jDriver; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\ForcedRoutingTableUpdateRequest; +use Laudis\Neo4j\TestkitBackend\Responses\DriverResponse; +use ReflectionClass; +use ReflectionException; + +/** + * @implements RequestHandlerInterface + */ +final class ForcedRoutingTableUpdate implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param ForcedRoutingTableUpdateRequest $request + * @throws ReflectionException + * @throws Exception + */ + public function handle($request): TestkitResponseInterface + { + $driver = $this->repository->getDriver($request->getDriverId()); + + if ($driver instanceof Neo4jDriver) { + $poolProperty = (new ReflectionClass(Neo4jDriver::class))->getProperty('pool'); + $poolProperty->setAccessible(true); + /** @var ConnectionPoolInterface $pool */ + $pool = $poolProperty->getValue($driver); + + $tableProperty = (new ReflectionClass(Neo4jConnectionPool::class))->getProperty('table'); + $tableProperty->setAccessible(true); + $tableProperty->setValue($pool, null); + } + + $driver->createSession()->run('RETURN 1 AS x'); + + return new DriverResponse($request->getDriverId()); + } +} diff --git a/testkit-backend/src/Handlers/GetFeatures.php b/testkit-backend/src/Handlers/GetFeatures.php new file mode 100644 index 00000000..2f7d1e28 --- /dev/null +++ b/testkit-backend/src/Handlers/GetFeatures.php @@ -0,0 +1,50 @@ + + * + * 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\Requests\GetFeaturesRequest; +use Laudis\Neo4j\TestkitBackend\Responses\FeatureListResponse; + +/** + * @implements RequestHandlerInterface + */ +final class GetFeatures implements RequestHandlerInterface +{ + /** @var iterable */ + private iterable $featuresConfig; + + /** + * @param iterable $featuresConfig + */ + public function __construct(iterable $featuresConfig) + { + $this->featuresConfig = $featuresConfig; + } + + /** + * @param GetFeaturesRequest $request + */ + public function handle($request): FeatureListResponse + { + $features = []; + foreach ($this->featuresConfig as $feature => $available) { + if ($available) { + $features[] = $feature; + } + } + + return new FeatureListResponse($features); + } +} diff --git a/testkit-backend/src/Handlers/GetRoutingTable.php b/testkit-backend/src/Handlers/GetRoutingTable.php new file mode 100644 index 00000000..94d9757d --- /dev/null +++ b/testkit-backend/src/Handlers/GetRoutingTable.php @@ -0,0 +1,74 @@ + + * + * 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 Exception; +use Laudis\Neo4j\Enum\RoutingRoles; +use Laudis\Neo4j\Neo4j\Neo4jConnectionPool; +use Laudis\Neo4j\Neo4j\Neo4jDriver; +use Laudis\Neo4j\Neo4j\RoutingTable; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\GetRoutingTableRequest; +use Laudis\Neo4j\TestkitBackend\Responses\FrontendErrorResponse; +use Laudis\Neo4j\TestkitBackend\Responses\RoutingTableResponse; +use ReflectionClass; +use ReflectionException; + +/** + * @implements RequestHandlerInterface + */ +final class GetRoutingTable implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param GetRoutingTableRequest $request + * + * @throws ReflectionException + * @throws Exception + */ + public function handle($request): TestkitResponseInterface + { + $driver = $this->repository->getDriver($request->getDriverId()); + + if ($driver instanceof Neo4jDriver) { + $poolProperty = (new ReflectionClass(Neo4jDriver::class))->getProperty('pool'); + $poolProperty->setAccessible(true); + /** @var Neo4jConnectionPool $pool */ + $pool = $poolProperty->getValue($driver); + + $tableProperty = (new ReflectionClass(Neo4jConnectionPool::class))->getProperty('table'); + $tableProperty->setAccessible(true); + /** @var RoutingTable $table */ + $table = $tableProperty->getValue($pool); + + return new RoutingTableResponse( + $request->getDatabase(), + $table->getTtl(), + $table->getWithRole(RoutingRoles::ROUTE()), + $table->getWithRole(RoutingRoles::FOLLOWER()), + $table->getWithRole(RoutingRoles::LEADER()) + ); + } + + return new FrontendErrorResponse('Only the neo4j scheme allows for a routing table'); + } +} diff --git a/testkit-backend/src/Handlers/NewDriver.php b/testkit-backend/src/Handlers/NewDriver.php new file mode 100644 index 00000000..090b9449 --- /dev/null +++ b/testkit-backend/src/Handlers/NewDriver.php @@ -0,0 +1,65 @@ + + * + * 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\Authentication\Authenticate; +use Laudis\Neo4j\Databags\DriverConfiguration; +use Laudis\Neo4j\DriverFactory; +use Laudis\Neo4j\Formatter\OGMFormatter; +use Laudis\Neo4j\Formatter\SummarizedResultFormatter; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\NewDriverRequest; +use Laudis\Neo4j\TestkitBackend\Responses\DriverResponse; +use Symfony\Component\Uid\Uuid; +use function var_export; + +/** + * @implements RequestHandlerInterface + */ +final class NewDriver implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param NewDriverRequest $request + */ + public function handle($request): DriverResponse + { + $user = $request->getAuthToken()->getPrincipal(); + $pass = $request->getAuthToken()->getCredentials(); + + $ua = $request->getUserAgent(); + $timeout = $request->getConnectionTimeoutMs(); + $config = DriverConfiguration::default(); + + if ($ua) { + $config = $config->withUserAgent($ua); + } + + $formatter = SummarizedResultFormatter::create(); + $authenticate = Authenticate::basic($user, $pass); + $driver = DriverFactory::create($request->getUri(), $config, $authenticate, $timeout, $formatter); + + $id = Uuid::v4(); + $this->repository->addDriver($id, $driver); + + return new DriverResponse($id); + } +} diff --git a/testkit-backend/src/Handlers/NewSession.php b/testkit-backend/src/Handlers/NewSession.php new file mode 100644 index 00000000..e059f1d9 --- /dev/null +++ b/testkit-backend/src/Handlers/NewSession.php @@ -0,0 +1,64 @@ + + * + * 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\Databags\SessionConfiguration; +use Laudis\Neo4j\Enum\AccessMode; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\NewSessionRequest; +use Laudis\Neo4j\TestkitBackend\Responses\SessionResponse; +use Symfony\Component\Uid\Uuid; + +/** + * @implements RequestHandlerInterface + */ +final class NewSession implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param NewSessionRequest $request + */ + public function handle($request): SessionResponse + { + $driver = $this->repository->getDriver($request->getDriverId()); + + $config = SessionConfiguration::default() + ->withAccessMode($request->getAccessMode() === 'r' ? AccessMode::READ() : AccessMode::WRITE()); + + if ($request->getBookmarks() !== null) { + $config = $config->withBookmarks($request->getBookmarks()); + } + + if ($request->getDatabase() !== null) { + $config = $config->withDatabase($request->getDatabase()); + } + + if ($request->getFetchSize() !== null) { + $config = $config->withFetchSize($request->getFetchSize()); + } + + $session = $driver->createSession($config); + $id = Uuid::v4(); + $this->repository->addSession($id, $session); + + return new SessionResponse($id); + } +} diff --git a/testkit-backend/src/Handlers/ResolverResolutionCompleted.php b/testkit-backend/src/Handlers/ResolverResolutionCompleted.php new file mode 100644 index 00000000..fd442a3a --- /dev/null +++ b/testkit-backend/src/Handlers/ResolverResolutionCompleted.php @@ -0,0 +1,25 @@ + + */ +final class ResolverResolutionCompleted implements RequestHandlerInterface +{ + /** + * @param ResolverResolutionCompletedRequest $request + */ + public function handle($request): TestkitResponseInterface + { + return new BackendErrorResponse('Resolver resolution not implemented yet'); // TODO + } +} diff --git a/testkit-backend/src/Handlers/ResultConsume.php b/testkit-backend/src/Handlers/ResultConsume.php new file mode 100644 index 00000000..9ce2d661 --- /dev/null +++ b/testkit-backend/src/Handlers/ResultConsume.php @@ -0,0 +1,49 @@ + + * + * 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\Databags\SummarizedResult; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\ResultConsumeRequest; +use Laudis\Neo4j\TestkitBackend\Responses\SummaryResponse; +use Psr\Http\Message\ResponseInterface; + +/** + * @implements RequestHandlerInterface + */ +final class ResultConsume implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param ResultConsumeRequest $request + */ + public function handle($request): TestkitResponseInterface + { + $result = $this->repository->getRecords($request->getResultId()); + + if ($result instanceof TestkitResponseInterface) { + return $result; + } + + return new SummaryResponse($result); + } +} diff --git a/testkit-backend/src/Handlers/ResultNext.php b/testkit-backend/src/Handlers/ResultNext.php new file mode 100644 index 00000000..6ea4fc9a --- /dev/null +++ b/testkit-backend/src/Handlers/ResultNext.php @@ -0,0 +1,63 @@ + + * + * 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\ResultNextRequest; +use Laudis\Neo4j\TestkitBackend\Responses\NullRecordResponse; +use Laudis\Neo4j\TestkitBackend\Responses\RecordResponse; +use Laudis\Neo4j\TestkitBackend\Responses\Types\CypherObject; + +/** + * @implements RequestHandlerInterface + */ +final class ResultNext implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param ResultNextRequest $request + */ + public function handle($request): TestkitResponseInterface + { + $record = $this->repository->getRecords($request->getResultId()); + if ($record instanceof TestkitResponseInterface) { + return $record; + } + + $iterator = $this->repository->getIterator($request->getResultId()); + + if (!$iterator->valid()) { + return new NullRecordResponse(); + } + + $current = $iterator->current(); + + $iterator->next(); + + $values = []; + foreach ($current as $value) { + $values[] = CypherObject::autoDetect($value); + } + + return new RecordResponse($values); + } +} diff --git a/testkit-backend/src/Handlers/RetryableNegative.php b/testkit-backend/src/Handlers/RetryableNegative.php new file mode 100644 index 00000000..6e9fcf7e --- /dev/null +++ b/testkit-backend/src/Handlers/RetryableNegative.php @@ -0,0 +1,25 @@ + + */ +final class RetryableNegative implements RequestHandlerInterface +{ + /** + * @param RetryableNegativeRequest $request + */ + public function handle($request): TestkitResponseInterface + { + return new BackendErrorResponse('Retryable negative not implemented yet'); // TODO + } +} diff --git a/testkit-backend/src/Handlers/RetryablePositive.php b/testkit-backend/src/Handlers/RetryablePositive.php new file mode 100644 index 00000000..3d61bd62 --- /dev/null +++ b/testkit-backend/src/Handlers/RetryablePositive.php @@ -0,0 +1,33 @@ + + * + * 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\Requests\RetryablePositiveRequest; +use Laudis\Neo4j\TestkitBackend\Responses\RetryableDoneResponse; + +/** + * @implements RequestHandlerInterface + */ +final class RetryablePositive implements RequestHandlerInterface +{ + /** + * @param RetryablePositiveRequest $request + */ + public function handle($request): TestkitResponseInterface + { + return new RetryableDoneResponse(); + } +} diff --git a/testkit-backend/src/Handlers/SessionBeginTransaction.php b/testkit-backend/src/Handlers/SessionBeginTransaction.php new file mode 100644 index 00000000..1f11b42f --- /dev/null +++ b/testkit-backend/src/Handlers/SessionBeginTransaction.php @@ -0,0 +1,63 @@ + + * + * 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\Databags\TransactionConfiguration; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\SessionBeginTransactionRequest; +use Laudis\Neo4j\TestkitBackend\Responses\RetryableTryResponse; +use Laudis\Neo4j\TestkitBackend\Responses\TransactionResponse; +use Symfony\Component\Uid\Uuid; + +/** + * @implements RequestHandlerInterface + */ +final class SessionBeginTransaction implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param SessionBeginTransactionRequest $request + */ + public function handle($request): TestkitResponseInterface + { + $session = $this->repository->getSession($request->getSessionId()); + + $config = TransactionConfiguration::default(); + + if ($request->getTimeout()) { + $config = $config->withTimeout($request->getTimeout()); + } + + if ($request->getTxMeta()) { + $config = $config->withMetaData($request->getTxMeta()); + } + + // TODO - Create beginReadTransaction and beginWriteTransaction + $transaction = $session->beginTransaction(null, $config); + $id = Uuid::v4(); + + $this->repository->addTransaction($id, $transaction); + $this->repository->bindTransactionToSession($request->getSessionId(), $id); + + return new TransactionResponse($id); + } +} diff --git a/testkit-backend/src/Handlers/SessionClose.php b/testkit-backend/src/Handlers/SessionClose.php new file mode 100644 index 00000000..4644058b --- /dev/null +++ b/testkit-backend/src/Handlers/SessionClose.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\TestkitBackend\Handlers; + +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\SessionCloseRequest; +use Laudis\Neo4j\TestkitBackend\Responses\SessionResponse; + +/** + * @implements RequestHandlerInterface + */ +final class SessionClose implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param SessionCloseRequest $request + */ + public function handle($request): SessionResponse + { + $this->repository->removeSession($request->getSessionId()); + + return new SessionResponse($request->getSessionId()); + } +} diff --git a/testkit-backend/src/Handlers/SessionLastBookmarks.php b/testkit-backend/src/Handlers/SessionLastBookmarks.php new file mode 100644 index 00000000..d7538611 --- /dev/null +++ b/testkit-backend/src/Handlers/SessionLastBookmarks.php @@ -0,0 +1,33 @@ + + * + * 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\Requests\SessionLastBookmarksRequest; +use Laudis\Neo4j\TestkitBackend\Responses\SkipTestResponse; + +/** + * @implements RequestHandlerInterface + */ +final class SessionLastBookmarks implements RequestHandlerInterface +{ + /** + * @param SessionLastBookmarksRequest $request + */ + public function handle($request): TestkitResponseInterface + { + return new SkipTestResponse('Bookmarks not implemented yet'); + } +} diff --git a/testkit-backend/src/Handlers/SessionReadTransaction.php b/testkit-backend/src/Handlers/SessionReadTransaction.php new file mode 100644 index 00000000..524d4aa5 --- /dev/null +++ b/testkit-backend/src/Handlers/SessionReadTransaction.php @@ -0,0 +1,62 @@ + + * + * 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\Databags\TransactionConfiguration; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\SessionReadTransactionRequest; +use Laudis\Neo4j\TestkitBackend\Responses\RetryableTryResponse; +use Symfony\Component\Uid\Uuid; + +/** + * @implements RequestHandlerInterface + */ +final class SessionReadTransaction implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param SessionReadTransactionRequest $request + */ + public function handle($request): TestkitResponseInterface + { + $session = $this->repository->getSession($request->getSessionId()); + + $config = TransactionConfiguration::default(); + + if ($request->getTimeout()) { + $config = $config->withTimeout($request->getTimeout()); + } + + if ($request->getTxMeta()) { + $config = $config->withMetaData($request->getTxMeta()); + } + + // TODO - Create beginReadTransaction and beginWriteTransaction + $transaction = $session->beginTransaction(null, $config); + $id = Uuid::v4(); + + $this->repository->addTransaction($id, $transaction); + $this->repository->bindTransactionToSession($request->getSessionId(), $id); + + return new RetryableTryResponse($id); + } +} diff --git a/testkit-backend/src/Handlers/SessionRun.php b/testkit-backend/src/Handlers/SessionRun.php new file mode 100644 index 00000000..28bca008 --- /dev/null +++ b/testkit-backend/src/Handlers/SessionRun.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\Handlers; + +use Laudis\Neo4j\Contracts\SessionInterface; +use Laudis\Neo4j\TestkitBackend\Requests\SessionRunRequest; +use Symfony\Component\Uid\Uuid; + +/** + * @extends AbstractRunner + */ +final class SessionRun extends AbstractRunner +{ + protected function getRunner($request): SessionInterface + { + return $this->repository->getSession($request->getSessionId()); + } + + protected function getId($request): Uuid + { + return $request->getSessionId(); + } +} diff --git a/testkit-backend/src/Handlers/SessionWriteTransaction.php b/testkit-backend/src/Handlers/SessionWriteTransaction.php new file mode 100644 index 00000000..672e433c --- /dev/null +++ b/testkit-backend/src/Handlers/SessionWriteTransaction.php @@ -0,0 +1,62 @@ + + * + * 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\Databags\TransactionConfiguration; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\SessionWriteTransactionRequest; +use Laudis\Neo4j\TestkitBackend\Responses\RetryableTryResponse; +use Symfony\Component\Uid\Uuid; + +/** + * @implements RequestHandlerInterface + */ +final class SessionWriteTransaction implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param SessionWriteTransactionRequest $request + */ + public function handle($request): TestkitResponseInterface + { + $session = $this->repository->getSession($request->getSessionId()); + + $config = TransactionConfiguration::default(); + + if ($request->getTimeout()) { + $config = $config->withTimeout($request->getTimeout()); + } + + if ($request->getTxMeta()) { + $config = $config->withMetaData($request->getTxMeta()); + } + + // TODO - Create beginReadTransaction and beginWriteTransaction + $transaction = $session->beginTransaction(null, $config); + $id = Uuid::v4(); + + $this->repository->addTransaction($id, $transaction); + $this->repository->bindTransactionToSession($request->getSessionId(), $id); + + return new RetryableTryResponse($id); + } +} diff --git a/testkit-backend/src/Handlers/StartTest.php b/testkit-backend/src/Handlers/StartTest.php new file mode 100644 index 00000000..9c9fbc23 --- /dev/null +++ b/testkit-backend/src/Handlers/StartTest.php @@ -0,0 +1,62 @@ + + * + * 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\Requests\StartTestRequest; +use Laudis\Neo4j\TestkitBackend\Responses\RunTestResponse; +use Laudis\Neo4j\TestkitBackend\Responses\SkipTestResponse; +use function is_string; + +/** + * @implements RequestHandlerInterface + */ +final class StartTest implements RequestHandlerInterface +{ + /** @var array */ + private array $acceptedTests; + + /** + * @param array $acceptedTests + */ + public function __construct(array $acceptedTests) + { + $this->acceptedTests = $acceptedTests; + } + + /** + * @param StartTestRequest $request + */ + public function handle($request): TestkitResponseInterface + { + $section = $this->acceptedTests; + foreach (explode('.', $request->getTestName()) as $key) { + if (isset($section[$key])) { + if ($section[$key] === false) { + return new SkipTestResponse('Test disabled in backend'); + } + if (is_string($section[$key])) { + return new SkipTestResponse($section[$key]); + } + /** @var array $section */ + $section = $section[$key]; + } else { + break; + } + } + + return new RunTestResponse(); + } +} diff --git a/testkit-backend/src/Handlers/TransactionCommit.php b/testkit-backend/src/Handlers/TransactionCommit.php new file mode 100644 index 00000000..6445a9b9 --- /dev/null +++ b/testkit-backend/src/Handlers/TransactionCommit.php @@ -0,0 +1,54 @@ + + * + * 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 ArrayIterator; +use Laudis\Neo4j\Exception\InvalidTransactionStateException; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\TransactionCommitRequest; +use Laudis\Neo4j\TestkitBackend\Responses\DriverErrorResponse; +use Laudis\Neo4j\TestkitBackend\Responses\ResultResponse; +use Laudis\Neo4j\TestkitBackend\Responses\TransactionResponse; +use Symfony\Component\Uid\Uuid; + +/** + * @implements RequestHandlerInterface + */ +final class TransactionCommit implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param TransactionCommitRequest $request + */ + public function handle($request): TestkitResponseInterface + { + $tsx = $this->repository->getTransaction($request->getTxId()); + + try { + $tsx->commit(); + } catch (InvalidTransactionStateException $e) { + return new DriverErrorResponse($request->getTxId(), '', $e->getMessage()); + } + + return new TransactionResponse($request->getTxId()); + } +} diff --git a/testkit-backend/src/Handlers/TransactionRollback.php b/testkit-backend/src/Handlers/TransactionRollback.php new file mode 100644 index 00000000..a44933ce --- /dev/null +++ b/testkit-backend/src/Handlers/TransactionRollback.php @@ -0,0 +1,54 @@ + + * + * 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 ArrayIterator; +use Laudis\Neo4j\Exception\InvalidTransactionStateException; +use Laudis\Neo4j\TestkitBackend\Contracts\RequestHandlerInterface; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\TestkitBackend\MainRepository; +use Laudis\Neo4j\TestkitBackend\Requests\TransactionRollbackRequest; +use Laudis\Neo4j\TestkitBackend\Responses\DriverErrorResponse; +use Laudis\Neo4j\TestkitBackend\Responses\ResultResponse; +use Laudis\Neo4j\TestkitBackend\Responses\TransactionResponse; +use Symfony\Component\Uid\Uuid; + +/** + * @implements RequestHandlerInterface + */ +final class TransactionRollback implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param TransactionRollbackRequest $request + */ + public function handle($request): TestkitResponseInterface + { + $tsx = $this->repository->getTransaction($request->getTxId()); + + try { + $tsx->rollback(); + } catch (InvalidTransactionStateException $e) { + return new DriverErrorResponse($request->getTxId(), '', $e->getMessage()); + } + + return new TransactionResponse($request->getTxId()); + } +} diff --git a/testkit-backend/src/Handlers/TransactionRun.php b/testkit-backend/src/Handlers/TransactionRun.php new file mode 100644 index 00000000..79d2caa2 --- /dev/null +++ b/testkit-backend/src/Handlers/TransactionRun.php @@ -0,0 +1,35 @@ + + * + * 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\Contracts\TransactionInterface; +use Laudis\Neo4j\TestkitBackend\Requests\TransactionRunRequest; +use Symfony\Component\Uid\Uuid; + + +/** + * @extends AbstractRunner + */ +final class TransactionRun extends AbstractRunner +{ + protected function getRunner($request): TransactionInterface + { + return $this->repository->getTransaction($request->getTxId()); + } + + protected function getId($request): Uuid + { + return $request->getTxId(); + } +} diff --git a/testkit-backend/src/Handlers/VerifyConnectivity.php b/testkit-backend/src/Handlers/VerifyConnectivity.php new file mode 100644 index 00000000..38bbb311 --- /dev/null +++ b/testkit-backend/src/Handlers/VerifyConnectivity.php @@ -0,0 +1,37 @@ + + */ +final class VerifyConnectivity implements RequestHandlerInterface +{ + private MainRepository $repository; + + public function __construct(MainRepository $repository) + { + $this->repository = $repository; + } + + /** + * @param VerifyConnectivityRequest $request + */ + public function handle($request): TestkitResponseInterface + { + $driver = $this->repository->getDriver($request->getDriverId()); + + $driver->createSession()->run('RETURN 2 as x'); + + return new DriverResponse($request->getDriverId()); + } +} diff --git a/testkit-backend/src/MainRepository.php b/testkit-backend/src/MainRepository.php new file mode 100644 index 00000000..de327045 --- /dev/null +++ b/testkit-backend/src/MainRepository.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend; + +use Iterator; +use Laudis\Neo4j\Contracts\DriverInterface; +use Laudis\Neo4j\Contracts\SessionInterface; +use Laudis\Neo4j\Contracts\UnmanagedTransactionInterface; +use Laudis\Neo4j\Databags\SummarizedResult; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\Types\CypherList; +use Laudis\Neo4j\Types\CypherMap; +use Symfony\Component\Uid\Uuid; + +/** + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Formatter\OGMFormatter + */ +final class MainRepository +{ + /** @var array>>> */ + private array $drivers; + /** @var array>>> */ + private array $sessions; + /** @var array>|TestkitResponseInterface> */ + private array $records; + /** @var array>> */ + private array $recordIterators; + /** @var array>>> */ + private array $transactions; + /** @var array */ + private array $sessionToTransactions = []; + + /** + * @param array>>> $drivers + * @param array>>> $sessions + * @param array>|TestkitResponseInterface> $records + * @param array>>> $transactions + */ + public function __construct(array $drivers, array $sessions, array $records, array $transactions) + { + $this->drivers = $drivers; + $this->sessions = $sessions; + $this->records = $records; + $this->transactions = $transactions; + $this->recordIterators = []; + } + + /** + * @param DriverInterface>> $driver + */ + public function addDriver(Uuid $id, DriverInterface $driver): void + { + $this->drivers[$id->toRfc4122()] = $driver; + } + + public function removeDriver(Uuid $id): void + { + unset($this->drivers[$id->toRfc4122()]); + } + + /** + * @return Iterator> + */ + public function getIterator(Uuid $id): Iterator + { + return $this->recordIterators[$id->toRfc4122()]; + } + + /** + * @return DriverInterface>> + */ + public function getDriver(Uuid $id): DriverInterface + { + return $this->drivers[$id->toRfc4122()]; + } + + /** + * @param SessionInterface>> $session + */ + public function addSession(Uuid $id, SessionInterface $session): void + { + $this->sessions[$id->toRfc4122()] = $session; + } + + public function removeSession(Uuid $id): void + { + unset($this->sessions[$id->toRfc4122()]); + } + + /** + * @return SessionInterface>> + */ + public function getSession(Uuid $id): SessionInterface + { + return $this->sessions[$id->toRfc4122()]; + } + + /** + * @param SummarizedResult>|TestkitResponseInterface $result + */ + public function addRecords(Uuid $id, $result): void + { + $this->records[$id->toRfc4122()] = $result; + if ($result instanceof SummarizedResult) { + /** @var SummarizedResult> $result */ + $this->recordIterators[$id->toRfc4122()] = $result->getIterator(); + } + } + + public function removeRecords(Uuid $id): void + { + unset($this->records[$id->toRfc4122()]); + } + + /** + * @return SummarizedResult>|TestkitResponseInterface + */ + public function getRecords(Uuid $id) + { + return $this->records[$id->toRfc4122()]; + } + + /** + * @param UnmanagedTransactionInterface>> $transaction + */ + public function addTransaction(Uuid $id, UnmanagedTransactionInterface $transaction): void + { + $this->transactions[$id->toRfc4122()] = $transaction; + } + + public function removeTransaction(Uuid $id): void + { + unset($this->transactions[$id->toRfc4122()]); + } + + /** + * @return UnmanagedTransactionInterface>> + */ + public function getTransaction(Uuid $id): UnmanagedTransactionInterface + { + return $this->transactions[$id->toRfc4122()]; + } + + public function bindTransactionToSession(Uuid $sessionId, Uuid $transactionId): void + { + $this->sessionToTransactions[$sessionId->toRfc4122()] = $transactionId; + } + + public function detachTransactionFromSession(Uuid $sessionId): void + { + unset($this->sessionToTransactions[$sessionId->toRfc4122()]); + } + + public function getTsxIdFromSession(Uuid $sessionId): Uuid + { + return $this->sessionToTransactions[$sessionId->toRfc4122()]; + } +} diff --git a/testkit-backend/src/Request.php b/testkit-backend/src/Request.php new file mode 100644 index 00000000..babd8392 --- /dev/null +++ b/testkit-backend/src/Request.php @@ -0,0 +1,13 @@ + + * + * 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 is_string; +use Laudis\Neo4j\TestkitBackend\Requests\AuthorizationTokenRequest; +use Laudis\Neo4j\TestkitBackend\Requests\CheckMultiDBSupportRequest; +use Laudis\Neo4j\TestkitBackend\Requests\DomainNameResolutionCompletedRequest; +use Laudis\Neo4j\TestkitBackend\Requests\DriverCloseRequest; +use Laudis\Neo4j\TestkitBackend\Requests\ForcedRoutingTableUpdateRequest; +use Laudis\Neo4j\TestkitBackend\Requests\GetFeaturesRequest; +use Laudis\Neo4j\TestkitBackend\Requests\GetRoutingTableRequest; +use Laudis\Neo4j\TestkitBackend\Requests\NewDriverRequest; +use Laudis\Neo4j\TestkitBackend\Requests\NewSessionRequest; +use Laudis\Neo4j\TestkitBackend\Requests\ResolverResolutionCompletedRequest; +use Laudis\Neo4j\TestkitBackend\Requests\ResultConsumeRequest; +use Laudis\Neo4j\TestkitBackend\Requests\ResultNextRequest; +use Laudis\Neo4j\TestkitBackend\Requests\RetryableNegativeRequest; +use Laudis\Neo4j\TestkitBackend\Requests\RetryablePositiveRequest; +use Laudis\Neo4j\TestkitBackend\Requests\SessionBeginTransactionRequest; +use Laudis\Neo4j\TestkitBackend\Requests\SessionCloseRequest; +use Laudis\Neo4j\TestkitBackend\Requests\SessionLastBookmarksRequest; +use Laudis\Neo4j\TestkitBackend\Requests\SessionReadTransactionRequest; +use Laudis\Neo4j\TestkitBackend\Requests\SessionRunRequest; +use Laudis\Neo4j\TestkitBackend\Requests\SessionWriteTransactionRequest; +use Laudis\Neo4j\TestkitBackend\Requests\StartTestRequest; +use Laudis\Neo4j\TestkitBackend\Requests\TransactionCommitRequest; +use Laudis\Neo4j\TestkitBackend\Requests\TransactionRollbackRequest; +use Laudis\Neo4j\TestkitBackend\Requests\TransactionRunRequest; +use Laudis\Neo4j\TestkitBackend\Requests\VerifyConnectivityRequest; +use Symfony\Component\Uid\Uuid; + +final class RequestFactory +{ + private const MAPPINGS = [ + 'StartTest' => StartTestRequest::class, + 'GetFeatures' => GetFeaturesRequest::class, + 'NewDriver' => NewDriverRequest::class, + 'AuthorizationToken' => AuthorizationTokenRequest::class, + 'VerifyConnectivity' => VerifyConnectivityRequest::class, + 'CheckMultiDBSupport' => CheckMultiDBSupportRequest::class, + 'ResolverResolutionCompleted' => ResolverResolutionCompletedRequest::class, + 'DomainNameResolutionCompleted' => DomainNameResolutionCompletedRequest::class, + 'DriverClose' => DriverCloseRequest::class, + 'NewSession' => NewSessionRequest::class, + 'SessionClose' => SessionCloseRequest::class, + 'SessionRun' => SessionRunRequest::class, + 'SessionReadTransaction' => SessionReadTransactionRequest::class, + 'SessionWriteTransaction' => SessionWriteTransactionRequest::class, + 'SessionBeginTransaction' => SessionBeginTransactionRequest::class, + 'SessionLastBookmarks' => SessionLastBookmarksRequest::class, + 'TransactionRun' => TransactionRunRequest::class, + 'TransactionCommit' => TransactionCommitRequest::class, + 'TransactionRollback' => TransactionRollbackRequest::class, + 'ResultNext' => ResultNextRequest::class, + 'ResultConsume' => ResultConsumeRequest::class, + 'RetryablePositive' => RetryablePositiveRequest::class, + 'RetryableNegative' => RetryableNegativeRequest::class, + 'ForcedRoutingTableUpdate' => ForcedRoutingTableUpdateRequest::class, + 'GetRoutingTable' => GetRoutingTableRequest::class, + ]; + + /** + * @param iterable $data + */ + public function create(string $name, iterable $data): object + { + $class = self::MAPPINGS[$name]; + + $params = []; + foreach ($data as $value) { + if (is_array($value) && isset($value['name'], $value['data'])) { + /** @psalm-suppress MixedArgument */ + $params[] = $this->create($value['name'], $value['data']); + } elseif (is_string($value) && Uuid::isValid($value)) { + $params[] = Uuid::fromString($value); + } else { + $params[] = $value; + } + } + + return new $class(...$params); + } +} diff --git a/testkit-backend/src/Requests/AuthorizationTokenRequest.php b/testkit-backend/src/Requests/AuthorizationTokenRequest.php new file mode 100644 index 00000000..7879409a --- /dev/null +++ b/testkit-backend/src/Requests/AuthorizationTokenRequest.php @@ -0,0 +1,62 @@ + + * + * 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 AuthorizationTokenRequest +{ + private string $scheme; + private string $principal; + private string $credentials; + private string $realm; + private string $ticket; + + public function __construct( + string $scheme, + string $principal, + string $credentials, + string $realm = null, + string $ticket = null + ) { + $this->scheme = $scheme; + $this->principal = $principal; + $this->credentials = $credentials; + $this->realm = $realm ?? ''; + $this->ticket = $ticket ?? ''; + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getPrincipal(): string + { + return $this->principal; + } + + public function getCredentials(): string + { + return $this->credentials; + } + + public function getRealm(): string + { + return $this->realm; + } + + public function getTicket(): string + { + return $this->ticket; + } +} diff --git a/testkit-backend/src/Requests/CheckMultiDBSupportRequest.php b/testkit-backend/src/Requests/CheckMultiDBSupportRequest.php new file mode 100644 index 00000000..9623323c --- /dev/null +++ b/testkit-backend/src/Requests/CheckMultiDBSupportRequest.php @@ -0,0 +1,23 @@ +driverId = $driverId; + } + + public function getDriverId(): Uuid + { + return $this->driverId; + } +} diff --git a/testkit-backend/src/Requests/DomainNameResolutionCompletedRequest.php b/testkit-backend/src/Requests/DomainNameResolutionCompletedRequest.php new file mode 100644 index 00000000..e7779097 --- /dev/null +++ b/testkit-backend/src/Requests/DomainNameResolutionCompletedRequest.php @@ -0,0 +1,37 @@ + */ + private iterable $addresses; + + /** + * @param iterable $addresses + */ + public function __construct(Uuid $requestId, iterable $addresses) + { + $this->requestId = $requestId; + $this->addresses = $addresses; + } + + public function getRequestId(): Uuid + { + return $this->requestId; + } + + /** + * @return iterable + */ + public function getAddresses(): iterable + { + return $this->addresses; + } +} diff --git a/testkit-backend/src/Requests/DriverCloseRequest.php b/testkit-backend/src/Requests/DriverCloseRequest.php new file mode 100644 index 00000000..44143886 --- /dev/null +++ b/testkit-backend/src/Requests/DriverCloseRequest.php @@ -0,0 +1,23 @@ +driverId = $driverId; + } + + public function getDriverId(): Uuid + { + return $this->driverId; + } +} diff --git a/testkit-backend/src/Requests/ForcedRoutingTableUpdateRequest.php b/testkit-backend/src/Requests/ForcedRoutingTableUpdateRequest.php new file mode 100644 index 00000000..d63a4e17 --- /dev/null +++ b/testkit-backend/src/Requests/ForcedRoutingTableUpdateRequest.php @@ -0,0 +1,44 @@ + */ + private ?iterable $bookmarks; + + /** + * @param iterable $bookmarks + */ + public function __construct(Uuid $driverId, ?string $database, ?iterable $bookmarks) + { + $this->driverId = $driverId; + $this->database = $database; + $this->bookmarks = $bookmarks; + } + + public function getDriverId(): Uuid + { + return $this->driverId; + } + + public function getDatabase(): ?string + { + return $this->database; + } + + /** + * @return iterable + */ + public function getBookmarks(): ?iterable + { + return $this->bookmarks; + } +} diff --git a/testkit-backend/src/Requests/GetFeaturesRequest.php b/testkit-backend/src/Requests/GetFeaturesRequest.php new file mode 100644 index 00000000..fe75b4da --- /dev/null +++ b/testkit-backend/src/Requests/GetFeaturesRequest.php @@ -0,0 +1,8 @@ +driverId = $driverId; + $this->database = $database; + } + + public function getDriverId(): Uuid + { + return $this->driverId; + } + + public function getDatabase(): ?string + { + return $this->database; + } +} diff --git a/testkit-backend/src/Requests/NewDriverRequest.php b/testkit-backend/src/Requests/NewDriverRequest.php new file mode 100644 index 00000000..7da514d4 --- /dev/null +++ b/testkit-backend/src/Requests/NewDriverRequest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +final class NewDriverRequest +{ + private string $uri; + private AuthorizationTokenRequest $authToken; + private ?string $userAgent; + private ?bool $resolverRegistered; + private ?bool $domainNameResolverRegistered; + private ?int $connectionTimeoutMs; + + public function __construct( + string $uri, + AuthorizationTokenRequest $authToken, + ?string $userAgent = null, + ?bool $resolverRegistered = null, + ?bool $domainNameResolverRegistered = null, + ?int $connectionTimeoutMs = null + ) { + $this->uri = $uri; + $this->authToken = $authToken; + $this->userAgent = $userAgent; + $this->resolverRegistered = $resolverRegistered; + $this->domainNameResolverRegistered = $domainNameResolverRegistered; + $this->connectionTimeoutMs = $connectionTimeoutMs; + } + + public function getUri(): string + { + return $this->uri; + } + + public function getAuthToken(): AuthorizationTokenRequest + { + return $this->authToken; + } + + public function getUserAgent(): ?string + { + return $this->userAgent; + } + + public function isResolverRegistered(): ?bool + { + return $this->resolverRegistered; + } + + public function isDomainNameResolverRegistered(): ?bool + { + return $this->domainNameResolverRegistered; + } + + public function getConnectionTimeoutMs(): ?int + { + return $this->connectionTimeoutMs; + } +} diff --git a/testkit-backend/src/Requests/NewSessionRequest.php b/testkit-backend/src/Requests/NewSessionRequest.php new file mode 100644 index 00000000..efcea65a --- /dev/null +++ b/testkit-backend/src/Requests/NewSessionRequest.php @@ -0,0 +1,71 @@ + + * + * 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 Symfony\Component\Uid\Uuid; + +final class NewSessionRequest +{ + private Uuid $driverId; + private string $accessMode; + /** @var iterable|null */ + private ?iterable $bookmarks; + private ?string $database; + private ?int $fetchSize; + + /** + * @param iterable|null $bookmarks + */ + public function __construct( + Uuid $driverId, + string $accessMode, + ?iterable $bookmarks, + ?string $database, + ?int $fetchSize + ) { + $this->driverId = $driverId; + $this->accessMode = $accessMode; + $this->bookmarks = $bookmarks; + $this->database = $database; + $this->fetchSize = $fetchSize; + } + + public function getDriverId(): Uuid + { + return $this->driverId; + } + + public function getAccessMode(): string + { + return $this->accessMode; + } + + /** + * @return iterable|null + */ + public function getBookmarks(): ?iterable + { + return $this->bookmarks; + } + + public function getDatabase(): ?string + { + return $this->database; + } + + public function getFetchSize(): ?int + { + return $this->fetchSize; + } +} diff --git a/testkit-backend/src/Requests/ResolverResolutionCompletedRequest.php b/testkit-backend/src/Requests/ResolverResolutionCompletedRequest.php new file mode 100644 index 00000000..e39c5f83 --- /dev/null +++ b/testkit-backend/src/Requests/ResolverResolutionCompletedRequest.php @@ -0,0 +1,37 @@ + */ + private iterable $addresses; + + /** + * @param iterable $addresses + */ + public function __construct(Uuid $requestId, iterable $addresses) + { + $this->requestId = $requestId; + $this->addresses = $addresses; + } + + public function getRequestId(): Uuid + { + return $this->requestId; + } + + /** + * @return iterable + */ + public function getAddresses(): iterable + { + return $this->addresses; + } +} diff --git a/testkit-backend/src/Requests/ResultConsumeRequest.php b/testkit-backend/src/Requests/ResultConsumeRequest.php new file mode 100644 index 00000000..f2331ab2 --- /dev/null +++ b/testkit-backend/src/Requests/ResultConsumeRequest.php @@ -0,0 +1,23 @@ +resultId = $resultId; + } + + public function getResultId(): Uuid + { + return $this->resultId; + } +} diff --git a/testkit-backend/src/Requests/ResultNextRequest.php b/testkit-backend/src/Requests/ResultNextRequest.php new file mode 100644 index 00000000..97797b7e --- /dev/null +++ b/testkit-backend/src/Requests/ResultNextRequest.php @@ -0,0 +1,23 @@ +resultId = $resultId; + } + + public function getResultId(): Uuid + { + return $this->resultId; + } +} diff --git a/testkit-backend/src/Requests/RetryableNegativeRequest.php b/testkit-backend/src/Requests/RetryableNegativeRequest.php new file mode 100644 index 00000000..43eb2a50 --- /dev/null +++ b/testkit-backend/src/Requests/RetryableNegativeRequest.php @@ -0,0 +1,38 @@ +sessionId = $sessionId; + $this->errorId = $errorId; + } + + public function getSessionId(): Uuid + { + return $this->sessionId; + } + + /** + * @return Uuid|string + */ + public function getErrorId() + { + return $this->errorId; + } +} diff --git a/testkit-backend/src/Requests/RetryablePositiveRequest.php b/testkit-backend/src/Requests/RetryablePositiveRequest.php new file mode 100644 index 00000000..08c26a03 --- /dev/null +++ b/testkit-backend/src/Requests/RetryablePositiveRequest.php @@ -0,0 +1,23 @@ +sessionId = $sessionId; + } + + public function getSessionId(): Uuid + { + return $this->sessionId; + } +} diff --git a/testkit-backend/src/Requests/SessionBeginTransactionRequest.php b/testkit-backend/src/Requests/SessionBeginTransactionRequest.php new file mode 100644 index 00000000..f60d3a51 --- /dev/null +++ b/testkit-backend/src/Requests/SessionBeginTransactionRequest.php @@ -0,0 +1,56 @@ + + * + * 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\Databags\TransactionConfiguration; +use Symfony\Component\Uid\Uuid; + +final class SessionBeginTransactionRequest +{ + private Uuid $sessionId; + /** @var iterable|null */ + private ?iterable $txMeta; + private ?int $timeout; + + /** + * @param iterable|null $txMeta + */ + public function __construct( + Uuid $sessionId, + ?iterable $txMeta = null, + ?int $timeout = null + ) { + $this->sessionId = $sessionId; + $this->txMeta = $txMeta; + $this->timeout = $timeout; + } + + public function getSessionId(): Uuid + { + return $this->sessionId; + } + + /** + * @return iterable + */ + public function getTxMeta(): iterable + { + return $this->txMeta ?? []; + } + + public function getTimeout(): int + { + return (int) ($this->timeout ?? TransactionConfiguration::DEFAULT_TIMEOUT); + } +} diff --git a/testkit-backend/src/Requests/SessionCloseRequest.php b/testkit-backend/src/Requests/SessionCloseRequest.php new file mode 100644 index 00000000..53c44ea8 --- /dev/null +++ b/testkit-backend/src/Requests/SessionCloseRequest.php @@ -0,0 +1,23 @@ +sessionId = $sessionId; + } + + public function getSessionId(): Uuid + { + return $this->sessionId; + } +} diff --git a/testkit-backend/src/Requests/SessionLastBookmarksRequest.php b/testkit-backend/src/Requests/SessionLastBookmarksRequest.php new file mode 100644 index 00000000..95215520 --- /dev/null +++ b/testkit-backend/src/Requests/SessionLastBookmarksRequest.php @@ -0,0 +1,23 @@ +sessionId = $sessionId; + } + + public function getSessionId(): Uuid + { + return $this->sessionId; + } +} diff --git a/testkit-backend/src/Requests/SessionReadTransactionRequest.php b/testkit-backend/src/Requests/SessionReadTransactionRequest.php new file mode 100644 index 00000000..c0c65665 --- /dev/null +++ b/testkit-backend/src/Requests/SessionReadTransactionRequest.php @@ -0,0 +1,55 @@ + + * + * 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 Symfony\Component\Uid\Uuid; + +final class SessionReadTransactionRequest +{ + private Uuid $sessionId; + /** @var iterable */ + private iterable $txMeta; + private ?int $timeout; + + /** + * @param iterable|null $txMeta + */ + public function __construct( + Uuid $sessionId, + ?iterable $txMeta = null, + ?int $timeout = null + ) { + $this->sessionId = $sessionId; + $this->txMeta = $txMeta ?? []; + $this->timeout = $timeout; + } + + public function getSessionId(): Uuid + { + return $this->sessionId; + } + + /** + * @return iterable + */ + public function getTxMeta(): iterable + { + return $this->txMeta; + } + + public function getTimeout(): ?int + { + return $this->timeout; + } +} diff --git a/testkit-backend/src/Requests/SessionRunRequest.php b/testkit-backend/src/Requests/SessionRunRequest.php new file mode 100644 index 00000000..5cc51e93 --- /dev/null +++ b/testkit-backend/src/Requests/SessionRunRequest.php @@ -0,0 +1,71 @@ + + * + * 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 Symfony\Component\Uid\Uuid; + +final class SessionRunRequest +{ + private Uuid $sessionId; + private string $cypher; + /** @var iterable */ + private iterable $params; + /** @var iterable|null */ + private ?iterable $txMeta; + private ?int $timeout; + + /** + * @param iterable|null $params + * @param iterable|null $txMeta + */ + public function __construct(Uuid $sessionId, string $cypher, ?iterable $params, ?iterable $txMeta, ?int $timeout) + { + $this->sessionId = $sessionId; + $this->cypher = $cypher; + $this->params = $params ?? []; + $this->txMeta = $txMeta; + $this->timeout = $timeout; + } + + public function getSessionId(): Uuid + { + return $this->sessionId; + } + + public function getCypher(): string + { + return $this->cypher; + } + + /** + * @return iterable + */ + public function getParams(): iterable + { + return $this->params; + } + + /** + * @return iterable|null + */ + public function getTxMeta(): ?iterable + { + return $this->txMeta; + } + + public function getTimeout(): ?int + { + return $this->timeout; + } +} diff --git a/testkit-backend/src/Requests/SessionWriteTransactionRequest.php b/testkit-backend/src/Requests/SessionWriteTransactionRequest.php new file mode 100644 index 00000000..56d9b7aa --- /dev/null +++ b/testkit-backend/src/Requests/SessionWriteTransactionRequest.php @@ -0,0 +1,55 @@ + + * + * 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 Symfony\Component\Uid\Uuid; + +final class SessionWriteTransactionRequest +{ + private Uuid $sessionId; + /** @var iterable */ + private iterable $txMeta; + private ?int $timeout; + + /** + * @param iterable|null $txMeta + */ + public function __construct( + Uuid $sessionId, + ?iterable $txMeta = null, + ?int $timeout = null + ) { + $this->sessionId = $sessionId; + $this->txMeta = $txMeta ?? []; + $this->timeout = $timeout; + } + + public function getSessionId(): Uuid + { + return $this->sessionId; + } + + /** + * @return iterable + */ + public function getTxMeta(): iterable + { + return $this->txMeta; + } + + public function getTimeout(): ?int + { + return $this->timeout; + } +} diff --git a/testkit-backend/src/Requests/StartTestRequest.php b/testkit-backend/src/Requests/StartTestRequest.php new file mode 100644 index 00000000..ef392f23 --- /dev/null +++ b/testkit-backend/src/Requests/StartTestRequest.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; + +final class StartTestRequest +{ + private string $testName; + + public function __construct(string $testName) + { + $this->testName = $testName; + } + + public function getTestName(): string + { + return $this->testName; + } +} diff --git a/testkit-backend/src/Requests/TransactionCommitRequest.php b/testkit-backend/src/Requests/TransactionCommitRequest.php new file mode 100644 index 00000000..1563731e --- /dev/null +++ b/testkit-backend/src/Requests/TransactionCommitRequest.php @@ -0,0 +1,23 @@ +txId = $txId; + } + + public function getTxId(): Uuid + { + return $this->txId; + } +} diff --git a/testkit-backend/src/Requests/TransactionRollbackRequest.php b/testkit-backend/src/Requests/TransactionRollbackRequest.php new file mode 100644 index 00000000..0a401e5b --- /dev/null +++ b/testkit-backend/src/Requests/TransactionRollbackRequest.php @@ -0,0 +1,23 @@ +txId = $txId; + } + + public function getTxId(): Uuid + { + return $this->txId; + } +} diff --git a/testkit-backend/src/Requests/TransactionRunRequest.php b/testkit-backend/src/Requests/TransactionRunRequest.php new file mode 100644 index 00000000..e1a380fb --- /dev/null +++ b/testkit-backend/src/Requests/TransactionRunRequest.php @@ -0,0 +1,46 @@ + */ + private iterable $params; + + /** + * @param Uuid $txId + * @param string $cypher + * @param iterable|null $params + */ + public function __construct(Uuid $txId, string $cypher, ?iterable $params = null) + { + $this->txId = $txId; + $this->cypher = $cypher; + $this->params = $params ?? []; + } + + public function getTxId(): Uuid + { + return $this->txId; + } + + public function getCypher(): string + { + return $this->cypher; + } + + /** + * @return iterable + */ + public function getParams(): iterable + { + return $this->params; + } +} diff --git a/testkit-backend/src/Requests/VerifyConnectivityRequest.php b/testkit-backend/src/Requests/VerifyConnectivityRequest.php new file mode 100644 index 00000000..c461a8de --- /dev/null +++ b/testkit-backend/src/Requests/VerifyConnectivityRequest.php @@ -0,0 +1,23 @@ +driverId = $driverId; + } + + public function getDriverId(): Uuid + { + return $this->driverId; + } +} diff --git a/testkit-backend/src/Responses/BackendErrorResponse.php b/testkit-backend/src/Responses/BackendErrorResponse.php new file mode 100644 index 00000000..6a7a72c1 --- /dev/null +++ b/testkit-backend/src/Responses/BackendErrorResponse.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\TestkitBackend\Responses; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; + +/** + * Indicates an internal error has occurred. + */ +final class BackendErrorResponse implements TestkitResponseInterface +{ + private string $message; + + public function __construct(string $message) + { + $this->message = $message; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'BackendError', + 'data' => [ + 'msg' => $this->message, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/BookmarksResponse.php b/testkit-backend/src/Responses/BookmarksResponse.php new file mode 100644 index 00000000..17e16b8a --- /dev/null +++ b/testkit-backend/src/Responses/BookmarksResponse.php @@ -0,0 +1,45 @@ + + * + * 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; + +/** + * Represents an array of bookmarks. + */ +final class BookmarksResponse implements TestkitResponseInterface +{ + /** + * @var iterable + */ + private iterable $bookmarks; + + /** + * @param iterable $bookmarks + */ + public function __construct(iterable $bookmarks) + { + $this->bookmarks = $bookmarks; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'Bookmarks', + 'data' => [ + 'bookmarks' => $this->bookmarks, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/DomainNameResolutionRequiredResponse.php b/testkit-backend/src/Responses/DomainNameResolutionRequiredResponse.php new file mode 100644 index 00000000..a61e9b72 --- /dev/null +++ b/testkit-backend/src/Responses/DomainNameResolutionRequiredResponse.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\TestkitResponseInterface; +use Symfony\Component\Uid\Uuid; + +/** + * Represents a need for new address resolution. + * + * This means that the backend expects the frontend to call the resolver function and submit a new request + * with the results of it. + */ +final class DomainNameResolutionRequiredResponse implements TestkitResponseInterface +{ + private Uuid $id; + private string $name; + + public function __construct(Uuid $id, string $name) + { + $this->id = $id; + $this->name = $name; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'DomainNameResolutionRequired', + 'data' => [ + 'id' => $this->id->toRfc4122(), + 'name' => $this->name, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/DriverErrorResponse.php b/testkit-backend/src/Responses/DriverErrorResponse.php new file mode 100644 index 00000000..cf0a03e9 --- /dev/null +++ b/testkit-backend/src/Responses/DriverErrorResponse.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\TestkitResponseInterface; +use Symfony\Component\Uid\Uuid; + +/** + * Base class for all kind of driver errors that is NOT a backend specific error. + */ +final class DriverErrorResponse implements TestkitResponseInterface +{ + private Uuid $id; + private string $errorType; + private string $message; + + public function __construct(Uuid $id, string $errorType, string $message) + { + $this->id = $id; + $this->errorType = $errorType; + $this->message = $message; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'DriverError', + 'data' => [ + 'id' => $this->id->toRfc4122(), + 'errorType' => $this->errorType, + 'msg' => $this->message, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/DriverResponse.php b/testkit-backend/src/Responses/DriverResponse.php new file mode 100644 index 00000000..3988a6e7 --- /dev/null +++ b/testkit-backend/src/Responses/DriverResponse.php @@ -0,0 +1,31 @@ +id = $id; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'Driver', + 'data' => [ + 'id' => $this->id->toRfc4122(), + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/FeatureListResponse.php b/testkit-backend/src/Responses/FeatureListResponse.php new file mode 100644 index 00000000..bfa35785 --- /dev/null +++ b/testkit-backend/src/Responses/FeatureListResponse.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\TestkitBackend\Responses; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; + +/** + * Indicates the features the driver supports. + */ +final class FeatureListResponse implements TestkitResponseInterface +{ + private array $features; + + public function __construct(array $features) + { + $this->features = $features; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'FeatureList', + 'data' => [ + 'features' => $this->features, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/FrontendErrorResponse.php b/testkit-backend/src/Responses/FrontendErrorResponse.php new file mode 100644 index 00000000..99bd8434 --- /dev/null +++ b/testkit-backend/src/Responses/FrontendErrorResponse.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\TestkitBackend\Responses; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; + +/** + * Represents an error originating from client code. + */ +final class FrontendErrorResponse implements TestkitResponseInterface +{ + private string $message; + + public function __construct(string $message) + { + $this->message = $message; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'FrontendError', + 'data' => [ + 'msg' => $this->message, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/MultiDBSupportResponse.php b/testkit-backend/src/Responses/MultiDBSupportResponse.php new file mode 100644 index 00000000..8214aa20 --- /dev/null +++ b/testkit-backend/src/Responses/MultiDBSupportResponse.php @@ -0,0 +1,43 @@ + + * + * 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; +use Symfony\Component\Uid\Uuid; + +/** + * Specifies whether the server or cluster the driver connects to supports multi-databases. + */ +final class MultiDBSupportResponse implements TestkitResponseInterface +{ + private Uuid $id; + private bool $available; + + public function __construct(Uuid $id, bool $available) + { + $this->id = $id; + $this->available = $available; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'MultiDBSupport', + 'data' => [ + 'id' => $this->id, + 'available' => $this->available, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/NullRecordResponse.php b/testkit-backend/src/Responses/NullRecordResponse.php new file mode 100644 index 00000000..dac25627 --- /dev/null +++ b/testkit-backend/src/Responses/NullRecordResponse.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\Responses; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; + +/** + * Represents the record end when iterating through it. + */ +final class NullRecordResponse implements TestkitResponseInterface +{ + public function jsonSerialize(): array + { + return [ + 'name' => 'NullRecord', + ]; + } +} diff --git a/testkit-backend/src/Responses/RecordResponse.php b/testkit-backend/src/Responses/RecordResponse.php new file mode 100644 index 00000000..13e49418 --- /dev/null +++ b/testkit-backend/src/Responses/RecordResponse.php @@ -0,0 +1,45 @@ + + * + * 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; + +/** + * Represents a record from a result. + */ +final class RecordResponse implements TestkitResponseInterface +{ + /** + * @var iterable + */ + private iterable $values; + + /** + * @param iterable $values + */ + public function __construct(iterable $values) + { + $this->values = $values; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'Record', + 'data' => [ + 'values' => $this->values, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/ResolverResolutionRequiredResponse.php b/testkit-backend/src/Responses/ResolverResolutionRequiredResponse.php new file mode 100644 index 00000000..e09afa79 --- /dev/null +++ b/testkit-backend/src/Responses/ResolverResolutionRequiredResponse.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\TestkitResponseInterface; +use Symfony\Component\Uid\Uuid; + +/** + * Represents a need for new address resolution. + * + * This means that the backend is expecting the frontend to call the resolver function and submit a new request + * with the results of it. + */ +final class ResolverResolutionRequiredResponse implements TestkitResponseInterface +{ + private Uuid $id; + private string $address; + + public function __construct(Uuid $id, string $address) + { + $this->id = $id; + $this->address = $address; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'ResolverResolutionRequired', + 'data' => [ + 'id' => $this->id->toRfc4122(), + 'address' => $this->address, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/ResultResponse.php b/testkit-backend/src/Responses/ResultResponse.php new file mode 100644 index 00000000..91001b1e --- /dev/null +++ b/testkit-backend/src/Responses/ResultResponse.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\TestkitResponseInterface; +use Symfony\Component\Uid\Uuid; + +/** + * Represents a result instance on the backend. + */ +final class ResultResponse implements TestkitResponseInterface +{ + private Uuid $id; + private iterable $keys; + + /** + * @param iterable $keys + */ + public function __construct(Uuid $id, iterable $keys) + { + $this->id = $id; + $this->keys = $keys; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'Result', + 'data' => [ + 'id' => $this->id->toRfc4122(), + 'keys' => $this->keys, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/RetryableDoneResponse.php b/testkit-backend/src/Responses/RetryableDoneResponse.php new file mode 100644 index 00000000..41b2e9e1 --- /dev/null +++ b/testkit-backend/src/Responses/RetryableDoneResponse.php @@ -0,0 +1,31 @@ + + * + * 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; +use Symfony\Component\Uid\Uuid; + +/** + * Indicates a retryable transaction is successfully committed. + */ +final class RetryableDoneResponse implements TestkitResponseInterface +{ + public function jsonSerialize(): array + { + return [ + 'name' => 'RetryableDone', + 'data' => [], + ]; + } +} diff --git a/testkit-backend/src/Responses/RetryableTryResponse.php b/testkit-backend/src/Responses/RetryableTryResponse.php new file mode 100644 index 00000000..a2251776 --- /dev/null +++ b/testkit-backend/src/Responses/RetryableTryResponse.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\TestkitResponseInterface; +use Symfony\Component\Uid\Uuid; + +/** + * Represents a retryable transaction. The backend created a transaction and will use a retryable function. + * All further requests will be applied through that retryable function. + */ +final class RetryableTryResponse implements TestkitResponseInterface +{ + private Uuid $id; + + public function __construct(Uuid $id) + { + $this->id = $id; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'RetryableTry', + 'data' => [ + 'id' => $this->id->toRfc4122(), + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/RoutingTableResponse.php b/testkit-backend/src/Responses/RoutingTableResponse.php new file mode 100644 index 00000000..05c950fb --- /dev/null +++ b/testkit-backend/src/Responses/RoutingTableResponse.php @@ -0,0 +1,59 @@ + + * + * 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; + +/** + * Represents the full routing table. + */ +final class RoutingTableResponse implements TestkitResponseInterface +{ + private ?string $database; + private int $ttl; + /** @var iterable */ + private iterable $routers; + /** @var iterable */ + private iterable $readers; + /** @var iterable */ + private iterable $writers; + + /** + * @param iterable $routers + * @param iterable $readers + * @param iterable $writers + */ + public function __construct(?string $database, int $ttl, iterable $routers, iterable $readers, iterable $writers) + { + $this->database = $database; + $this->ttl = $ttl; + $this->routers = $routers; + $this->readers = $readers; + $this->writers = $writers; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'RoutingTable', + 'data' => [ + 'database' => $this->database, + 'ttl' => $this->ttl, + 'routers' => $this->routers, + 'readers' => $this->readers, + 'writers' => $this->writers, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/RunTestResponse.php b/testkit-backend/src/Responses/RunTestResponse.php new file mode 100644 index 00000000..6be51777 --- /dev/null +++ b/testkit-backend/src/Responses/RunTestResponse.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\Responses; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; + +/** + * Indicates the test can start. + */ +final class RunTestResponse implements TestkitResponseInterface +{ + public function jsonSerialize(): array + { + return [ + 'name' => 'RunTest', + ]; + } +} diff --git a/testkit-backend/src/Responses/SessionResponse.php b/testkit-backend/src/Responses/SessionResponse.php new file mode 100644 index 00000000..3cb844fc --- /dev/null +++ b/testkit-backend/src/Responses/SessionResponse.php @@ -0,0 +1,32 @@ +id = $id; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'Session', + 'data' => [ + 'id' => $this->id->toRfc4122(), + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/SkipTestResponse.php b/testkit-backend/src/Responses/SkipTestResponse.php new file mode 100644 index 00000000..e2ebd302 --- /dev/null +++ b/testkit-backend/src/Responses/SkipTestResponse.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\TestkitBackend\Responses; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; + +/** + * Indicates the test should be skipped. + */ +final class SkipTestResponse implements TestkitResponseInterface +{ + private string $reason; + + public function __construct(string $reason) + { + $this->reason = $reason; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'SkipTest', + 'data' => [ + 'reason' => $this->reason, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/SummaryResponse.php b/testkit-backend/src/Responses/SummaryResponse.php new file mode 100644 index 00000000..f21ae5a4 --- /dev/null +++ b/testkit-backend/src/Responses/SummaryResponse.php @@ -0,0 +1,51 @@ + + * + * 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\Databags\SummarizedResult; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; + +/** + * Represents summary when consuming a result. + */ +final class SummaryResponse implements TestkitResponseInterface +{ + private SummarizedResult $result; + + public function __construct(SummarizedResult $result) + { + $this->result = $result; + } + + public function jsonSerialize(): array + { + $summary = $this->result->getSummary(); + + return [ + 'name' => 'Summary', + 'data' => [ + 'counters' => $summary->getCounters(), + 'database' => $summary->getDatabaseInfo()->getName(), + 'notifications' => $summary->getNotifications(), + 'plan' => $summary->getPlan(), + 'profile' => $summary->getProfiledPlan(), + 'query' => $summary->getStatement(), + 'queryType' => $summary->getQueryType(), + 'resultAvailableAfter' => $summary->getResultAvailableAfter(), + 'resultConsumedAfter' => $summary->getResultConsumedAfter(), + 'serverInfo' => $summary->getServerInfo(), + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/TransactionResponse.php b/testkit-backend/src/Responses/TransactionResponse.php new file mode 100644 index 00000000..618e046c --- /dev/null +++ b/testkit-backend/src/Responses/TransactionResponse.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\Responses; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Symfony\Component\Uid\Uuid; + +/** + * Represents a transaction instance on the backend. + */ +final class TransactionResponse implements TestkitResponseInterface +{ + private Uuid $id; + + public function __construct(Uuid $id) + { + $this->id = $id; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'Transaction', + 'data' => [ + 'id' => $this->id->toRfc4122(), + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/Types/CypherNode.php b/testkit-backend/src/Responses/Types/CypherNode.php new file mode 100644 index 00000000..a995e128 --- /dev/null +++ b/testkit-backend/src/Responses/Types/CypherNode.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\TestkitBackend\Responses\Types; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; + +final class CypherNode implements TestkitResponseInterface +{ + private CypherObject $id; + private CypherObject $labels; + private CypherObject $props; + + public function __construct(CypherObject $id, CypherObject $labels, CypherObject $props) + { + $this->id = $id; + $this->labels = $labels; + $this->props = $props; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'CypherNode', + 'data' => [ + 'id' => $this->id, + 'labels' => $this->labels, + 'props' => $this->props, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/Types/CypherObject.php b/testkit-backend/src/Responses/Types/CypherObject.php new file mode 100644 index 00000000..0222b583 --- /dev/null +++ b/testkit-backend/src/Responses/Types/CypherObject.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Responses\Types; + +use function get_debug_type; +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; +use Laudis\Neo4j\Types\CypherList; +use Laudis\Neo4j\Types\CypherMap; +use Laudis\Neo4j\Types\Node; +use Laudis\Neo4j\Types\Path; +use Laudis\Neo4j\Types\Relationship; +use Laudis\Neo4j\Types\UnboundRelationship; +use RuntimeException; + +/** + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Formatter\OGMFormatter + */ +final class CypherObject implements TestkitResponseInterface +{ + /** @var CypherList|CypherMap|int|bool|float|string|Node|Relationship|Path|null */ + private $value; + private string $name; + + /** + * @param CypherList|CypherMap|int|bool|float|string|Node|Relationship|Path|null $value + */ + public function __construct(string $name, $value) + { + $this->value = $value; + $this->name = $name; + } + + /** + * @return bool|float|int|CypherList|CypherMap|Node|Path|Relationship|string|null + */ + public function getValue() + { + return $this->value; + } + + /** + * @param OGMTypes $value + */ + public static function autoDetect($value): TestkitResponseInterface + { + switch (get_debug_type($value)) { + case 'null': + $tbr = new CypherObject('CypherNull', $value); + break; + case CypherList::class: + /** @var CypherList $value */ + $list = []; + foreach ($value as $item) { + $list[] = self::autoDetect($item); + } + + $tbr = new CypherObject('CypherList', new CypherList($list)); + break; + case CypherMap::class: + /** @var CypherMap $value */ + if ($value->count() === 2 && $value->hasKey('name') && $value->hasKey('data')) { + $tbr = new CypherObject('CypherMap', $value); + } else { + $map = []; + foreach ($value as $key => $item) { + $map[$key] = self::autoDetect($item); + } + + $tbr = new CypherObject('CypherMap', new CypherMap($map)); + } + break; + case 'int': + $tbr = new CypherObject('CypherInt', $value); + break; + case 'bool': + $tbr = new CypherObject('CypherBool', $value); + break; + case 'float': + $tbr = new CypherObject('CypherFloat', $value); + break; + case 'string': + $tbr = new CypherObject('CypherString', $value); + break; + case Node::class: + $labels = []; + foreach ($value->getLabels() as $label) { + $labels[] = self::autoDetect($label); + } + $props = []; + foreach ($value->getProperties() as $key => $property) { + /** @psalm-suppress MixedArgumentTypeCoercion */ + $props[$key] = self::autoDetect($property); + } + + $tbr = new CypherNode( + new CypherObject('CypherInt', $value->getId()), + new CypherObject('CypherList', new CypherList($labels)), + new CypherObject('CypherMap', new CypherMap($props)) + ); + break; + case Relationship::class: + $props = []; + foreach ($value->getProperties() as $key => $property) { + /** @psalm-suppress MixedArgumentTypeCoercion */ + $props[$key] = self::autoDetect($property); + } + + $tbr = new CypherRelationship( + new CypherObject('CypherInt', $value->getId()), + new CypherObject('CypherInt', $value->getStartNodeId()), + new CypherObject('CypherInt', $value->getEndNodeId()), + new CypherObject('CypherString', $value->getType()), + new CypherObject('CypherMap', new CypherMap($props)), + ); + break; + case Path::class: + $nodes = []; + foreach ($value->getNodes() as $node) { + $nodes[] = self::autoDetect($node); + } + $rels = []; + foreach ($value->getRelationships() as $i => $rel) { + $rels[] = self::autoDetect(new Relationship( + $rel->getId(), + $value->getNodes()->get($i)->getId(), + $value->getNodes()->get($i + 1)->getId(), + $rel->getType(), + $rel->getProperties() + )); + } + $tbr = new CypherPath( + new CypherObject('CypherList', new CypherList($nodes)), + new CypherObject('CypherList', new CypherList($rels)) + ); + break; + case UnboundRelationship::class: + $props = []; + foreach ($value->getProperties() as $key => $property) { + /** @psalm-suppress MixedArgumentTypeCoercion */ + $props[$key] = self::autoDetect($property); + } + + $tbr = new CypherRelationship( + new CypherObject('CypherInt', $value->getId()), + new CypherObject('CypherNull', null), + new CypherObject('CypherNull', null), + new CypherObject('CypherString', $value->getType()), + new CypherObject('CypherMap', new CypherMap($props)) + ); + break; + default: + throw new RuntimeException('Unexpected type: '.get_debug_type($value)); + } + + return $tbr; + } + + public function jsonSerialize(): array + { + return [ + 'name' => $this->name, + 'data' => [ + 'value' => $this->value, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/Types/CypherPath.php b/testkit-backend/src/Responses/Types/CypherPath.php new file mode 100644 index 00000000..28b40222 --- /dev/null +++ b/testkit-backend/src/Responses/Types/CypherPath.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\TestkitBackend\Responses\Types; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; + +final class CypherPath implements TestkitResponseInterface +{ + private CypherObject $nodes; + private CypherObject $relationships; + + public function __construct(CypherObject $nodes, CypherObject $relationships) + { + $this->nodes = $nodes; + $this->relationships = $relationships; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'CypherPath', + 'data' => [ + 'nodes' => $this->nodes, + 'relationships' => $this->relationships, + ], + ]; + } +} diff --git a/testkit-backend/src/Responses/Types/CypherRelationship.php b/testkit-backend/src/Responses/Types/CypherRelationship.php new file mode 100644 index 00000000..271ee66b --- /dev/null +++ b/testkit-backend/src/Responses/Types/CypherRelationship.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\TestkitBackend\Responses\Types; + +use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; + +final class CypherRelationship implements TestkitResponseInterface +{ + private CypherObject $id; + private CypherObject $startNodeId; + private CypherObject $endNodeId; + private CypherObject $type; + private CypherObject $props; + + public function __construct(CypherObject $id, CypherObject $startNodeId, CypherObject $endNodeId, CypherObject $type, CypherObject $props) + { + $this->id = $id; + $this->startNodeId = $startNodeId; + $this->endNodeId = $endNodeId; + $this->type = $type; + $this->props = $props; + } + + public function jsonSerialize(): array + { + return [ + 'name' => 'CypherRelationship', + 'data' => [ + 'id' => $this->id, + 'startNodeId' => $this->startNodeId, + 'endNodeId' => $this->endNodeId, + 'type' => $this->type, + 'props' => $this->props, + ], + ]; + } +} diff --git a/testkit-backend/src/Socket.php b/testkit-backend/src/Socket.php new file mode 100644 index 00000000..ea702a92 --- /dev/null +++ b/testkit-backend/src/Socket.php @@ -0,0 +1,152 @@ + + * + * 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 error_get_last; +use function getenv; +use function is_numeric; +use function is_string; +use function json_encode; +use const PHP_EOL; +use RuntimeException; +use function stream_get_line; +use const STREAM_SHUT_RDWR; +use function stream_socket_accept; +use function stream_socket_server; +use function stream_socket_shutdown; + +final class Socket +{ + /** @var resource */ + private $streamSocketServer; + /** @var resource|null */ + private $socket; + + /** + * @param resource $streamSocketServer + */ + public function __construct($streamSocketServer) + { + $this->streamSocketServer = $streamSocketServer; + } + + public static function fromEnvironment(): self + { + $address = self::loadAddress(); + $port = self::loadPort(); + + return self::fromAddressAndPort($address, $port); + } + + private static function loadAddress(): string + { + $address = getenv('TESTKIT_BACKEND_ADDRESS'); + if (!is_string($address)) { + $address = '0.0.0.0'; + } + + return $address; + } + + private static function loadPort(): int + { + $port = getenv('TESTKIT_BACKEND_PORT'); + if (!is_numeric($port)) { + $port = 9876; + } + + return (int) $port; + } + + public static function fromAddressAndPort(string $address, int $port): self + { + $bind = 'tcp://'.$address.':'.$port; + $streamSocketServer = stream_socket_server($bind, $errorNumber, $errorString); + if ($streamSocketServer === false) { + throw new RuntimeException('stream_socket_server() failed: reason: '.$errorNumber.':'.$errorString); + } + +// stream_set_blocking($streamSocketServer, false); + + return new self($streamSocketServer); + } + + public function reset(): void + { + if ($this->socket !== null && !stream_socket_shutdown($this->socket, STREAM_SHUT_RDWR)) { + throw new RuntimeException(json_encode(error_get_last(), JSON_THROW_ON_ERROR)); + } + + $this->socket = null; + } + + public function readMessage(): ?string + { + if ($this->socket === null) { + $socket = stream_socket_accept($this->streamSocketServer, 2 ** 20); + if ($socket === false) { + throw new RuntimeException(json_encode(error_get_last(), JSON_THROW_ON_ERROR)); + } + + $this->socket = $socket; + } + + $length = 2 ** 30; + $this->getLine($this->socket, $length); + $message = $this->getLine($this->socket, $length); + $this->getLine($this->socket, $length); + + return $message; + } + + public function write(string $message): void + { + if ($this->socket === null) { + throw new RuntimeException('Trying to write to an uninitialised socket'); + } + + $result = stream_socket_sendto($this->socket, $message); + if ($result === -1) { + throw new RuntimeException(json_encode(error_get_last() ?? 'Unknown error', JSON_THROW_ON_ERROR)); + } + } + + public static function setupEnvironment(): void + { + error_reporting(E_ALL); + // Allow the script to hang around waiting for connections. + set_time_limit(0); + } + + public function __destruct() + { + $this->reset(); + if (!stream_socket_shutdown($this->streamSocketServer, STREAM_SHUT_RDWR)) { + throw new RuntimeException(json_encode(error_get_last(), JSON_THROW_ON_ERROR)); + } + } + + /** + * @param resource $socket + */ + private function getLine($socket, int $length): ?string + { + $line = stream_get_line($socket, $length, PHP_EOL); + if ($line === false) { + return null; + } + + return $line; + } +} diff --git a/testkit-backend/testkit.sh b/testkit-backend/testkit.sh new file mode 100644 index 00000000..13418bcd --- /dev/null +++ b/testkit-backend/testkit.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +export TEST_NEO4J_HOST=localhost +export TEST_NEO4J_USER=neo4j +export TEST_NEO4J_PASS=test +export TEST_DRIVER_NAME=php + +cd ../../testkit || (echo 'cannot cd into testkit' && exit) +exec python3 -m unittest -v "$@" diff --git a/tests/Integration/ClientIntegrationTest.php b/tests/Integration/ClientIntegrationTest.php index b19225b8..86c6bcb3 100644 --- a/tests/Integration/ClientIntegrationTest.php +++ b/tests/Integration/ClientIntegrationTest.php @@ -23,6 +23,7 @@ use Laudis\Neo4j\Formatter\OGMFormatter; use Laudis\Neo4j\Types\CypherList; use Laudis\Neo4j\Types\CypherMap; +use function str_starts_with; /** * @psalm-import-type OGMTypes from \Laudis\Neo4j\Formatter\OGMFormatter @@ -48,6 +49,9 @@ public function testEqualEffect(): void $prev = null; foreach ($this->connectionAliases() as $current) { + if (str_starts_with($current[0], 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } if ($prev !== null) { $x = $this->getClient()->runStatement($statement, $prev); $y = $this->getClient()->runStatement($statement, $current[0]); @@ -56,6 +60,8 @@ public function testEqualEffect(): void } $prev = $current[0]; } + + self::assertTrue(true); } /** @@ -63,6 +69,10 @@ public function testEqualEffect(): void */ public function testAvailabilityFullImplementation(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $results = $this->getClient()->getDriver($alias) ->createSession() ->beginTransaction() @@ -102,13 +112,15 @@ public function testTransactionFunction(string $alias): void */ public function testValidRun(string $alias): void { - $response = $this->getClient()->run(<<<'CYPHER' + $response = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run(<<<'CYPHER' MERGE (x:TestNode {test: $test}) WITH x MERGE (y:OtherTestNode {test: $otherTest}) WITH x, y, {c: 'd'} AS map, [1, 2, 3] AS list RETURN x, y, x.test AS test, map, list -CYPHER, ['test' => 'a', 'otherTest' => 'b'], $alias); +CYPHER, ['test' => 'a', 'otherTest' => 'b']); + }, $alias); self::assertEquals(1, $response->count()); $map = $response->first(); @@ -127,7 +139,9 @@ public function testInvalidRun(string $alias): void { $exception = false; try { - $this->getClient()->run('MERGE (x:Tes0342hdm21.())', ['test' => 'a', 'otherTest' => 'b'], $alias); + $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('MERGE (x:Tes0342hdm21.())', ['test' => 'a', 'otherTest' => 'b']); + }, $alias); } catch (Neo4jException $e) { $exception = true; } @@ -139,16 +153,15 @@ public function testInvalidRun(string $alias): void */ public function testValidStatement(string $alias): void { - $response = $this->getClient()->runStatement( - Statement::create(<<<'CYPHER' + $response = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->runStatement(Statement::create(<<<'CYPHER' MERGE (x:TestNode {test: $test}) WITH x MERGE (y:OtherTestNode {test: $otherTest}) WITH x, y, {c: 'd'} AS map, [1, 2, 3] AS list RETURN x, y, x.test AS test, map, list -CYPHER, ['test' => 'a', 'otherTest' => 'b']), - $alias - ); +CYPHER, ['test' => 'a', 'otherTest' => 'b'])); + }, $alias); self::assertEquals(1, $response->count()); $map = $response->first(); @@ -168,7 +181,7 @@ public function testInvalidStatement(string $alias): void $exception = false; try { $statement = Statement::create('MERGE (x:Tes0342hdm21.())', ['test' => 'a', 'otherTest' => 'b']); - $this->getClient()->runStatement($statement, $alias); + $this->getClient()->transaction(static fn (TransactionInterface $tsx) => $tsx->runStatement($statement), $alias); } catch (Neo4jException $e) { $exception = true; } @@ -180,23 +193,15 @@ public function testInvalidStatement(string $alias): void */ public function testStatements(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $params = ['test' => 'a', 'otherTest' => 'b']; $response = $this->getClient()->runStatements([ - Statement::create(<<<'CYPHER' -MERGE (x:TestNode {test: $test}) -CYPHER, - $params - ), - Statement::create(<<<'CYPHER' -MERGE (x:OtherTestNode {test: $otherTest}) -CYPHER, - $params - ), - Statement::create(<<<'CYPHER' -RETURN 1 AS x -CYPHER, - [] - ), + Statement::create('MERGE (x:TestNode {test: $test})', $params), + Statement::create('MERGE (x:OtherTestNode {test: $otherTest})', $params), + Statement::create('RETURN 1 AS x', []), ], $alias ); @@ -213,19 +218,15 @@ public function testStatements(string $alias): void */ public function testInvalidStatements(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $this->expectException(Neo4jException::class); $params = ['test' => 'a', 'otherTest' => 'b']; $this->getClient()->runStatements([ - Statement::create(<<<'CYPHER' -MERGE (x:TestNode {test: $test}) -CYPHER, - $params - ), - Statement::create(<<<'CYPHER' -MERGE (x:OtherTestNode {test: $otherTest}) -CYPHER, - $params - ), + Statement::create('MERGE (x:TestNode {test: $test})', $params), + Statement::create('MERGE (x:OtherTestNode {test: $otherTest})', $params), Statement::create('1 AS x;erns', []), ], $alias); } @@ -235,6 +236,10 @@ public function testInvalidStatements(string $alias): void */ public function testMultipleTransactions(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $x = $this->getClient()->beginTransaction(null, $alias); $y = $this->getClient()->beginTransaction(null, $alias); self::assertNotSame($x, $y); @@ -245,9 +250,9 @@ public function testMultipleTransactions(string $alias): void public function testInvalidConnection(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The provided alias: "ghqkneq;tr" was not found in the client'); + $this->expectExceptionMessage('The provided alias: "gh" was not found in the client'); - $this->getClient()->run('RETURN 1 AS x', [], 'ghqkneq;tr'); + $this->getClient()->transaction(static fn (TransactionInterface $tsx) => $tsx->run('RETURN 1 AS x'), 'gh'); } public function testInvalidConnectionCheck(): void diff --git a/tests/Integration/ComplexQueryTest.php b/tests/Integration/ComplexQueryTest.php index 079145d6..372d338a 100644 --- a/tests/Integration/ComplexQueryTest.php +++ b/tests/Integration/ComplexQueryTest.php @@ -13,7 +13,6 @@ namespace Laudis\Neo4j\Tests\Integration; -use Bolt\error\ConnectException; use Generator; use function getenv; use InvalidArgumentException; @@ -21,9 +20,8 @@ use Laudis\Neo4j\Common\Uri; use Laudis\Neo4j\Contracts\ClientInterface; use Laudis\Neo4j\Contracts\FormatterInterface; -use Laudis\Neo4j\Contracts\TransactionInterface; +use Laudis\Neo4j\Contracts\TransactionInterface as TSX; use Laudis\Neo4j\Databags\SummarizedResult; -use Laudis\Neo4j\Databags\TransactionConfiguration; use Laudis\Neo4j\Exception\Neo4jException; use Laudis\Neo4j\Formatter\SummarizedResultFormatter; use Laudis\Neo4j\ParameterHelper; @@ -63,9 +61,11 @@ protected static function createClient(): ClientInterface */ public function testListParameterHelper(string $alias): void { - $result = $this->getClient()->run(<<<'CYPHER' -MATCH (x) WHERE x.slug IN $listOrMap RETURN x -CYPHER, ['listOrMap' => ParameterHelper::asList([])], $alias); + $result = $this->getClient()->transaction(static function (TSX $tsx) { + return $tsx->run('MATCH (x) WHERE x.slug IN $listOrMap RETURN x', [ + 'listOrMap' => ParameterHelper::asList([]), + ]); + }, $alias); self::assertEquals(0, $result->count()); } @@ -74,9 +74,11 @@ public function testListParameterHelper(string $alias): void */ public function testValidListParameterHelper(string $alias): void { - $result = $this->getClient()->run(<<<'CYPHER' -RETURN $listOrMap AS x -CYPHER, ['listOrMap' => ParameterHelper::asList([1, 2, 3])], $alias); + $result = $this->getClient()->transaction(static function (TSX $tsx) { + return $tsx->run('RETURN $listOrMap AS x', [ + 'listOrMap' => ParameterHelper::asList([1, 2, 3]), + ]); + }, $alias); self::assertEquals(1, $result->count()); self::assertEquals(new CypherList([1, 2, 3]), $result->first()->get('x')); } @@ -87,7 +89,7 @@ public function testValidListParameterHelper(string $alias): void public function testMergeTransactionFunction(string $alias): void { $this->expectException(Neo4jException::class); - $this->getClient()->writeTransaction(static function (TransactionInterface $tsx) { + $this->getClient()->writeTransaction(static function (TSX $tsx) { /** @psalm-suppress ALL */ return $tsx->run('MERGE (x {y: "z"}:X) return x')->first() ->getAsMap('x') @@ -100,9 +102,11 @@ public function testMergeTransactionFunction(string $alias): void */ public function testValidMapParameterHelper(string $alias): void { - $result = $this->getClient()->run(<<<'CYPHER' -RETURN $listOrMap AS x -CYPHER, ['listOrMap' => ParameterHelper::asMap(['a' => 'b', 'c' => 'd'])], $alias); + $result = $this->getClient()->transaction(static function (TSX $tsx) { + return $tsx->run('RETURN $listOrMap AS x', [ + 'listOrMap' => ParameterHelper::asMap(['a' => 'b', 'c' => 'd']), + ]); + }, $alias); self::assertEquals(1, $result->count()); self::assertEquals(new CypherMap(['a' => 'b', 'c' => 'd']), $result->first()->get('x')); } @@ -112,12 +116,14 @@ public function testValidMapParameterHelper(string $alias): void */ public function testArrayParameterHelper(string $alias): void { - $this->getClient()->run(<<<'CYPHER' + $this->expectNotToPerformAssertions(); + $this->getClient()->transaction(static function (TSX $tsx) { + return $tsx->run(<<<'CYPHER' MERGE (x:Node {slug: 'a'}) WITH x MATCH (x) WHERE x.slug IN $listOrMap RETURN x -CYPHER, ['listOrMap' => []], $alias); - self::assertTrue(true); +CYPHER, ['listOrMap' => []]); + }, $alias); } /** @@ -126,11 +132,13 @@ public function testArrayParameterHelper(string $alias): void public function testInvalidParameter(string $alias): void { $this->expectException(InvalidArgumentException::class); - $this->getClient()->run(<<<'CYPHER' + $this->getClient()->transaction(function (TSX $tsx) { + return $tsx->run(<<<'CYPHER' MERGE (x:Node {slug: 'a'}) WITH x MATCH (x) WHERE x.slug IN $listOrMap RETURN x -CYPHER, ['listOrMap' => self::generate()], $alias); +CYPHER, ['listOrMap' => self::generate()]); + }, $alias); } private static function generate(): Generator @@ -148,11 +156,13 @@ public function testInvalidParameters(string $alias): void $this->expectException(InvalidArgumentException::class); /** @var iterable|scalar|null> $generator */ $generator = self::generate(); - $this->getClient()->run(<<<'CYPHER' + $this->getClient()->transaction(static function (TSX $tsx) use ($generator) { + return $tsx->run(<<<'CYPHER' MERGE (x:Node {slug: 'a'}) WITH x MATCH (x) WHERE x.slug IN $listOrMap RETURN x -CYPHER, $generator, $alias); +CYPHER, ['listOrMap' => $generator]); + }, $alias); } /** @@ -160,11 +170,9 @@ public function testInvalidParameters(string $alias): void */ public function testCreationAndResult(string $alias): void { - $result = $this->getClient()->run(<<<'CYPHER' -MERGE (x:Node {x:$x}) -RETURN x -CYPHER - , ['x' => 'x'], $alias)->first(); + $result = $this->getClient()->transaction(static function (TSX $tsx) { + return $tsx->run('MERGE (x:Node {x:$x}) RETURN x', ['x' => 'x']); + }, $alias)->first(); self::assertEquals(['x' => 'x'], $result->getAsNode('x')->getProperties()->toArray()); } @@ -178,13 +186,14 @@ public function testPath(string $alias): void self::markTestSkipped('Http cannot detected nested attributes'); } - $results = $this->getClient()->run(<<<'CYPHER' + $results = $this->getClient()->transaction(static function (TSX $tsx) { + return $tsx->run(<<<'CYPHER' MERGE (b:Node {x:$x}) - [:HasNode {attribute: $xy}] -> (:Node {y:$y}) - [:HasNode {attribute: $yz}] -> (:Node {z:$z}) WITH b MATCH (x:Node) - [y:HasNode*2] -> (z:Node) RETURN x, y, z -CYPHER - , ['x' => 'x', 'xy' => 'xy', 'y' => 'y', 'yz' => 'yz', 'z' => 'z'], $alias); +CYPHER, ['x' => 'x', 'xy' => 'xy', 'y' => 'y', 'yz' => 'yz', 'z' => 'z']); + }, $alias); self::assertEquals(1, $results->count()); $result = $results->first(); @@ -210,10 +219,11 @@ public function testPath(string $alias): void */ public function testNullListAndMap(string $alias): void { - $results = $this->getClient()->run(<<<'CYPHER' + $results = $this->getClient()->transaction(static function (TSX $tsx) { + return $tsx->run(<<<'CYPHER' RETURN null AS x, [1, 2, 3] AS y, {x: 'x', y: 'y', z: 'z'} AS z -CYPHER - , ['x' => 'x', 'xy' => 'xy', 'y' => 'y', 'yz' => 'yz', 'z' => 'z'], $alias); +CYPHER, ['x' => 'x', 'xy' => 'xy', 'y' => 'y', 'yz' => 'yz', 'z' => 'z']); + }, $alias); self::assertEquals(1, $results->count()); $result = $results->first(); @@ -228,14 +238,15 @@ public function testNullListAndMap(string $alias): void */ public function testListAndMapInput(string $alias): void { - $results = $this->getClient()->run(<<<'CYPHER' + $results = $this->getClient()->transaction(static function (TSX $tsx) { + return $tsx->run(<<<'CYPHER' MERGE (x:Node {x: $x.x}) WITH x MERGE (y:Node {list: $y}) RETURN x, y LIMIT 1 -CYPHER - , ['x' => ['x' => 'x'], 'y' => [1, 2, 3]], $alias); +CYPHER, ['x' => ['x' => 'x'], 'y' => [1, 2, 3]]); + }, $alias); self::assertEquals(1, $results->count()); $result = $results->first(); @@ -249,18 +260,18 @@ public function testListAndMapInput(string $alias): void */ public function testPathReturnType(string $alias): void { - $this->getClient()->run(<<<'CYPHER' + $results = $this->getClient()->transaction(static function (TSX $tsx) { + $tsx->run(<<<'CYPHER' MERGE (:Node {x: 'x'}) - [:Rel] -> (x:Node {x: 'y'}) WITH x MERGE (x) - [:Rel] -> (:Node {x: 'z'}) -CYPHER - , [], $alias); +CYPHER, []); - $results = $this->getClient()->run(<<<'CYPHER' + return $tsx->run(<<<'CYPHER' MATCH (a:Node {x: 'x'}), (b:Node {x: 'z'}), p = shortestPath((a)-[*]-(b)) RETURN p -CYPHER - , [], $alias); +CYPHER); + }, $alias); self::assertEquals(1, $results->count()); $result = $results->first(); @@ -314,7 +325,8 @@ public function testPeriodicCommitFail(string $alias): void USING PERIODIC COMMIT 10 LOAD CSV FROM 'file:///csv-example.csv' AS line MERGE (n:File {name: line[0]}); -CYPHER); +CYPHER + ); $tsx->commit(); } diff --git a/tests/Integration/ConsistencyTest.php b/tests/Integration/ConsistencyTest.php index 144b5eaf..21f08926 100644 --- a/tests/Integration/ConsistencyTest.php +++ b/tests/Integration/ConsistencyTest.php @@ -14,8 +14,10 @@ namespace Laudis\Neo4j\Tests\Integration; use Laudis\Neo4j\Contracts\FormatterInterface; +use Laudis\Neo4j\Contracts\TransactionInterface as TSX; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Formatter\BasicFormatter; +use function str_starts_with; /** * @psalm-import-type BasicResults from \Laudis\Neo4j\Formatter\BasicFormatter @@ -35,12 +37,15 @@ protected static function formatter(): FormatterInterface */ public function testConsistency(string $alias): void { - $this->getClient()->run('MATCH (x) DETACH DELETE x', [], $alias); - $res = $this->getClient()->run('MERGE (n:zzz {name: "bbbb"}) RETURN n', [], $alias); - self::assertEquals(1, $res->count()); - self::assertEquals(['name' => 'bbbb'], $res->first()->get('n')); + $res = $this->getClient()->transaction(function (TSX $tsx) { + $tsx->run('MATCH (x) DETACH DELETE x', []); + $res = $tsx->run('MERGE (n:zzz {name: "bbbb"}) RETURN n'); + self::assertEquals(1, $res->count()); + self::assertEquals(['name' => 'bbbb'], $res->first()->get('n')); + + return $tsx->run('MATCH (n:zzz {name: $name}) RETURN n', ['name' => 'bbbb']); + }, $alias); - $res = $this->getClient()->run('MATCH (n:zzz {name: $name}) RETURN n', ['name' => 'bbbb'], $alias); self::assertEquals(1, $res->count()); self::assertEquals(['name' => 'bbbb'], $res->first()->get('n')); } @@ -50,6 +55,10 @@ public function testConsistency(string $alias): void */ public function testConsistencyTransaction(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $this->getClient()->run('MATCH (x) DETACH DELETE x', [], $alias); $tsx = $this->getClient()->beginTransaction([ Statement::create('CREATE (n:aaa) SET n.name="aaa" return n'), diff --git a/tests/Integration/OGMFormatterIntegrationTest.php b/tests/Integration/OGMFormatterIntegrationTest.php index 4e4ca0e0..fa63083a 100644 --- a/tests/Integration/OGMFormatterIntegrationTest.php +++ b/tests/Integration/OGMFormatterIntegrationTest.php @@ -13,12 +13,14 @@ namespace Laudis\Neo4j\Tests\Integration; +use function compact; use DateInterval; use Exception; use function json_encode; use JsonException; use Laudis\Neo4j\Contracts\FormatterInterface; use Laudis\Neo4j\Contracts\PointInterface; +use Laudis\Neo4j\Contracts\TransactionInterface; use Laudis\Neo4j\Formatter\OGMFormatter; use Laudis\Neo4j\Types\CartesianPoint; use Laudis\Neo4j\Types\CypherList; @@ -55,7 +57,9 @@ protected static function formatter(): FormatterInterface */ public function testNull(string $alias): void { - $results = $this->getClient()->run('RETURN null as x', [], $alias); + $results = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('RETURN null as x'); + }, $alias); self::assertNull($results->first()->get('x')); } @@ -70,7 +74,9 @@ public function testNull(string $alias): void */ public function testList(string $alias): void { - $results = $this->getClient()->run('RETURN range(5, 15) as list, range(16, 35) as list2', [], $alias); + $results = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('RETURN range(5, 15) as list, range(16, 35) as list2'); + }, $alias); $list = $results->first()->get('list'); $list2 = $results->first()->get('list2'); @@ -90,7 +96,9 @@ public function testList(string $alias): void */ public function testMap(string $alias): void { - $map = $this->getClient()->run('RETURN {a: "b", c: "d"} as map', [], $alias)->first()->get('map'); + $map = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('RETURN {a: "b", c: "d"} as map')->first()->get('map'); + }, $alias); self::assertInstanceOf(CypherMap::class, $map); self::assertEquals(['a' => 'b', 'c' => 'd'], $map->toArray()); self::assertEquals(json_encode(['a' => 'b', 'c' => 'd'], JSON_THROW_ON_ERROR), json_encode($map, JSON_THROW_ON_ERROR)); @@ -101,7 +109,9 @@ public function testMap(string $alias): void */ public function testBoolean(string $alias): void { - $results = $this->getClient()->run('RETURN true as bool1, false as bool2', [], $alias); + $results = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('RETURN true as bool1, false as bool2'); + }, $alias); self::assertEquals(1, $results->count()); self::assertIsBool($results->first()->get('bool1')); @@ -113,11 +123,14 @@ public function testBoolean(string $alias): void */ public function testInteger(string $alias): void { - $results = $this->getClient()->run(<<getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run(<<count()); self::assertEquals(1, $results[0]['x.num']); @@ -130,7 +143,9 @@ public function testInteger(string $alias): void */ public function testFloat(string $alias): void { - $results = $this->getClient()->run('RETURN 0.1 AS float', [], $alias); + $results = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('RETURN 0.1 AS float'); + }, $alias); self::assertIsFloat($results->first()->get('float')); } @@ -140,7 +155,9 @@ public function testFloat(string $alias): void */ public function testString(string $alias): void { - $results = $this->getClient()->run('RETURN "abc" AS string', [], $alias); + $results = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('RETURN "abc" AS string'); + }, $alias); self::assertIsString($results->first()->get('string')); } @@ -152,10 +169,12 @@ public function testString(string $alias): void */ public function testDate(string $alias): void { - $query = $this->articlesQuery(); - $query .= 'RETURN article.datePublished as published_at'; + $results = $this->getClient()->transaction(function (TransactionInterface $tsx) { + $query = $this->articlesQuery(); + $query .= 'RETURN article.datePublished as published_at'; - $results = $this->getClient()->run($query, [], $alias); + return $tsx->run($query); + }, $alias); self::assertEquals(3, $results->count()); @@ -185,12 +204,14 @@ public function testDate(string $alias): void */ public function testTime(string $alias): void { - $results = $this->getClient()->run('RETURN time("12:00:00.000000000") AS time', [], $alias); + $results = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('RETURN time("12:00:00.000000000") AS time'); + }, $alias); $time = $results->first()->get('time'); self::assertInstanceOf(Time::class, $time); - self::assertEquals((float) 12 * 60 * 60, $time->getSeconds()); - self::assertEquals((float) 12 * 60 * 60, $time->seconds); + self::assertEquals(12.0 * 60 * 60, $time->getSeconds()); + self::assertEquals(12.0 * 60 * 60, $time->seconds); } /** @@ -198,7 +219,9 @@ public function testTime(string $alias): void */ public function testLocalTime(string $alias): void { - $results = $this->getClient()->run('RETURN localtime("12") AS time', [], $alias); + $results = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('RETURN localtime("12") AS time'); + }, $alias); /** @var LocalTime $time */ $time = $results->first()->get('time'); @@ -222,10 +245,12 @@ public function testLocalTime(string $alias): void */ public function testDateTime(string $alias): void { - $query = $this->articlesQuery(); - $query .= 'RETURN article.created as created_at'; + $results = $this->getClient()->transaction(function (TransactionInterface $tsx) { + $query = $this->articlesQuery(); + $query .= 'RETURN article.created as created_at'; - $results = $this->getClient()->run($query, [], $alias); + return $tsx->run($query); + }, $alias); self::assertEquals(3, $results->count()); @@ -254,7 +279,9 @@ public function testDateTime(string $alias): void */ public function testLocalDateTime(string $alias): void { - $result = $this->getClient()->run('RETURN localdatetime() as local', [], $alias)->first()->get('local'); + $result = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('RETURN localdatetime() as local')->first()->get('local'); + }, $alias); self::assertInstanceOf(LocalDateTime::class, $result); $date = $result->toDateTime(); @@ -269,7 +296,8 @@ public function testLocalDateTime(string $alias): void */ public function testDuration(string $alias): void { - $results = $this->getClient()->run(<<getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run(<<count()); self::assertEquals(new Duration(0, 14, 58320, 0), $results[0]['aDuration']); @@ -311,7 +341,9 @@ public function testDuration(string $alias): void */ public function testPoint(string $alias): void { - $result = $this->getClient()->run('RETURN point({x: 3, y: 4}) AS point', [], $alias); + $result = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('RETURN point({x: 3, y: 4}) AS point'); + }, $alias); self::assertInstanceOf(CypherList::class, $result); $row = $result->first(); self::assertInstanceOf(CypherMap::class, $row); @@ -351,10 +383,12 @@ public function testNode(string $alias): void $email = 'a@b.c'; $type = 'pepperoni'; - $results = $this->getClient()->run( - 'MERGE (u:User{email: $email})-[:LIKES]->(p:Food:Pizza {type: $type}) ON CREATE SET u.uuid=$uuid RETURN u, p', - compact('email', 'uuid', 'type'), $alias - ); + $results = $this->getClient()->transaction(static function (TransactionInterface $tsx) use ($email, $uuid, $type) { + return $tsx->run( + 'MERGE (u:User{email: $email})-[:LIKES]->(p:Food:Pizza {type: $type}) ON CREATE SET u.uuid=$uuid RETURN u, p', + compact('email', 'uuid', 'type') + ); + }, $alias); self::assertEquals(1, $results->count()); @@ -397,11 +431,11 @@ public function testNode(string $alias): void */ public function testRelationship(string $alias): void { - $this->getClient()->run('MATCH (n) DETACH DELETE n', [], $alias); - $result = $this->getClient()->run(<< (y:Y {y: 1}) -RETURN xy -CYPHER, [], $alias)->first()->get('xy'); + $result = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + $tsx->run('MATCH (n) DETACH DELETE n'); + + return $tsx->run('MERGE (x:X {x: 1}) - [xy:XY {x: 1, y: 1}] -> (y:Y {y: 1}) RETURN xy')->first()->get('xy'); + }, $alias); self::assertInstanceOf(Relationship::class, $result); self::assertEquals('XY', $result->getType()); @@ -425,13 +459,14 @@ public function testRelationship(string $alias): void */ public function testPath(string $alias): void { - $results = $this->getClient()->run(<<<'CYPHER' + $results = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run(<<<'CYPHER' MERGE (b:Node {x:$x}) - [:HasNode {attribute: $xy}] -> (:Node {y:$y}) - [:HasNode {attribute: $yz}] -> (:Node {z:$z}) WITH b MATCH (x:Node) - [y:HasNode*2] -> (z:Node) RETURN x, y, z -CYPHER - , ['x' => 'x', 'xy' => 'xy', 'y' => 'y', 'yz' => 'yz', 'z' => 'z'], $alias); +CYPHER, ['x' => 'x', 'xy' => 'xy', 'y' => 'y', 'yz' => 'yz', 'z' => 'z']); + }, $alias); self::assertEquals(1, $results->count()); } @@ -441,12 +476,12 @@ public function testPath(string $alias): void */ public function testPath2(string $alias): void { - $statement = <<<'CYPHER' + $results = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run(<<<'CYPHER' CREATE path = ((a:Node {x:$x}) - [b:HasNode {attribute: $xy}] -> (c:Node {y:$y}) - [d:HasNode {attribute: $yz}] -> (e:Node {z:$z})) RETURN path -CYPHER; - - $results = $this->getClient()->run($statement, ['x' => 'x', 'xy' => 'xy', 'y' => 'y', 'yz' => 'yz', 'z' => 'z'], $alias); +CYPHER, ['x' => 'x', 'xy' => 'xy', 'y' => 'y', 'yz' => 'yz', 'z' => 'z']); + }, $alias); self::assertEquals(1, $results->count()); $path = $results->first()->get('path'); @@ -468,11 +503,13 @@ public function testPath2(string $alias): void */ public function testPathMultiple(string $alias): void { - $this->getClient()->run('MATCH (x) DETACH DELETE (x)', [], $alias); - $this->getClient()->run('CREATE (:Node) - [:HasNode] -> (:Node)', [], $alias); - $this->getClient()->run('CREATE (:Node) - [:HasNode] -> (:Node)', [], $alias); + $result = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + $tsx->run('MATCH (x) DETACH DELETE (x)'); + $tsx->run('CREATE (:Node) - [:HasNode] -> (:Node)'); + $tsx->run('CREATE (:Node) - [:HasNode] -> (:Node)'); - $result = $this->getClient()->run('RETURN (:Node) - [:HasNode] -> (:Node) as paths', [], $alias); + return $tsx->run('RETURN (:Node) - [:HasNode] -> (:Node) as paths'); + }, $alias); self::assertCount(1, $result); $paths = $result->first()->get('paths'); @@ -489,25 +526,17 @@ public function testPathMultiple(string $alias): void */ public function testPropertyTypes(string $alias): void { - $point = 'point({x: 3, y: 4})'; - $list = 'range(5, 15)'; - $date = 'date("2019-06-01")'; - $dateTime = 'datetime("2019-06-01T18:40:32.142+0100")'; - $duration = 'duration({days: 14, hours:16, minutes: 12})'; - $localDateTime = 'localdatetime()'; - $localTime = 'localtime("12")'; - $time = 'time("12:00:00.000000000")'; - - $result = $this->getClient()->run(<<getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run(<<first()->get('a'); diff --git a/tests/Integration/SummarizedResultFormatterTest.php b/tests/Integration/SummarizedResultFormatterTest.php index 9632bd48..ebdea7db 100644 --- a/tests/Integration/SummarizedResultFormatterTest.php +++ b/tests/Integration/SummarizedResultFormatterTest.php @@ -13,12 +13,15 @@ namespace Laudis\Neo4j\Tests\Integration; +use function bin2hex; use Exception; use Laudis\Neo4j\Contracts\FormatterInterface; +use Laudis\Neo4j\Contracts\TransactionInterface; use Laudis\Neo4j\Databags\SummarizedResult; use Laudis\Neo4j\Databags\SummaryCounters; use Laudis\Neo4j\Formatter\SummarizedResultFormatter; use Laudis\Neo4j\Types\CypherMap; +use function random_bytes; /** * @psalm-import-type OGMTypes from \Laudis\Neo4j\Formatter\OGMFormatter @@ -37,7 +40,9 @@ protected static function formatter(): FormatterInterface */ public function testAcceptanceRead(string $alias): void { - $result = $this->getClient()->run('RETURN 1 AS one', [], $alias); + $result = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('RETURN 1 AS one'); + }, $alias); self::assertInstanceOf(SummarizedResult::class, $result); self::assertEquals(1, $result->first()->get('one')); } @@ -49,6 +54,9 @@ public function testAcceptanceRead(string $alias): void */ public function testAcceptanceWrite(string $alias): void { - self::assertEquals(new SummaryCounters(1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, true), $this->getClient()->run('CREATE (x:X {y: $x}) RETURN x', ['x' => bin2hex(random_bytes(128))], $alias)->getSummary()->getCounters()); + $counters = $this->getClient()->transaction(static function (TransactionInterface $tsx) { + return $tsx->run('CREATE (x:X {y: $x}) RETURN x', ['x' => bin2hex(random_bytes(128))]); + }, $alias)->getSummary()->getCounters(); + self::assertEquals(new SummaryCounters(1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, true), $counters); } } diff --git a/tests/Integration/TransactionIntegrationTest.php b/tests/Integration/TransactionIntegrationTest.php index 98c106f0..b0c17344 100644 --- a/tests/Integration/TransactionIntegrationTest.php +++ b/tests/Integration/TransactionIntegrationTest.php @@ -15,6 +15,7 @@ use Laudis\Neo4j\Contracts\FormatterInterface; use Laudis\Neo4j\Databags\Statement; +use Laudis\Neo4j\Exception\InvalidTransactionStateException; use Laudis\Neo4j\Exception\Neo4jException; use Laudis\Neo4j\Formatter\BasicFormatter; @@ -35,6 +36,10 @@ protected static function formatter(): FormatterInterface */ public function testValidRun(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $response = $this->getClient()->beginTransaction(null, $alias)->run(<<<'CYPHER' MERGE (x:TestNode {test: $test}) WITH x @@ -58,6 +63,10 @@ public function testValidRun(string $alias): void */ public function testInvalidRun(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $transaction = $this->getClient()->beginTransaction(null, $alias); $exception = false; try { @@ -74,6 +83,10 @@ public function testInvalidRun(string $alias): void */ public function testValidStatement(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $response = $this->getClient()->beginTransaction(null, $alias)->runStatement( Statement::create(<<<'CYPHER' MERGE (x:TestNode {test: $test}) @@ -99,6 +112,10 @@ public function testValidStatement(string $alias): void */ public function testInvalidStatement(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $transaction = $this->getClient()->beginTransaction(null, $alias); $exception = false; try { @@ -115,6 +132,10 @@ public function testInvalidStatement(string $alias): void */ public function testStatements(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $transaction = $this->getClient()->beginTransaction(null, $alias); $params = ['test' => 'a', 'otherTest' => 'b']; $response = $transaction->runStatements([ @@ -147,6 +168,10 @@ public function testStatements(string $alias): void */ public function testInvalidStatements(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $transaction = $this->getClient()->beginTransaction(null, $alias); $exception = false; try { @@ -175,6 +200,10 @@ public function testInvalidStatements(string $alias): void */ public function testCommitValidEmpty(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $result = $this->getClient()->beginTransaction(null, $alias)->commit(); self::assertEquals(0, $result->count()); } @@ -184,6 +213,10 @@ public function testCommitValidEmpty(string $alias): void */ public function testCommitValidFilled(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $result = $this->getClient()->beginTransaction(null, $alias)->commit([Statement::create(<<<'CYPHER' UNWIND [1, 2, 3] AS x RETURN x @@ -198,6 +231,10 @@ public function testCommitValidFilled(string $alias): void */ public function testCommitValidFilledWithInvalidStatement(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $transaction = $this->getClient()->beginTransaction(null, $alias); $exception = false; try { @@ -213,12 +250,16 @@ public function testCommitValidFilledWithInvalidStatement(string $alias): void */ public function testCommitInvalid(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $transaction = $this->getClient()->beginTransaction(null, $alias); $transaction->commit(); $exception = false; try { $transaction->commit(); - } catch (Neo4jException $e) { + } catch (InvalidTransactionStateException|Neo4jException $e) { $exception = true; } self::assertTrue($exception); @@ -229,6 +270,10 @@ public function testCommitInvalid(string $alias): void */ public function testRollbackValid(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $transaction = $this->getClient()->beginTransaction(null, $alias); $transaction->rollback(); self::assertTrue(true); @@ -239,12 +284,16 @@ public function testRollbackValid(string $alias): void */ public function testRollbackInvalid(string $alias): void { + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + $transaction = $this->getClient()->beginTransaction(null, $alias); $transaction->rollback(); $exception = false; try { $transaction->rollback(); - } catch (Neo4jException $e) { + } catch (InvalidTransactionStateException|Neo4jException $e) { $exception = true; } self::assertTrue($exception); diff --git a/tests/Performance/PerformanceTest.php b/tests/Performance/PerformanceTest.php index f561e749..35c8c47e 100644 --- a/tests/Performance/PerformanceTest.php +++ b/tests/Performance/PerformanceTest.php @@ -13,11 +13,17 @@ use function array_pop; use function base64_encode; +use Bolt\error\ConnectException; use function count; use Laudis\Neo4j\Contracts\FormatterInterface; +use Laudis\Neo4j\Contracts\TransactionInterface as TSX; +use Laudis\Neo4j\Contracts\UnmanagedTransactionInterface; use Laudis\Neo4j\Formatter\BasicFormatter; use Laudis\Neo4j\Tests\Integration\EnvironmentAwareIntegrationTest; use function random_bytes; +use RuntimeException; +use function sleep; +use function str_starts_with; /** * @psalm-import-type BasicResults from \Laudis\Neo4j\Formatter\BasicFormatter @@ -36,51 +42,40 @@ protected static function formatter(): FormatterInterface */ public function testBigRandomData(string $alias): void { - $tsx = $this->getClient()->getDriver($alias) - ->createSession() - ->beginTransaction(); + $this->expectException(RuntimeException::class); + $this->expectErrorMessage('Rollback please'); - $params = [ - 'id' => 'xyz', - ]; + $this->getClient()->transaction(static function (TSX $tsx) { + $params = [ + 'id' => 'xyz', + ]; - for ($i = 0; $i < 100000; ++$i) { - $params[base64_encode(random_bytes(32))] = base64_encode(random_bytes(128)); - } - - $tsx->run('MATCH (a :label {id:$id}) RETURN a', $params); + for ($i = 0; $i < 100000; ++$i) { + $params[base64_encode(random_bytes(32))] = base64_encode(random_bytes(128)); + } - $tsx->rollback(); + $tsx->run('MATCH (a :label {id:$id}) RETURN a', $params); - self::assertTrue(true); + throw new RuntimeException('Rollback please'); + }, $alias); } + /** + * @throws ConnectException + */ public function testMultipleTransactions(): void { $aliases = $this->connectionAliases(); $tsxs = []; for ($i = 0; $i < 1000; ++$i) { $alias = $aliases[$i % count($aliases)][0]; - if ($i % 2 === 0) { - $tsx = $this->getClient()->beginTransaction(null, $alias); - $x = $tsx->run('RETURN 1 AS x')->first()->get('x'); - $tsxs[] = $tsx; - } else { - $x = $this->getClient()->run('RETURN 1 AS x', [], $alias)->first()->get('x'); - } - self::assertEquals(1, $x); - if ($i % 200 === 49) { - self::assertEquals(1, $x); - for ($j = 0; $j < 19; ++$j) { - $tsx = array_pop($tsxs); - $x = $tsx->run('RETURN 1 AS x')->first()->get('x'); + $tsxs = $this->addTransactionOrRun($i, $alias, $tsxs); - self::assertEquals(1, $x); - - if ($j % 2 === 0) { - $tsx->commit(); - } + if (count($tsxs) >= 50) { + shuffle($tsxs); + for ($j = 0; $j < 25; ++$j) { + $tsxs = $this->testAndDestructTransaction($tsxs, $j); } } } @@ -91,7 +86,11 @@ public function testMultipleTransactions(): void */ public function testMultipleTransactionsCorrectness(string $alias): void { - $this->getClient()->run('MATCH (x) DETACH DELETE (x)', [], $alias); + if (str_starts_with($alias, 'neo4j')) { + self::markTestSkipped('Cannot guarantee successful test in cluster'); + } + + $this->getClient()->transaction(static fn (TSX $tsx) => $tsx->run('MATCH (x) DETACH DELETE (x)'), $alias); for ($i = 0; $i < 2; ++$i) { $tsxs = []; @@ -114,4 +113,68 @@ public function testMultipleTransactionsCorrectness(string $alias): void self::assertEquals(($i + 1) * 100, $this->getClient()->run('MATCH (x) RETURN count(x) AS x', [], $alias)->first()->get('x')); } } + + /** + * @param list> $tsxs + * + * @throws ConnectException + * + * @return list> + */ + private function addTransactionOrRun(int $i, string $alias, array $tsxs, int $retriesLeft = 10): array + { + try { + if ($i % 2 === 0) { + $tsx = $this->getClient()->beginTransaction(null, $alias); + $x = $tsx->run('RETURN 1 AS x')->first()->get('x'); + $tsxs[] = $tsx; + } else { + $x = $this->getClient()->run('RETURN 1 AS x', [], $alias)->first()->get('x'); + } + self::assertEquals(1, $x); + } catch (ConnectException $e) { + --$retriesLeft; + if ($retriesLeft === 0) { + throw $e; + } + + sleep(5); + + return $this->addTransactionOrRun($i, $alias, $tsxs, $retriesLeft); + } + + return $tsxs; + } + + /** + * @param list> $tsxs + * + * @throws ConnectException + * + * @return list> + */ + private function testAndDestructTransaction(array $tsxs, int $j, int $retriesLeft = 10): array + { + $tsx = array_pop($tsxs); + try { + $x = $tsx->run('RETURN 1 AS x')->first()->get('x'); + + self::assertEquals(1, $x); + + if ($j % 2 === 0) { + $tsx->commit(); + } + } catch (ConnectException $e) { + --$retriesLeft; + if ($retriesLeft === 0) { + throw $e; + } + + sleep(5); + + return $this->testAndDestructTransaction($tsxs, $j, $retriesLeft); + } + + return $tsxs; + } } diff --git a/tests/Unit/ParameterHelperTest.php b/tests/Unit/ParameterHelperTest.php index a51f6c95..2937b5e5 100644 --- a/tests/Unit/ParameterHelperTest.php +++ b/tests/Unit/ParameterHelperTest.php @@ -148,7 +148,7 @@ public function __toString(): string public function testInvalidType(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Parameters must be iterable, scalar, null or stringable'); + $this->expectExceptionMessage('Requests must be iterable, scalar, null or string able'); ParameterHelper::asParameter(new stdClass()); } }