diff --git a/.github/workflows/cs-fixer.yml b/.github/workflows/cs-fixer.yml index 1c271932..856d1909 100644 --- a/.github/workflows/cs-fixer.yml +++ b/.github/workflows/cs-fixer.yml @@ -9,6 +9,7 @@ on: pull_request: paths: - '**/*.php' + workflow_dispatch: jobs: php-cs-fixer: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d90fff2..402b09cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,7 @@ on: branches: - main pull_request: + workflow_dispatch: concurrency: group: ${{ github.ref }} diff --git a/.gitignore b/.gitignore index c7ea18d7..3b39edc1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ + #IDE .idea/ diff --git a/composer.json b/composer.json index 1a89e801..aea16885 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,8 @@ "guzzlehttp/guzzle": "^7.9", "psr/http-client": "^1.0", "ext-json": "*", - "php": "^8.1" + "php": "^8.1", + "nyholm/psr7": "^1.8" }, "require-dev": { "phpunit/phpunit": "^11.0", @@ -39,4 +40,4 @@ "cs:fix": "vendor/bin/php-cs-fixer fix --allow-risky=yes" } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 30ce16f6..09363afc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f7e021131a30160aaf3ddfd798dc6633", + "content-hash": "61138d5fcabcefc4b2ce114e34230d1c", "packages": [ { "name": "guzzlehttp/guzzle", @@ -331,6 +331,84 @@ ], "time": "2024-07-18T11:15:46+00:00" }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, { "name": "psr/http-client", "version": "1.0.3", diff --git a/src/BasicAuthentication.php b/src/BasicAuthentication.php index fdf724af..d8b0430c 100644 --- a/src/BasicAuthentication.php +++ b/src/BasicAuthentication.php @@ -3,21 +3,25 @@ namespace Neo4j\QueryAPI; use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; class BasicAuthentication implements AuthenticateInterface { - public function __construct(private string $username, private string $password) + private string $username; + private string $password; + + public function __construct(?string $username = null, ?string $password = null) { - $this->username = $username; - $this->password = $password; + // Use provided values or fallback to environment variables + $this->username = $username ?? getenv("NEO4J_USERNAME") ?: ''; + $this->password = $password ?? getenv("NEO4J_PASSWORD") ?: ''; } public function authenticate(RequestInterface $request): RequestInterface { - $authHeader = 'Basic ' . base64_encode($this->username . ':' . $this->password); + $authHeader = $this->getHeader(); return $request->withHeader('Authorization', $authHeader); } + public function getHeader(): string { return 'Basic ' . base64_encode($this->username . ':' . $this->password); @@ -27,5 +31,4 @@ public function getType(): string { return 'Basic'; } - } diff --git a/src/BearerAuthentication.php b/src/BearerAuthentication.php index 567b0c8a..83dd2de1 100644 --- a/src/BearerAuthentication.php +++ b/src/BearerAuthentication.php @@ -15,6 +15,7 @@ public function authenticate(RequestInterface $request): RequestInterface $authHeader = 'Bearer ' . $this->token; return $request->withHeader('Authorization', $authHeader); } + public function getHeader(): string { return 'Bearer ' . $this->token; diff --git a/src/Neo4jPhp.php b/src/Neo4jPhp.php new file mode 100644 index 00000000..6a1892d3 --- /dev/null +++ b/src/Neo4jPhp.php @@ -0,0 +1,108 @@ + 'Basic ' . $credentials, + 'Content-Type' => 'application/json', + ]; + + // Initialize the client with the authorization header + $client = new \GuzzleHttp\Client([ + 'base_uri' => rtrim($neo4jAddress, '/'), + 'timeout' => 10.0, + 'headers' => $headers, + ]); + + // Step 2: Create the Cypher query + $cypherQuery = 'MATCH (n) RETURN n LIMIT 10'; + $parameters = []; // No parameters in this query + $database = 'neo4j'; // Default Neo4j database + + echo "Running Cypher Query: $cypherQuery\n"; + + // Prepare the payload for the Cypher query + $payload = [ + 'statement' => $cypherQuery, + 'parameters' => new stdClass(), // No parameters + 'includeCounters' => true, + ]; + + // Step 3: Send the request to Neo4j + $response = $client->post("/db/{$database}/query/v2", [ + 'json' => $payload, + ]); + + // Parse the response body as JSON + $responseData = json_decode($response->getBody()->getContents(), true); + + // Check for errors in the response + if (isset($responseData['errors']) && count($responseData['errors']) > 0) { + echo "Error: " . $responseData['errors'][0]['message'] . "\n"; + } else { + // Step 4: Output the result of the query + echo "Query Results:\n"; + foreach ($responseData['data'] as $row) { + print_r($row); // Print each row's data + } + } + + // Step 5: Begin a new transaction + $transactionResponse = $client->post("/db/neo4j/query/v2/tx"); + $transactionData = json_decode($transactionResponse->getBody()->getContents(), true); + $transactionId = $transactionData['transaction']['id']; // Retrieve the transaction ID + + echo "Transaction started successfully.\n"; + echo "Transaction ID: $transactionId\n"; + + // You can also fetch additional transaction details if available in the response + // Example: transaction metadata or counters + if (isset($transactionData['transaction']['metadata'])) { + echo "Transaction Metadata: \n"; + print_r($transactionData['transaction']['metadata']); + } + + // Step 6: Execute a query within the transaction + $cypherTransactionQuery = 'MATCH (n) SET n.modified = true RETURN n LIMIT 5'; + $transactionPayload = [ + 'statement' => $cypherTransactionQuery, + 'parameters' => new stdClass(), // No parameters + ]; + + // Execute the transaction query + $transactionQueryResponse = $client->post("/db/neo4j/query/v2/tx/{$transactionId}/commit", [ + 'json' => $transactionPayload, + ]); + + $transactionQueryData = json_decode($transactionQueryResponse->getBody()->getContents(), true); + + // Check for any errors in the transaction query + if (isset($transactionQueryData['errors']) && count($transactionQueryData['errors']) > 0) { + echo "Transaction Error: " . $transactionQueryData['errors'][0]['message'] . "\n"; + } else { + echo "Transaction Query Results:\n"; + print_r($transactionQueryData['data']); // Print transaction results + } + +} catch (RequestException $e) { + echo "Request Error: " . $e->getMessage() . "\n"; +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/src/Neo4jQueryAPI.php b/src/Neo4jQueryAPI.php index b901b772..77512eb2 100644 --- a/src/Neo4jQueryAPI.php +++ b/src/Neo4jQueryAPI.php @@ -2,7 +2,6 @@ namespace Neo4j\QueryAPI; -use Exception; use GuzzleHttp\Client; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Utils; @@ -16,7 +15,6 @@ use Neo4j\QueryAPI\Results\ResultRow; use Psr\Http\Client\ClientInterface; use Psr\Http\Client\RequestExceptionInterface; -use Psr\Http\Message\RequestInterface; use RuntimeException; use stdClass; @@ -57,52 +55,46 @@ public static function login(string $address, AuthenticateInterface $auth = null public function run(string $cypher, array $parameters = [], string $database = 'neo4j', Bookmarks $bookmark = null): ResultSet { try { - // Prepare the payload $payload = [ 'statement' => $cypher, 'parameters' => empty($parameters) ? new stdClass() : $parameters, 'includeCounters' => true, ]; - // Include bookmarks if provided + if ($bookmark !== null) { $payload['bookmarks'] = $bookmark->getBookmarks(); } - // Create the HTTP request + $request = new Request('POST', '/db/' . $database . '/query/v2'); $request = $this->auth->authenticate($request); $request = $request->withHeader('Content-Type', 'application/json'); $request = $request->withBody(Utils::streamFor(json_encode($payload))); - - // Send the request and get the response $response = $this->client->sendRequest($request); $contents = $response->getBody()->getContents(); - // Parse the response data + $data = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); - // Check for errors in the response from Neo4j + if (isset($data['errors']) && count($data['errors']) > 0) { - // If errors exist in the response, throw a Neo4jException + $error = $data['errors'][0]; throw new Neo4jException( - $error, // Pass the entire error array instead of just the message + $error, 0, - null, - $error + null ); } - // Parse the result set and return it + return $this->parseResultSet($data); } catch (RequestExceptionInterface $e) { - // Handle exceptions from the HTTP request $this->handleException($e); } catch (Neo4jException $e) { - // Catch Neo4j specific exceptions (if thrown) - throw $e; // Re-throw the exception + throw $e; } } @@ -164,32 +156,22 @@ private function handleException(RequestExceptionInterface $e): void throw $e; } - public function beginTransaction(): array + public function beginTransaction(string $database = 'neo4j'): Transaction { - $request = new Request('POST', '/db/neo4j/tx'); // Adjust endpoint as needed + $request = new Request('POST', '/db/neo4j/query/v2/tx'); + $request = $this->auth->authenticate($request); + $request = $request->withHeader('Content-Type', 'application/json'); - // Apply authentication, if provided - if ($this->auth instanceof AuthenticateInterface) { - $request = $this->auth->authenticate($request); - } - - try { - $response = $this->client->send($request); - $responseBody = json_decode($response->getBody()->getContents(), true); + $response = $this->client->sendRequest($request); + $contents = $response->getBody()->getContents(); - // Validate the response for transaction ID - if (isset($responseBody['commit'])) { - return $responseBody; // Successful transaction - } + $clusterAffinity = $response->getHeaderLine('neo4j-cluster-affinity'); + $responseData = json_decode($contents, true); + $transactionId = $responseData['transaction']['id']; - throw new RuntimeException('Missing transaction ID in the response.'); - } catch (Exception $e) { - throw new RuntimeException("Failed to begin transaction: {$e->getMessage()}", 0, $e); - } + return new Transaction($this->client, $clusterAffinity, $transactionId); } - - private function createProfileData(array $data): ProfiledQueryPlan { $ogm = new OGM(); diff --git a/src/Neo4jRequestFactory.php b/src/Neo4jRequestFactory.php new file mode 100644 index 00000000..7423f788 --- /dev/null +++ b/src/Neo4jRequestFactory.php @@ -0,0 +1,92 @@ +psr17Factory = $psr17Factory; + $this->streamFactory = $streamFactory; + $this->baseUri = $baseUri; + $this->authHeader = $authHeader; + } + + public function buildRunQueryRequest( + string $database, + string $cypher, + array $parameters = [], + bool $includeCounters = true, + ?array $bookmarks = null + ): RequestInterface { + $payload = [ + 'statement' => $cypher, + 'parameters' => empty($parameters) ? new \stdClass() : $parameters, + 'includeCounters' => $includeCounters, + ]; + + if ($bookmarks !== null) { + $payload['bookmarks'] = $bookmarks; + } + + $uri = rtrim($this->baseUri, '/') . "/db/{$database}/query/v2"; + + return $this->createRequest('POST', $uri, json_encode($payload)); + } + + public function buildBeginTransactionRequest(string $database): RequestInterface + { + $uri = rtrim($this->baseUri, '/') . "/db/{$database}/query/v2/tx"; + return $this->createRequest('POST', $uri); + } + + public function buildCommitRequest(string $database, string $transactionId): RequestInterface + { + $uri = rtrim($this->baseUri, '/') . "/db/{$database}/query/v2/tx/{$transactionId}/commit"; + return $this->createRequest('POST', $uri); + } + + public function buildRollbackRequest(string $database, string $transactionId): RequestInterface + { + $uri = rtrim($this->baseUri, '/') . "/db/{$database}/query/v2/tx/{$transactionId}/rollback"; + return $this->createRequest('POST', $uri); + } + + private function createRequest(string $method, string $uri, ?string $body = null): RequestInterface + { + $request = $this->psr17Factory->createRequest($method, $uri); + + $headers = [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + + if ($this->authHeader) { + $headers['Authorization'] = $this->authHeader; + } + + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + if ($body !== null) { + $stream = $this->streamFactory->createStream($body); + $request = $request->withBody($stream); + } + + return $request; + } +} diff --git a/src/NoAuth.php b/src/NoAuth.php index aa1565c6..0444ef49 100644 --- a/src/NoAuth.php +++ b/src/NoAuth.php @@ -11,3 +11,16 @@ public function authenticate(RequestInterface $request): RequestInterface return $request; } } + +/* +namespace Neo4j\QueryAPI; + +use Psr\Http\Message\RequestInterface; + +class NoAuth implements AuthenticateInterface +{ + public function authenticate(RequestInterface $request): RequestInterface + { + return $request; + } +}*/ diff --git a/src/Objects/Authentication.php b/src/Objects/Authentication.php index 9d1f0bae..e62d885c 100644 --- a/src/Objects/Authentication.php +++ b/src/Objects/Authentication.php @@ -7,15 +7,24 @@ use Neo4j\QueryAPI\BasicAuthentication; use Neo4j\QueryAPI\BearerAuthentication; use Neo4j\QueryAPI\NoAuth; -use Psr\Http\Message\RequestInterface; class Authentication { - public static function basic(): AuthenticateInterface + public static function basic(string $username, string $password): AuthenticateInterface { - return new BasicAuthentication(getenv("NEO4J_USERNAME"), getenv("NEO4J_PASSWORD")); + return new BasicAuthentication($username, $password); } + public static function fromEnvironment(): AuthenticateInterface + { + // Fetch credentials from environment variables + $username = getenv("NEO4J_USERNAME") ?: ''; + $password = getenv("NEO4J_PASSWORD") ?: ''; + + return new BasicAuthentication($username, $password); + } + + public static function noAuth(): AuthenticateInterface { diff --git a/src/Transaction.php b/src/Transaction.php index 50c26e75..46af85be 100644 --- a/src/Transaction.php +++ b/src/Transaction.php @@ -3,6 +3,7 @@ namespace Neo4j\QueryAPI; use Neo4j\QueryAPI\Exception\Neo4jException; +use Neo4j\QueryAPI\Objects\Authentication; use Neo4j\QueryAPI\Objects\Bookmarks; use Neo4j\QueryAPI\Objects\ResultCounters; use Neo4j\QueryAPI\Objects\ResultSet; @@ -31,6 +32,7 @@ public function run(string $query, array $parameters): ResultSet { $response = $this->client->post("/db/neo4j/query/v2/tx/{$this->transactionId}", [ 'headers' => [ + 'Authorization' => Authentication::basic('neo4j', '9lWmptqBgxBOz8NVcTJjgs3cHPyYmsy63ui6Spmw1d0')->getheader(), 'neo4j-cluster-affinity' => $this->clusterAffinity, ], 'json' => [ @@ -115,6 +117,7 @@ public function commit(): void { $this->client->post("/db/neo4j/query/v2/tx/{$this->transactionId}/commit", [ 'headers' => [ + 'Authorization' => Authentication::basic('neo4j', '9lWmptqBgxBOz8NVcTJjgs3cHPyYmsy63ui6Spmw1d0')->getheader(), 'neo4j-cluster-affinity' => $this->clusterAffinity, ], ]); diff --git a/src/requestFactory.php b/src/requestFactory.php new file mode 100644 index 00000000..60d51d43 --- /dev/null +++ b/src/requestFactory.php @@ -0,0 +1,67 @@ +buildBeginTransactionRequest($database); + $beginTxResponse = $client->sendRequest($beginTxRequest); + $beginTxData = json_decode($beginTxResponse->getBody()->getContents(), true); + + // Extract the transaction ID + $transactionId = $beginTxData['transaction']['id'] ?? null; + if (!$transactionId) { + throw new RuntimeException("Transaction ID not found in response."); + } + + echo "Transaction ID: {$transactionId}" . PHP_EOL; + + $runQueryRequest = $requestFactory->buildRunQueryRequest($database, $cypher, $parameters); + $runQueryResponse = $client->sendRequest($runQueryRequest); + + $queryResults = json_decode($runQueryResponse->getBody()->getContents(), true); + echo "Query Results: " . json_encode($queryResults, JSON_PRETTY_PRINT) . PHP_EOL; + + // Step 3: Commit the transaction + $commitRequest = $requestFactory->buildCommitRequest($database, $transactionId); + $commitResponse = $client->sendRequest($commitRequest); + + echo "Transaction committed successfully!" . PHP_EOL; + + // Optional: Output commit response + echo "Commit Response: " . $commitResponse->getBody()->getContents() . PHP_EOL; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . PHP_EOL; + + // Rollback the transaction in case of failure + if (isset($transactionId)) { + $rollbackRequest = $requestFactory->buildRollbackRequest($database, $transactionId); + $rollbackResponse = $client->sendRequest($rollbackRequest); + + echo "Transaction rolled back." . PHP_EOL; + echo "Rollback Response: " . $rollbackResponse->getBody()->getContents() . PHP_EOL; + } +} diff --git a/src/transaction_Script.php b/src/transaction_Script.php new file mode 100644 index 00000000..b6b6b422 --- /dev/null +++ b/src/transaction_Script.php @@ -0,0 +1,96 @@ +post("{$neo4jAddress}/db/neo4j/tx", [ + 'headers' => [ + 'Authorization' => 'Basic ' . $credentials, + 'Content-Type' => 'application/json', + ] + ]); + + // Extract the transaction ID and cluster affinity from the response + $data = json_decode($response->getBody()->getContents(), true); + + // Check if the transaction was created and extract necessary values + if (isset($data['tx'])) { + $transactionId = $data['tx']['id']; + $clusterAffinity = $data['neo4j-cluster-affinity']; // Usually returned as part of response headers + + return [$transactionId, $clusterAffinity]; + } + + throw new Exception("Failed to start transaction or missing transaction ID in the response."); +} + +// Start the transaction and extract transactionId and clusterAffinity +list($transactionId, $clusterAffinity) = startTransaction($client, $neo4jAddress, $username, $password); + +// Create a new Transaction instance with the extracted values +$transaction = new Transaction($client, $clusterAffinity, $transactionId); + +// Function to run a Cypher query +function runQuery($transaction, $query, $parameters = []) +{ + try { + $results = $transaction->run($query, $parameters); + echo "Query Results:\n"; + foreach ($results->getRows() as $row) { + print_r($row->getData()); + } + } catch (Exception $e) { + echo "Error running query: " . $e->getMessage() . "\n"; + } +} + +// Function to commit the transaction +function commitTransaction($transaction) +{ + try { + $transaction->commit(); + echo "Transaction committed successfully.\n"; + } catch (Exception $e) { + echo "Error committing transaction: " . $e->getMessage() . "\n"; + } +} + +// Function to rollback the transaction +function rollbackTransaction($transaction) +{ + try { + $transaction->rollback(); + echo "Transaction rolled back successfully.\n"; + } catch (Exception $e) { + echo "Error rolling back transaction: " . $e->getMessage() . "\n"; + } +} + +// Example usage: running a query within the transaction +$query = "CREATE (n:Person {name: 'John Doe'}) RETURN n"; +runQuery($transaction, $query); + +// Now, let's commit the transaction +commitTransaction($transaction); + +// Running another query after commit to verify changes +$query = "MATCH (n:Person {name: 'John Doe'}) RETURN n"; +runQuery($transaction, $query); diff --git a/tests/Integration/Neo4jOGMTest.php b/tests/Integration/Neo4jOGMTest.php index df775b8f..b8500a61 100644 --- a/tests/Integration/Neo4jOGMTest.php +++ b/tests/Integration/Neo4jOGMTest.php @@ -21,20 +21,18 @@ public static function integerDataProvider(): array 'Test with age 30' => [ 'CREATE (n:Person {age: $age}) RETURN n.age', ['age' => 30], - 30, // Expected result should be just the integer, not an array + 30, ], 'Test with age 40' => [ 'CREATE (n:Person {age: $age}) RETURN n.age', ['age' => 40], - 40, // Expected result should be just the integer + 40, ], ]; } - - - public static function nullDataProvider() + public static function nullDataProvider(): array { return [ @@ -52,7 +50,7 @@ public static function booleanDataProvider(): array return [ ['query1', ['_value' => true], true], ['query2', ['_value' => false], false], - ['query3', ['_value' => null], null], // Optional if you want to test null as well. + ['query3', ['_value' => null], null], ]; } @@ -60,8 +58,8 @@ public static function stringDataProvider(): array { return [ ['query1', ['_value' => 'Hello, world!'], 'Hello, world!'], - ['query2', ['_value' => ''], ''], // Test empty string - ['query3', ['_value' => null], null], // Optional if null handling is needed + ['query2', ['_value' => ''], ''], + ['query3', ['_value' => null], null], ]; } @@ -136,9 +134,9 @@ public function testWithWGS84_2DPoint(): void ]); $this->assertInstanceOf(Point::class, $point); - $this->assertEquals(1.2, $point->getX()); // x is longitude - $this->assertEquals(3.4, $point->getY()); // y is latitude - $this->assertNull($point->getZ()); // Ensure z is null for 2D point + $this->assertEquals(1.2, $point->getX()); + $this->assertEquals(3.4, $point->getY()); + $this->assertNull($point->getZ()); $this->assertEquals(4326, $point->getSrid()); } diff --git a/tests/Integration/Neo4jQueryAPIIntegrationTest.php b/tests/Integration/Neo4jQueryAPIIntegrationTest.php index 3117044c..88f49466 100644 --- a/tests/Integration/Neo4jQueryAPIIntegrationTest.php +++ b/tests/Integration/Neo4jQueryAPIIntegrationTest.php @@ -2,7 +2,6 @@ namespace Neo4j\QueryAPI\Tests\Integration; -use Neo4j\QueryAPI\Objects\Authentication; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Handler\MockHandler; @@ -10,24 +9,30 @@ use GuzzleHttp\Psr7\Response; use Neo4j\QueryAPI\Exception\Neo4jException; use Neo4j\QueryAPI\Neo4jQueryAPI; -use Neo4j\QueryAPI\Objects\Bookmarks; +use Neo4j\QueryAPI\Neo4jRequestFactory; +use Neo4j\QueryAPI\Objects\Authentication; use Neo4j\QueryAPI\Objects\ProfiledQueryPlan; +use Neo4j\QueryAPI\Objects\Bookmarks; use Neo4j\QueryAPI\Objects\ResultCounters; use Neo4j\QueryAPI\Objects\ResultSet; use Neo4j\QueryAPI\Results\ResultRow; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Neo4j\QueryAPI\Transaction; use Psr\Http\Client\RequestExceptionInterface; class Neo4jQueryAPIIntegrationTest extends TestCase { private Neo4jQueryAPI $api; + private Neo4jRequestFactory $request ; /** * @throws GuzzleException */ public function setUp(): void { $this->api = $this->initializeApi(); + // Clear database and populate test data $this->clearDatabase(); $this->populateTestData(); @@ -37,14 +42,14 @@ private function initializeApi(): Neo4jQueryAPI { return Neo4jQueryAPI::login( getenv('NEO4J_ADDRESS'), - Authentication::basic(), + Authentication::fromEnvironment(), ); } - public function testCounters(): void { $result = $this->api->run('CREATE (x:Node {hello: "world"})'); + $this->assertEquals(1, $result->getQueryCounters()->getNodesCreated()); } @@ -172,13 +177,11 @@ public function testProfileCreateKnowsBidirectionalRelationshipsMock(): void CREATE (a)-[:KNOWS]->(b), (b)-[:KNOWS]->(a); "; - // Mock response $body = file_get_contents(__DIR__ . '/../resources/responses/complex-query-profile.json'); $mockSack = new MockHandler([ new Response(200, [], $body), ]); - // Set up Guzzle HTTP client with the mock handler $handler = HandlerStack::create($mockSack); $client = new Client(['handler' => $handler]); @@ -191,7 +194,6 @@ public function testProfileCreateKnowsBidirectionalRelationshipsMock(): void // Execute the query $result = $api->run($query); - // Validate the profiled query plan $plan = $result->getProfiledQueryPlan(); $this->assertNotNull($plan, "The result of the query should not be null."); @@ -204,7 +206,6 @@ public function testProfileCreateKnowsBidirectionalRelationshipsMock(): void public function testProfileCreateActedInRelationships(): void { - $query = " PROFILE UNWIND range(1, 50) AS i MATCH (p:Person {id: i}), (m:Movie {year: 2000 + i}) diff --git a/tests/Integration/Neo4jTransactionIntegrationTest.php b/tests/Integration/Neo4jTransactionIntegrationTest.php new file mode 100644 index 00000000..bc1f338f --- /dev/null +++ b/tests/Integration/Neo4jTransactionIntegrationTest.php @@ -0,0 +1,81 @@ +api = $this->initializeApi(); + $this->clearDatabase(); + $this->populateTestData(); + } + + /** + * @throws Exception + */ + private function initializeApi(): Neo4jQueryAPI + { + return Neo4jQueryAPI::login( + getenv('NEO4J_ADDRESS'), + Authentication::fromEnvironment(), + ); + } + + /** + * @throws GuzzleException + */ + private function clearDatabase(): void + { + $this->api->run('MATCH (n) DETACH DELETE n', []); + } + + /** + * @throws GuzzleException + */ + private function populateTestData(): void + { + $names = ['bob1', 'alicy']; + foreach ($names as $name) { + $this->api->run('CREATE (:Person {name: $name})', ['name' => $name]); + } + } + public function testTransactionCommit(): void + { + // Begin a new transaction + $tsx = $this->api->beginTransaction(); + + // Generate a random name for the node + $name = (string)mt_rand(1, 100000); + + // Create a node within the transaction + $tsx->run("CREATE (x:Human {name: \$name})", ['name' => $name]); + + // Validate that the node does not exist in the database before the transaction is committed + $results = $this->api->run("MATCH (x:Human {name: \$name}) RETURN x", ['name' => $name]); + $this->assertCount(0, $results); + + // Validate that the node exists within the transaction + $results = $tsx->run("MATCH (x:Human {name: \$name}) RETURN x", ['name' => $name]); + $this->assertCount(1, $results); + + // Commit the transaction + $tsx->commit(); + + // Validate that the node now exists in the database + $results = $this->api->run("MATCH (x:Human {name: \$name}) RETURN x", ['name' => $name]); + $this->assertCount(1, $results); // Updated to expect 1 result + } + +} diff --git a/tests/Unit/AuthenticationTest.php b/tests/Unit/AuthenticationTest.php index 4942c247..acfa46bc 100644 --- a/tests/Unit/AuthenticationTest.php +++ b/tests/Unit/AuthenticationTest.php @@ -22,42 +22,36 @@ public function testBearerToken(): void public function testBasicAuthentication(): void { - // Mocked username and password + $mockUsername = 'mockUser'; $mockPassword = 'mockPass'; - // Mock environment variables to return the mocked values putenv('NEO4J_USERNAME=' . $mockUsername); putenv('NEO4J_PASSWORD=' . $mockPassword); - // Use Authentication::basic() to get the Basic authentication instance - $auth = Authentication::basic(); - // Assert: Ensure correct Basic auth header is generated + $auth = Authentication::basic(getenv('NEO4J_USERNAME'), getenv('NEO4J_PASSWORD')); + + $expectedHeader = 'Basic ' . base64_encode("$mockUsername:$mockPassword"); $this->assertEquals($expectedHeader, $auth->getHeader(), 'Basic authentication header mismatch.'); $this->assertEquals('Basic', $auth->getType(), 'Type should be Basic.'); - // Clean up: Remove environment variables after the test putenv('NEO4J_USERNAME'); putenv('NEO4J_PASSWORD'); } public function testFallbackToEnvironmentVariables(): void { - // Mock environment variables for Neo4j username and password putenv('NEO4J_USERNAME=mockEnvUser'); putenv('NEO4J_PASSWORD=mockEnvPass'); - // Use Authentication::basic() to get the Basic authentication instance - $auth = Authentication::basic(); + $auth = Authentication::basic(getenv('NEO4J_USERNAME'), getenv('NEO4J_PASSWORD')); - // Assert: Ensure that the correct Basic authentication header is generated $expectedHeader = 'Basic ' . base64_encode("mockEnvUser:mockEnvPass"); $this->assertEquals($expectedHeader, $auth->getHeader(), 'Basic authentication with environment variables mismatch.'); $this->assertEquals('Basic', $auth->getType(), 'Type should be Basic.'); - // Clean up environment variables putenv('NEO4J_USERNAME'); putenv('NEO4J_PASSWORD'); } diff --git a/tests/Unit/Neo4jQueryAPIUnitTest.php b/tests/Unit/Neo4jQueryAPIUnitTest.php index ab3eb276..8195a525 100644 --- a/tests/Unit/Neo4jQueryAPIUnitTest.php +++ b/tests/Unit/Neo4jQueryAPIUnitTest.php @@ -6,6 +6,7 @@ use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use Neo4j\QueryAPI\Neo4jQueryAPI; use Neo4j\QueryAPI\Objects\Authentication; @@ -13,6 +14,7 @@ use Neo4j\QueryAPI\Objects\ResultCounters; use Neo4j\QueryAPI\Objects\ResultSet; use Neo4j\QueryAPI\Results\ResultRow; +use Neo4j\QueryAPI\AuthenticateInterface; use PHPUnit\Framework\TestCase; class Neo4jQueryAPIUnitTest extends TestCase @@ -27,57 +29,67 @@ protected function setUp(): void private function initializeApi(): Neo4jQueryAPI { - return Neo4jQueryAPI::login($this->address, Authentication::basic()); + return Neo4jQueryAPI::login( + $this->address, + Authentication::fromEnvironment() + ); } public function testCorrectClientSetup(): void { - // Initialize the API and get the Neo4jQueryAPI instance + $neo4jQueryAPI = $this->initializeApi(); - // Use reflection to access private `client` property $clientReflection = new \ReflectionClass(Neo4jQueryAPI::class); + + $clientProperty = $clientReflection->getProperty('client'); - // Make the private property accessible $client = $clientProperty->getValue($neo4jQueryAPI); - - // Assert that the client is of type Guzzle's Client $this->assertInstanceOf(Client::class, $client); - // Get the client's configuration and check headers - $config = $client->getConfig(); - $expectedAuthHeader = 'Basic ' . base64_encode(getenv('NEO4J_USERNAME') . ':' . getenv('NEO4J_PASSWORD')); + $authProperty = $clientReflection->getProperty('auth'); + $auth = $authProperty->getValue($neo4jQueryAPI); + $this->assertInstanceOf(AuthenticateInterface::class, $auth); - // Check if the configuration matches - $this->assertEquals(rtrim($this->address, '/'), $config['base_uri']); - //$this->assertArrayHasKey('Authorization', $config['headers'], 'Authorization header missing.'); - //$this->assertEquals($expectedAuthHeader, $config['headers']['Authorization'], 'Authorization header value mismatch.'); - $this->assertEquals('application/vnd.neo4j.query', $config['headers']['Content-Type']); - $this->assertEquals('application/vnd.neo4j.query', $config['headers']['Accept']); - } + + $expectedAuth = Authentication::fromEnvironment(); + $this->assertEquals($expectedAuth->getHeader(), $auth->getHeader(), 'Authentication headers mismatch'); + + $request = new Request('GET', '/test-endpoint'); + $authenticatedRequest = $auth->authenticate($request); + $expectedAuthHeader = 'Basic ' . base64_encode(getenv("NEO4J_USERNAME") . ':' . getenv("NEO4J_PASSWORD")); + $this->assertEquals($expectedAuthHeader, $authenticatedRequest->getHeaderLine('Authorization')); + + $requestWithHeaders = $authenticatedRequest->withHeader('Content-Type', 'application/vnd.neo4j.query'); + $this->assertEquals('application/vnd.neo4j.query', $requestWithHeaders->getHeaderLine('Content-Type')); + } + /** * @throws GuzzleException */ public function testRunSuccess(): void { - // Mock response for a successful query $mock = new MockHandler([ new Response(200, ['X-Foo' => 'Bar'], '{"data": {"fields": ["hello"], "values": [[{"$type": "String", "_value": "world"}]]}}'), ]); - $auth = Authentication::basic(); + $auth = Authentication::fromEnvironment(); $handlerStack = HandlerStack::create($mock); $client = new Client(['handler' => $handlerStack]); $neo4jQueryAPI = new Neo4jQueryAPI($client, $auth); $cypherQuery = 'MATCH (n:Person) RETURN n LIMIT 5'; - $result = $neo4jQueryAPI->run($cypherQuery); - $this->assertEquals(new ResultSet([new ResultRow(['hello' => 'world'])], new ResultCounters(), new Bookmarks([])), $result); - } + $expectedResult = new ResultSet( + [new ResultRow(['hello' => 'world'])], + new ResultCounters(), + new Bookmarks([]) + ); + $this->assertEquals($expectedResult, $result); + } } diff --git a/tests/Unit/Neo4jRequestFactoryTest.php b/tests/Unit/Neo4jRequestFactoryTest.php new file mode 100644 index 00000000..f5a11e55 --- /dev/null +++ b/tests/Unit/Neo4jRequestFactoryTest.php @@ -0,0 +1,235 @@ +psr17Factory = $this->createMock(RequestFactoryInterface::class); + $this->streamFactory = $this->createMock(StreamFactoryInterface::class); + } + + /** + * Test for buildRunQueryRequest + */ + public function testBuildRunQueryRequest() + { + $cypher = 'MATCH (n) RETURN n'; + $parameters = ['param1' => 'value1']; + $database = 'neo4j'; + + + $payload = json_encode([ + 'statement' => $cypher, + 'parameters' => $parameters, + 'includeCounters' => true, + ]); + $uri = "{$this->baseUri}/db/{$database}/query/v2"; + + + $mockRequest = new Request('POST', $uri); + + + $mockStream = Utils::streamFor($payload); + + + $this->streamFactory->method('createStream') + ->willReturn($mockStream); + + $this->psr17Factory->method('createRequest') + ->willReturn($mockRequest); + + $factory = new Neo4jRequestFactory( + $this->psr17Factory, + $this->streamFactory, + $this->baseUri, + $this->authHeader + ); + $request = $factory->buildRunQueryRequest($database, $cypher, $parameters); + + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals($uri, (string) $request->getUri()); + $this->assertJsonStringEqualsJsonString($payload, (string) $request->getBody()); + } + + /** + * Test for buildBeginTransactionRequest + */ + public function testBuildBeginTransactionRequest() + { + $database = 'neo4j'; + $uri = "{$this->baseUri}/db/{$database}/query/v2/tx"; + + $mockRequest = new Request('POST', $uri); + $mockStream = Utils::streamFor(''); + + $this->streamFactory->method('createStream') + ->willReturn($mockStream); + + $this->psr17Factory->method('createRequest') + ->willReturn($mockRequest); + + $factory = new Neo4jRequestFactory( + $this->psr17Factory, + $this->streamFactory, + $this->baseUri + ); + $request = $factory->buildBeginTransactionRequest($database); + + // Assertions + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals($uri, (string) $request->getUri()); + } + + /** + * Test for buildCommitRequest + */ + public function testBuildCommitRequest() + { + $database = 'neo4j'; + $transactionId = '12345'; + $uri = "{$this->baseUri}/db/{$database}/query/v2/tx/{$transactionId}/commit"; + + $mockRequest = new Request('POST', $uri); + $mockStream = Utils::streamFor(''); + + $this->streamFactory->method('createStream') + ->willReturn($mockStream); + + $this->psr17Factory->method('createRequest') + ->willReturn($mockRequest); + + $factory = new Neo4jRequestFactory( + $this->psr17Factory, + $this->streamFactory, + $this->baseUri + ); + $request = $factory->buildCommitRequest($database, $transactionId); + + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals($uri, (string) $request->getUri()); + } + + /** + * Test for buildRollbackRequest + */ + public function testBuildRollbackRequest() + { + $database = 'neo4j'; + $transactionId = '12345'; + $uri = "{$this->baseUri}/db/{$database}/query/v2/tx/{$transactionId}/rollback"; + + $mockRequest = new Request('POST', $uri); + $mockStream = Utils::streamFor(''); + + $this->streamFactory->method('createStream') + ->willReturn($mockStream); + + $this->psr17Factory->method('createRequest') + ->willReturn($mockRequest); + + $factory = new Neo4jRequestFactory( + $this->psr17Factory, + $this->streamFactory, + $this->baseUri + ); + $request = $factory->buildRollbackRequest($database, $transactionId); + + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals($uri, (string) $request->getUri()); + } + + /** + * Test for the createRequest method (Private method should be tested indirectly through other public methods) + */ + public function testCreateRequestWithHeadersAndBody() + { + $cypher = 'MATCH (n) RETURN n'; + $parameters = ['param1' => 'value1']; + $database = 'neo4j'; + $uri = "{$this->baseUri}/db/{$database}/query/v2"; + $payload = json_encode([ + 'statement' => $cypher, + 'parameters' => $parameters, + 'includeCounters' => true, + ]); + + $mockStream = Utils::streamFor($payload); + $this->streamFactory->method('createStream') + ->willReturn($mockStream); + + $mockRequest = new Request('POST', $uri); + $this->psr17Factory->method('createRequest') + ->willReturn($mockRequest); + + $factory = new Neo4jRequestFactory( + $this->psr17Factory, + $this->streamFactory, + $this->baseUri, + $this->authHeader + ); + + $request = $factory->buildRunQueryRequest($database, $cypher, $parameters); + + $this->assertEquals('application/json', $request->getHeaderLine('Content-Type')); + $this->assertEquals('application/json', $request->getHeaderLine('Accept')); + $this->assertEquals($this->authHeader, $request->getHeaderLine('Authorization')); + + // Assertions for body + $this->assertJsonStringEqualsJsonString($payload, (string) $request->getBody()); + } + + + public function testCreateRequestWithoutAuthorizationHeader() + { + $cypher = 'MATCH (n) RETURN n'; + $parameters = ['param1' => 'value1']; + $database = 'neo4j'; + $uri = "{$this->baseUri}/db/{$database}/query/v2"; + $payload = json_encode([ + 'statement' => $cypher, + 'parameters' => $parameters, + 'includeCounters' => true, + ]); + + $mockStream = Utils::streamFor($payload); + $this->streamFactory->method('createStream') + ->willReturn($mockStream); + + $mockRequest = new Request('POST', $uri); + $this->psr17Factory->method('createRequest') + ->willReturn($mockRequest); + + $factory = new Neo4jRequestFactory( + $this->psr17Factory, + $this->streamFactory, + $this->baseUri + ); + + $request = $factory->buildRunQueryRequest($database, $cypher, $parameters); + + $this->assertEquals('application/json', $request->getHeaderLine('Content-Type')); + $this->assertEquals('application/json', $request->getHeaderLine('Accept')); + $this->assertEmpty($request->getHeaderLine('Authorization')); // No Authorization header + + $this->assertJsonStringEqualsJsonString($payload, (string) $request->getBody()); + } +}