Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/Configuration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Neo4j\QueryAPI;

use Neo4j\QueryAPI\Objects\Bookmarks;
use Neo4j\QueryAPI\Enums\AccessMode;

class Configuration
{
public function __construct(
public readonly string $database = 'neo4j',
public readonly bool $includeCounters = true,
public readonly Bookmarks $bookmark = new Bookmarks([]),
public readonly AccessMode $accessMode = AccessMode::WRITE,
) {}
}
9 changes: 9 additions & 0 deletions src/Enums/AccessMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Neo4j\QueryAPI\Enums;

enum AccessMode: string
{
case READ = 'READ';
case WRITE = 'WRITE';
}
35 changes: 16 additions & 19 deletions src/Exception/Neo4jException.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
/**
* @api
*/

class Neo4jException extends Exception
{
private readonly string $errorCode;
Expand All @@ -18,25 +17,38 @@ class Neo4jException extends Exception
public function __construct(
array $errorDetails = [],
int $statusCode = 0,
?\Throwable $previous = null
?\Throwable $previous = null,
) {
$this->errorCode = $errorDetails['code'] ?? 'Neo.UnknownError';
$errorParts = explode('.', $this->errorCode);
$this->errorType = $errorParts[1] ?? null;
$this->errorSubType = $errorParts[2] ?? null;
$this->errorName = $errorParts[3] ?? null;


$message = $errorDetails['message'] ?? 'An unknown error occurred.';
parent::__construct($message, $statusCode, $previous);
}

/**
* Create a Neo4jException instance from a Neo4j error response array.
*
* @param array $response The error response from Neo4j.
* @param \Throwable|null $exception Optional previous exception for chaining.
* @return self
*/
public static function fromNeo4jResponse(array $response, ?\Throwable $exception = null): self
{
$errorDetails = $response['errors'][0] ?? ['message' => 'Unknown error', 'code' => 'Neo.UnknownError'];


return new self($errorDetails, previous: $exception);
}

public function getErrorCode(): string
{
return $this->errorCode;
}


public function getType(): ?string
{
return $this->errorType;
Expand All @@ -51,19 +63,4 @@ public function getName(): ?string
{
return $this->errorName;
}

/**
* Create a Neo4jException instance from a Neo4j error response array.
*
* @param array $response The error response from Neo4j.
* @param \Throwable|null $exception Optional previous exception for chaining.
* @return self
*/
public static function fromNeo4jResponse(array $response, ?\Throwable $exception = null): self
{
$errorDetails = $response['errors'][0] ?? [];
$statusCode = $errorDetails['statusCode'] ?? 0;

return new self($errorDetails, (int)$statusCode, $exception);
}
}
242 changes: 65 additions & 177 deletions src/Neo4jQueryAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,227 +3,115 @@
namespace Neo4j\QueryAPI;

use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Utils;
use Neo4j\QueryAPI\Exception\Neo4jException;
use Neo4j\QueryAPI\Objects\Authentication;
use GuzzleHttp\Exception\RequestException;
use Neo4j\QueryAPI\Enums\AccessMode;
use Neo4j\QueryAPI\Objects\Bookmarks;
use Neo4j\QueryAPI\Objects\ProfiledQueryPlan;
use Neo4j\QueryAPI\Objects\ProfiledQueryPlanArguments;
use Neo4j\QueryAPI\Objects\ResultCounters;
use Neo4j\QueryAPI\Objects\ResultSet;
use Neo4j\QueryAPI\Results\ResultRow;
use Psr\Http\Client\ClientInterface;
use Neo4j\QueryAPI\Results\ResultSet;
use Neo4j\QueryAPI\Exception\Neo4jException;
use Psr\Http\Client\RequestExceptionInterface;
use RuntimeException;
use stdClass;
use Psr\Http\Message\ResponseInterface;
use Neo4j\QueryAPI\loginConfig;

class Neo4jQueryAPI
{
private ClientInterface $client;
private AuthenticateInterface $auth;
private Client $client;
private LoginConfig $loginConfig;
private Configuration $config;
private ResponseParser $responseParser;

public function __construct(ClientInterface $client, AuthenticateInterface $auth)
public function __construct(LoginConfig $loginConfig, ResponseParser $responseParser, Configuration $config)
{
$this->client = $client;
$this->auth = $auth;
$this->loginConfig = $loginConfig;
$this->responseParser = $responseParser;
$this->config = $config;

$this->client = new Client([
'base_uri' => rtrim($this->loginConfig->baseUrl, '/'),
'timeout' => 10.0,
'headers' => [
'Authorization' => 'Basic ' . $this->loginConfig->authToken,
'Accept' => 'application/vnd.neo4j.query',
],
]);
}


/**
* @throws \Exception
* Static method to create an instance with login details.
*/
public static function login(string $address, AuthenticateInterface $auth = null): self
public static function login(): self
{
$client = new Client([
'base_uri' => rtrim($address, '/'),
'timeout' => 10.0,
'headers' => [
'Content-Type' => 'application/vnd.neo4j.query',
'Accept' => 'application/vnd.neo4j.query',
],
]);
$loginConfig = loginConfig::fromEnv();
$config = new Configuration();

return new self($client, $auth ?? Authentication::basic());
return new self($loginConfig, new ResponseParser(new OGM()), $config);
}



/**
* Executes a Cypher query on the Neo4j database.
* Executes a Cypher query.
*
* @throws Neo4jException
* @throws RequestExceptionInterface
* @throws Neo4jException|RequestExceptionInterface
*/
public function run(string $cypher, array $parameters = [], string $database = 'neo4j', Bookmarks $bookmark = null): ResultSet
public function run(string $cypher, array $parameters = []): ResultSet
{
try {
$payload = [
'statement' => $cypher,
'parameters' => empty($parameters) ? new stdClass() : $parameters,
'includeCounters' => true,
'statement' => $cypher,
'parameters' => empty($parameters) ? new \stdClass() : $parameters,
'includeCounters' => $this->config->includeCounters,
'accessMode' => $this->config->accessMode->value,
];


if ($bookmark !== null) {
$payload['bookmarks'] = $bookmark->getBookmarks();
if (!empty($this->config->bookmark)) {
$payload['bookmarks'] = $this->config->bookmark;
}


$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)));
$response = $this->client->sendRequest($request);
$contents = $response->getBody()->getContents();

// if ($impersonatedUser !== null) {
// $payload['impersonatedUser'] = $impersonatedUser;
// }
error_log('Neo4j Payload: ' . json_encode($payload));

$data = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
$response = $this->client->post("/db/{$this->config->database}/query/v2", ['json' => $payload]);

return $this->responseParser->parseRunQueryResponse($response);
} catch (RequestException $e) {
error_log('Neo4j Request Failed: ' . $e->getMessage());

if (isset($data['errors']) && count($data['errors']) > 0) {

$error = $data['errors'][0];
throw new Neo4jException(
$error,
0,
null
);
}


return $this->parseResultSet($data);

} catch (RequestExceptionInterface $e) {
$this->handleException($e);
} catch (Neo4jException $e) {
throw $e;
$this->handleRequestException($e);
}
}

