diff --git a/.github/workflows/integration-test-cluster-neo4j-4.yml b/.github/workflows/integration-test-cluster-neo4j-4.yml index 9aa390ac..62ebd992 100644 --- a/.github/workflows/integration-test-cluster-neo4j-4.yml +++ b/.github/workflows/integration-test-cluster-neo4j-4.yml @@ -21,6 +21,7 @@ jobs: run: | echo "CONNECTION=neo4j://neo4j:testtest@neo4j" > .env - uses: hoverkraft-tech/compose-action@v2.0.2 + name: Start services with: compose-file: './docker-compose-neo4j-4.yml' up-flags: '--build --remove-orphans' diff --git a/.github/workflows/integration-test-cluster-neo4j-5.yml b/.github/workflows/integration-test-cluster-neo4j-5.yml index 58289707..37588031 100644 --- a/.github/workflows/integration-test-cluster-neo4j-5.yml +++ b/.github/workflows/integration-test-cluster-neo4j-5.yml @@ -21,6 +21,7 @@ jobs: run: | echo "CONNECTION=neo4j://neo4j:testtest@neo4j" > .env - uses: hoverkraft-tech/compose-action@v2.0.2 + name: Start services with: compose-file: './docker-compose.yml' up-flags: '--build --remove-orphans' diff --git a/.github/workflows/integration-test-single-server.yml b/.github/workflows/integration-test-single-server.yml index bbd88a65..aa47b687 100644 --- a/.github/workflows/integration-test-single-server.yml +++ b/.github/workflows/integration-test-single-server.yml @@ -19,6 +19,7 @@ jobs: run: | echo "CONNECTION=neo4j://neo4j:testtest@neo4j" > .env - uses: hoverkraft-tech/compose-action@v2.0.2 + name: Start services with: compose-file: './docker-compose-neo4j-4.yml' up-flags: '--build --remove-orphans' @@ -44,6 +45,7 @@ jobs: run: | echo "CONNECTION=neo4j://neo4j:testtest@neo4j" > .env - uses: hoverkraft-tech/compose-action@v2.0.2 + name: Start services with: compose-file: './docker-compose.yml' up-flags: '--build' diff --git a/composer.json b/composer.json index a1183e37..3dab5e11 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,8 @@ "suggest": { "ext-bcmath": "Needed to implement bolt protocol", "ext-sysvsem": "Needed for enabling connection pooling between processes", - "composer-runtime-api": "Install composer 2 for auto detection of version in user agent" + "composer-runtime-api": "Install composer 2 for auto detection of version in user agent", + "psr/log": "Needed to enable logging" }, "require-dev": { "phpunit/phpunit": "^10.0", @@ -51,12 +52,12 @@ "friendsofphp/php-cs-fixer": "3.15.0", "psalm/plugin-phpunit": "^0.18", "monolog/monolog": "^2.2", - "psr/log": "^1.1", "symfony/uid": "^5.0", "symfony/var-dumper": "^5.0", "cache/integration-tests": "dev-master", "kubawerlos/php-cs-fixer-custom-fixers": "3.13.*", - "rector/rector": "^1.0" + "rector/rector": "^1.0", + "psr/log": "^3.0" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index bd9af323..1f945826 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,6 +12,6 @@ - + diff --git a/src/Authentication/Authenticate.php b/src/Authentication/Authenticate.php index 34b1a287..2b711146 100644 --- a/src/Authentication/Authenticate.php +++ b/src/Authentication/Authenticate.php @@ -15,6 +15,7 @@ use function explode; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Contracts\AuthenticateInterface; use Psr\Http\Message\UriInterface; @@ -32,9 +33,9 @@ final class Authenticate * * @pure */ - public static function basic(string $username, string $password): BasicAuth + public static function basic(string $username, string $password, ?Neo4jLogger $logger = null): BasicAuth { - return new BasicAuth($username, $password); + return new BasicAuth($username, $password, $logger); } /** @@ -42,9 +43,9 @@ public static function basic(string $username, string $password): BasicAuth * * @pure */ - public static function kerberos(string $token): KerberosAuth + public static function kerberos(string $token, ?Neo4jLogger $logger = null): KerberosAuth { - return new KerberosAuth($token); + return new KerberosAuth($token, $logger); } /** @@ -52,9 +53,9 @@ public static function kerberos(string $token): KerberosAuth * * @pure */ - public static function oidc(string $token): OpenIDConnectAuth + public static function oidc(string $token, ?Neo4jLogger $logger = null): OpenIDConnectAuth { - return new OpenIDConnectAuth($token); + return new OpenIDConnectAuth($token, $logger); } /** @@ -62,9 +63,9 @@ public static function oidc(string $token): OpenIDConnectAuth * * @pure */ - public static function disabled(): NoAuth + public static function disabled(?Neo4jLogger $logger = null): NoAuth { - return new NoAuth(); + return new NoAuth($logger); } /** @@ -72,7 +73,7 @@ public static function disabled(): NoAuth * * @pure */ - public static function fromUrl(UriInterface $uri): AuthenticateInterface + public static function fromUrl(UriInterface $uri, ?Neo4jLogger $logger = null): AuthenticateInterface { /** * @psalm-suppress ImpureMethodCall Uri is a pure object: @@ -86,9 +87,9 @@ public static function fromUrl(UriInterface $uri): AuthenticateInterface $explode = explode(':', $userInfo); [$user, $pass] = $explode; - return self::basic($user, $pass); + return self::basic($user, $pass, $logger); } - return self::disabled(); + return self::disabled($logger); } } diff --git a/src/Authentication/BasicAuth.php b/src/Authentication/BasicAuth.php index 852060ff..675e667c 100644 --- a/src/Authentication/BasicAuth.php +++ b/src/Authentication/BasicAuth.php @@ -22,10 +22,12 @@ use Bolt\protocol\V5_3; use Bolt\protocol\V5_4; use Exception; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Common\ResponseHelper; use Laudis\Neo4j\Contracts\AuthenticateInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\UriInterface; +use Psr\Log\LogLevel; /** * Authenticates connections using a basic username and password. @@ -37,14 +39,13 @@ final class BasicAuth implements AuthenticateInterface */ public function __construct( private readonly string $username, - private readonly string $password + private readonly string $password, + private readonly ?Neo4jLogger $logger, ) {} - /** - * @psalm-mutation-free - */ public function authenticateHttp(RequestInterface $request, UriInterface $uri, string $userAgent): RequestInterface { + $this->logger?->log(LogLevel::DEBUG, 'Authenticating using BasicAuth'); $combo = base64_encode($this->username.':'.$this->password); /** @@ -64,8 +65,10 @@ public function authenticateHttp(RequestInterface $request, UriInterface $uri, s public function authenticateBolt(V4_4|V5|V5_1|V5_2|V5_3|V5_4 $protocol, string $userAgent): array { if (method_exists($protocol, 'logon')) { + $this->logger?->log(LogLevel::DEBUG, 'HELLO', ['user_agent' => $userAgent]); $protocol->hello(['user_agent' => $userAgent]); $response = ResponseHelper::getResponse($protocol); + $this->logger?->log(LogLevel::DEBUG, 'LOGON', ['scheme' => 'basic', 'principal' => $this->username]); $protocol->logon([ 'scheme' => 'basic', 'principal' => $this->username, @@ -76,6 +79,7 @@ public function authenticateBolt(V4_4|V5|V5_1|V5_2|V5_3|V5_4 $protocol, string $ /** @var array{server: string, connection_id: string, hints: list} */ return $response->content; } else { + $this->logger?->log(LogLevel::DEBUG, 'HELLO', ['user_agent' => $userAgent, 'scheme' => 'basic', 'principal' => $this->username]); $protocol->hello([ 'user_agent' => $userAgent, 'scheme' => 'basic', diff --git a/src/Authentication/KerberosAuth.php b/src/Authentication/KerberosAuth.php index 24c2d590..3c57d32f 100644 --- a/src/Authentication/KerberosAuth.php +++ b/src/Authentication/KerberosAuth.php @@ -20,10 +20,12 @@ use Bolt\protocol\V5_3; use Bolt\protocol\V5_4; use Exception; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Common\ResponseHelper; use Laudis\Neo4j\Contracts\AuthenticateInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\UriInterface; +use Psr\Log\LogLevel; use function sprintf; @@ -36,14 +38,13 @@ final class KerberosAuth implements AuthenticateInterface * @psalm-external-mutation-free */ public function __construct( - private readonly string $token + private readonly string $token, + private readonly ?Neo4jLogger $logger, ) {} - /** - * @psalm-mutation-free - */ public function authenticateHttp(RequestInterface $request, UriInterface $uri, string $userAgent): RequestInterface { + $this->logger?->log(LogLevel::DEBUG, 'Authenticating using KerberosAuth'); /** * @psalm-suppress ImpureMethodCall Request is a pure object: * @@ -61,8 +62,10 @@ public function authenticateHttp(RequestInterface $request, UriInterface $uri, s public function authenticateBolt(V4_4|V5|V5_1|V5_2|V5_3|V5_4 $protocol, string $userAgent): array { if (method_exists($protocol, 'logon')) { + $this->logger?->log(LogLevel::DEBUG, 'HELLO', ['user_agent' => $userAgent]); $protocol->hello(['user_agent' => $userAgent]); $response = ResponseHelper::getResponse($protocol); + $this->logger?->log(LogLevel::DEBUG, 'LOGON', ['scheme' => 'kerberos', 'principal' => '']); $protocol->logon([ 'scheme' => 'kerberos', 'principal' => '', @@ -73,6 +76,7 @@ public function authenticateBolt(V4_4|V5|V5_1|V5_2|V5_3|V5_4 $protocol, string $ /** @var array{server: string, connection_id: string, hints: list} */ return $response->content; } else { + $this->logger?->log(LogLevel::DEBUG, 'HELLO', ['user_agent' => $userAgent, 'scheme' => 'kerberos', 'principal' => '']); $protocol->hello([ 'user_agent' => $userAgent, 'scheme' => 'kerberos', diff --git a/src/Authentication/NoAuth.php b/src/Authentication/NoAuth.php index d277a3d5..51d246cb 100644 --- a/src/Authentication/NoAuth.php +++ b/src/Authentication/NoAuth.php @@ -20,10 +20,12 @@ use Bolt\protocol\V5_3; use Bolt\protocol\V5_4; use Exception; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Common\ResponseHelper; use Laudis\Neo4j\Contracts\AuthenticateInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\UriInterface; +use Psr\Log\LogLevel; use function sprintf; @@ -33,10 +35,15 @@ final class NoAuth implements AuthenticateInterface { /** - * @psalm-mutation-free + * @pure */ + public function __construct( + private readonly ?Neo4jLogger $logger + ) {} + public function authenticateHttp(RequestInterface $request, UriInterface $uri, string $userAgent): RequestInterface { + $this->logger?->log(LogLevel::DEBUG, 'Authentication disabled'); /** * @psalm-suppress ImpureMethodCall Request is a pure object: * @@ -53,8 +60,10 @@ public function authenticateHttp(RequestInterface $request, UriInterface $uri, s public function authenticateBolt(V4_4|V5|V5_1|V5_2|V5_3|V5_4 $protocol, string $userAgent): array { if (method_exists($protocol, 'logon')) { + $this->logger?->log(LogLevel::DEBUG, 'HELLO', ['user_agent' => $userAgent]); $protocol->hello(['user_agent' => $userAgent]); $response = ResponseHelper::getResponse($protocol); + $this->logger?->log(LogLevel::DEBUG, 'LOGON', ['scheme' => 'none']); $protocol->logon([ 'scheme' => 'none', ]); @@ -63,6 +72,7 @@ public function authenticateBolt(V4_4|V5|V5_1|V5_2|V5_3|V5_4 $protocol, string $ /** @var array{server: string, connection_id: string, hints: list} */ return $response->content; } else { + $this->logger?->log(LogLevel::DEBUG, 'HELLO', ['user_agent' => $userAgent, 'scheme' => 'none']); $protocol->hello([ 'user_agent' => $userAgent, 'scheme' => 'none', diff --git a/src/Authentication/OpenIDConnectAuth.php b/src/Authentication/OpenIDConnectAuth.php index bc05d7cc..18b2b796 100644 --- a/src/Authentication/OpenIDConnectAuth.php +++ b/src/Authentication/OpenIDConnectAuth.php @@ -20,10 +20,12 @@ use Bolt\protocol\V5_3; use Bolt\protocol\V5_4; use Exception; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Common\ResponseHelper; use Laudis\Neo4j\Contracts\AuthenticateInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\UriInterface; +use Psr\Log\LogLevel; use function sprintf; @@ -33,14 +35,13 @@ final class OpenIDConnectAuth implements AuthenticateInterface * @psalm-external-mutation-free */ public function __construct( - private readonly string $token + private readonly string $token, + private readonly ?Neo4jLogger $logger ) {} - /** - * @psalm-mutation-free - */ public function authenticateHttp(RequestInterface $request, UriInterface $uri, string $userAgent): RequestInterface { + $this->logger?->log(LogLevel::DEBUG, 'Authenticating using OpenIDConnectAuth'); /** * @psalm-suppress ImpureMethodCall Request is a pure object: * @@ -58,8 +59,10 @@ public function authenticateHttp(RequestInterface $request, UriInterface $uri, s public function authenticateBolt(V4_4|V5|V5_1|V5_2|V5_3|V5_4 $protocol, string $userAgent): array { if (method_exists($protocol, 'logon')) { + $this->logger?->log(LogLevel::DEBUG, 'HELLO', ['user_agent' => $userAgent]); $protocol->hello(['user_agent' => $userAgent]); $response = ResponseHelper::getResponse($protocol); + $this->logger?->log(LogLevel::DEBUG, 'LOGON', ['scheme' => 'bearer']); $protocol->logon([ 'scheme' => 'bearer', 'credentials' => $this->token, @@ -69,6 +72,7 @@ public function authenticateBolt(V4_4|V5|V5_1|V5_2|V5_3|V5_4 $protocol, string $ /** @var array{server: string, connection_id: string, hints: list} */ return $response->content; } else { + $this->logger?->log(LogLevel::DEBUG, 'HELLO', ['user_agent' => $userAgent, 'scheme' => 'bearer']); $protocol->hello([ 'user_agent' => $userAgent, 'scheme' => 'bearer', diff --git a/src/Basic/Driver.php b/src/Basic/Driver.php index ca76954d..7b73a876 100644 --- a/src/Basic/Driver.php +++ b/src/Basic/Driver.php @@ -57,4 +57,9 @@ public static function create(string|UriInterface $uri, ?DriverConfiguration $co return new self($driver); } + + public function closeConnections(): void + { + $this->driver->closeConnections(); + } } diff --git a/src/Bolt/BoltConnection.php b/src/Bolt/BoltConnection.php index dba4e9a2..7255b6c5 100644 --- a/src/Bolt/BoltConnection.php +++ b/src/Bolt/BoltConnection.php @@ -22,7 +22,9 @@ use Bolt\protocol\V5_2; use Bolt\protocol\V5_3; use Bolt\protocol\V5_4; +use Exception; use Laudis\Neo4j\Common\ConnectionConfiguration; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Contracts\AuthenticateInterface; use Laudis\Neo4j\Contracts\ConnectionInterface; use Laudis\Neo4j\Contracts\FormatterInterface; @@ -33,10 +35,11 @@ use Laudis\Neo4j\Exception\Neo4jException; use Laudis\Neo4j\Types\CypherList; use Psr\Http\Message\UriInterface; +use Psr\Log\LogLevel; use WeakReference; /** - * @implements ConnectionInterface + * @implements ConnectionInterface * * @psalm-import-type BoltMeta from FormatterInterface */ @@ -58,7 +61,7 @@ class BoltConnection implements ConnectionInterface private array $subscribedResults = []; /** - * @return array{0: V4_4|V5|V5_1|V5_2|V5_3|V5_4, 1: Connection} + * @return array{0: V4_4|V5|V5_1|V5_2|V5_3|V5_4|null, 1: Connection} */ public function getImplementation(): array { @@ -69,12 +72,13 @@ public function getImplementation(): array * @psalm-mutation-free */ public function __construct( - private V4_4|V5|V5_1|V5_2|V5_3|V5_4 $boltProtocol, + private V4_4|V5|V5_1|V5_2|V5_3|V5_4|null $boltProtocol, private readonly Connection $connection, private readonly AuthenticateInterface $auth, private readonly string $userAgent, /** @psalm-readonly */ - private readonly ConnectionConfiguration $config + private readonly ConnectionConfiguration $config, + private readonly ?Neo4jLogger $logger, ) {} public function getEncryptionLevel(): string @@ -135,12 +139,26 @@ public function getAuthentication(): AuthenticateInterface return $this->auth; } - /** - * @psalm-mutation-free - */ public function isOpen(): bool { - return !in_array($this->protocol()->serverState, [ServerState::DISCONNECTED, ServerState::DEFUNCT], true); + if (!isset($this->boltProtocol)) { + return false; + } + + return !in_array( + $this->protocol()->serverState, + [ServerState::DISCONNECTED, ServerState::DEFUNCT], + true + ); + } + + public function isStreaming(): bool + { + return in_array( + $this->protocol()->serverState, + [ServerState::STREAMING, ServerState::TX_STREAMING], + true + ); } public function setTimeout(float $timeout): void @@ -150,7 +168,8 @@ public function setTimeout(float $timeout): void public function consumeResults(): void { - if ($this->protocol()->serverState !== ServerState::STREAMING && $this->protocol()->serverState !== ServerState::TX_STREAMING) { + $this->logger?->log(LogLevel::DEBUG, 'Consuming results'); + if (!$this->isStreaming()) { $this->subscribedResults = []; return; @@ -174,6 +193,7 @@ public function consumeResults(): void */ public function reset(): void { + $this->logger?->log(LogLevel::DEBUG, 'RESET'); $response = $this->protocol() ->reset() ->getResponse(); @@ -191,6 +211,7 @@ public function begin(?string $database, ?float $timeout, BookmarkHolder $holder $this->consumeResults(); $extra = $this->buildRunExtra($database, $timeout, $holder, AccessMode::WRITE()); + $this->logger?->log(LogLevel::DEBUG, 'BEGIN', $extra); $response = $this->protocol() ->begin($extra) ->getResponse(); @@ -205,6 +226,7 @@ public function begin(?string $database, ?float $timeout, BookmarkHolder $holder public function discard(?int $qid): void { $extra = $this->buildResultExtra(null, $qid); + $this->logger?->log(LogLevel::DEBUG, 'DISCARD', $extra); $response = $this->protocol() ->discard($extra) ->getResponse(); @@ -218,9 +240,16 @@ public function discard(?int $qid): void * * @return BoltMeta */ - public function run(string $text, array $parameters, ?string $database, ?float $timeout, BookmarkHolder $holder, ?AccessMode $mode): array - { + public function run( + string $text, + array $parameters, + ?string $database, + ?float $timeout, + BookmarkHolder $holder, + ?AccessMode $mode + ): array { $extra = $this->buildRunExtra($database, $timeout, $holder, $mode); + $this->logger?->log(LogLevel::DEBUG, 'RUN', $extra); $response = $this->protocol() ->run($text, $parameters, $extra) ->getResponse(); @@ -236,6 +265,7 @@ public function run(string $text, array $parameters, ?string $database, ?float $ */ public function commit(): void { + $this->logger?->log(LogLevel::DEBUG, 'COMMIT'); $this->consumeResults(); $response = $this->protocol() @@ -251,6 +281,7 @@ public function commit(): void */ public function rollback(): void { + $this->logger?->log(LogLevel::DEBUG, 'ROLLBACK'); $this->consumeResults(); $response = $this->protocol() @@ -261,6 +292,10 @@ public function rollback(): void public function protocol(): V4_4|V5|V5_1|V5_2|V5_3|V5_4 { + if (!isset($this->boltProtocol)) { + throw new Exception('Connection is closed'); + } + return $this->boltProtocol; } @@ -274,6 +309,7 @@ public function protocol(): V4_4|V5|V5_1|V5_2|V5_3|V5_4 public function pull(?int $qid, ?int $fetchSize): array { $extra = $this->buildResultExtra($fetchSize, $qid); + $this->logger?->log(LogLevel::DEBUG, 'PULL', $extra); $tbr = []; /** @var Response $response */ @@ -287,13 +323,19 @@ public function pull(?int $qid, ?int $fetchSize): array } public function __destruct() + { + $this->close(); + } + + public function close(): void { try { if ($this->isOpen()) { - if ($this->protocol()->serverState === ServerState::STREAMING || $this->protocol()->serverState === ServerState::TX_STREAMING) { + if ($this->isStreaming()) { $this->consumeResults(); } + $this->logger?->log(LogLevel::DEBUG, 'GOODBYE'); $this->protocol()->goodbye(); unset($this->boltProtocol); // has to be set to null as the sockets don't recover nicely contrary to what the underlying code might lead you to believe; @@ -339,6 +381,10 @@ private function buildResultExtra(?int $fetchSize, ?int $qid): array public function getServerState(): string { + if (!isset($this->boltProtocol)) { + return ServerState::DISCONNECTED->name; + } + return $this->protocol()->serverState->name; } @@ -355,6 +401,7 @@ public function getUserAgent(): string private function assertNoFailure(Response $response): void { if ($response->signature === Signature::FAILURE) { + $this->logger?->log(LogLevel::ERROR, 'FAILURE'); $this->protocol()->reset()->getResponse(); // what if the reset fails? what should be expected behaviour? throw Neo4jException::fromBoltResponse($response); } diff --git a/src/Bolt/BoltDriver.php b/src/Bolt/BoltDriver.php index 23bb1bad..0fa16706 100644 --- a/src/Bolt/BoltDriver.php +++ b/src/Bolt/BoltDriver.php @@ -72,7 +72,7 @@ public static function create(string|UriInterface $uri, ?DriverConfiguration $co } $configuration ??= DriverConfiguration::default(); - $authenticate ??= Authenticate::fromUrl($uri); + $authenticate ??= Authenticate::fromUrl($uri, $configuration->getLogger()); $semaphore = $configuration->getSemaphoreFactory()->create($uri, $configuration); /** @psalm-suppress InvalidArgument */ @@ -90,7 +90,7 @@ public static function create(string|UriInterface $uri, ?DriverConfiguration $co */ public function createSession(?SessionConfiguration $config = null): SessionInterface { - $sessionConfig = SessionConfiguration::fromUri($this->parsedUrl); + $sessionConfig = SessionConfiguration::fromUri($this->parsedUrl, $this->pool->getLogger()); if ($config !== null) { $sessionConfig = $sessionConfig->merge($config); } @@ -109,4 +109,9 @@ public function verifyConnectivity(?SessionConfiguration $config = null): bool return true; } + + public function closeConnections(): void + { + $this->pool->close(); + } } diff --git a/src/Bolt/ConnectionPool.php b/src/Bolt/ConnectionPool.php index d3d6556f..2b2bfa25 100644 --- a/src/Bolt/ConnectionPool.php +++ b/src/Bolt/ConnectionPool.php @@ -15,6 +15,7 @@ use Generator; use Laudis\Neo4j\BoltFactory; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Contracts\AuthenticateInterface; use Laudis\Neo4j\Contracts\ConnectionInterface; use Laudis\Neo4j\Contracts\ConnectionPoolInterface; @@ -41,21 +42,27 @@ final class ConnectionPool implements ConnectionPoolInterface public function __construct( private readonly SemaphoreInterface $semaphore, private readonly BoltFactory $factory, - private readonly ConnectionRequestData $data + private readonly ConnectionRequestData $data, + private readonly ?Neo4jLogger $logger ) {} - public static function create(UriInterface $uri, AuthenticateInterface $auth, DriverConfiguration $conf, SemaphoreInterface $semaphore): self - { + public static function create( + UriInterface $uri, + AuthenticateInterface $auth, + DriverConfiguration $conf, + SemaphoreInterface $semaphore + ): self { return new self( $semaphore, - BoltFactory::create(), + BoltFactory::create($conf->getLogger()), new ConnectionRequestData( $uri->getHost(), $uri, $auth, $conf->getUserAgent(), $conf->getSslConfiguration() - ) + ), + $conf->getLogger() ); } @@ -106,6 +113,11 @@ public function release(ConnectionInterface $connection): void } } + public function getLogger(): ?Neo4jLogger + { + return $this->logger; + } + /** * @return BoltConnection|null */ @@ -157,4 +169,12 @@ private function returnAnyAvailableConnection(SessionConfiguration $config): ?Co return null; } + + public function close(): void + { + foreach ($this->activeConnections as $activeConnection) { + $activeConnection->close(); + } + $this->activeConnections = []; + } } diff --git a/src/Bolt/Session.php b/src/Bolt/Session.php index 0cf01535..84214e00 100644 --- a/src/Bolt/Session.php +++ b/src/Bolt/Session.php @@ -15,6 +15,7 @@ use Exception; use Laudis\Neo4j\Common\GeneratorHelper; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Common\TransactionHelper; use Laudis\Neo4j\Contracts\ConnectionPoolInterface; use Laudis\Neo4j\Contracts\FormatterInterface; @@ -30,6 +31,7 @@ use Laudis\Neo4j\Exception\Neo4jException; use Laudis\Neo4j\Neo4j\Neo4jConnectionPool; use Laudis\Neo4j\Types\CypherList; +use Psr\Log\LogLevel; /** * A session using bolt connections. @@ -65,6 +67,7 @@ public function runStatements(iterable $statements, ?TransactionConfiguration $c { $tbr = []; + $this->getLogger()?->log(LogLevel::INFO, 'Running statements', ['statements' => $statements]); $config = $this->mergeTsxConfig($config); foreach ($statements as $statement) { $tbr[] = $this->beginInstantTransaction($this->config, $config)->runStatement($statement); @@ -93,6 +96,7 @@ public function run(string $statement, iterable $parameters = [], ?TransactionCo public function writeTransaction(callable $tsxHandler, ?TransactionConfiguration $config = null) { + $this->getLogger()?->log(LogLevel::INFO, 'Beginning write transaction', ['config' => $config]); $config = $this->mergeTsxConfig($config); return TransactionHelper::retry( @@ -103,6 +107,7 @@ public function writeTransaction(callable $tsxHandler, ?TransactionConfiguration public function readTransaction(callable $tsxHandler, ?TransactionConfiguration $config = null) { + $this->getLogger()?->log(LogLevel::INFO, 'Beginning read transaction', ['config' => $config]); $config = $this->mergeTsxConfig($config); return TransactionHelper::retry( @@ -118,6 +123,7 @@ public function transaction(callable $tsxHandler, ?TransactionConfiguration $con public function beginTransaction(?iterable $statements = null, ?TransactionConfiguration $config = null): UnmanagedTransactionInterface { + $this->getLogger()?->log(LogLevel::INFO, 'Beginning transaction', ['statements' => $statements, 'config' => $config]); $config = $this->mergeTsxConfig($config); $tsx = $this->startTransaction($config, $this->config); @@ -133,6 +139,7 @@ private function beginInstantTransaction( SessionConfiguration $config, TransactionConfiguration $tsxConfig ): TransactionInterface { + $this->getLogger()?->log(LogLevel::INFO, 'Starting instant transaction', ['config' => $tsxConfig]); $connection = $this->acquireConnection($tsxConfig, $config); return new BoltUnmanagedTransaction($this->config->getDatabase(), $this->formatter, $connection, $this->config, $tsxConfig, $this->bookmarkHolder); @@ -143,6 +150,7 @@ private function beginInstantTransaction( */ private function acquireConnection(TransactionConfiguration $config, SessionConfiguration $sessionConfig): BoltConnection { + $this->getLogger()?->log(LogLevel::INFO, 'Acquiring connection', ['config' => $config, 'sessionConfig' => $sessionConfig]); $connection = $this->pool->acquire($sessionConfig); /** @var BoltConnection $connection */ $connection = GeneratorHelper::getReturnFromGenerator($connection); @@ -160,6 +168,7 @@ private function acquireConnection(TransactionConfiguration $config, SessionConf private function startTransaction(TransactionConfiguration $config, SessionConfiguration $sessionConfig): UnmanagedTransactionInterface { + $this->getLogger()?->log(LogLevel::INFO, 'Starting transaction', ['config' => $config, 'sessionConfig' => $sessionConfig]); try { $connection = $this->acquireConnection($config, $sessionConfig); @@ -183,4 +192,9 @@ public function getLastBookmark(): Bookmark { return $this->bookmarkHolder->getBookmark(); } + + private function getLogger(): ?Neo4jLogger + { + return $this->pool->getLogger(); + } } diff --git a/src/BoltFactory.php b/src/BoltFactory.php index f0b4611f..bd0e555d 100644 --- a/src/BoltFactory.php +++ b/src/BoltFactory.php @@ -21,6 +21,7 @@ use Laudis\Neo4j\Bolt\SystemWideConnectionFactory; use Laudis\Neo4j\Bolt\UriConfiguration; use Laudis\Neo4j\Common\ConnectionConfiguration; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Contracts\BasicConnectionFactoryInterface; use Laudis\Neo4j\Contracts\ConnectionInterface; use Laudis\Neo4j\Databags\ConnectionRequestData; @@ -40,12 +41,13 @@ class BoltFactory public function __construct( private readonly BasicConnectionFactoryInterface $connectionFactory, private readonly ProtocolFactory $protocolFactory, - private readonly SslConfigurationFactory $sslConfigurationFactory + private readonly SslConfigurationFactory $sslConfigurationFactory, + private readonly ?Neo4jLogger $logger = null ) {} - public static function create(): self + public static function create(?Neo4jLogger $logger): self { - return new self(SystemWideConnectionFactory::getInstance(), new ProtocolFactory(), new SslConfigurationFactory()); + return new self(SystemWideConnectionFactory::getInstance(), new ProtocolFactory(), new SslConfigurationFactory(), $logger); } public function createConnection(ConnectionRequestData $data, SessionConfiguration $sessionConfig): BoltConnection @@ -73,11 +75,15 @@ public function createConnection(ConnectionRequestData $data, SessionConfigurati $sslLevel ); - return new BoltConnection($protocol, $connection, $data->getAuth(), $data->getUserAgent(), $config); + return new BoltConnection($protocol, $connection, $data->getAuth(), $data->getUserAgent(), $config, $this->logger); } public function canReuseConnection(ConnectionInterface $connection, ConnectionRequestData $data, SessionConfiguration $config): bool { + if (!$connection->isOpen()) { + return false; + } + $databaseInfo = $connection->getDatabaseInfo(); $database = $databaseInfo?->getName(); diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index f6c91caf..cf515701 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -52,7 +52,7 @@ public function __construct( private SessionConfiguration $defaultSessionConfig, /** @psalm-readonly */ private TransactionConfiguration $defaultTransactionConfig, - private DriverSetupManager $driverSetups + private DriverSetupManager $driverSetups, ) {} /** @@ -80,7 +80,7 @@ public function withDriver(string $alias, string $url, ?AuthenticateInterface $a { $uri = Uri::create($url); - $authentication ??= Authenticate::fromUrl($uri); + $authentication ??= Authenticate::fromUrl($uri, $this->driverSetups->getLogger()); return $this->withParsedUrl($alias, $uri, $authentication, $priority ?? 0); } diff --git a/src/Common/DriverSetupManager.php b/src/Common/DriverSetupManager.php index e01e204d..a6ab7a85 100644 --- a/src/Common/DriverSetupManager.php +++ b/src/Common/DriverSetupManager.php @@ -113,7 +113,7 @@ public function getDriver(SessionConfiguration $config, ?string $alias = null): /** @var SplPriorityQueue */ $this->driverSetups['default'] = new SplPriorityQueue(); - $setup = new DriverSetup(Uri::create(self::DEFAULT_DRIVER_CONFIG), Authenticate::disabled()); + $setup = new DriverSetup(Uri::create(self::DEFAULT_DRIVER_CONFIG), Authenticate::disabled($config->getLogger())); $this->driverSetups['default']->insert($setup, PHP_INT_MIN); return $this->getDriver($config); @@ -192,4 +192,12 @@ public function withFormatter(FormatterInterface $formatter): self return $tbr; } + + /** + * @psalm-mutation-free + */ + public function getLogger(): ?Neo4jLogger + { + return $this->configuration->getLogger(); + } } diff --git a/src/Common/Neo4jLogger.php b/src/Common/Neo4jLogger.php new file mode 100644 index 00000000..60276411 --- /dev/null +++ b/src/Common/Neo4jLogger.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\Common; + +use InvalidArgumentException; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; + +class Neo4jLogger +{ + private const LOG_LEVELS = [ + LogLevel::EMERGENCY, + LogLevel::ALERT, + LogLevel::CRITICAL, + LogLevel::ERROR, + LogLevel::WARNING, + LogLevel::NOTICE, + LogLevel::INFO, + LogLevel::DEBUG, + ]; + + public function __construct( + private readonly string $level, + private readonly ?LoggerInterface $logger, + ) {} + + public function log(string $level, string $message, array $context = []): void + { + if ($this->logger === null || !$this->shouldLog($level)) { + return; + } + + match ($level) { + LogLevel::EMERGENCY => $this->logger->emergency($message, $context), + LogLevel::ALERT => $this->logger->alert($message, $context), + LogLevel::CRITICAL => $this->logger->critical($message, $context), + LogLevel::ERROR => $this->logger->error($message, $context), + LogLevel::WARNING => $this->logger->warning($message, $context), + LogLevel::NOTICE => $this->logger->notice($message, $context), + LogLevel::INFO => $this->logger->info($message, $context), + LogLevel::DEBUG => $this->logger->debug($message, $context), + default => throw new InvalidArgumentException("Invalid log level: $level"), + }; + } + + public function getLevel(): string + { + return $this->level; + } + + public function getLogger(): ?LoggerInterface + { + return $this->logger; + } + + private function shouldLog(string $level): bool + { + return array_search($level, self::LOG_LEVELS) <= array_search($this->level, self::LOG_LEVELS); + } +} diff --git a/src/Contracts/AuthenticateInterface.php b/src/Contracts/AuthenticateInterface.php index ba36ca14..1379e545 100644 --- a/src/Contracts/AuthenticateInterface.php +++ b/src/Contracts/AuthenticateInterface.php @@ -25,8 +25,6 @@ interface AuthenticateInterface { /** - * @psalm-mutation-free - * * Authenticates a RequestInterface with the provided configuration Uri and userAgent. */ public function authenticateHttp(RequestInterface $request, UriInterface $uri, string $userAgent): RequestInterface; diff --git a/src/Contracts/ConnectionInterface.php b/src/Contracts/ConnectionInterface.php index 3d917d99..e95988b6 100644 --- a/src/Contracts/ConnectionInterface.php +++ b/src/Contracts/ConnectionInterface.php @@ -96,8 +96,6 @@ public function setTimeout(float $timeout): void; /** * Checks to see if the connection is open. - * - * @psalm-mutation-free */ public function isOpen(): bool; @@ -112,4 +110,9 @@ public function getEncryptionLevel(): string; * Returns the user agent handling this connection. */ public function getUserAgent(): string; + + /** + * Closes the connection. + */ + public function close(): void; } diff --git a/src/Contracts/ConnectionPoolInterface.php b/src/Contracts/ConnectionPoolInterface.php index 56978b66..98dd372c 100644 --- a/src/Contracts/ConnectionPoolInterface.php +++ b/src/Contracts/ConnectionPoolInterface.php @@ -44,4 +44,9 @@ public function acquire(SessionConfiguration $config): Generator; * Releases a connection back to the pool. */ public function release(ConnectionInterface $connection): void; + + /** + * Closes all connections in the pool. + */ + public function close(): void; } diff --git a/src/Contracts/DriverInterface.php b/src/Contracts/DriverInterface.php index ea0ca2b3..9d9b0018 100644 --- a/src/Contracts/DriverInterface.php +++ b/src/Contracts/DriverInterface.php @@ -38,4 +38,9 @@ public function createSession(?SessionConfiguration $config = null): SessionInte * Returns true if the driver can make a valid connection with the server. */ public function verifyConnectivity(?SessionConfiguration $config = null): bool; + + /** + * Closes all connections in the pool. + */ + public function closeConnections(): void; } diff --git a/src/Databags/DriverConfiguration.php b/src/Databags/DriverConfiguration.php index 05b81ddc..18d57613 100644 --- a/src/Databags/DriverConfiguration.php +++ b/src/Databags/DriverConfiguration.php @@ -21,8 +21,11 @@ use function is_callable; use Laudis\Neo4j\Common\Cache; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Common\SemaphoreFactory; use Laudis\Neo4j\Contracts\SemaphoreFactoryInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; use Psr\SimpleCache\CacheInterface; use function sprintf; @@ -42,11 +45,13 @@ final class DriverConfiguration private $cache; /** @var callable():(SemaphoreFactoryInterface|null)|SemaphoreFactoryInterface|null */ private $semaphoreFactory; + private ?Neo4jLogger $logger; /** * @param callable():(HttpPsrBindings|null)|HttpPsrBindings|null $httpPsrBindings * @param callable():(CacheInterface|null)|CacheInterface|null $cache * @param callable():(SemaphoreFactoryInterface|null)|SemaphoreFactoryInterface|null $semaphore + * @param string|null $logLevel The log level to use. If null, LogLevel::INFO is used. * * @psalm-external-mutation-free */ @@ -57,11 +62,18 @@ public function __construct( private int|null $maxPoolSize, CacheInterface|callable|null $cache, private float|null $acquireConnectionTimeout, - callable|SemaphoreFactoryInterface|null $semaphore + callable|SemaphoreFactoryInterface|null $semaphore, + ?string $logLevel, + ?LoggerInterface $logger ) { $this->httpPsrBindings = $httpPsrBindings; $this->cache = $cache; $this->semaphoreFactory = $semaphore; + if ($logger !== null) { + $this->logger = new Neo4jLogger($logLevel ?? LogLevel::INFO, $logger); + } else { + $this->logger = null; + } } /** @@ -69,9 +81,28 @@ public function __construct( * * @pure */ - public static function create(?string $userAgent, callable|HttpPsrBindings|null $httpPsrBindings, SslConfiguration $sslConfig, int $maxPoolSize, CacheInterface $cache, float $acquireConnectionTimeout, SemaphoreFactoryInterface $semaphore): self - { - return new self($userAgent, $httpPsrBindings, $sslConfig, $maxPoolSize, $cache, $acquireConnectionTimeout, $semaphore); + public static function create( + ?string $userAgent, + callable|HttpPsrBindings|null $httpPsrBindings, + SslConfiguration $sslConfig, + int $maxPoolSize, + CacheInterface $cache, + float $acquireConnectionTimeout, + SemaphoreFactoryInterface $semaphore, + ?string $logLevel, + ?LoggerInterface $logger, + ): self { + return new self( + $userAgent, + $httpPsrBindings, + $sslConfig, + $maxPoolSize, + $cache, + $acquireConnectionTimeout, + $semaphore, + $logLevel, + $logger + ); } /** @@ -82,7 +113,17 @@ public static function create(?string $userAgent, callable|HttpPsrBindings|null */ public static function default(): self { - return new self(null, HttpPsrBindings::default(), SslConfiguration::default(), null, null, null, null); + return new self( + null, + HttpPsrBindings::default(), + SslConfiguration::default(), + null, + null, + null, + null, + null, + null + ); } /** @@ -155,7 +196,9 @@ public function getSslConfiguration(): SslConfiguration public function getHttpPsrBindings(): HttpPsrBindings { - $this->httpPsrBindings = (is_callable($this->httpPsrBindings)) ? call_user_func($this->httpPsrBindings) : $this->httpPsrBindings; + $this->httpPsrBindings = (is_callable($this->httpPsrBindings)) ? call_user_func( + $this->httpPsrBindings + ) : $this->httpPsrBindings; return $this->httpPsrBindings ??= HttpPsrBindings::default(); } @@ -198,7 +241,9 @@ public function getCache(): CacheInterface public function getSemaphoreFactory(): SemaphoreFactoryInterface { - $this->semaphoreFactory = (is_callable($this->semaphoreFactory)) ? call_user_func($this->semaphoreFactory) : $this->semaphoreFactory; + $this->semaphoreFactory = (is_callable($this->semaphoreFactory)) ? call_user_func( + $this->semaphoreFactory + ) : $this->semaphoreFactory; return $this->semaphoreFactory ??= SemaphoreFactory::getInstance(); } @@ -234,4 +279,20 @@ public function withSemaphoreFactory($factory): self return $tbr; } + + /** + * @psalm-immutable + */ + public function getLogger(): ?Neo4jLogger + { + return $this->logger; + } + + public function withLogger(?string $logLevel, ?LoggerInterface $logger): self + { + $tbr = clone $this; + $tbr->logger = new Neo4jLogger($logLevel ?? LogLevel::INFO, $logger); + + return $tbr; + } } diff --git a/src/Databags/SessionConfiguration.php b/src/Databags/SessionConfiguration.php index e0af5938..56adcdc9 100644 --- a/src/Databags/SessionConfiguration.php +++ b/src/Databags/SessionConfiguration.php @@ -13,6 +13,7 @@ namespace Laudis\Neo4j\Databags; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Enum\AccessMode; use function parse_str; @@ -38,7 +39,8 @@ public function __construct( private readonly ?string $database = null, private readonly int|null $fetchSize = null, private readonly AccessMode|null $accessMode = null, - private readonly array|null $bookmarks = null + private readonly array|null $bookmarks = null, + private readonly ?Neo4jLogger $logger = null, ) {} /** @@ -46,9 +48,9 @@ public function __construct( * * @param list|null $bookmarks */ - public static function create(string|null $database = null, int|null $fetchSize = null, AccessMode|null $defaultAccessMode = null, array|null $bookmarks = null): self + public static function create(string|null $database = null, int|null $fetchSize = null, AccessMode|null $defaultAccessMode = null, array|null $bookmarks = null, ?Neo4jLogger $logger = null): self { - return new self($database, $fetchSize, $defaultAccessMode, $bookmarks); + return new self($database, $fetchSize, $defaultAccessMode, $bookmarks, $logger); } /** @@ -64,7 +66,7 @@ public static function default(): self */ public function withDatabase(?string $database): self { - return new self($database, $this->fetchSize, $this->accessMode, $this->bookmarks); + return new self($database, $this->fetchSize, $this->accessMode, $this->bookmarks, $this->logger); } /** @@ -72,7 +74,7 @@ public function withDatabase(?string $database): self */ public function withFetchSize(?int $size): self { - return new self($this->database, $size, $this->accessMode, $this->bookmarks); + return new self($this->database, $size, $this->accessMode, $this->bookmarks, $this->logger); } /** @@ -80,7 +82,7 @@ public function withFetchSize(?int $size): self */ public function withAccessMode(?AccessMode $defaultAccessMode): self { - return new self($this->database, $this->fetchSize, $defaultAccessMode, $this->bookmarks); + return new self($this->database, $this->fetchSize, $defaultAccessMode, $this->bookmarks, $this->logger); } /** @@ -90,7 +92,15 @@ public function withAccessMode(?AccessMode $defaultAccessMode): self */ public function withBookmarks(?array $bookmarks): self { - return new self($this->database, $this->fetchSize, $this->accessMode, $bookmarks); + return new self($this->database, $this->fetchSize, $this->accessMode, $bookmarks, $this->logger); + } + + /** + * Creates a new session with the provided logger. + */ + public function withLogger(?Neo4jLogger $logger): self + { + return new self($this->database, $this->fetchSize, $this->accessMode, $this->bookmarks, $logger); } /** @@ -129,6 +139,11 @@ public function getBookmarks(): array return $this->bookmarks ?? []; } + public function getLogger(): ?Neo4jLogger + { + return $this->logger; + } + /** * Creates a new configuration by merging the provided configuration with the current one. * The set values of the provided configuration will override the values of this configuration. @@ -148,7 +163,7 @@ public function merge(SessionConfiguration $config): self * * @pure */ - public static function fromUri(UriInterface $uri): self + public static function fromUri(UriInterface $uri, ?Neo4jLogger $logger): self { /** * @psalm-suppress ImpureMethodCall Uri is a pure object: @@ -159,6 +174,11 @@ public static function fromUri(UriInterface $uri): self /** @psalm-suppress ImpureFunctionCall */ parse_str($uri, $query); $tbr = SessionConfiguration::default(); + + if ($logger !== null) { + $tbr = $tbr->withLogger($logger); + } + if (array_key_exists('database', $query)) { $database = (string) $query['database']; $tbr = $tbr->withDatabase($database); diff --git a/src/DriverFactory.php b/src/DriverFactory.php index 56bca058..fe555b99 100644 --- a/src/DriverFactory.php +++ b/src/DriverFactory.php @@ -26,6 +26,7 @@ use Laudis\Neo4j\Http\HttpDriver; use Laudis\Neo4j\Neo4j\Neo4jDriver; use Psr\Http\Message\UriInterface; +use Psr\Log\LoggerInterface; /** * Factory for creating drivers directly. @@ -45,7 +46,7 @@ final class DriverFactory * : DriverInterface * ) */ - public static function create(string|UriInterface $uri, ?DriverConfiguration $configuration = null, ?AuthenticateInterface $authenticate = null, FormatterInterface $formatter = null): DriverInterface + public static function create(string|UriInterface $uri, ?DriverConfiguration $configuration = null, ?AuthenticateInterface $authenticate = null, FormatterInterface $formatter = null, ?string $logLevel = null, ?LoggerInterface $logger = null): DriverInterface { if (is_string($uri)) { $uri = Uri::create($uri); diff --git a/src/Http/HttpConnectionPool.php b/src/Http/HttpConnectionPool.php index ac2fd8a2..2c0e3929 100644 --- a/src/Http/HttpConnectionPool.php +++ b/src/Http/HttpConnectionPool.php @@ -127,4 +127,9 @@ public function release(ConnectionInterface $connection): void { // Nothing to release in the current HTTP Protocol implementation } + + public function close(): void + { + // Nothing to close in the current HTTP Protocol implementation + } } diff --git a/src/Http/HttpDriver.php b/src/Http/HttpDriver.php index 4a03bd5b..76bcfa8d 100644 --- a/src/Http/HttpDriver.php +++ b/src/Http/HttpDriver.php @@ -76,20 +76,21 @@ public static function create(string|UriInterface $uri, ?DriverConfiguration $co $uri = Uri::create($uri); } + $configuration ??= DriverConfiguration::default(); if ($formatter !== null) { return new self( $uri, - $configuration ?? DriverConfiguration::default(), + $configuration, $formatter, - $authenticate ?? Authenticate::fromUrl($uri) + $authenticate ?? Authenticate::fromUrl($uri, $configuration->getLogger()) ); } return new self( $uri, - $configuration ?? DriverConfiguration::default(), + $configuration, OGMFormatter::create(), - $authenticate ?? Authenticate::fromUrl($uri) + $authenticate ?? Authenticate::fromUrl($uri, $configuration->getLogger()) ); } @@ -100,7 +101,7 @@ public function createSession(?SessionConfiguration $config = null): SessionInte { $factory = $this->resolvableFactory(); $config ??= SessionConfiguration::default(); - $config = $config->merge(SessionConfiguration::fromUri($this->uri)); + $config = $config->merge(SessionConfiguration::fromUri($this->uri, null)); $streamFactoryResolve = $this->streamFactory(); $tsxUrl = $this->tsxUrl($config); @@ -197,4 +198,9 @@ private function tsxUrl(SessionConfiguration $config): Resolvable return str_replace('{databaseName}', $database, $tsx); }); } + + public function closeConnections(): void + { + // Nothing to close in the current HTTP Protocol implementation + } } diff --git a/src/Neo4j/Neo4jConnectionPool.php b/src/Neo4j/Neo4jConnectionPool.php index db1f3930..75de8213 100644 --- a/src/Neo4j/Neo4jConnectionPool.php +++ b/src/Neo4j/Neo4jConnectionPool.php @@ -27,6 +27,7 @@ use Laudis\Neo4j\BoltFactory; use Laudis\Neo4j\Common\Cache; use Laudis\Neo4j\Common\GeneratorHelper; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Common\Uri; use Laudis\Neo4j\Contracts\AddressResolverInterface; use Laudis\Neo4j\Contracts\AuthenticateInterface; @@ -40,6 +41,7 @@ use Laudis\Neo4j\Enum\AccessMode; use Laudis\Neo4j\Enum\RoutingRoles; use Psr\Http\Message\UriInterface; +use Psr\Log\LogLevel; use Psr\SimpleCache\CacheInterface; use function random_int; @@ -73,14 +75,20 @@ public function __construct( private readonly BoltFactory $factory, private readonly ConnectionRequestData $data, private readonly CacheInterface $cache, - private readonly AddressResolverInterface $resolver + private readonly AddressResolverInterface $resolver, + private readonly ?Neo4jLogger $logger, ) {} - public static function create(UriInterface $uri, AuthenticateInterface $auth, DriverConfiguration $conf, AddressResolverInterface $resolver, SemaphoreInterface $semaphore): self - { + public static function create( + UriInterface $uri, + AuthenticateInterface $auth, + DriverConfiguration $conf, + AddressResolverInterface $resolver, + SemaphoreInterface $semaphore + ): self { return new self( $semaphore, - BoltFactory::create(), + BoltFactory::create($conf->getLogger()), new ConnectionRequestData( $uri->getHost(), $uri, @@ -89,7 +97,8 @@ public static function create(UriInterface $uri, AuthenticateInterface $auth, Dr $conf->getSslConfiguration() ), Cache::getInstance(), - $resolver + $resolver, + $conf->getLogger() ); } @@ -105,7 +114,7 @@ public function createOrGetPool(string $hostname, UriInterface $uri): Connection $key = $this->createKey($data); if (!array_key_exists($key, self::$pools)) { - self::$pools[$key] = new ConnectionPool($this->semaphore, $this->factory, $data); + self::$pools[$key] = new ConnectionPool($this->semaphore, $this->factory, $data, $this->logger); } return self::$pools[$key]; @@ -125,14 +134,14 @@ public function acquire(SessionConfiguration $config): Generator $latestError = null; if ($table == null) { - $addresses = (function () { - yield gethostbyname($this->data->getUri()->getHost()); - yield from $this->resolver->getAddresses($this->data->getUri()->getHost()); - })(); + $addresses = $this->getAddresses($this->data->getUri()->getHost()); foreach ($addresses as $address) { $triedAddresses[] = $address; - $pool = $this->createOrGetPool($this->data->getUri()->getHost(), $this->data->getUri()->withHost($address)); + $pool = $this->createOrGetPool( + $this->data->getUri()->getHost(), + $this->data->getUri()->withHost($address) + ); try { /** @var BoltConnection $connection */ $connection = GeneratorHelper::getReturnFromGenerator($pool->acquire($config)); @@ -144,6 +153,7 @@ public function acquire(SessionConfiguration $config): Generator } $this->cache->set($key, $table, $table->getTtl()); + // TODO: release probably logs off the connection, it is not preferable $pool->release($connection); break; @@ -163,6 +173,11 @@ public function acquire(SessionConfiguration $config): Generator return $this->createOrGetPool($this->data->getUri()->getHost(), $server)->acquire($config); } + public function getLogger(): ?Neo4jLogger + { + return $this->logger; + } + /** * @throws Exception */ @@ -187,8 +202,9 @@ private function getNextServer(RoutingTable $table, AccessMode $mode): ?Uri */ private function routingTable(BoltConnection $connection, SessionConfiguration $config): RoutingTable { - $bolt = $connection->getImplementation()[0]; + $bolt = $connection->protocol(); + $this->getLogger()?->log(LogLevel::DEBUG, 'ROUTE', ['db' => $config->getDatabase()]); /** @var array{rt: array{servers: list, role:string}>, ttl: int}} $route */ $route = $bolt->route([], [], ['db' => $config->getDatabase()]) ->getResponse() @@ -202,7 +218,9 @@ private function routingTable(BoltConnection $connection, SessionConfiguration $ public function release(ConnectionInterface $connection): void { - $this->createOrGetPool($connection->getServerAddress()->getHost(), $connection->getServerAddress())->release($connection); + $this->createOrGetPool($connection->getServerAddress()->getHost(), $connection->getServerAddress())->release( + $connection + ); } private function createKey(ConnectionRequestData $data, ?SessionConfiguration $config = null): string @@ -211,7 +229,14 @@ private function createKey(ConnectionRequestData $data, ?SessionConfiguration $c $key = implode( ':', - array_filter([$data->getUserAgent(), $uri->getHost(), $config ? $config->getDatabase() : null, $uri->getPort() ?? '7687']) + array_filter( + [ + $data->getUserAgent(), + $uri->getHost(), + $config ? $config->getDatabase() : null, + $uri->getPort() ?? '7687', + ] + ) ); return str_replace([ @@ -225,4 +250,22 @@ private function createKey(ConnectionRequestData $data, ?SessionConfiguration $c ':', ], '|', $key); } + + public function close(): void + { + foreach (self::$pools as $pool) { + $pool->close(); + } + self::$pools = []; + $this->cache->clear(); + } + + /** + * @return Generator + */ + private function getAddresses(string $host): Generator + { + yield gethostbyname($host); + yield from $this->resolver->getAddresses($host); + } } diff --git a/src/Neo4j/Neo4jDriver.php b/src/Neo4j/Neo4jDriver.php index 24cf9f71..bc2a1695 100644 --- a/src/Neo4j/Neo4jDriver.php +++ b/src/Neo4j/Neo4jDriver.php @@ -75,7 +75,7 @@ public static function create(string|UriInterface $uri, ?DriverConfiguration $co } $configuration ??= DriverConfiguration::default(); - $authenticate ??= Authenticate::fromUrl($uri); + $authenticate ??= Authenticate::fromUrl($uri, $configuration->getLogger()); $resolver ??= new DNSAddressResolver(); $semaphore = $configuration->getSemaphoreFactory()->create($uri, $configuration); @@ -95,7 +95,7 @@ public static function create(string|UriInterface $uri, ?DriverConfiguration $co public function createSession(?SessionConfiguration $config = null): SessionInterface { $config ??= SessionConfiguration::default(); - $config = $config->merge(SessionConfiguration::fromUri($this->parsedUrl)); + $config = $config->merge(SessionConfiguration::fromUri($this->parsedUrl, $this->pool->getLogger())); return new Session($config, $this->pool, $this->formatter); } @@ -111,4 +111,9 @@ public function verifyConnectivity(?SessionConfiguration $config = null): bool return true; } + + public function closeConnections(): void + { + $this->pool->close(); + } } diff --git a/tests/EnvironmentAwareIntegrationTest.php b/tests/EnvironmentAwareIntegrationTest.php index d84375ad..f71408f9 100644 --- a/tests/EnvironmentAwareIntegrationTest.php +++ b/tests/EnvironmentAwareIntegrationTest.php @@ -17,27 +17,40 @@ use Laudis\Neo4j\Basic\Driver; use Laudis\Neo4j\Basic\Session; +use Laudis\Neo4j\Common\Neo4jLogger; use Laudis\Neo4j\Common\Uri; +use Laudis\Neo4j\Databags\DriverConfiguration; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use RuntimeException; abstract class EnvironmentAwareIntegrationTest extends TestCase { - protected static Session $session; - protected static Driver $driver; - protected static Uri $uri; + protected Session $session; + protected Driver $driver; + protected Uri $uri; + protected Neo4jLogger $logger; - public static function setUpBeforeClass(): void + public function setUp(): void { - parent::setUpBeforeClass(); + parent::setUp(); $connection = $_ENV['CONNECTION'] ?? false; if (!is_string($connection)) { $connection = 'bolt://localhost'; } - self::$uri = Uri::create($connection); - self::$driver = Driver::create(self::$uri); - self::$session = self::$driver->createSession(); + /** @noinspection PhpUnhandledExceptionInspection */ + $conf = DriverConfiguration::default()->withLogger(LogLevel::DEBUG, $this->createMock(LoggerInterface::class)); + $logger = $conf->getLogger(); + if ($logger === null) { + throw new RuntimeException('Logger not set'); + } + $this->logger = $logger; + $this->uri = Uri::create($connection); + $this->driver = Driver::create($this->uri, $conf); + $this->session = $this->driver->createSession(); } /** @@ -47,7 +60,7 @@ public function getSession(array|string|null $forceScheme = null): Session { $this->skipUnsupportedScheme($forceScheme); - return self::$session; + return $this->session; } /** @@ -57,7 +70,7 @@ public function getUri(array|string|null $forceScheme = null): Uri { $this->skipUnsupportedScheme($forceScheme); - return self::$uri; + return $this->uri; } /** @@ -80,7 +93,7 @@ private function skipUnsupportedScheme(array|string|null $forceScheme): void $options[] = $scheme.'+ssc'; } - if (!in_array(self::$uri->getScheme(), $options)) { + if (!in_array($this->uri->getScheme(), $options)) { /** @psalm-suppress MixedArgumentTypeCoercion */ $this->markTestSkipped(sprintf( 'Connection only for types: "%s"', @@ -96,6 +109,11 @@ protected function getDriver(array|string|null $forceScheme = null): Driver { $this->skipUnsupportedScheme($forceScheme); - return self::$driver; + return $this->driver; + } + + protected function getNeo4jLogger(): Neo4jLogger + { + return $this->logger; } } diff --git a/tests/Integration/BoltResultIntegrationTest.php b/tests/Integration/BoltResultIntegrationTest.php index 9db763a7..4868aace 100644 --- a/tests/Integration/BoltResultIntegrationTest.php +++ b/tests/Integration/BoltResultIntegrationTest.php @@ -44,7 +44,7 @@ public function testIterationLong(): void SessionConfiguration::default() ); - $connection->getImplementation()[0]->run('UNWIND range(1, 100000) AS i RETURN i') + $connection->protocol()->run('UNWIND range(1, 100000) AS i RETURN i') ->getResponse(); $result = new BoltResult($connection, 1000, -1); foreach ($result as $i => $x) { diff --git a/tests/Integration/EdgeCasesTest.php b/tests/Integration/EdgeCasesTest.php index cc61895e..cc19152b 100644 --- a/tests/Integration/EdgeCasesTest.php +++ b/tests/Integration/EdgeCasesTest.php @@ -24,15 +24,15 @@ final class EdgeCasesTest extends EnvironmentAwareIntegrationTest { - public static function setUpBeforeClass(): void + public function setUp(): void { - parent::setUpBeforeClass(); - self::$session->run(MoviesFixture::CQL); + parent::setUp(); + $this->session->run(MoviesFixture::CQL); } public function testCanHandleMapLiterals(): void { - $results = self::$session->run('MATCH (n:Person)-[r:ACTED_IN]->(m) RETURN n, {movie: m, roles: r.roles} AS actInfo LIMIT 5'); + $results = $this->session->run('MATCH (n:Person)-[r:ACTED_IN]->(m) RETURN n, {movie: m, roles: r.roles} AS actInfo LIMIT 5'); foreach ($results as $result) { $actorInfo = $result->get('actInfo'); diff --git a/tests/Integration/Neo4jLoggerTest.php b/tests/Integration/Neo4jLoggerTest.php new file mode 100644 index 00000000..40f9bd06 --- /dev/null +++ b/tests/Integration/Neo4jLoggerTest.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Laudis\Neo4j\Tests\Integration; + +use Laudis\Neo4j\Bolt\Session; +use Laudis\Neo4j\Databags\SessionConfiguration; +use Laudis\Neo4j\Databags\Statement; +use Laudis\Neo4j\Databags\TransactionConfiguration; +use Laudis\Neo4j\Tests\EnvironmentAwareIntegrationTest; +use PHPUnit\Framework\MockObject\MockObject; + +class Neo4jLoggerTest extends EnvironmentAwareIntegrationTest +{ + public function testLogger(): void + { + if ($this->getUri()->getScheme() === 'http') { + self::markTestSkipped('This test is not applicable for the HTTP driver'); + } + + // Close connections so that we can test the logger logging + // during authentication while acquiring a new connection + $this->driver->closeConnections(); + + /** @var MockObject $logger */ + $logger = $this->getNeo4jLogger()->getLogger(); + /** @var Session $session */ + $session = $this->getSession(); + + /** @var array $infoLogs */ + $infoLogs = []; + $expectedInfoLogs = [ + [ + 'Running statements', + [ + 'statements' => [new Statement('RETURN 1 as test', [])], + ], + ], + [ + 'Starting instant transaction', + [ + 'config' => new TransactionConfiguration(null, null), + ], + ], + [ + 'Acquiring connection', + [ + 'config' => new TransactionConfiguration(null, null), + ], + ], + ]; + $logger->expects(self::exactly(count($expectedInfoLogs)))->method('info')->willReturnCallback( + static function (string $message, array $context) use (&$infoLogs) { + $infoLogs[] = [$message, $context]; + } + ); + + $debugLogs = []; + $expectedDebugLogs = [ + [ + 'HELLO', + [ + 'user_agent' => 'neo4j-php-client/2', + ], + ], + [ + 'LOGON', + [ + 'scheme' => 'basic', + 'principal' => 'neo4j', + ], + ], + [ + 'RUN', + [ + 'mode' => 'w', + ], + ], + [ + 'DISCARD', + [], + ], + ]; + + if ($this->getUri()->getScheme() === 'neo4j') { + array_splice( + $expectedDebugLogs, + 0, + 0, + [ + [ + 'HELLO', + [ + 'user_agent' => 'neo4j-php-client/2', + ], + ], + [ + 'LOGON', + [ + 'scheme' => 'basic', + 'principal' => 'neo4j', + ], + ], + [ + 'ROUTE', + [ + 'db' => null, + ], + ], + [ + 'GOODBYE', + [], + ], + ], + ); + } + + $logger->expects(self::exactly(count($expectedDebugLogs)))->method('debug')->willReturnCallback( + static function (string $message, array $context) use (&$debugLogs) { + $debugLogs[] = [$message, $context]; + } + ); + + $session->run('RETURN 1 as test'); + + self::assertCount(3, $infoLogs); + self::assertEquals(array_slice($expectedInfoLogs, 0, 2), array_slice($infoLogs, 0, 2)); + /** @psalm-suppress PossiblyUndefinedIntArrayOffset */ + self::assertEquals($expectedInfoLogs[2][0], $infoLogs[2][0]); + /** @psalm-suppress PossiblyUndefinedIntArrayOffset */ + self::assertInstanceOf(SessionConfiguration::class, $infoLogs[2][1]['sessionConfig']); + + self::assertEquals($expectedDebugLogs, $debugLogs); + } +} diff --git a/tests/Unit/BoltConnectionPoolTest.php b/tests/Unit/BoltConnectionPoolTest.php index bf717c61..3c77634a 100644 --- a/tests/Unit/BoltConnectionPoolTest.php +++ b/tests/Unit/BoltConnectionPoolTest.php @@ -156,13 +156,16 @@ private function setupPool(Generator $semaphoreGenerator): void ->willReturnCallback(fn (MockObject $x): MockObject => $x); $this->pool = new ConnectionPool( - $this->semaphore, $this->factory, new ConnectionRequestData( + $this->semaphore, + $this->factory, + new ConnectionRequestData( '', Uri::create(''), Authenticate::disabled(), '', SslConfiguration::default() - ) + ), + null, ); } }