diff --git a/README.md b/README.md index d4647da..23da5b2 100755 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Running a query can be done via `open()` (which yields one record at a time) or use com\neo4j\Graph; use util\cmd\Console; -$g= new Graph('http://user:pass@neo4j-db.example.com:7474/db/data'); +$g= new Graph('http://user:pass@neo4j-db.example.com'); $q= $g->open('MATCH (t:Topic) RETURN t.name, t.canonical'); foreach ($q as $record) { Console::writeLine('#', $record['t.canonical'], ': ', $record['t.name']); @@ -32,8 +32,8 @@ Formatting parameters uses *printf*-like format tokens. These will take care of use com\neo4j\Graph; use util\cmd\Console; -$g= new Graph('http://user:pass@neo4j-db.example.com:7474/db/data'); -$g->query('CREATE (p:Person) SET t.name = %s, t.id = %d', $name, $id); +$g= new Graph('http://user:pass@neo4j-db.example.com'); +$g->query('CREATE (p:Person) SET p.name = %s, p.id = %d', $name, $id); ``` Batch statements can be executed via the `execute()` method. diff --git a/src/main/php/com/neo4j/BoltProtocol.class.php b/src/main/php/com/neo4j/BoltProtocol.class.php new file mode 100755 index 0000000..09453f8 --- /dev/null +++ b/src/main/php/com/neo4j/BoltProtocol.class.php @@ -0,0 +1,147 @@ +sock= new Socket($endpoint->getHost(), $endpoint->getPort(7687)); + if ($user= $endpoint->getUser()) { + $this->init= ['scheme' => 'basic', 'principal' => $user, 'credentials' => $endpoint->getPassword()]; + } else { + $this->init= ['scheme' => 'none']; + } + $this->serialization= new Serialization(); + } + + /** Reads a number of specified bytes */ + private function readBytes($n) { + $bytes= ''; + do { + $bytes.= $this->sock->readBinary($n); + } while (strlen($bytes) < $n && !$this->sock->eof()); + return $bytes; + } + + /** Sends a message */ + private function send($signature, ... $args) { + $s= pack('ca', 0xb0 + sizeof($args), $signature); + foreach ($args as $arg) { + $s.= $this->serialization->serialize($arg); + } + + for ($l= strlen($s), $o= 0; $o < $l; $o+= 65536) { + $p= min($l - $o, 65535); + $this->sock->write(pack('n', $p).substr($s, $o, $p)); + } + $this->sock->write(self::EOR); + } + + /** Receives one receive at a time */ + private function receive() { + $r= ''; + while (self::EOR !== ($length= $this->readBytes(2))) { + $r.= $this->readBytes(unpack('n', $length)[1]); + } + + return $r; + } + + /** + * Initialize communication + * + * @see https://boltprotocol.org/v1/#handshake + * @return void + * @throws com.neo4j.UnexpectedResponse + */ + private function init() { + $this->sock->write(self::PREAMBLE.pack('NNNN', 1, 0, 0, 0)); + $protocol= unpack('N', $this->readBytes(4)); + if (0 === $protocol[1]) { + throw new UnexpectedResponse(['Protocol handshake failed, server does not support protocol version']); + } + + $this->send(self::INIT, nameof($this), $this->init); + $answer= $this->receive(); + if (self::SUCCESS !== $answer{1}) { + $offset= 2; + throw new CannotAuthenticate([$this->serialization->unserialize($answer, $offset)]); + } + } + + /** @return [:var][] */ + private function records() { + $records= []; + do { + $answer= $this->receive(); + if (self::RECORD !== $answer{1}) break; + $records[]= $this->serialization->unserialize($answer); + } while (true); + + return $records; + } + + /** + * Commits multiple statements + * + * @param [:var][] $payload + * @return [:var] Results + * @throws com.neo4j.UnexpectedResponse + */ + public function commit($payload) { + if (!$this->sock->isConnected()) { + $this->sock->connect(); + $this->init(); + } + + $r= ['results' => [], 'errors' => []]; + foreach ($payload['statements'] as $s) { + $this->send(self::RUN, $s['statement'], isset($s['parameters']) ? $s['parameters'] : []); + + $answer= $this->receive(); + $offset= 2; + if (self::SUCCESS === $answer{1}) { + $this->send(self::PULL_ALL); + $r['results'][]= ['columns' => $this->serialization->unserialize($answer, $offset)['fields'], 'data' => $this->records()]; + } else if (self::FAILURE === $answer{1}) { + $this->send(self::ACK_FAILURE); + $this->receive(); + $r['errors'][]= $this->serialization->unserialize($answer, $offset); + } else { + $this->send(self::RESET); + $this->receive(); + $r['errors'][]= ['Ignored']; + } + } + return $r; + } + + /** @return void */ + public function close() { + $this->sock->isConnected() && $this->sock->close(); + } +} \ No newline at end of file diff --git a/src/main/php/com/neo4j/CannotAuthenticate.class.php b/src/main/php/com/neo4j/CannotAuthenticate.class.php new file mode 100755 index 0000000..5c72caf --- /dev/null +++ b/src/main/php/com/neo4j/CannotAuthenticate.class.php @@ -0,0 +1,8 @@ +conn= $endpoint instanceof HttpConnection ? $endpoint : new HttpConnection($endpoint); + $this->protocol= $endpoint instanceof Protocol ? $endpoint : Protocol::forEndpoint($endpoint); $this->cypher= new Cypher(); - $this->json= Format::dense(); - $this->base= rtrim($this->conn->getURL()->getPath(), '/'); - } - - /** - * Commits multiple statements using `transaction/commit` endpoint. - * - * @param [:var][] $payload - * @return [:var] Results - */ - protected function commit($payload) { - $req= $this->conn->create(new HttpRequest()); - $req->setMethod('POST'); - $req->setTarget($this->base.'/transaction/commit'); - $req->setHeader('X-Stream', 'true'); - $req->setHeader('Content-Type', 'application/json'); - $req->setParameters(new RequestData(Json::of($payload, $this->json))); - - $res= $this->conn->send($req); - if (200 !== $res->statusCode()) { - throw new QueryFailed(['Unexpected HTTP response status '.$res->statusCode()]); - } - - return Json::read(new StreamInput($res->in())); } /** @@ -103,7 +72,7 @@ public function execute($statements) { $list[]= is_array($statement) ? $statement : ['statement' => $statement]; } - $response= $this->commit(['statements' => $list]); + $response= $this->protocol->commit(['statements' => $list]); if (empty($response['errors'])) { return $response['results']; } else { diff --git a/src/main/php/com/neo4j/HttpProtocol.class.php b/src/main/php/com/neo4j/HttpProtocol.class.php new file mode 100755 index 0000000..8aff954 --- /dev/null +++ b/src/main/php/com/neo4j/HttpProtocol.class.php @@ -0,0 +1,63 @@ +conn= new HttpConnection($endpoint + ->setPort($endpoint->getPort(7474)) + ->setPath($endpoint->getPath('/db/data')) + ); + $this->json= Format::dense(); + $this->base= rtrim($this->conn->getURL()->getPath(), '/'); + } + + /** + * Commits multiple statements using `transaction/commit` endpoint. + * + * @param [:var][] $payload + * @return [:var] Results + * @throws com.neo4j.UnexpectedResponse + */ + public function commit($payload) { + $req= $this->conn->create(new HttpRequest()); + $req->setMethod('POST'); + $req->setTarget($this->base.'/transaction/commit'); + $req->setHeader('X-Stream', 'true'); + $req->setHeader('Content-Type', 'application/json'); + $req->setParameters(new RequestData(Json::of($payload, $this->json))); + + try { + $res= $this->conn->send($req); + } catch (IOException $e) { + throw new QueryFailed(['I/O error'], $e); + } + + if (200 === $res->statusCode()) { + return Json::read(new StreamInput($res->in())); + } else if (401 === $res->statusCode()) { + throw new CannotAuthenticate([Json::read(new StreamInput($res->in()))]); + } else { + throw new UnexpectedResponse(['Unexpected HTTP response status '.$res->statusCode(), $res->readData().'...']); + } + } + + /** @return void */ + public function close() { + // NOOP + } +} \ No newline at end of file diff --git a/src/main/php/com/neo4j/Protocol.class.php b/src/main/php/com/neo4j/Protocol.class.php new file mode 100755 index 0000000..f3f3e3f --- /dev/null +++ b/src/main/php/com/neo4j/Protocol.class.php @@ -0,0 +1,35 @@ +getScheme()) { + case 'http': case 'https': return new HttpProtocol($url); + case 'bolt': return new BoltProtocol($url); + default: throw new IllegalArgumentException('Unsupported protocol "'.$url->getScheme().'"'); + } + } + + /** Commits a payload and returns records */ + public abstract function commit($payload); + + /** Call close() */ + public function __destruct() { + $this->close(); + } +} \ No newline at end of file diff --git a/src/main/php/com/neo4j/QueryFailed.class.php b/src/main/php/com/neo4j/QueryFailed.class.php index 2dcc30a..4896eb3 100755 --- a/src/main/php/com/neo4j/QueryFailed.class.php +++ b/src/main/php/com/neo4j/QueryFailed.class.php @@ -1,13 +1,8 @@ 2147483647 || $value < -2147483648) { + $packed= pack('q', $value); + return "\xcb".(self::$reverse ? strrev($packed) : $packed); + } else if ($value > 32767 || $value < -32768) { + $packed= pack('l', $value); + return "\xca".(self::$reverse ? strrev($packed) : $packed); + } else if ($value > 127 || $value < -128) { + $packed= pack('s', $value); + return "\xc9".(self::$reverse ? strrev($packed) : $packed); + } else { + return "\xc8".pack('c', $value); + } + } else if (is_string($value)) { + return $this->marker(0x80, 0xd0, strlen($value)).$value; + } else if (is_array($value)) { + if (0 === key($value)) { + $r= $this->marker(0x90, 0xd4, sizeof($value)); + foreach ($value as $val) { + $r.= $this->serialize($val); + } + return $r; + } else { + $r= $this->marker(0xa0, 0xd8, sizeof($value)); + foreach ($value as $key => $val) { + $r.= $this->serialize($key).$this->serialize($val); + } + return $r; + } + } else if (is_float($value)) { + $packed= pack('d', $value); + return "\xc1".(self::$reverse ? strrev($packed) : $packed); + } else { + throw new IllegalStateException('Cannot serialize '.typeof($value)->getName()); + } + } + + /** Unserializes lists */ + private function lists($l, $value, &$offset= 0) { + $r= []; + for ($i= 0; $i < $l; $i++) { + $r[]= $this->unserialize($value, $offset); + } + return $r; + } + + /** Unserializes maps */ + private function maps($l, $value, &$offset= 0) { + $r= []; + for ($i= 0; $i < $l; $i++) { + $r[$this->unserialize($value, $offset)]= $this->unserialize($value, $offset); + } + return $r; + } + + /** Unserializes structs */ + private function structs($l, $value, &$offset= 0) { + $signature= $value{$offset++}; + $args= []; + for ($i= 0; $i < $l; $i++) { + $args[]= $this->unserialize($value, $offset); + } + + if ("\x71" === $signature) { // Record + return ['row' => $args[0], 'meta' => null]; + } else if ("\x4e" === $signature) { // Node, return properties + return $args[2]; + } else if ("\x50" === $signature) { // Path, return (a.properties)-[r.properties]->(b.properties)<...> + $p= [$args[0][0]]; + for ($i= 0, $s= sizeof($args[2]); $i < $s; ) { + $r= $args[2][$i++]; + if ($r < 0) { + $p[]= $args[1][-$r - 1]; + } else { + $p[]= $args[1][$r - 1]; + } + $p[]= $args[0][$args[2][$i++]]; + } + return $p; + } else if ("\x52" === $signature) { // Relationship, return properties + return $args[4]; + } else if ("\x72" === $signature) { // UnboundRelationship, return properties + return $args[2]; + } else { + throw new IllegalStateException(sprintf('Unknown value struct with signature 0x%02x', ord($signature))); + } + } + + /** + * Unserialize a given input string + * + * @param string $input + * @return var + */ + public function unserialize($input, &$offset= 0) { + $marker= $input{$offset}; + if ("\xc0" === $marker) { + $offset+= 1; + return null; + } else if ("\xc1" === $marker) { + $offset+= 9; + $bytes= substr($input, $offset - 8, 8); + return unpack('d', self::$reverse ? strrev($bytes) : $bytes)[1]; + } else if ("\xc2" === $marker) { + $offset+= 1; + return false; + } else if ("\xc3" === $marker) { + $offset+= 1; + return true; + } else if ("\xc8" === $marker) { + $offset+= 2; + return unpack('c', $input{$offset - 1})[1]; + } else if ("\xc9" === $marker) { + $offset+= 3; + $bytes= substr($input, $offset - 2, 2); + return unpack('s', self::$reverse ? strrev($bytes) : $bytes)[1]; + } else if ("\xca" === $marker) { + $offset+= 5; + $bytes= substr($input, $offset - 4, 4); + return unpack('l', self::$reverse ? strrev($bytes) : $bytes)[1]; + } else if ("\xcb" === $marker) { + $offset+= 9; + $bytes= substr($input, $offset - 8, 8); + return unpack('q', self::$reverse ? strrev($bytes) : $bytes)[1]; + } else if ("\xd0" === $marker) { + $l= unpack('C', $input{$offset + 1})[1]; + $offset+= $l + 2; + return substr($input, $offset - $l, $l); + } else if ("\xd1" === $marker) { + $l= unpack('n', substr($input, $offset + 1, 2))[1]; + $offset+= $l + 3; + return substr($input, $offset - $l, $l); + } else if ("\xd2" === $marker) { + $l= unpack('N', substr($input, $offset + 1, 4))[1]; + $offset+= $l + 5; + return substr($input, $offset - $l, $l); + } else if ("\xd4" === $marker) { + $l= ord($input{$offset + 1}); + $offset+= 2; + return $this->lists($l, $input, $offset); + } else if ("\xd5" === $marker) { + $l= unpack('n', substr($input, $offset + 1, 2))[1]; + $offset+= 3; + return $this->lists($l, $input, $offset); + } else if ("\xd6" === $marker) { + $l= unpack('N', substr($input, $offset + 1, 4))[1]; + $offset+= 5; + return $this->lists($l, $input, $offset); + } else if ("\xd8" === $marker) { + $l= unpack('C', $input{$offset + 1})[1]; + $offset+= 2; + return $this->maps($l, $input, $offset); + } else if ("\xd9" === $marker) { + $l= unpack('n', substr($input, $offset + 1, 2))[1]; + $offset+= 3; + return $this->maps($l, $input, $offset); + } else if ("\xda" === $marker) { + $l= unpack('N', substr($input, $offset + 1, 4))[1]; + $offset+= 5; + return $this->maps($l, $input, $offset); + } else if ($marker >= "\x00" && $marker <= "\x7f") { + $offset+= 1; + return ord($marker); + } else if ($marker >= "\xf0" && $marker <= "\xff") { + $offset+= 1; + return ord($marker) - 0x100; + } else if ($marker >= "\x80" && $marker <= "\x8f") { + $l= ord($marker) - 0x80; + $offset+= $l + 1; + return 0 === $l ? '' : substr($input, $offset - $l, $l); + } else if ($marker >= "\x90" && $marker <= "\x9f") { + $l= ord($marker) - 0x90; + $offset++; + return $this->lists($l, $input, $offset); + } else if ($marker >= "\xa0" && $marker <= "\xaf") { + $l= ord($marker) - 0xa0; + $offset++; + return $this->maps($l, $input, $offset); + } else if ($marker >= "\xb0" && $marker <= "\xbf") { + $l= ord($marker) - 0xb0; + $offset++; + return $this->structs($l, $input, $offset); + } else { + throw new IllegalStateException(sprintf('Unknown marker 0x%02x', ord($marker))); + } + } +} diff --git a/src/main/php/com/neo4j/UnexpectedResponse.class.php b/src/main/php/com/neo4j/UnexpectedResponse.class.php new file mode 100755 index 0000000..5771a38 --- /dev/null +++ b/src/main/php/com/neo4j/UnexpectedResponse.class.php @@ -0,0 +1,13 @@ + ['id(n)'], 'data' => [['row' => [6], 'meta' => [null]]]]; @@ -26,15 +27,11 @@ static function __static() { /** Creates a fixture with a given function for producing results */ private function newFixture($resultsFor= null) { - return newinstance(Graph::class, [$resultsFor ?: function($payload) { return null; }], [ - '__construct' => function($resultsFor) { - parent::__construct('http://localhost:7474/db/data'); - $this->resultsFor= $resultsFor; - }, - 'commit' => function($payload) { - return $this->resultsFor->__invoke($payload); - } - ]); + return new Graph(newinstance(Protocol::class, [$resultsFor ?: function($payload) { return null; }], [ + '__construct' => function($resultsFor) { $this->resultsFor= $resultsFor; }, + 'commit' => function($payload) { return $this->resultsFor->__invoke($payload); }, + 'close' => function() { /* NOOP */ } + ])); } #[@test] @@ -48,8 +45,8 @@ public function can_create_with_url() { } #[@test] - public function can_create_with_http_connection() { - new Graph(new HttpConnection('http://localhost:7474/db/data')); + public function can_create_with_protocol() { + new Graph(new HttpProtocol(new URL('http://localhost:7474/db/data'))); } #[@test] diff --git a/src/test/php/com/neo4j/unittest/ProtocolTest.class.php b/src/test/php/com/neo4j/unittest/ProtocolTest.class.php new file mode 100755 index 0000000..8898721 --- /dev/null +++ b/src/test/php/com/neo4j/unittest/ProtocolTest.class.php @@ -0,0 +1,31 @@ +assertInstanceOf(HttpProtocol::class, Protocol::forEndpoint($endpoint)); + } + + #[@test, @values([ + # 'bolt://localhost:7687/', + # new URL('bolt://localhost:7687/') + #])] + public function bolt($endpoint) { + $this->assertInstanceOf(BoltProtocol::class, Protocol::forEndpoint($endpoint)); + } + + #[@test, @expect(class= IllegalArgumentException::class, withMessage= '/Unsupported protocol "test"/')] + public function unsupported_protocol() { + Protocol::forEndpoint('test://localhost'); + } +} \ No newline at end of file diff --git a/src/test/php/com/neo4j/unittest/SerializationTest.class.php b/src/test/php/com/neo4j/unittest/SerializationTest.class.php new file mode 100755 index 0000000..63eb84f --- /dev/null +++ b/src/test/php/com/neo4j/unittest/SerializationTest.class.php @@ -0,0 +1,138 @@ +fixture= new Serialization(); + } + + #[@test] + public function null() { + $this->assertEquals(null, $this->fixture->unserialize($this->fixture->serialize(null))); + } + + #[@test, @values([true, false])] + public function booleans($value) { + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([0.1, 1.5, -6.1])] + public function floats($value) { + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([-16, -1, 0, 1, 127])] + public function tiny_int($value) { + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([-128, -100, -17])] + public function int_8($value) { + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([-32768, -1000, -129, 6100, 32767, 40000])] + public function int_16($value) { + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([-2147483648, -32769, 2147483647])] + public function int_32($value) { + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([-9223372036854775807, -2147483649, 2147483648, 9223372036854775807])] + public function int_64($value) { + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([0, 1, 15])] + public function tiny_string($length) { + $value= str_repeat('*', $length); + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([16, 255])] + public function string_8($length) { + $value= str_repeat('*', $length); + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([256, 65535])] + public function string_16($length) { + $value= str_repeat('*', $length); + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([65536, self::MAX_SIZE])] + public function string_32($length) { + $value= str_repeat('*', $length); + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([0, 1, 15])] + public function tiny_list($size) { + $value= array_fill(0, $size, '*'); + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([16, 255])] + public function list_8($size) { + $value= array_fill(0, $size, '*'); + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([256, 65535])] + public function list_16($size) { + $value= array_fill(0, $size, '*'); + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([65536, self::MAX_SIZE])] + public function list_32($size) { + $value= array_fill(0, $size, '*'); + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([0, 1, 15])] + public function tiny_map($entries) { + $value= []; + for ($i= 0; $i < $entries; $i++) { + $value['_'.$i]= $i; + } + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([16, 255])] + public function map_8($entries) { + $value= []; + for ($i= 0; $i < $entries; $i++) { + $value['_'.$i]= $i; + } + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([256, 65535])] + public function map_16($entries) { + $value= []; + for ($i= 0; $i < $entries; $i++) { + $value['_'.$i]= $i; + } + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } + + #[@test, @values([65536, self::MAX_SIZE])] + public function map_32($entries) { + $value= []; + for ($i= 0; $i < $entries; $i++) { + $value['_'.$i]= $i; + } + $this->assertEquals($value, $this->fixture->unserialize($this->fixture->serialize($value))); + } +} \ No newline at end of file