private function parseResultSet(array $data): ResultSet
{
$ogm = new OGM();

$keys = $data['data']['fields'] ?? [];
$values = $data['data']['values'] ?? [];

if (!is_array($values)) {
throw new RuntimeException('Unexpected response format: values is not an array.');
}

$rows = array_map(function ($resultRow) use ($ogm, $keys) {
$row = [];
foreach ($keys as $index => $key) {
$fieldData = $resultRow[$index] ?? null;
$row[$key] = $ogm->map($fieldData);
}
return new ResultRow($row);
}, $values);

$resultCounters = new ResultCounters(
containsUpdates: $data['counters']['containsUpdates'] ?? false,
nodesCreated: $data['counters']['nodesCreated'] ?? 0,
nodesDeleted: $data['counters']['nodesDeleted'] ?? 0,
propertiesSet: $data['counters']['propertiesSet'] ?? 0,
relationshipsCreated: $data['counters']['relationshipsCreated'] ?? 0,
relationshipsDeleted: $data['counters']['relationshipsDeleted'] ?? 0,
labelsAdded: $data['counters']['labelsAdded'] ?? 0,
labelsRemoved: $data['counters']['labelsRemoved'] ?? 0,
indexesAdded: $data['counters']['indexesAdded'] ?? 0,
indexesRemoved: $data['counters']['indexesRemoved'] ?? 0,
constraintsAdded: $data['counters']['constraintsAdded'] ?? 0,
constraintsRemoved: $data['counters']['constraintsRemoved'] ?? 0,
containsSystemUpdates: $data['counters']['containsSystemUpdates'] ?? false,
systemUpdates: $data['counters']['systemUpdates'] ?? 0
);

$profile = isset($data['profiledQueryPlan']) ? $this->createProfileData($data['profiledQueryPlan']) : null;

return new ResultSet(
$rows,
$resultCounters,
new Bookmarks($data['bookmarks'] ?? []),
$profile
);
}

