diff --git a/.github/workflows/neo4j.2025.yml b/.github/workflows/neo4j.2025.yml new file mode 100644 index 0000000..50bbd70 --- /dev/null +++ b/.github/workflows/neo4j.2025.yml @@ -0,0 +1,48 @@ +name: Tests with Neo4j^2025 on PHP^8 + +on: + pull_request: + branches: [ master ] + +jobs: + db-tests-2025-2204: + runs-on: ubuntu-22.04 + name: "Running Integration tests for PHP ${{ matrix.php-version }} on Neo4j ${{ matrix.neo4j-version }}" + strategy: + fail-fast: false + matrix: + neo4j-version: ['2025.07', '2025'] + php-version: ['8.1', '8.2', '8.3', '8.4'] + + services: + neo4j: + image: neo4j:${{ matrix.neo4j-version }} + env: + NEO4J_AUTH: neo4j/nothing123 + NEO4J_PLUGINS: '["apoc"]' + ports: + - 7687:7687 + - 7474:7474 + options: >- + --health-cmd "wget http://localhost:7474 || exit 1" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, sockets + coverage: xdebug + ini-values: max_execution_time=0 + + - name: Install dependencies + run: composer install --no-progress + + - name: Test with phpunit + env: + GDB_USERNAME: neo4j + GDB_PASSWORD: nothing123 + run: vendor/bin/phpunit --configuration phpunit.xml --testsuite "Database" diff --git a/.github/workflows/db.44.2204.yml b/.github/workflows/neo4j.4.4.yml similarity index 100% rename from .github/workflows/db.44.2204.yml rename to .github/workflows/neo4j.4.4.yml diff --git a/.github/workflows/db.50.2204.yml b/.github/workflows/neo4j.5.yml similarity index 93% rename from .github/workflows/db.50.2204.yml rename to .github/workflows/neo4j.5.yml index d12478d..050ff58 100644 --- a/.github/workflows/db.50.2204.yml +++ b/.github/workflows/neo4j.5.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - neo4j-version: ['5.4', '5.6', '5.8', '5.12', '5.13', '5.23', '5.26', '2025'] + neo4j-version: ['5.4', '5.6', '5.8', '5.12', '5.22', '5.25', '5.26'] php-version: ['8.1', '8.2', '8.3', '8.4'] services: diff --git a/.github/workflows/no-db.2204.yml b/.github/workflows/no-db.yml similarity index 100% rename from .github/workflows/no-db.2204.yml rename to .github/workflows/no-db.yml diff --git a/README.md b/README.md index 09d6384..9944b21 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Website: https://stefanak.serv00.net/ ## :label: Version support -We are trying to keep up and this library supports **Bolt <= 5.8**. +We are trying to keep up and this library supports **Bolt <= 6**. ## :books: Supported ecosystems @@ -198,7 +198,7 @@ foreach ($protocol->getResponses() as $response) { } ``` -:information_source: Default settings for bolt protocol version is 4.3, 4.4 and 5.0 to 5.8. If you are within this list you can ommit calling `$bolt->setProtocolVersions();`. +:information_source: Default settings for bolt protocol version is 4.3, 4.4, 5.0 to 5.8 and 6. If you are within this list you can ommit calling `$bolt->setProtocolVersions();`. ### Autoload diff --git a/src/Bolt.php b/src/Bolt.php index c4aff7e..a918144 100644 --- a/src/Bolt.php +++ b/src/Bolt.php @@ -34,7 +34,7 @@ public function __construct(private IConnection $connection) $this->track(); } - $this->setProtocolVersions('5.8.8', '4.4.4'); + $this->setProtocolVersions(6, '5.8.8', '4.4.4'); } private function track(): void diff --git a/src/packstream/v1/Packer.php b/src/packstream/v1/Packer.php index 4318c1a..39a9d95 100644 --- a/src/packstream/v1/Packer.php +++ b/src/packstream/v1/Packer.php @@ -131,8 +131,7 @@ private function packString(string $str): iterable private function packFloat(float $value): iterable { - $packed = pack('d', $value); - yield chr(0xC1) . ($this->littleEndian ? strrev($packed) : $packed); + yield chr(0xC1) . pack('E', $value); } /** @@ -233,7 +232,7 @@ private function packStructure(IStructure $structure): iterable } $packerMethod = 'pack' . ucfirst($packerMethod); - yield from [$this, $packerMethod]($structure->{$parameter->getName()}); + yield from call_user_func([$this, $packerMethod], $structure->{$parameter->getName()}); } } diff --git a/src/packstream/v1/Unpacker.php b/src/packstream/v1/Unpacker.php index 7468cbd..39369b1 100644 --- a/src/packstream/v1/Unpacker.php +++ b/src/packstream/v1/Unpacker.php @@ -103,7 +103,7 @@ private function u(): mixed if ($output !== null) { return $output; } - $output = $this->unpackStruct($marker); + $output = $this->unpackStructure($marker); if ($output !== null) { return $output; } @@ -120,7 +120,7 @@ private function u(): mixed * @return array|IStructure|null * @throws UnpackException */ - private function unpackStruct(int $marker): array|IStructure|null + private function unpackStructure(int $marker): array|IStructure|null { if ($marker >> 4 == 0b1011) { //TINY_STRUCT $size = 0b10110000 ^ $marker; @@ -238,7 +238,7 @@ private function unpackFloat(int $marker): ?float { if ($marker == 0xC1) { $value = $this->next(8); - return (float)unpack('d', $this->littleEndian ? strrev($value) : $value)[1]; + return (float)unpack('E', $value)[1]; } else { return null; } diff --git a/src/protocol/V6.php b/src/protocol/V6.php new file mode 100644 index 0000000..39adbb8 --- /dev/null +++ b/src/protocol/V6.php @@ -0,0 +1,32 @@ + Date::class, + 0x54 => Time::class, + 0x74 => LocalTime::class, + 0x49 => DateTime::class, + 0x69 => DateTimeZoneId::class, + 0x64 => LocalDateTime::class, + 0x45 => Duration::class, + 0x58 => Point2D::class, + 0x59 => Point3D::class, + 0x56 => Vector::class, + ]; + + protected array $unpackStructuresLt = [ + 0x4E => Node::class, + 0x52 => Relationship::class, + 0x72 => UnboundRelationship::class, + 0x50 => Path::class, + 0x44 => Date::class, + 0x54 => Time::class, + 0x74 => LocalTime::class, + 0x49 => DateTime::class, + 0x69 => DateTimeZoneId::class, + 0x64 => LocalDateTime::class, + 0x45 => Duration::class, + 0x58 => Point2D::class, + 0x59 => Point3D::class, + 0x56 => Vector::class, + ]; +} diff --git a/src/protocol/v6/structures/Vector.php b/src/protocol/v6/structures/Vector.php new file mode 100644 index 0000000..1a00c25 --- /dev/null +++ b/src/protocol/v6/structures/Vector.php @@ -0,0 +1,143 @@ +type_marker, (string)$this->data]); + } + + private static array $formats = ['s', 'l', 'q']; + + /** + * Encode array as vector structure + * @param int[]|float[] $data + * @return self + * @throws \InvalidArgumentException + */ + public static function encode(array $data): self + { + if (count($data) === 0) { + throw new \InvalidArgumentException('Vector cannot be empty'); + } + if (count($data) > 4096) { + throw new \InvalidArgumentException('Vector cannot have more than 4096 elements'); + } + + $allIntegers = array_reduce($data, fn($carry, $item) => $carry && is_int($item), true); + $allFloats = array_reduce($data, fn($carry, $item) => $carry && is_float($item), true); + + // Check if all values are integer or float + if (!$allIntegers && !$allFloats) { + throw new \InvalidArgumentException('All values in the vector must be integer xor float'); + } + + $minValue = min($data); + $maxValue = max($data); + $marker = 0; + $packed = []; + $packFormat = ''; + + if ($allIntegers) { + if ($minValue >= -128 && $maxValue <= 127) { // INT_8 + $marker = 0xC8; + $packFormat = 'c'; + } elseif ($minValue >= -32768 && $maxValue <= 32767) { // INT_16 + $marker = 0xC9; + $packFormat = 's'; + } elseif ($minValue >= -2147483648 && $maxValue <= 2147483647) { // INT_32 + $marker = 0xCA; + $packFormat = 'l'; + } else { // INT_64 + $marker = 0xCB; + $packFormat = 'q'; + } + } elseif ($allFloats) { + if ($minValue >= 1.4e-45 && $maxValue <= 3.4028235e+38) { // Single precision float (FLOAT_32) + $marker = 0xC6; + $packFormat = 'G'; + } else { // Double precision float (FLOAT_64) + $marker = 0xC1; + $packFormat = 'E'; + } + } + + if ($marker === 0) { + throw new \InvalidArgumentException('Unsupported data type for vector'); + } + + // Pack the data + $littleEndian = unpack('S', "\x01\x00")[1] === 1; + foreach ($data as $entry) { + $value = pack($packFormat, $entry); + $packed[] = in_array($packFormat, self::$formats) && $littleEndian ? strrev($value) : $value; + } + + return new self(new Bytes([chr($marker)]), new Bytes($packed)); + } + + /** + * Decode vector structure .. returns binary $this->data as array + * @return int[]|float[] + * @throws \InvalidArgumentException + */ + public function decode(): array + { + switch (ord($this->type_marker[0])) { + case 0xC8: // INT_8 + $size = 1; + $unpackFormat = 'c'; + break; + case 0xC9: // INT_16 + $size = 2; + $unpackFormat = 's'; + break; + case 0xCA: // INT_32 + $size = 4; + $unpackFormat = 'l'; + break; + case 0xCB: // INT_64 + $size = 8; + $unpackFormat = 'q'; + break; + case 0xC6: // FLOAT_32 + $size = 4; + $unpackFormat = 'G'; + break; + case 0xC1: // FLOAT_64 + $size = 8; + $unpackFormat = 'E'; + break; + default: + throw new \InvalidArgumentException('Unknown vector type marker: ' . $this->type_marker[0]); + } + + $output = []; + $littleEndian = unpack('S', "\x01\x00")[1] === 1; + foreach(mb_str_split((string)$this->data, $size, '8bit') as $value) { + $output[] = unpack($unpackFormat, in_array($unpackFormat, self::$formats) && $littleEndian ? strrev($value) : $value)[1]; + } + + return $output; + } +} diff --git a/tests/TestLayer.php b/tests/TestLayer.php index 8286a0d..0d35b5f 100644 --- a/tests/TestLayer.php +++ b/tests/TestLayer.php @@ -75,6 +75,8 @@ protected function getCompatibleBoltVersion(string $url = null): float|int $neo4jVersion = $decoded['neo4j_version']; + if (version_compare($neo4jVersion, '2025.08', '>=')) + return 6; if (version_compare($neo4jVersion, '5.26', '>=')) return 5.8; if (version_compare($neo4jVersion, '5.23', '>=')) diff --git a/tests/protocol/V6Test.php b/tests/protocol/V6Test.php new file mode 100644 index 0000000..1a5150d --- /dev/null +++ b/tests/protocol/V6Test.php @@ -0,0 +1,22 @@ +mockConnection()); + $this->assertInstanceOf(V6::class, $cls); + return $cls; + } +} diff --git a/tests/structures/V6/StructuresTest.php b/tests/structures/V6/StructuresTest.php new file mode 100644 index 0000000..4709ee3 --- /dev/null +++ b/tests/structures/V6/StructuresTest.php @@ -0,0 +1,67 @@ +assertInstanceOf(\Bolt\connection\StreamSocket::class, $conn); + + $bolt = new Bolt($conn); + $this->assertInstanceOf(Bolt::class, $bolt); + + $protocol = $bolt->build(); + $this->assertInstanceOf(AProtocol::class, $protocol); + + if (version_compare($protocol->getVersion(), '6', '<')) { + $this->markTestSkipped('Tests available only for version 6 and higher.'); + } + + $this->sayHello($protocol, $GLOBALS['NEO_USER'], $GLOBALS['NEO_PASS']); + + return $protocol; + } + + // todo ..also test encode and decode in Vector class + /** + * @depends testInit + */ + public function testVector(AProtocol $protocol) + { + $this->markTestIncomplete('This test has not been implemented yet.'); + //unpack + // $res = iterator_to_array( + // $protocol + // ->run('RETURN ', [], ['mode' => 'r']) + // ->pull() + // ->getResponses(), + // false + // ); + // $this->assertInstanceOf(Vector::class, $res[1]->content[0]); + + //pack + // $res = iterator_to_array( + // $protocol + // ->run('RETURN toString($p)', [ + // 'p' => $res[1]->content[0] + // ], ['mode' => 'r']) + // ->pull() + // ->getResponses(), + // false + // ); + // $this->assertStringStartsWith('point(', $res[1]->content[0]); + } +} diff --git a/tests/structures/v5/StructuresTest.php b/tests/structures/v5/StructuresTest.php index f6619fa..ca2e6dc 100644 --- a/tests/structures/v5/StructuresTest.php +++ b/tests/structures/v5/StructuresTest.php @@ -44,12 +44,6 @@ public function testInit(): AProtocol return $protocol; } - private string $expectedDateTimeClass = DateTime::class; - use DateTimeTrait; - - private string $expectedDateTimeZoneIdClass = DateTimeZoneId::class; - use DateTimeZoneIdTrait; - /** * @depends testInit */