diff --git a/src/Authentication/BasicAuth.php b/src/Authentication/BasicAuth.php index 3cc71d83..206550bd 100644 --- a/src/Authentication/BasicAuth.php +++ b/src/Authentication/BasicAuth.php @@ -35,7 +35,7 @@ public function __construct( /** * @throws Exception * - * @return array{server: string, connection_id: string, hints: list} + * @return array{server: string, connection_id: string, hints: list, patch_bolt?: list} */ public function authenticateBolt(BoltConnection $connection, string $userAgent): array { @@ -44,6 +44,9 @@ public function authenticateBolt(BoltConnection $connection, string $userAgent): $protocol = $connection->protocol(); if (method_exists($protocol, 'logon')) { $helloMetadata = ['user_agent' => $userAgent]; + if ($connection->getProtocol()->needsBoltUtcPatchInHello()) { + $helloMetadata['patch_bolt'] = ['utc']; + } $responseHello = $factory->createHelloMessage($helloMetadata)->send()->getResponse(); @@ -55,7 +58,7 @@ public function authenticateBolt(BoltConnection $connection, string $userAgent): $response = $factory->createLogonMessage($credentials)->send()->getResponse(); - /** @var array{server: string, connection_id: string, hints: list} */ + /** @var array{server: string, connection_id: string, hints: list, patch_bolt?: list} */ return array_merge($responseHello->content, $response->content); } @@ -65,10 +68,13 @@ public function authenticateBolt(BoltConnection $connection, string $userAgent): 'principal' => $this->username, 'credentials' => $this->password, ]; + if ($connection->getProtocol()->needsBoltUtcPatchInHello()) { + $helloMetadata['patch_bolt'] = ['utc']; + } $response = $factory->createHelloMessage($helloMetadata)->send()->getResponse(); - /** @var array{server: string, connection_id: string, hints: list} */ + /** @var array{server: string, connection_id: string, hints: list, patch_bolt?: list} */ return $response->content; } diff --git a/src/Authentication/KerberosAuth.php b/src/Authentication/KerberosAuth.php index 7b9f32a6..4903cfcb 100644 --- a/src/Authentication/KerberosAuth.php +++ b/src/Authentication/KerberosAuth.php @@ -37,7 +37,7 @@ public function __construct( /** * @throws Exception * - * @return array{server: string, connection_id: string, hints: list} + * @return array{server: string, connection_id: string, hints: list, patch_bolt?: list} */ public function authenticateBolt(BoltConnection $connection, string $userAgent): array { @@ -56,7 +56,7 @@ public function authenticateBolt(BoltConnection $connection, string $userAgent): ])->send()->getResponse(); /** - * @var array{server: string, connection_id: string, hints: list} + * @var array{server: string, connection_id: string, hints: list, patch_bolt?: list} */ return $response->content; } diff --git a/src/Authentication/NoAuth.php b/src/Authentication/NoAuth.php index 82210aee..c8eda107 100644 --- a/src/Authentication/NoAuth.php +++ b/src/Authentication/NoAuth.php @@ -33,7 +33,7 @@ public function __construct( /** * @throws Exception * - * @return array{server: string, connection_id: string, hints: list} + * @return array{server: string, connection_id: string, hints: list, patch_bolt?: list} */ public function authenticateBolt(BoltConnection $connection, string $userAgent): array { @@ -46,7 +46,7 @@ public function authenticateBolt(BoltConnection $connection, string $userAgent): $response = $factory->createLogonMessage(['scheme' => 'none'])->send()->getResponse(); - /** @var array{server: string, connection_id: string, hints: list} */ + /** @var array{server: string, connection_id: string, hints: list, patch_bolt?: list} */ return $response->content; } @@ -57,7 +57,7 @@ public function authenticateBolt(BoltConnection $connection, string $userAgent): $response = $factory->createHelloMessage($helloMetadata)->send()->getResponse(); - /** @var array{server: string, connection_id: string, hints: list} */ + /** @var array{server: string, connection_id: string, hints: list, patch_bolt?: list} */ return $response->content; } diff --git a/src/Authentication/OpenIDConnectAuth.php b/src/Authentication/OpenIDConnectAuth.php index 947bbcd6..08a51230 100644 --- a/src/Authentication/OpenIDConnectAuth.php +++ b/src/Authentication/OpenIDConnectAuth.php @@ -43,7 +43,7 @@ public function authenticateHttp(RequestInterface $request, UriInterface $uri, s /** * @throws Exception * - * @return array{server: string, connection_id: string, hints: list} + * @return array{server: string, connection_id: string, hints: list, patch_bolt?: list} */ public function authenticateBolt(BoltConnection $connection, string $userAgent): array { @@ -61,7 +61,7 @@ public function authenticateBolt(BoltConnection $connection, string $userAgent): ])->send()->getResponse(); /** - * @var array{server: string, connection_id: string, hints: list} + * @var array{server: string, connection_id: string, hints: list, patch_bolt?: list} */ return $response->content; } diff --git a/src/Bolt/BoltConnection.php b/src/Bolt/BoltConnection.php index 77275989..8a456c2b 100644 --- a/src/Bolt/BoltConnection.php +++ b/src/Bolt/BoltConnection.php @@ -18,12 +18,17 @@ use Bolt\error\ConnectException as BoltConnectException; use Bolt\protocol\Response; use Bolt\protocol\V3; +use Bolt\protocol\V4; +use Bolt\protocol\V4_1; +use Bolt\protocol\V4_2; +use Bolt\protocol\V4_3; use Bolt\protocol\V4_4; use Bolt\protocol\V5; use Bolt\protocol\V5_1; use Bolt\protocol\V5_2; use Bolt\protocol\V5_3; use Bolt\protocol\V5_4; +use Bolt\protocol\V6; use Exception; use Laudis\Neo4j\Common\ConnectionConfiguration; use Laudis\Neo4j\Common\Neo4jLogger; @@ -45,7 +50,7 @@ use WeakReference; /** - * @implements ConnectionInterface + * @implements ConnectionInterface * * @psalm-import-type BoltMeta from SummarizedResultFormatter */ @@ -85,7 +90,7 @@ class BoltConnection implements ConnectionInterface private ?Neo4jException $deferredPullFailure = null; /** - * @return array{0: V3|V4_4|V5|V5_1|V5_2|V5_3|V5_4|null, 1: Connection} + * @return array{0: V3|V4|V4_1|V4_2|V4_3|V4_4|V5|V5_1|V5_2|V5_3|V5_4|V6|null, 1: Connection} */ public function getImplementation(): array { @@ -96,7 +101,7 @@ public function getImplementation(): array * @psalm-mutation-free */ public function __construct( - private V3|V4_4|V5|V5_1|V5_2|V5_3|V5_4|null $boltProtocol, + private V3|V4|V4_1|V4_2|V4_3|V4_4|V5|V5_1|V5_2|V5_3|V5_4|V6|null $boltProtocol, private readonly Connection $connection, private readonly AuthenticateInterface $auth, private readonly string $userAgent, @@ -145,6 +150,14 @@ public function getProtocol(): ConnectionProtocol return $this->config->getProtocol(); } + /** + * @psalm-mutation-free + */ + public function isBoltUtcPatchNegotiated(): bool + { + return $this->config->isBoltUtcPatchNegotiated(); + } + /** * @psalm-mutation-free */ @@ -321,7 +334,7 @@ public function rollback(): void $this->assertNoFailure($response); } - public function protocol(): V3|V4_4|V5|V5_1|V5_2|V5_3|V5_4 + public function protocol(): V3|V4|V4_1|V4_2|V4_3|V4_4|V5|V5_1|V5_2|V5_3|V5_4|V6 { if (!isset($this->boltProtocol)) { throw new Exception('Connection is closed'); diff --git a/src/Bolt/BoltUnmanagedTransaction.php b/src/Bolt/BoltUnmanagedTransaction.php index 80fe4198..2d605366 100644 --- a/src/Bolt/BoltUnmanagedTransaction.php +++ b/src/Bolt/BoltUnmanagedTransaction.php @@ -157,7 +157,11 @@ public function run(string $statement, iterable $parameters = []): SummarizedRes */ public function runStatement(Statement $statement): SummarizedResult { - $parameters = ParameterHelper::formatParameters($statement->getParameters(), $this->connection->getProtocol()); + $parameters = ParameterHelper::formatParameters( + $statement->getParameters(), + $this->connection->getProtocol(), + $this->connection->isBoltUtcPatchNegotiated(), + ); $start = microtime(true); // Only drain an outstanding autocommit result (STREAMING). In an explicit transaction (TX_STREAMING) diff --git a/src/Bolt/ProtocolFactory.php b/src/Bolt/ProtocolFactory.php index 0d3ef9d5..edb5e9c8 100644 --- a/src/Bolt/ProtocolFactory.php +++ b/src/Bolt/ProtocolFactory.php @@ -16,17 +16,29 @@ use Bolt\Bolt; use Bolt\connection\IConnection; use Bolt\protocol\V3; +use Bolt\protocol\V4; +use Bolt\protocol\V4_1; +use Bolt\protocol\V4_2; +use Bolt\protocol\V4_3; use Bolt\protocol\V4_4; use Bolt\protocol\V5; use Bolt\protocol\V5_1; use Bolt\protocol\V5_2; use Bolt\protocol\V5_3; use Bolt\protocol\V5_4; +use Bolt\protocol\V6; use RuntimeException; class ProtocolFactory { - public function createProtocol(IConnection $connection): V3|V4_4|V5|V5_1|V5_2|V5_3|V5_4 + /** + * Bolt 4.3+ range proposal: 4.4 and the three minors below (4.3, 4.2, 4.1) in one uint32 (00 03 04 04). + * + * @see https://neo4j.com/docs/bolt/current/bolt/handshake/ + */ + private const HANDSHAKE_BOLT_4_4_DOWN_TO_4_1 = 0x00030404; + + public function createProtocol(IConnection $connection): V3|V4|V4_1|V4_2|V4_3|V4_4|V5|V5_1|V5_2|V5_3|V5_4|V6 { $boltOptoutEnv = getenv('BOLT_ANALYTICS_OPTOUT'); if ($boltOptoutEnv === false) { @@ -34,12 +46,12 @@ public function createProtocol(IConnection $connection): V3|V4_4|V5|V5_1|V5_2|V5 } $bolt = new Bolt($connection); - // Newest first; include 3.0 for legacy servers and TestKit stub (BOLT 3) - $bolt->setProtocolVersions('5.4.4', 4.4, '3.0'); + // Newest first: 6, 5.4.4, Bolt 4.4–4.1 range (single uint32), 3.0 — fits 4 slots and satisfies 4.2 / 4.3 stubs + $bolt->setProtocolVersions(6, '5.4.4', self::HANDSHAKE_BOLT_4_4_DOWN_TO_4_1, '3.0'); $protocol = $bolt->build(); - if (!($protocol instanceof V3 || $protocol instanceof V4_4 || $protocol instanceof V5 || $protocol instanceof V5_1 || $protocol instanceof V5_2 || $protocol instanceof V5_3 || $protocol instanceof V5_4)) { - throw new RuntimeException('Client only supports bolt version 3.0 to 5.4'); + if (!($protocol instanceof V3 || $protocol instanceof V4 || $protocol instanceof V4_1 || $protocol instanceof V4_2 || $protocol instanceof V4_3 || $protocol instanceof V4_4 || $protocol instanceof V5 || $protocol instanceof V5_1 || $protocol instanceof V5_2 || $protocol instanceof V5_3 || $protocol instanceof V5_4 || $protocol instanceof V6)) { + throw new RuntimeException('Client only supports bolt version 3.0 to 6.x'); } return $protocol; diff --git a/src/BoltFactory.php b/src/BoltFactory.php index 204e8933..337205e4 100644 --- a/src/BoltFactory.php +++ b/src/BoltFactory.php @@ -90,6 +90,11 @@ public function createConnection(ConnectionRequestData $data, SessionConfigurati $connection->setRecvTimeoutHint((float) $response['hints']['connection.recv_timeout_seconds']); } + $patchBolt = $response['patch_bolt'] ?? null; + $config->setBoltUtcPatchNegotiated( + is_array($patchBolt) && in_array('utc', $patchBolt, true) + ); + return $connection; } diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index aed74592..fa8df25b 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -31,7 +31,7 @@ /** * Immutable factory for creating a client. * - * @psalm-import-type OGMTypes from SummarizedResultFormatter + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias */ final class ClientBuilder { diff --git a/src/Common/ConnectionConfiguration.php b/src/Common/ConnectionConfiguration.php index babcb818..7ba9d628 100644 --- a/src/Common/ConnectionConfiguration.php +++ b/src/Common/ConnectionConfiguration.php @@ -30,6 +30,7 @@ public function __construct( private readonly ?AccessMode $accessMode, private readonly ?DatabaseInfo $databaseInfo, private readonly string $encryptionLevel, + private bool $boltUtcPatchNegotiated = false, ) { } @@ -71,4 +72,17 @@ public function getEncryptionLevel(): string { return $this->encryptionLevel; } + + /** + * True when the server echoed patch_bolt containing "utc" (Bolt 4.3–4.x UTC DateTime wire format). + */ + public function isBoltUtcPatchNegotiated(): bool + { + return $this->boltUtcPatchNegotiated; + } + + public function setBoltUtcPatchNegotiated(bool $boltUtcPatchNegotiated): void + { + $this->boltUtcPatchNegotiated = $boltUtcPatchNegotiated; + } } diff --git a/src/Contracts/AuthenticateInterface.php b/src/Contracts/AuthenticateInterface.php index 0e3d4433..b8c3fa7e 100644 --- a/src/Contracts/AuthenticateInterface.php +++ b/src/Contracts/AuthenticateInterface.php @@ -21,7 +21,7 @@ interface AuthenticateInterface /** * Authenticates a Bolt connection with the provided configuration Uri and userAgent. * - * @return array{server: string, connection_id: string, hints: list} + * @return array{server: string, connection_id: string, hints: list, patch_bolt?: list} */ public function authenticateBolt(BoltConnection $connection, string $userAgent): array; diff --git a/src/Databags/SummarizedResult.php b/src/Databags/SummarizedResult.php index a140f4a1..eac043be 100644 --- a/src/Databags/SummarizedResult.php +++ b/src/Databags/SummarizedResult.php @@ -15,16 +15,15 @@ use Closure; use Generator; -use Laudis\Neo4j\Formatter\SummarizedResultFormatter; use Laudis\Neo4j\Types\CypherList; use Laudis\Neo4j\Types\CypherMap; /** * A result containing the values and the summary. * - * @psalm-import-type OGMTypes from SummarizedResultFormatter + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias * - * @extends CypherList> + * @extends \Laudis\Neo4j\Types\CypherList<\Laudis\Neo4j\Types\CypherMap> */ final class SummarizedResult extends CypherList { diff --git a/src/Enum/ConnectionProtocol.php b/src/Enum/ConnectionProtocol.php index e5d68b0e..681f97b1 100644 --- a/src/Enum/ConnectionProtocol.php +++ b/src/Enum/ConnectionProtocol.php @@ -24,6 +24,7 @@ use Bolt\protocol\V5_2; use Bolt\protocol\V5_3; use Bolt\protocol\V5_4; +use Bolt\protocol\V6; use JsonSerializable; use Laudis\TypedEnum\TypedEnum; @@ -41,6 +42,7 @@ * @method static ConnectionProtocol BOLT_V5_2() * @method static ConnectionProtocol BOLT_V5_3() * @method static ConnectionProtocol BOLT_V5_4() + * @method static ConnectionProtocol BOLT_V6() * * @extends TypedEnum * @@ -61,19 +63,30 @@ final class ConnectionProtocol extends TypedEnum implements JsonSerializable private const BOLT_V5_2 = '5.2'; private const BOLT_V5_3 = '5.3'; private const BOLT_V5_4 = '5.4'; + private const BOLT_V6 = '6'; /** * @pure * * @psalm-suppress ImpureMethodCall */ - public static function determineBoltVersion(V3|V4|V4_1|V4_2|V4_3|V4_4|V5|V5_1|V5_2|V5_3|V5_4 $bolt): self + public static function determineBoltVersion(V3|V4|V4_1|V4_2|V4_3|V4_4|V5|V5_1|V5_2|V5_3|V5_4|V6 $bolt): self { $version = self::resolve($bolt->getVersion()); return $version[0] ?? self::BOLT_V44(); } + /** + * Bolt 4.3–4.x: negotiate fixed DateTime via HELLO (TestKit echo_date_time_patched.script). + * + * @psalm-suppress ImpureMethodCall see compare() + */ + public function needsBoltUtcPatchInHello(): bool + { + return $this->compare(self::BOLT_V43()) >= 0 && $this->compare(self::BOLT_V5()) < 0; + } + public function compare(ConnectionProtocol $protocol): int { $x = 0; diff --git a/src/Formatter/Specialised/BoltOGMTranslator.php b/src/Formatter/Specialised/BoltOGMTranslator.php index 8959c90a..dc96781c 100644 --- a/src/Formatter/Specialised/BoltOGMTranslator.php +++ b/src/Formatter/Specialised/BoltOGMTranslator.php @@ -26,9 +26,14 @@ use Bolt\protocol\v1\structures\Relationship as BoltRelationship; use Bolt\protocol\v1\structures\Time as BoltTime; use Bolt\protocol\v1\structures\UnboundRelationship as BoltUnboundRelationship; +use Bolt\protocol\v5\structures\DateTimeZoneId as BoltV5DateTimeZoneId; +use Bolt\protocol\v6\structures\UnsupportedType as BoltUnsupportedType; use Bolt\protocol\v6\structures\Vector as BoltVector; +use DateTimeImmutable; +use DateTimeZone; +use Laudis\Neo4j\Databags\Neo4jError; use Laudis\Neo4j\Enum\VectorTypeMarker; -use Laudis\Neo4j\Formatter\SummarizedResultFormatter; +use Laudis\Neo4j\Exception\Neo4jException; use Laudis\Neo4j\Types\Abstract3DPoint; use Laudis\Neo4j\Types\AbstractPoint; use Laudis\Neo4j\Types\Cartesian3DPoint; @@ -46,15 +51,17 @@ use Laudis\Neo4j\Types\Relationship; use Laudis\Neo4j\Types\Time; use Laudis\Neo4j\Types\UnboundRelationship; +use Laudis\Neo4j\Types\UnsupportedType; use Laudis\Neo4j\Types\Vector; use Laudis\Neo4j\Types\WGS843DPoint; use Laudis\Neo4j\Types\WGS84Point; +use Throwable; use UnexpectedValueException; /** * Translates Bolt objects to Driver Types. * - * @psalm-import-type OGMTypes from SummarizedResultFormatter + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias * * @psalm-immutable * @@ -85,6 +92,7 @@ public function __construct() BoltPoint3D::class => $this->makeFromBoltPoint3D(...), BoltDateTimeZoneId::class => $this->makeBoltTimezoneIdentifier(...), BoltVector::class => $this->makeFromBoltVector(...), + BoltUnsupportedType::class => $this->makeFromBoltUnsupportedType(...), 'array' => $this->mapArray(...), 'int' => static fn (int $x): int => $x, 'null' => static fn (): ?object => null, @@ -141,7 +149,46 @@ private function makeBoltTimezoneIdentifier(BoltDateTimeZoneId $time): DateTimeZ /** @var non-empty-string $tzId */ $tzId = $time->tz_id; - return new DateTimeZoneId($time->seconds, $time->nanoseconds, $tzId); + $tz = $this->phpTimeZoneOrNeo4jError($tzId); + + if ($time instanceof BoltV5DateTimeZoneId) { + return new DateTimeZoneId($time->seconds, $time->nanoseconds, $tzId); + } + + // Legacy 0x66: wire seconds are UTC epoch + zone offset at that instant (inverse of Types\DateTimeZoneId encode). + $legacySeconds = $time->seconds; + $utcSeconds = $legacySeconds; + for ($i = 0; $i < 8; ++$i) { + $instant = (new DateTimeImmutable('@'.$utcSeconds)) + ->modify(sprintf('+%d microseconds', intdiv($time->nanoseconds, 1000))); + if ($instant === false) { + throw new UnexpectedValueException('Invalid Bolt legacy DateTimeZoneId'); + } + $offset = $tz->getOffset($instant); + $nextUtc = $legacySeconds - $offset; + if ($nextUtc === $utcSeconds) { + break; + } + $utcSeconds = $nextUtc; + } + + return new DateTimeZoneId($utcSeconds, $time->nanoseconds, $tzId); + } + + /** + * PHP only supports a subset of IANA IDs; unknown zones must surface as a driver error (TestKit unknown zoned datetime). + * + * @param non-empty-string $tzId + * + * @throws Neo4jException + */ + private function phpTimeZoneOrNeo4jError(string $tzId): DateTimeZone + { + try { + return new DateTimeZone($tzId); + } catch (Throwable $e) { + throw new Neo4jException([Neo4jError::fromMessageAndCode('Neo.ClientError.Statement.TypeError', $e->getMessage())], $e); + } } private function makeFromBoltDuration(BoltDuration $duration): Duration @@ -272,16 +319,41 @@ private function makeFromBoltPoint3D(BoltPoint3D $x): Abstract3DPoint throw new UnexpectedValueException('An srid of '.$x->srid.' has been returned, which has not been implemented.'); } + /** + * @psalm-suppress ImpureMethodCall Bolt decode / Vector helpers are side-effect-free at runtime + */ private function makeFromBoltVector(BoltVector $value): Vector { - /** @psalm-suppress ImpureMethodCall Vector::decode() only reads protocol data but Psalm treats Bolt structures as potentially stateful */ $decoded = $value->decode(); - // Cast to string then read first byte to avoid Bytes::offsetGet (ImpureMethodCall) and to satisfy Psalm that ord() never receives null - $bytesStr = (string) $value->type_marker; - $markerByte = $bytesStr !== '' ? ord($bytesStr[0]) : null; + $bytesStr = (string) $value->data; + $hex = $bytesStr === '' + ? '' + : implode(' ', array_map(static fn (string $b): string => sprintf('%02x', ord($b)), str_split($bytesStr, 1))); + $bytesStrMarker = (string) $value->type_marker; + $markerByte = $bytesStrMarker !== '' ? ord($bytesStrMarker[0]) : null; $typeMarker = $markerByte !== null ? VectorTypeMarker::tryFrom($markerByte) : null; + $dtype = $typeMarker !== null && $markerByte !== null + ? Vector::markerByteToDtypeString($markerByte) + : null; + + /** @var list $decoded */ + return new Vector($decoded, $typeMarker, $dtype, $hex); + } - return new Vector(array_values($decoded), $typeMarker); + /** + * @psalm-suppress ImpureMethodCall factory reads Bolt structure fields only + */ + private function makeFromBoltUnsupportedType(BoltUnsupportedType $value): UnsupportedType + { + $rawMsg = $value->extra['message'] ?? null; + $message = is_string($rawMsg) ? $rawMsg : null; + + return UnsupportedType::fromBolt( + $value->name, + $value->minimum_protocol_major, + $value->minimum_protocol_minor, + $message, + ); } private function makeFromBoltPath(BoltPath $path): Path diff --git a/src/Formatter/SummarizedResultFormatter.php b/src/Formatter/SummarizedResultFormatter.php index 9712fd13..0de788f1 100644 --- a/src/Formatter/SummarizedResultFormatter.php +++ b/src/Formatter/SummarizedResultFormatter.php @@ -13,6 +13,8 @@ namespace Laudis\Neo4j\Formatter; +use Generator; + use function in_array; use function is_int; @@ -33,31 +35,19 @@ use Laudis\Neo4j\Databags\SummaryCounters; use Laudis\Neo4j\Enum\QueryTypeEnum; use Laudis\Neo4j\Formatter\Specialised\BoltOGMTranslator; -use Laudis\Neo4j\Types\Cartesian3DPoint; -use Laudis\Neo4j\Types\CartesianPoint; use Laudis\Neo4j\Types\CypherList; use Laudis\Neo4j\Types\CypherMap; use Laudis\Neo4j\Types\Date; -use Laudis\Neo4j\Types\DateTime; -use Laudis\Neo4j\Types\DateTimeZoneId; -use Laudis\Neo4j\Types\Duration; -use Laudis\Neo4j\Types\LocalDateTime; -use Laudis\Neo4j\Types\LocalTime; -use Laudis\Neo4j\Types\Node; -use Laudis\Neo4j\Types\Path; -use Laudis\Neo4j\Types\Relationship; use Laudis\Neo4j\Types\Time; -use Laudis\Neo4j\Types\Vector; -use Laudis\Neo4j\Types\WGS843DPoint; -use Laudis\Neo4j\Types\WGS84Point; use function microtime; /** * Decorates the result of the provided format with an extensive summary. * - * @psalm-type OGMTypes = string|int|float|bool|null|Date|DateTime|Duration|LocalDateTime|LocalTime|Time|Node|Relationship|Path|Cartesian3DPoint|CartesianPoint|WGS84Point|WGS843DPoint|DateTimeZoneId|Vector|CypherList|CypherMap - * @psalm-type OGMResults = CypherList> + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias + * + * @psalm-type OGMResults = \Laudis\Neo4j\Types\CypherList<\Laudis\Neo4j\Types\CypherMap> * @psalm-type CypherStats = array{ * nodes_created: int, * nodes_deleted: int, @@ -285,9 +275,13 @@ private function formatProfiledPlan(array $profiledPlanData): ProfiledQueryPlan */ private function processBoltResult(array $meta, BoltResult $result, BoltConnection $connection, BookmarkHolder $holder): CypherList { - $tbr = (new CypherList(function () use ($result, $meta) { - foreach ($result as $row) { - yield $this->formatRow($meta, $row); + $translator = $this->translator; + $tbr = (new CypherList(function () use ($result, $meta, $translator) { + foreach ($result as $i => $row) { + // Defer {@see BoltOGMTranslator::mapValueToType} until the row is read so one bad value + // (e.g. unknown IANA zone) does not close the list generator before remaining RECORDs are yielded + // (TestKit echo_unknown_then_known_zoned_date_time.script). + yield $i => $this->lazyMapRow($meta, $row, $translator); } }))->withCacheLimit($this->clientSideCacheLimitFromBoltFetchSize($result->getFetchSize())); @@ -302,25 +296,21 @@ private function processBoltResult(array $meta, BoltResult $result, BoltConnecti } /** - * @psalm-mutation-free - * - * @param BoltMeta $meta + * @param list $row * * @return CypherMap */ - private function formatRow(array $meta, array $result): CypherMap + private function lazyMapRow(array $meta, array $row, BoltOGMTranslator $translator): CypherMap { - /** @var array $map */ - $map = []; if (!array_key_exists('fields', $meta)) { - return new CypherMap($map); + return new CypherMap([]); } - foreach ($meta['fields'] as $i => $column) { - $map[$column] = $this->translator->mapValueToType($result[$i]); - } - - return new CypherMap($map); + return new CypherMap(function () use ($meta, $row, $translator): Generator { + foreach ($meta['fields'] as $i => $column) { + yield $column => $translator->mapValueToType($row[$i]); + } + }); } private function formatPlan(array $plan): Plan diff --git a/src/Neo4j/Neo4jConnectionPool.php b/src/Neo4j/Neo4jConnectionPool.php index 5eeaa5f8..1bf01e3f 100644 --- a/src/Neo4j/Neo4jConnectionPool.php +++ b/src/Neo4j/Neo4jConnectionPool.php @@ -315,6 +315,7 @@ private function routingTable(BoltConnection $connection, SessionConfiguration $ $this->getLogger()?->log(LogLevel::DEBUG, 'ROUTE', ['db' => $config->getDatabase()]); /** @var array{rt: array{servers: list, role:string}>, ttl: int}} $route */ + /** @psalm-suppress PossiblyUndefinedMethod, InvalidArgument upstream Bolt route() signatures vary by protocol version */ $route = $bolt->route([], [], ['db' => $config->getDatabase()]) ->getResponse() ->content; diff --git a/src/ParameterHelper.php b/src/ParameterHelper.php index 8f232680..7a427b8a 100644 --- a/src/ParameterHelper.php +++ b/src/ParameterHelper.php @@ -36,6 +36,8 @@ use Laudis\Neo4j\Enum\ConnectionProtocol; use Laudis\Neo4j\Types\CypherList; use Laudis\Neo4j\Types\CypherMap; +use Laudis\Neo4j\Types\DateTime as Neo4jDateTime; +use Laudis\Neo4j\Types\DateTimeZoneId as Neo4jDateTimeZoneId; use stdClass; /** @@ -82,13 +84,14 @@ public static function asMap(iterable $iterable): CypherMap public static function asParameter( mixed $value, ConnectionProtocol $protocol, + bool $boltUtcPatchNegotiated = false, ): iterable|int|float|bool|string|stdClass|IStructure|null { return self::passThroughBoltStructure($value) ?? self::cypherMapToStdClass($value) ?? self::emptySequenceToArray($value) ?? - self::convertBoltConvertibles($value) ?? - self::convertTemporalTypes($value, $protocol) ?? - self::filledIterableToArray($value, $protocol) ?? + self::convertBoltConvertibles($value, $protocol, $boltUtcPatchNegotiated) ?? + self::convertTemporalTypes($value, $protocol, $boltUtcPatchNegotiated) ?? + self::filledIterableToArray($value, $protocol, $boltUtcPatchNegotiated) ?? self::stringAbleToString($value) ?? self::filterInvalidType($value); } @@ -159,10 +162,10 @@ private static function cypherMapToStdClass(mixed $value): ?stdClass return null; } - private static function filledIterableToArray(mixed $value, ConnectionProtocol $protocol): ?array + private static function filledIterableToArray(mixed $value, ConnectionProtocol $protocol, bool $boltUtcPatchNegotiated): ?array { if (is_iterable($value)) { - return self::iterableToArray($value, $protocol); + return self::iterableToArray($value, $protocol, $boltUtcPatchNegotiated); } return null; @@ -173,8 +176,11 @@ private static function filledIterableToArray(mixed $value, ConnectionProtocol $ * * @return CypherMap */ - public static function formatParameters(iterable $parameters, ConnectionProtocol $connection): CypherMap - { + public static function formatParameters( + iterable $parameters, + ConnectionProtocol $connection, + bool $boltUtcPatchNegotiated = false, + ): CypherMap { /** @var array $tbr */ $tbr = []; /** @@ -186,13 +192,13 @@ public static function formatParameters(iterable $parameters, ConnectionProtocol $msg = 'The parameters must have an integer or string as key values, '.gettype($key).' received.'; throw new InvalidArgumentException($msg); } - $tbr[(string) $key] = self::asParameter($value, $connection); + $tbr[(string) $key] = self::asParameter($value, $connection, $boltUtcPatchNegotiated); } return new CypherMap($tbr); } - private static function iterableToArray(iterable $value, ConnectionProtocol $protocol): array + private static function iterableToArray(iterable $value, ConnectionProtocol $protocol, bool $boltUtcPatchNegotiated): array { $tbr = []; /** @@ -201,7 +207,7 @@ private static function iterableToArray(iterable $value, ConnectionProtocol $pro */ foreach ($value as $key => $val) { if (is_int($key) || is_string($key)) { - $tbr[$key] = self::asParameter($val, $protocol); + $tbr[$key] = self::asParameter($val, $protocol, $boltUtcPatchNegotiated); } else { $msg = 'Iterable parameters must have an integer or string as key values, '.gettype( $key @@ -213,8 +219,19 @@ private static function iterableToArray(iterable $value, ConnectionProtocol $pro return $tbr; } - private static function convertBoltConvertibles(mixed $value): ?IStructure - { + private static function convertBoltConvertibles( + mixed $value, + ConnectionProtocol $protocol, + bool $boltUtcPatchNegotiated, + ): ?IStructure { + if ($value instanceof Neo4jDateTimeZoneId) { + return $value->convertToBoltWithProtocol($protocol, $boltUtcPatchNegotiated); + } + + if ($value instanceof Neo4jDateTime) { + return $value->convertToBoltWithProtocol($protocol, $boltUtcPatchNegotiated); + } + if ($value instanceof BoltConvertibleInterface) { return $value->convertToBolt(); } @@ -222,10 +239,14 @@ private static function convertBoltConvertibles(mixed $value): ?IStructure return null; } - private static function convertTemporalTypes(mixed $value, ConnectionProtocol $protocol): ?IStructure - { + private static function convertTemporalTypes( + mixed $value, + ConnectionProtocol $protocol, + bool $boltUtcPatchNegotiated, + ): ?IStructure { if ($value instanceof DateTimeInterface) { - if ($protocol->compare(ConnectionProtocol::BOLT_V44()) > 0) { + $useV5ZonedDateTime = $protocol->compare(ConnectionProtocol::BOLT_V44()) > 0 || $boltUtcPatchNegotiated; + if ($useV5ZonedDateTime) { return new \Bolt\protocol\v5\structures\DateTimeZoneId( $value->getTimestamp(), ((int) $value->format('u')) * 1000, diff --git a/src/Types/AbstractPropertyObject.php b/src/Types/AbstractPropertyObject.php index 673e6cf9..83711d45 100644 --- a/src/Types/AbstractPropertyObject.php +++ b/src/Types/AbstractPropertyObject.php @@ -15,12 +15,11 @@ use BadMethodCallException; use Laudis\Neo4j\Contracts\HasPropertiesInterface; -use Laudis\Neo4j\Formatter\SummarizedResultFormatter; use function sprintf; /** - * @psalm-import-type OGMTypes from SummarizedResultFormatter + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias * * @template PropertyTypes * @template ObjectTypes diff --git a/src/Types/DateTime.php b/src/Types/DateTime.php index 6d565c0c..367a6f03 100644 --- a/src/Types/DateTime.php +++ b/src/Types/DateTime.php @@ -18,6 +18,7 @@ use DateTimeZone; use Exception; use Laudis\Neo4j\Contracts\BoltConvertibleInterface; +use Laudis\Neo4j\Enum\ConnectionProtocol; use function sprintf; @@ -79,8 +80,8 @@ public function toDateTime(): DateTimeImmutable { $dateTime = new DateTimeImmutable(sprintf('@%s', $this->getSeconds())); $dateTime = $dateTime->modify(sprintf('+%s microseconds', $this->nanoseconds / 1000)); - /** @psalm-suppress PossiblyFalseReference */ - $dateTime = $dateTime->setTimezone(new DateTimeZone(sprintf("%+'05d", $this->getTimeZoneOffsetSeconds() / 3600 * 100))); + /** @psalm-suppress PossiblyFalseReference, ImpureMethodCall */ + $dateTime = $dateTime->setTimezone(self::fixedOffsetTimeZone($this->getTimeZoneOffsetSeconds())); if ($this->legacy) { /** @@ -95,6 +96,25 @@ public function toDateTime(): DateTimeImmutable return $dateTime; } + /** + * PHP {@see DateTimeZone} needs a real offset form such as "+00:30", not a mangled HHMM integer + * (e.g. offset1800s must not become "+0050", which is 50 minutes and breaks TestKit offset-only datetimes). + */ + private static function fixedOffsetTimeZone(int $tzOffsetSeconds): DateTimeZone + { + $sign = $tzOffsetSeconds < 0 ? '-' : '+'; + $abs = abs($tzOffsetSeconds); + $hours = intdiv($abs, 3600); + $minutes = intdiv($abs % 3600, 60); + $seconds = $abs % 60; + $name = $seconds === 0 + ? sprintf('%s%02d:%02d', $sign, $hours, $minutes) + : sprintf('%s%02d:%02d:%02d', $sign, $hours, $minutes, $seconds); + + /** @var non-empty-string $name */ + return new DateTimeZone($name); + } + /** * @return array{seconds: int, nanoseconds: int, tzOffsetSeconds: int} */ @@ -120,4 +140,30 @@ public function convertToBolt(): IStructure return new \Bolt\protocol\v5\structures\DateTime($this->getSeconds(), $this->getNanoseconds(), $this->getTimeZoneOffsetSeconds()); } + + /** + * Legacy 0x46 for Bolt below 5 when the UTC patch is not negotiated (incl. 4.4 non-patched TestKit stubs). + * v5/0x49 for Bolt 5+ or when the server echoed patch_bolt utc (4.3–4.4). + */ + public function convertToBoltWithProtocol( + ConnectionProtocol $protocol, + bool $boltUtcPatchNegotiated = false, + ): IStructure { + /** @psalm-suppress ImpureMethodCall */ + $isLegacyWire = $protocol->compare(ConnectionProtocol::BOLT_V5()) < 0 && !$boltUtcPatchNegotiated; + if ($isLegacyWire) { + // Legacy Bolt DateTime (0x46): seconds are UTC epoch + tz offset, not plain Unix UTC. + return new \Bolt\protocol\v1\structures\DateTime( + $this->getSeconds() + $this->getTimeZoneOffsetSeconds(), + $this->getNanoseconds(), + $this->getTimeZoneOffsetSeconds(), + ); + } + + return new \Bolt\protocol\v5\structures\DateTime( + $this->getSeconds(), + $this->getNanoseconds(), + $this->getTimeZoneOffsetSeconds(), + ); + } } diff --git a/src/Types/DateTimeZoneId.php b/src/Types/DateTimeZoneId.php index 1ebf88a1..fd2e5acd 100644 --- a/src/Types/DateTimeZoneId.php +++ b/src/Types/DateTimeZoneId.php @@ -18,6 +18,7 @@ use DateTimeZone; use Exception; use Laudis\Neo4j\Contracts\BoltConvertibleInterface; +use Laudis\Neo4j\Enum\ConnectionProtocol; use function sprintf; @@ -62,6 +63,8 @@ public function getNanoseconds(): int /** * Returns the timezone identifier. + * + * @return non-empty-string */ public function getTimezoneIdentifier(): string { @@ -109,4 +112,30 @@ public function convertToBolt(): IStructure { return new \Bolt\protocol\v1\structures\DateTimeZoneId($this->getSeconds(), $this->getNanoseconds(), $this->getTimezoneIdentifier()); } + + public function convertToBoltWithProtocol( + ConnectionProtocol $protocol, + bool $boltUtcPatchNegotiated = false, + ): IStructure { + /** @psalm-suppress ImpureMethodCall */ + $isLegacyWire = $protocol->compare(ConnectionProtocol::BOLT_V5()) < 0 && !$boltUtcPatchNegotiated; + if ($isLegacyWire) { + $utc = (new DateTimeImmutable(sprintf('@%d', $this->getSeconds()))) + ->modify(sprintf('+%d microseconds', intdiv($this->getNanoseconds(), 1000))); + $local = $utc->setTimezone(new DateTimeZone($this->getTimezoneIdentifier())); + $legacySeconds = $this->getSeconds() + $local->getOffset(); + + return new \Bolt\protocol\v1\structures\DateTimeZoneId( + $legacySeconds, + $this->getNanoseconds(), + $this->getTimezoneIdentifier(), + ); + } + + return new \Bolt\protocol\v5\structures\DateTimeZoneId( + $this->getSeconds(), + $this->getNanoseconds(), + $this->getTimezoneIdentifier(), + ); + } } diff --git a/src/Types/Node.php b/src/Types/Node.php index 4c529fab..a2eec930 100644 --- a/src/Types/Node.php +++ b/src/Types/Node.php @@ -14,14 +14,13 @@ namespace Laudis\Neo4j\Types; use Laudis\Neo4j\Exception\PropertyDoesNotExistException; -use Laudis\Neo4j\Formatter\SummarizedResultFormatter; use function sprintf; /** * A Node class representing a Node in cypher. * - * @psalm-import-type OGMTypes from SummarizedResultFormatter + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias * * @psalm-immutable * @psalm-immutable diff --git a/src/Types/OGMTypesAlias.php b/src/Types/OGMTypesAlias.php new file mode 100644 index 00000000..415d03df --- /dev/null +++ b/src/Types/OGMTypesAlias.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\Types; + +/** + * Psalm anchor for the Bolt/OGM value union. Defined in the Types namespace so + * unqualified class names in the OGMTypes union resolve to Laudis\Neo4j\Types\*. + * + * @psalm-type OGMTypes = string|int|float|bool|null|\Laudis\Neo4j\Types\Date|\Laudis\Neo4j\Types\DateTime|\Laudis\Neo4j\Types\Duration|\Laudis\Neo4j\Types\LocalDateTime|\Laudis\Neo4j\Types\LocalTime|\Laudis\Neo4j\Types\Time|\Laudis\Neo4j\Types\Node|\Laudis\Neo4j\Types\Relationship|\Laudis\Neo4j\Types\Path|\Laudis\Neo4j\Types\Cartesian3DPoint|\Laudis\Neo4j\Types\CartesianPoint|\Laudis\Neo4j\Types\WGS84Point|\Laudis\Neo4j\Types\WGS843DPoint|\Laudis\Neo4j\Types\DateTimeZoneId|\Laudis\Neo4j\Types\Vector|\Laudis\Neo4j\Types\UnsupportedType|\Laudis\Neo4j\Types\CypherList|\Laudis\Neo4j\Types\CypherMap + */ +final class OGMTypesAlias +{ + private function __construct() + { + } +} diff --git a/src/Types/Relationship.php b/src/Types/Relationship.php index fe3b5dc1..fa16c605 100644 --- a/src/Types/Relationship.php +++ b/src/Types/Relationship.php @@ -13,12 +13,10 @@ namespace Laudis\Neo4j\Types; -use Laudis\Neo4j\Formatter\SummarizedResultFormatter; - /** * A Relationship class representing a Relationship in cypher. * - * @psalm-import-type OGMTypes from SummarizedResultFormatter + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias * * @psalm-immutable */ diff --git a/src/Types/UnboundRelationship.php b/src/Types/UnboundRelationship.php index cde5fdb1..941f7488 100644 --- a/src/Types/UnboundRelationship.php +++ b/src/Types/UnboundRelationship.php @@ -14,14 +14,13 @@ namespace Laudis\Neo4j\Types; use Laudis\Neo4j\Exception\PropertyDoesNotExistException; -use Laudis\Neo4j\Formatter\SummarizedResultFormatter; use function sprintf; /** * A relationship without any nodes attached to it. * - * @psalm-import-type OGMTypes from SummarizedResultFormatter + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias * * @psalm-immutable * diff --git a/src/Types/UnsupportedType.php b/src/Types/UnsupportedType.php new file mode 100644 index 00000000..4b8c53be --- /dev/null +++ b/src/Types/UnsupportedType.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\Types; + +use Bolt\protocol\IStructure; +use Bolt\protocol\v6\structures\UnsupportedType as BoltUnsupportedType; +use InvalidArgumentException; +use Laudis\Neo4j\Contracts\BoltConvertibleInterface; + +/** + * OGM type for Bolt UnsupportedType (Bolt 6+). + * + * @psalm-immutable + */ +final class UnsupportedType implements BoltConvertibleInterface +{ + public function __construct( + private readonly string $name, + private readonly string $minimumProtocol, + private readonly ?string $message = null, + ) { + } + + public static function fromBolt(string $name, int $minimumProtocolMajor, int $minimumProtocolMinor, ?string $message): self + { + return new self($name, $minimumProtocolMajor.'.'.$minimumProtocolMinor, $message); + } + + public function getName(): string + { + return $this->name; + } + + public function getMinimumProtocol(): string + { + return $this->minimumProtocol; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function convertToBolt(): IStructure + { + $parts = explode('.', $this->minimumProtocol, 2); + $major = (int) $parts[0]; + $minor = (int) ($parts[1] ?? '0'); + if ($major < 0 || $minor < 0) { + throw new InvalidArgumentException('Invalid minimum protocol: '.$this->minimumProtocol); + } + + $extra = []; + if ($this->message !== null) { + $extra['message'] = $this->message; + } + + return new BoltUnsupportedType($this->name, $major, $minor, $extra); + } +} diff --git a/src/Types/Vector.php b/src/Types/Vector.php index 698fc5cc..17e710fa 100644 --- a/src/Types/Vector.php +++ b/src/Types/Vector.php @@ -13,30 +13,58 @@ namespace Laudis\Neo4j\Types; +use Bolt\packstream\Bytes; +use Bolt\protocol\IStructure; +use Bolt\protocol\v6\structures\TypeMarker as BoltTypeMarker; +use Bolt\protocol\v6\structures\Vector as BoltVector; +use InvalidArgumentException; +use Laudis\Neo4j\Contracts\BoltConvertibleInterface; use Laudis\Neo4j\Enum\VectorTypeMarker; /** - * Neo4j Vector type (e.g. embedding). Holds a list of numbers. - * - * This type is only produced when decoding results from the server (Bolt). It is not supported - * as a query parameter; use a plain list of numbers if you need to pass vector-like data. + * Neo4j Vector type (e.g. embedding). Holds a list of numbers and/or a raw Bolt payload for exact round-trips. * * @psalm-immutable * * @extends AbstractPropertyObject, list> */ -final class Vector extends AbstractPropertyObject +final class Vector extends AbstractPropertyObject implements BoltConvertibleInterface { /** * @param list $values - * @param VectorTypeMarker|null $typeMarker Bolt type marker (how values were encoded); set when received from server + * @param VectorTypeMarker|null $typeMarker Bolt type marker (how values were encoded); set when received from server + * @param string|null $wireDtype TestKit dtype string (i8, i16, …) when preserving raw wire form + * @param string|null $wireDataHex Spaced lower-case hex of payload bytes (TestKit / Bolt echo) */ public function __construct( private readonly array $values, private readonly ?VectorTypeMarker $typeMarker = null, + private readonly ?string $wireDtype = null, + private readonly ?string $wireDataHex = null, ) { } + /** + * Build from TestKit / NutKit CypherVector (dtype + spaced hex), preserving bytes for parameters and equality. + */ + public static function fromWire(string $dtype, string $dataHexSpaced): self + { + $normalizedHex = self::normalizeHexString($dataHexSpaced); + $dtypeNorm = strtolower($dtype); + $marker = self::dtypeStringToBoltMarker($dtypeNorm); + $binary = self::hexSpacedToBinary($normalizedHex); + $boltVec = new BoltVector( + new Bytes([chr($marker->value)]), + new Bytes(self::binaryStringToByteArray($binary)), + ); + + /** @var list $decoded */ + $decoded = $boltVec->decode(); + $vtMarker = VectorTypeMarker::from($marker->value); + + return new self($decoded, $vtMarker, $dtypeNorm, $normalizedHex); + } + /** * @return list */ @@ -54,16 +82,28 @@ public function getTypeMarker(): ?VectorTypeMarker return $this->typeMarker; } + public function getWireDtype(): ?string + { + return $this->wireDtype; + } + + public function getWireDataHex(): ?string + { + return $this->wireDataHex; + } + /** - * @return array{values: list, typeMarker: string|null} + * @return array{values: list, typeMarker: string|null, wireDtype: string|null, wireDataHex: string|null} * - * @psalm-suppress ImplementedReturnTypeMismatch parent expects array> but we add typeMarker (string|null) for clarity + * @psalm-suppress ImplementedReturnTypeMismatch shape differs from AbstractCypherObject template */ public function toArray(): array { return [ 'values' => $this->values, 'typeMarker' => $this->typeMarker?->name, + 'wireDtype' => $this->wireDtype, + 'wireDataHex' => $this->wireDataHex, ]; } @@ -71,4 +111,100 @@ public function getProperties(): CypherMap { return new CypherMap($this); } + + /** + * @psalm-suppress ImpureMethodCall Bolt encode paths are deterministic + */ + public function convertToBolt(): IStructure + { + if ($this->wireDtype !== null && $this->wireDataHex !== null && $this->typeMarker !== null) { + $marker = BoltTypeMarker::from($this->typeMarker->value); + $binary = self::hexSpacedToBinary($this->wireDataHex); + + return new BoltVector( + new Bytes([chr($marker->value)]), + new Bytes(self::binaryStringToByteArray($binary)), + ); + } + + $boltMarker = $this->typeMarker !== null + ? BoltTypeMarker::from($this->typeMarker->value) + : null; + + return BoltVector::encode($this->values, $boltMarker); + } + + private static function normalizeHexString(string $dataHexSpaced): string + { + $trimmed = trim($dataHexSpaced); + if ($trimmed === '') { + return ''; + } + + $parts = preg_split('/\s+/', $trimmed, -1, PREG_SPLIT_NO_EMPTY); + if ($parts === false) { + return ''; + } + + return implode(' ', array_map(static function (string $p): string { + return strtolower($p); + }, $parts)); + } + + /** + * @return list + */ + private static function binaryStringToByteArray(string $binary): array + { + if ($binary === '') { + return []; + } + + /** @var list */ + return str_split($binary, 1); + } + + private static function hexSpacedToBinary(string $normalizedHex): string + { + if ($normalizedHex === '') { + return ''; + } + + $parts = explode(' ', $normalizedHex); + $out = ''; + foreach ($parts as $p) { + if (strlen($p) !== 2 || !ctype_xdigit($p)) { + throw new InvalidArgumentException('Invalid hex byte in vector data: '.$p); + } + $out .= chr((int) hexdec($p)); + } + + return $out; + } + + private static function dtypeStringToBoltMarker(string $dtype): BoltTypeMarker + { + return match ($dtype) { + 'i8' => BoltTypeMarker::INT_8, + 'i16' => BoltTypeMarker::INT_16, + 'i32' => BoltTypeMarker::INT_32, + 'i64' => BoltTypeMarker::INT_64, + 'f32' => BoltTypeMarker::FLOAT_32, + 'f64' => BoltTypeMarker::FLOAT_64, + default => throw new InvalidArgumentException('Unknown vector dtype: '.$dtype), + }; + } + + public static function markerByteToDtypeString(int $byte): string + { + return match ($byte) { + 0xC8 => 'i8', + 0xC9 => 'i16', + 0xCA => 'i32', + 0xCB => 'i64', + 0xC6 => 'f32', + 0xC1 => 'f64', + default => throw new InvalidArgumentException('Unknown vector type marker byte: '.$byte), + }; + } } diff --git a/testkit-backend/features.php b/testkit-backend/features.php index d7405993..ed83c8c2 100644 --- a/testkit-backend/features.php +++ b/testkit-backend/features.php @@ -85,6 +85,10 @@ 'Feature:API:Type.Spatial' => true, // The driver supports sending and receiving temporal data types. 'Feature:API:Type.Temporal' => true, + // The driver supports sending and receiving the unsupported-type sentinel (Bolt 6+ stubs). + 'Feature:API:Type.UnsupportedType' => true, + // The driver supports sending and receiving vector values (Bolt 6+ stubs). + 'Feature:API:Type.Vector' => true, // The driver supports single-sign-on (SSO) by providing a bearer auth token // API. 'Feature:Auth:Bearer' => true, @@ -127,6 +131,8 @@ 'Feature:Bolt:5.7' => false, // The driver supports Bolt protocol version 5.8 'Feature:Bolt:5.8' => false, + // The driver supports Bolt protocol version 6.0 (stub datatypes: vector, unsupported type). + 'Feature:Bolt:6.0' => true, // The driver supports negotiating the Bolt protocol version with the server // using handshake manifest v1. 'Feature:Bolt:HandshakeManifestV1' => true, diff --git a/testkit-backend/src/Handlers/AbstractRunner.php b/testkit-backend/src/Handlers/AbstractRunner.php index a648b595..20e837df 100644 --- a/testkit-backend/src/Handlers/AbstractRunner.php +++ b/testkit-backend/src/Handlers/AbstractRunner.php @@ -14,7 +14,10 @@ namespace Laudis\Neo4j\TestkitBackend\Handlers; use Bolt\error\ConnectException as BoltConnectException; +use DateTimeImmutable; +use DateTimeZone; use Exception; +use InvalidArgumentException; use Laudis\Neo4j\Contracts\SessionInterface; use Laudis\Neo4j\Contracts\TransactionInterface; use Laudis\Neo4j\Databags\Neo4jError; @@ -31,11 +34,15 @@ use Laudis\Neo4j\Types\AbstractCypherObject; use Laudis\Neo4j\Types\CypherList; use Laudis\Neo4j\Types\CypherMap; +use Laudis\Neo4j\Types\DateTime as Neo4jDateTime; +use Laudis\Neo4j\Types\DateTimeZoneId as Neo4jDateTimeZoneId; +use Laudis\Neo4j\Types\UnsupportedType; +use Laudis\Neo4j\Types\Vector; use Psr\Log\LoggerInterface; use Symfony\Component\Uid\Uuid; /** - * @psalm-import-type OGMTypes from \Laudis\Neo4j\Formatter\OGMFormatter + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias * * @template T of \Laudis\Neo4j\TestkitBackend\Requests\SessionRunRequest|\Laudis\Neo4j\TestkitBackend\Requests\TransactionRunRequest * @@ -128,6 +135,31 @@ public function handle($request): ResultResponse|DriverErrorResponse */ public static function decodeToValue(array $param) { + if ($param['name'] === 'CypherVector') { + /** @var array{dtype: string, data: string} $d */ + $d = $param['data']; + + return Vector::fromWire((string) $d['dtype'], (string) $d['data']); + } + if ($param['name'] === 'CypherUnsupportedType') { + /** @var array{name: string, minimumProtocol?: string, minimum_protocol?: string, message?: string|null} $d */ + $d = $param['data']; + $minProto = $d['minimumProtocol'] ?? $d['minimum_protocol'] ?? ''; + $msg = $d['message'] ?? null; + + return new UnsupportedType( + (string) $d['name'], + (string) $minProto, + $msg !== null ? (string) $msg : null, + ); + } + if ($param['name'] === 'CypherDateTime') { + /** @var array $d */ + $d = $param['data']; + + return self::decodeCypherDateTime($d); + } + $value = $param['data']['value']; if (is_iterable($value)) { if ($param['name'] === 'CypherMap') { @@ -162,6 +194,67 @@ public static function decodeToValue(array $param) return $value; } + /** + * @param array $d + */ + private static function decodeCypherDateTime(array $d): Neo4jDateTime|Neo4jDateTimeZoneId + { + $year = (int) $d['year']; + $month = (int) $d['month']; + $day = (int) $d['day']; + $hour = (int) $d['hour']; + $minute = (int) $d['minute']; + $second = (int) $d['second']; + $nanosecond = (int) $d['nanosecond']; + /** @var string|null $timezoneId */ + $timezoneId = $d['timezone_id'] ?? null; + $utcOffsetS = array_key_exists('utc_offset_s', $d) ? $d['utc_offset_s'] : null; + + if ($timezoneId !== null && $timezoneId !== '') { + $tz = new DateTimeZone($timezoneId); + } else { + $tz = self::timezoneFromUtcOffsetSeconds((int) ($utcOffsetS ?? 0)); + } + + $microPart = intdiv($nanosecond, 1000); + $formatted = sprintf( + '%04d-%02d-%02d %02d:%02d:%02d.%06d', + $year, + $month, + $day, + $hour, + $minute, + $second, + $microPart + ); + $immutable = DateTimeImmutable::createFromFormat('Y-m-d H:i:s.u', $formatted, $tz); + if ($immutable === false) { + throw new InvalidArgumentException('Invalid CypherDateTime wall clock'); + } + + $utc = $immutable->setTimezone(new DateTimeZone('UTC')); + $unixSeconds = (int) $utc->format('U'); + $nanoseconds = (int) $utc->format('u') * 1000 + ($nanosecond % 1000); + + if ($timezoneId !== null && $timezoneId !== '') { + return new Neo4jDateTimeZoneId($unixSeconds, $nanoseconds, $timezoneId); + } + + $tzOffsetSeconds = $immutable->getOffset(); + + return new Neo4jDateTime($unixSeconds, $nanoseconds, $tzOffsetSeconds, false); + } + + private static function timezoneFromUtcOffsetSeconds(int $offsetSeconds): DateTimeZone + { + $sign = $offsetSeconds >= 0 ? '+' : '-'; + $abs = abs($offsetSeconds); + $h = intdiv($abs, 3600); + $m = intdiv($abs % 3600, 60); + + return new DateTimeZone($sign.sprintf('%02d:%02d', $h, $m)); + } + /** * @param T $request * diff --git a/testkit-backend/src/Handlers/ResultNext.php b/testkit-backend/src/Handlers/ResultNext.php index c3c98f39..b9bd159b 100644 --- a/testkit-backend/src/Handlers/ResultNext.php +++ b/testkit-backend/src/Handlers/ResultNext.php @@ -43,6 +43,7 @@ public function __construct(MainRepository $repository) */ public function handle($request): TestkitResponseInterface { + $iterator = null; try { $record = $this->repository->getRecords($request->getResultId()); if ($record instanceof TestkitResponseInterface) { @@ -70,13 +71,22 @@ public function handle($request): TestkitResponseInterface } $this->repository->addPendingIteratorNext($request->getResultId()); + $this->repository->clearPendingDriverError($request->getResultId()); return new RecordResponse($values); } catch (Neo4jException $e) { - $response = new DriverErrorResponse($request->getResultId(), $e); - $this->repository->addRecords($request->getResultId(), $response); + $this->repository->setPendingDriverError($request->getResultId(), $e); + if ($iterator !== null) { + try { + if ($iterator->valid()) { + $iterator->next(); + } + } catch (Throwable) { + // Iterator may be exhausted or the stream broken after a row-level decode error. + } + } - return $response; + return new DriverErrorResponse($request->getResultId(), $e); } catch (BoltException $e) { $neo4jError = Neo4jError::fromMessageAndCode('Neo.ClientError.General.ConnectionError', $e->getMessage()); $wrapped = new Neo4jException([$neo4jError], $e); diff --git a/testkit-backend/src/Handlers/RetryableNegative.php b/testkit-backend/src/Handlers/RetryableNegative.php index d9c787bd..3aadda68 100644 --- a/testkit-backend/src/Handlers/RetryableNegative.php +++ b/testkit-backend/src/Handlers/RetryableNegative.php @@ -62,19 +62,26 @@ public function handle($request): TestkitResponseInterface if ($errorId !== '' && $errorId !== null) { try { $errorUuid = $errorId instanceof Uuid ? $errorId : Uuid::fromString($errorId); - $errorResponse = $this->repository->getRecords($errorUuid); - if ($errorResponse instanceof DriverErrorResponse) { - $resolvedException = $errorResponse->getException(); + $resolvedException = $this->repository->takePendingDriverError($errorUuid); + if ($resolvedException === null) { + $errorResponse = $this->repository->getRecords($errorUuid); + if ($errorResponse instanceof DriverErrorResponse) { + $resolvedException = $errorResponse->getException(); + } } } catch (Throwable $e) { $this->logger->debug('Could not retrieve error for RetryableNegative', ['exception' => $e->getMessage()]); } } - // After FAILURE on PULL, BoltConnection RESETs before throwing Neo4jException — ROLLBACK is invalid on the wire. - $skipRollback = $resolvedException instanceof Neo4jException; + $transientRetry = $resolvedException instanceof Neo4jException + && $resolvedException->getClassification() === 'TransientError'; - if (!$skipRollback) { + // Managed tx retry ({@see Session::executeRead}): same {@see BoltUnmanagedTransaction} is reused. Calling + // rollback() when the server is already READY (e.g. after PULL FAILURE + RESET) marks the client + // ROLLED_BACK and the next attempt fails with "Can't run a query on a rolled back transaction." + // Skip rollback for transient errors so {@see BoltUnmanagedTransaction::ensureBeginSent} can issue BEGIN again. + if (!$transientRetry) { try { $tsx->rollback(); } catch (Throwable $e) { @@ -83,7 +90,7 @@ public function handle($request): TestkitResponseInterface } if ($resolvedException instanceof Neo4jException) { - if ($resolvedException->getClassification() === 'TransientError') { + if ($transientRetry) { return new RetryableTryResponse($transactionId); } diff --git a/testkit-backend/src/MainRepository.php b/testkit-backend/src/MainRepository.php index 05ec7146..156942d9 100644 --- a/testkit-backend/src/MainRepository.php +++ b/testkit-backend/src/MainRepository.php @@ -18,12 +18,14 @@ use Laudis\Neo4j\Contracts\SessionInterface; use Laudis\Neo4j\Contracts\UnmanagedTransactionInterface; use Laudis\Neo4j\Databags\SummarizedResult; +use Laudis\Neo4j\Exception\Neo4jException; +use Laudis\Neo4j\Exception\TransactionException; use Laudis\Neo4j\TestkitBackend\Contracts\TestkitResponseInterface; use Laudis\Neo4j\Types\CypherMap; use Symfony\Component\Uid\Uuid; /** - * @psalm-import-type OGMTypes from \Laudis\Neo4j\Formatter\OGMFormatter + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias */ final class MainRepository { @@ -55,6 +57,15 @@ final class MainRepository */ private array $pendingIteratorNextCount = []; + /** + * Last driver error from {@see Handlers\ResultNext} for this result id, kept + * separately so {@see addRecords} does not replace the live {@see SummarizedResult} iterator (e.g. stub + * "unknown then known" zoned datetime). Consumed by {@see RetryableNegative}. + * + * @var array + */ + private array $pendingDriverErrorsByResultId = []; + /** * @param array>>> $drivers * @param array>>> $sessions @@ -206,10 +217,33 @@ public function removeRecords(Uuid $id): void $this->recordIterators[$key], $this->iteratorFetchedFirst[$key], $this->peekPrimed[$key], - $this->pendingIteratorNextCount[$key] + $this->pendingIteratorNextCount[$key], + $this->pendingDriverErrorsByResultId[$key] ); } + public function setPendingDriverError(Uuid $resultId, Neo4jException|TransactionException $exception): void + { + $this->pendingDriverErrorsByResultId[$resultId->toRfc4122()] = $exception; + } + + public function clearPendingDriverError(Uuid $resultId): void + { + unset($this->pendingDriverErrorsByResultId[$resultId->toRfc4122()]); + } + + public function takePendingDriverError(Uuid $resultId): Neo4jException|TransactionException|null + { + $key = $resultId->toRfc4122(); + if (!array_key_exists($key, $this->pendingDriverErrorsByResultId)) { + return null; + } + $exception = $this->pendingDriverErrorsByResultId[$key]; + unset($this->pendingDriverErrorsByResultId[$key]); + + return $exception; + } + /** * @return SummarizedResult|TestkitResponseInterface */ diff --git a/testkit-backend/src/Responses/RecordListResponse.php b/testkit-backend/src/Responses/RecordListResponse.php index 30df4b7e..31697ad0 100644 --- a/testkit-backend/src/Responses/RecordListResponse.php +++ b/testkit-backend/src/Responses/RecordListResponse.php @@ -19,7 +19,7 @@ /** * Response to ResultList — full materialized record list. * - * @psalm-import-type OGMTypes from \Laudis\Neo4j\Formatter\OGMFormatter + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias */ final class RecordListResponse implements TestkitResponseInterface { diff --git a/testkit-backend/src/Responses/Types/CypherObject.php b/testkit-backend/src/Responses/Types/CypherObject.php index 46a94117..0d4b1147 100644 --- a/testkit-backend/src/Responses/Types/CypherObject.php +++ b/testkit-backend/src/Responses/Types/CypherObject.php @@ -13,20 +13,26 @@ namespace Laudis\Neo4j\TestkitBackend\Responses\Types; +use Bolt\protocol\v6\structures\TypeMarker as BoltTypeMarker; +use Bolt\protocol\v6\structures\Vector as BoltVector; + 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\DateTime as Neo4jDateTime; +use Laudis\Neo4j\Types\DateTimeZoneId as Neo4jDateTimeZoneId; use Laudis\Neo4j\Types\Node; use Laudis\Neo4j\Types\Path; use Laudis\Neo4j\Types\Relationship; use Laudis\Neo4j\Types\UnboundRelationship; +use Laudis\Neo4j\Types\UnsupportedType; use Laudis\Neo4j\Types\Vector; use RuntimeException; /** - * @psalm-import-type OGMTypes from \Laudis\Neo4j\Formatter\OGMFormatter + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias */ final class CypherObject implements TestkitResponseInterface { @@ -87,11 +93,31 @@ public static function autoDetect($value): TestkitResponseInterface break; case Vector::class: /** @var Vector $value */ - $list = []; - foreach ($value->getValues() as $item) { - $list[] = self::autoDetect($item); + if ($value->getWireDtype() !== null && $value->getWireDataHex() !== null) { + $tbr = new CypherObject('CypherVector', [ + 'dtype' => $value->getWireDtype(), + 'data' => $value->getWireDataHex(), + ]); + } else { + $boltTm = $value->getTypeMarker() !== null + ? BoltTypeMarker::from($value->getTypeMarker()->value) + : null; + $enc = BoltVector::encode($value->getValues(), $boltTm); + $dataStr = (string) $enc->data; + $hex = $dataStr === '' + ? '' + : implode(' ', array_map(static fn (string $b): string => sprintf('%02x', ord($b)), str_split($dataStr, 1))); + $dtype = Vector::markerByteToDtypeString(ord((string) $enc->type_marker[0])); + $tbr = new CypherObject('CypherVector', ['dtype' => $dtype, 'data' => $hex]); } - $tbr = new CypherObject('Vector', new CypherList($list)); + break; + case UnsupportedType::class: + /** @var UnsupportedType $value */ + $tbr = new CypherObject('CypherUnsupportedType', [ + 'name' => $value->getName(), + 'minimumProtocol' => $value->getMinimumProtocol(), + 'message' => $value->getMessage(), + ]); break; case 'int': $tbr = new CypherObject('CypherInt', $value); @@ -237,6 +263,12 @@ public static function autoDetect($value): TestkitResponseInterface new CypherObject('CypherNull', null) ); break; + case Neo4jDateTime::class: + $tbr = new CypherObject('CypherDateTime', self::neo4jDateTimeToTestkitData($value)); + break; + case Neo4jDateTimeZoneId::class: + $tbr = new CypherObject('CypherDateTime', self::neo4jDateTimeZoneIdToTestkitData($value)); + break; default: throw new RuntimeException('Unexpected type: '.get_debug_type($value)); } @@ -244,8 +276,87 @@ public static function autoDetect($value): TestkitResponseInterface return $tbr; } + /** + * @return array{ + * year: int, + * month: int, + * day: int, + * hour: int, + * minute: int, + * second: int, + * nanosecond: int, + * utc_offset_s: int, + * timezone_id: null + * } + */ + private static function neo4jDateTimeToTestkitData(Neo4jDateTime $value): array + { + $local = $value->toDateTime(); + + return [ + 'year' => (int) $local->format('Y'), + 'month' => (int) $local->format('n'), + 'day' => (int) $local->format('j'), + 'hour' => (int) $local->format('G'), + 'minute' => (int) $local->format('i'), + 'second' => (int) $local->format('s'), + 'nanosecond' => $value->getNanoseconds(), + 'utc_offset_s' => $value->getTimeZoneOffsetSeconds(), + 'timezone_id' => null, + ]; + } + + /** + * @return array{ + * year: int, + * month: int, + * day: int, + * hour: int, + * minute: int, + * second: int, + * nanosecond: int, + * utc_offset_s: int, + * timezone_id: string + * } + */ + private static function neo4jDateTimeZoneIdToTestkitData(Neo4jDateTimeZoneId $value): array + { + $local = $value->toDateTime(); + + return [ + 'year' => (int) $local->format('Y'), + 'month' => (int) $local->format('n'), + 'day' => (int) $local->format('j'), + 'hour' => (int) $local->format('G'), + 'minute' => (int) $local->format('i'), + 'second' => (int) $local->format('s'), + 'nanosecond' => $value->getNanoseconds(), + 'utc_offset_s' => $local->getOffset(), + 'timezone_id' => $value->getTimezoneIdentifier(), + ]; + } + public function jsonSerialize(): array { + if ($this->name === 'CypherVector' && is_array($this->value)) { + return [ + 'name' => $this->name, + 'data' => $this->value, + ]; + } + if ($this->name === 'CypherUnsupportedType' && is_array($this->value)) { + return [ + 'name' => $this->name, + 'data' => $this->value, + ]; + } + if ($this->name === 'CypherDateTime' && is_array($this->value)) { + return [ + 'name' => $this->name, + 'data' => $this->value, + ]; + } + return [ 'name' => $this->name, 'data' => [ diff --git a/testkit-backend/testkit.sh b/testkit-backend/testkit.sh index 5190fb99..d997e2b6 100755 --- a/testkit-backend/testkit.sh +++ b/testkit-backend/testkit.sh @@ -216,6 +216,30 @@ python3 -m unittest -v \ tests.stub.iteration.test_iteration_tx_run.TestIterationTxRun.test_all_v3 \ tests.stub.iteration.test_iteration_tx_run.TestIterationTxRun.test_nested \ tests.stub.iteration.test_iteration_tx_run.TestIterationTxRun.test_nested_using_list \ +\ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV3x0.test_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV3x0.test_zoned_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x2.test_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x2.test_zoned_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x3.test_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x3.test_date_time_with_patch \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x3.test_zoned_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x3.test_zoned_date_time_with_patch \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_date_time_with_patch \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_zoned_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_zoned_date_time_with_patch \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_unknown_zoned_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_unknown_zoned_date_time_patched \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_unknown_then_known_zoned_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV4x4.test_unknown_then_known_zoned_date_time_patched \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV5x0.test_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV5x0.test_zoned_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV5x0.test_unknown_zoned_date_time \ + tests.stub.datatypes.test_temporal_types.TestTemporalTypesV5x0.test_unknown_then_known_zoned_date_time \ + tests.stub.datatypes.test_unsupported_type.TestUnsupportedTypes.test_unsupported_type \ + tests.stub.datatypes.test_unsupported_type.TestUnsupportedTypes.test_unsupported_type_in_list \ + tests.stub.datatypes.test_vector_types.TestVectorTypes.test_vector \ EXIT_CODE=$? diff --git a/tests/Unit/BasicAuthTest.php b/tests/Unit/BasicAuthTest.php index 04258922..c0f301b8 100644 --- a/tests/Unit/BasicAuthTest.php +++ b/tests/Unit/BasicAuthTest.php @@ -21,6 +21,7 @@ use Laudis\Neo4j\Bolt\BoltConnection; use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Databags\Neo4jError; +use Laudis\Neo4j\Enum\ConnectionProtocol; use Laudis\Neo4j\Exception\Neo4jException; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; @@ -67,6 +68,7 @@ public function testAuthenticateBoltSuccess(): void $mockConnection = $this->createMock(BoltConnection::class); $mockConnection->method('protocol')->willReturn($mockProtocol); + $mockConnection->method('getProtocol')->willReturn(ConnectionProtocol::BOLT_V5()); $result = $this->auth->authenticateBolt($mockConnection, $userAgent); $this->assertArrayHasKey('server', $result); @@ -88,6 +90,7 @@ public function testAuthenticateBoltFailure(): void $mockConnection = $this->createMock(BoltConnection::class); $mockConnection->method('protocol')->willReturn($mockProtocol); + $mockConnection->method('getProtocol')->willReturn(ConnectionProtocol::BOLT_V5()); $error = Neo4jError::fromMessageAndCode('Neo.ClientError.Security.Unauthorized', 'Invalid credentials'); $exception = new Neo4jException([$error]); diff --git a/tests/Unit/BoltOGMTranslatorTest.php b/tests/Unit/BoltOGMTranslatorTest.php new file mode 100644 index 00000000..c066c164 --- /dev/null +++ b/tests/Unit/BoltOGMTranslatorTest.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\Tests\Unit; + +use Bolt\protocol\v1\structures\DateTimeZoneId as BoltV1DateTimeZoneId; +use Bolt\protocol\v5\structures\DateTimeZoneId as BoltV5DateTimeZoneId; +use Laudis\Neo4j\Exception\Neo4jException; +use Laudis\Neo4j\Formatter\Specialised\BoltOGMTranslator; +use Laudis\Neo4j\Types\DateTimeZoneId; +use PHPUnit\Framework\TestCase; + +final class BoltOGMTranslatorTest extends TestCase +{ + public function testLegacyBoltDateTimeZoneIdDecodesToUtcSeconds(): void + { + $utcUnix = 1654595525; + $offset = 7200; + $legacyWireSeconds = $utcUnix + $offset; + + $bolt = new BoltV1DateTimeZoneId($legacyWireSeconds, 0, 'Europe/Stockholm'); + $translator = new BoltOGMTranslator(); + $value = $translator->mapValueToType($bolt); + self::assertInstanceOf(DateTimeZoneId::class, $value); + + self::assertSame($utcUnix, $value->getSeconds()); + self::assertSame(11, (int) $value->toDateTime()->format('G')); + } + + public function testV5BoltDateTimeZoneIdPassesUtcSecondsThrough(): void + { + $utcUnix = 1654595525; + $bolt = new BoltV5DateTimeZoneId($utcUnix, 0, 'Europe/Stockholm'); + $translator = new BoltOGMTranslator(); + $value = $translator->mapValueToType($bolt); + self::assertInstanceOf(DateTimeZoneId::class, $value); + + self::assertSame($utcUnix, $value->getSeconds()); + } + + public function testUnknownIanaZoneOnLegacyStructureThrowsNeo4jException(): void + { + $bolt = new BoltV1DateTimeZoneId(0, 0, 'Europe/Neo4j'); + $translator = new BoltOGMTranslator(); + + try { + $translator->mapValueToType($bolt); + self::fail('Expected Neo4jException'); + } catch (Neo4jException $e) { + self::assertStringContainsString('Europe/Neo4j', $e->getNeo4jMessage() ?? ''); + self::assertSame('Neo.ClientError.Statement.TypeError', $e->getNeo4jCode()); + } + } + + public function testUnknownIanaZoneOnV5StructureThrowsNeo4jException(): void + { + $bolt = new BoltV5DateTimeZoneId(0, 0, 'Europe/Neo4j'); + $translator = new BoltOGMTranslator(); + + try { + $translator->mapValueToType($bolt); + self::fail('Expected Neo4jException'); + } catch (Neo4jException $e) { + self::assertStringContainsString('Europe/Neo4j', $e->getNeo4jMessage() ?? ''); + } + } +} diff --git a/tests/Unit/ParameterHelperTest.php b/tests/Unit/ParameterHelperTest.php index 2a18b8e1..41557d04 100644 --- a/tests/Unit/ParameterHelperTest.php +++ b/tests/Unit/ParameterHelperTest.php @@ -20,6 +20,8 @@ use Iterator; use Laudis\Neo4j\Enum\ConnectionProtocol; use Laudis\Neo4j\ParameterHelper; +use Laudis\Neo4j\Types\DateTime as Neo4jDateTime; +use Laudis\Neo4j\Types\DateTimeZoneId as Neo4jDateTimeZoneId; use PHPUnit\Framework\TestCase; use stdClass; use Stringable; @@ -175,4 +177,44 @@ public function testDateTime5(): void self::assertInstanceOf(\Bolt\protocol\v5\structures\DateTimeZoneId::class, $date); } + + public function testNeo4jDateTimeBolt43LegacyUsesV1Structure(): void + { + $dt = new Neo4jDateTime(1654595525, 0, 7200, false); + $p = ParameterHelper::asParameter($dt, ConnectionProtocol::BOLT_V43(), false); + + self::assertInstanceOf(\Bolt\protocol\v1\structures\DateTime::class, $p); + } + + public function testNeo4jDateTimeBolt43UtcPatchUsesV5Structure(): void + { + $dt = new Neo4jDateTime(1654595525, 0, 7200, false); + $p = ParameterHelper::asParameter($dt, ConnectionProtocol::BOLT_V43(), true); + + self::assertInstanceOf(\Bolt\protocol\v5\structures\DateTime::class, $p); + } + + public function testNeo4jDateTimeZoneIdBolt43UtcPatchUsesV5Structure(): void + { + $dt = new Neo4jDateTimeZoneId(1654595525, 0, 'Europe/Stockholm'); + $p = ParameterHelper::asParameter($dt, ConnectionProtocol::BOLT_V43(), true); + + self::assertInstanceOf(\Bolt\protocol\v5\structures\DateTimeZoneId::class, $p); + } + + public function testNeo4jDateTimeBolt44WithoutPatchUsesV1Structure(): void + { + $dt = new Neo4jDateTime(1654595525, 0, 7200, false); + $p = ParameterHelper::asParameter($dt, ConnectionProtocol::BOLT_V44(), false); + + self::assertInstanceOf(\Bolt\protocol\v1\structures\DateTime::class, $p); + } + + public function testNeo4jDateTimeBolt44WithPatchUsesV5Structure(): void + { + $dt = new Neo4jDateTime(1654595525, 0, 7200, false); + $p = ParameterHelper::asParameter($dt, ConnectionProtocol::BOLT_V44(), true); + + self::assertInstanceOf(\Bolt\protocol\v5\structures\DateTime::class, $p); + } } diff --git a/tests/Unit/SummarizedResultListTest.php b/tests/Unit/SummarizedResultListTest.php index f1418866..dc525336 100644 --- a/tests/Unit/SummarizedResultListTest.php +++ b/tests/Unit/SummarizedResultListTest.php @@ -14,7 +14,6 @@ namespace Laudis\Neo4j\Tests\Unit; use Laudis\Neo4j\Databags\SummarizedResult; -use Laudis\Neo4j\Formatter\SummarizedResultFormatter; use Laudis\Neo4j\Types\CypherList; use Laudis\Neo4j\Types\CypherMap; use PHPUnit\Framework\TestCase; @@ -22,7 +21,7 @@ /** * Mirrors TestKit stub: next() then list() must return only remaining rows (no rewind / duplicate). * - * @psalm-import-type OGMTypes from SummarizedResultFormatter + * @psalm-import-type OGMTypes from \Laudis\Neo4j\Types\OGMTypesAlias */ final class SummarizedResultListTest extends TestCase {