private function handleException(RequestExceptionInterface $e): void
{
$response = $e->getResponse();
if ($response !== null) {
$contents = $response->getBody()->getContents();
$errorResponse = json_decode($contents, true);
throw Neo4jException::fromNeo4jResponse($errorResponse, $e);
}
throw $e;
}

/**
* Starts a transaction.
*/
public function beginTransaction(string $database = 'neo4j'): Transaction
{
$request = new Request('POST', '/db/neo4j/query/v2/tx');
$request = $this->auth->authenticate($request);
$request = $request->withHeader('Content-Type', 'application/json');

$response = $this->client->sendRequest($request);
$contents = $response->getBody()->getContents();
$response = $this->client->post("/db/{$database}/query/v2/tx");

$clusterAffinity = $response->getHeaderLine('neo4j-cluster-affinity');
$responseData = json_decode($contents, true);
$responseData = json_decode($response->getBody(), true);
$transactionId = $responseData['transaction']['id'];

return new Transaction($this->client, $clusterAffinity, $transactionId);
}

private function createProfileData(array $data): ProfiledQueryPlan
/**
* Handles request exceptions by parsing error details and throwing a Neo4jException.
*
* @throws Neo4jException
*/
private function handleRequestException(RequestExceptionInterface $e): void
{
$ogm = new OGM();

$mappedArguments = array_map(function ($value) use ($ogm) {
if (is_array($value) && array_key_exists('$type', $value) && array_key_exists('_value', $value)) {
return $ogm->map($value);
}
return $value;
}, $data['arguments'] ?? []);

$queryArguments = new ProfiledQueryPlanArguments(
globalMemory: $mappedArguments['GlobalMemory'] ?? null,
plannerImpl: $mappedArguments['planner-impl'] ?? null,
memory: $mappedArguments['Memory'] ?? null,
stringRepresentation: $mappedArguments['string-representation'] ?? null,
runtime: $mappedArguments['runtime'] ?? null,
time: $mappedArguments['Time'] ?? null,
pageCacheMisses: $mappedArguments['PageCacheMisses'] ?? null,
pageCacheHits: $mappedArguments['PageCacheHits'] ?? null,
runtimeImpl: $mappedArguments['runtime-impl'] ?? null,
version: $mappedArguments['version'] ?? null,
dbHits: $mappedArguments['DbHits'] ?? null,
batchSize: $mappedArguments['batch-size'] ?? null,
details: $mappedArguments['Details'] ?? null,
plannerVersion: $mappedArguments['planner-version'] ?? null,
pipelineInfo: $mappedArguments['PipelineInfo'] ?? null,
runtimeVersion: $mappedArguments['runtime-version'] ?? null,
id: $mappedArguments['Id'] ?? null,
estimatedRows: $mappedArguments['EstimatedRows'] ?? null,
planner: $mappedArguments['planner'] ?? null,
rows: $mappedArguments['Rows'] ?? null
);

$profiledQueryPlan = new ProfiledQueryPlan(
$data['dbHits'],
$data['records'],
$data['hasPageCacheStats'],
$data['pageCacheHits'],
$data['pageCacheMisses'],
$data['pageCacheHitRatio'],
$data['time'],
$data['operatorType'],
$queryArguments,
children: [],
identifiers: $data['identifiers'] ?? []
);

foreach ($data['children'] ?? [] as $child) {
$profiledQueryPlan->addChild($this->createProfileData($child));
$response = $e->getResponse();
if ($response instanceof ResponseInterface) {
$errorResponse = json_decode((string)$response->getBody(), true);
throw Neo4jException::fromNeo4jResponse($errorResponse, $e);
}

return $profiledQueryPlan;
throw new Neo4jException(['message' => $e->getMessage()], 500, $e);
}
}
Loading
Loading