diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 7c1936b..dcff7f6 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -19,6 +19,6 @@ jobs: php_version: 8.0 version: 2 - name: "PHP-CS-Fixer" - run: vendor/bin/php-cs-fixer fix --dry-run + run: composer check-cs - name: "PSalm" - run: ./console debug:config && vendor/bin/psalm --show-info=true + run: composer psalm diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2a0eb5e..f75f202 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,14 +15,8 @@ jobs: max-parallel: 10 matrix: php: [ '8.0', '8.1', '8.2'] - sf_version: [ '5.4.*', '6.2.*', '6.3.*' ] + sf_version: [ '5.4.*', '6.0.*', '6.3.*' ] exclude: - - php: 7.4 - sf_version: 6.2.* - - php: 8.0 - sf_version: 6.2.* - - php: 7.4 - sf_version: 6.3.* - php: 8.0 sf_version: 6.3.* @@ -39,5 +33,4 @@ jobs: php_version: ${{ matrix.php }} memory_limit: 1024M version: 9 - testsuite: Unit bootstrap: vendor/autoload.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index fe1cbef..65763ce 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -24,6 +24,7 @@ try { $finder = PhpCsFixer\Finder::create() ->in(__DIR__.'/src') + ->in(__DIR__.'/config') ->in(__DIR__.'/tests'); } catch (Throwable $e) { echo $e->getMessage()."\n"; @@ -35,28 +36,6 @@ ->setRiskyAllowed(true) ->setRules([ '@Symfony' => true, - - 'array_syntax' => ['syntax' => 'short'], - 'linebreak_after_opening_tag' => true, - 'ordered_imports' => true, - 'phpdoc_order' => true, - 'phpdoc_to_comment' => false, - 'yoda_style' => false, - 'declare_strict_types' => true, - 'global_namespace_import' => [ - 'import_classes' => true, - 'import_constants' => true, - 'import_functions' => true, - ], - ConstructorEmptyBracesFixer::name() => true, - IssetToArrayKeyExistsFixer::name() => true, - MultilineCommentOpeningClosingAloneFixer::name() => true, - MultilinePromotedPropertiesFixer::name() => true, - PhpUnitAssertArgumentsOrderFixer::name() => true, - PhpdocNoSuperfluousParamFixer::name() => true, - PhpdocParamOrderFixer::name() => true, - StringableInterfaceFixer::name() => true, ]) ->setFinder($finder) - ->registerCustomFixers(new PhpCsFixerCustomFixers\Fixers()) ; diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8722551 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM php:8.1-cli +RUN apt-get update \ + && apt-get install -y \ + libzip-dev \ + unzip \ + git \ + wget \ + && docker-php-ext-install -j$(nproc) bcmath sockets \ + && pecl install xdebug \ + && docker-php-ext-enable xdebug && \ + curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + +WORKDIR /opt/project + + + diff --git a/bin/console.php b/bin/console.php new file mode 100644 index 0000000..645f886 --- /dev/null +++ b/bin/console.php @@ -0,0 +1,10 @@ +run(); \ No newline at end of file diff --git a/composer.json b/composer.json index e2891ef..d825c55 100644 --- a/composer.json +++ b/composer.json @@ -2,17 +2,9 @@ "name": "neo4j/neo4j-bundle", "description": "Symfony integration for Neo4j", "type": "symfony-bundle", - "keywords": ["neo4j"], + "keywords": ["neo4j", "symfony", "bundle", "graph", "database", "cypher"], "license": "MIT", "authors": [ - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" - }, - { - "name": "Xavier Coureau", - "email": "xavier@pandawan-technology.com" - }, { "name": "Ghlen Nagels", "email": "ghlen@nagels.tech" @@ -20,11 +12,11 @@ ], "require": { "php": ">=8.0", - "laudis/neo4j-php-client": "dev-main", + "laudis/neo4j-php-client": "^3.0.5", "twig/twig": "^3.0", "ext-json": "*", "symfony/dependency-injection": "^5.4 || ^6.0", - "symfony/config": "^6.0" + "symfony/config": "^5.4 || ^6.0" }, "require-dev": { "matthiasnoback/symfony-dependency-injection-test": "^4.3", @@ -35,9 +27,9 @@ "symfony/http-kernel": "^5.4 || ^6.0", "symfony/test-pack": "^1.1", "symfony/yaml": "^5.4 || ^6.0", - "vimeo/psalm": "^5.12", + "vimeo/psalm": "^5.15.0", "kubawerlos/php-cs-fixer-custom-fixers": "^3.0", - "friendsofphp/php-cs-fixer": "^3.0", + "friendsofphp/php-cs-fixer": "^3.30", "psalm/plugin-phpunit": "^0.18" }, "autoload": { @@ -55,5 +47,10 @@ "allow-plugins": { "php-http/discovery": false } + }, + "scripts": { + "psalm": "php bin/console.php cache:warmup && vendor/bin/psalm --show-info=true", + "fix-cs": "vendor/bin/php-cs-fixer fix", + "check-cs": "vendor/bin/php-cs-fixer fix --dry-run" } } diff --git a/config/data-collector.xml b/config/data-collector.xml deleted file mode 100644 index 2b6f64a..0000000 --- a/config/data-collector.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..8ec9920 --- /dev/null +++ b/config/services.php @@ -0,0 +1,54 @@ +services(); + + $services->set('neo4j.event_handler', EventHandler::class) + ->autowire() + ->autoconfigure(); + + $services->set('neo4j.client_factory', ClientFactory::class) + ->args([ + service('neo4j.event_handler'), + ]); + + $services->set('neo4j.client', SymfonyClient::class) + ->factory([service('neo4j.client_factory'), 'create']) + ->public(); + + $services->set('neo4j.driver', Driver::class) + ->factory([service('neo4j.client'), 'getDriver']) + ->public(); + + $services->set('neo4j.session', Session::class) + ->factory([service('neo4j.driver'), 'createSession']) + ->share(false) + ->public(); + + $services->set('neo4j.transaction', TransactionInterface::class) + ->factory([service('neo4j.session'), 'beginTransaction']) + ->share(false) + ->public(); + + $services->alias(ClientInterface::class, 'neo4j.client'); + $services->alias(DriverInterface::class, 'neo4j.driver'); + $services->alias(SessionInterface::class, 'neo4j.session'); + $services->alias(TransactionInterface::class, 'neo4j.transaction'); + + $services->set('neo4j.subscriber', Neo4jProfileListener::class) + ->tag('kernel.event_subscriber'); +}; diff --git a/config/services.xml b/config/services.xml deleted file mode 100644 index de3a028..0000000 --- a/config/services.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ae9637b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.7' +networks: + neo4j-symfony: + +services: + app: + user: ${UID-1000}:${GID-1000} + build: + context: . + ports: + - ${DOCKER_HOST_APP_PORT:-8000}:80 + volumes: + - ./:/opt/project + environment: + - NEO4J_HOST=neo4j + - NEO4J_DATABASE=neo4j + - NEO4J_PORT=7687 + - NEO4J_USER=neo4j + - NEO4J_PASSWORD=testtest + working_dir: /opt/project + networks: + - neo4j-symfony + + neo4j: + environment: + - NEO4J_AUTH=neo4j/testtest + image: neo4j:5 + ports: + - ${DOCKER_HOST_NEO4J_HTTP_PORT:-7474}:7474 + - ${DOCKER_HOST_NEO4J_BOLT_PORT:-7687}:7687 + networks: + - neo4j-symfony + diff --git a/docs/README.md b/docs/README.md index 3750677..addc374 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,27 +7,46 @@ [![Total Downloads](https://img.shields.io/packagist/dt/neo4j/neo4j-bundle.svg?style=flat-square)](https://packagist.org/packages/neo4j/neo4j-bundle) -## Install +Installation +============ -Via Composer +Make sure Composer is installed globally, as explained in the +[installation chapter](https://getcomposer.org/doc/00-intro.md) +of the Composer documentation. -``` bash +Applications that use Symfony Flex +---------------------------------- + +Open a command console, enter your project directory and execute: + +```console +$ composer require neo4j/neo4j-bundle +``` + +Applications that don't use Symfony Flex +---------------------------------------- + +### Step 1: Download the Bundle + +Open a command console, enter your project directory and execute the +following command to download the latest stable version of this bundle: + +```console $ composer require neo4j/neo4j-bundle ``` -Enable the bundle in your kernel: +### Step 2: Enable the Bundle + +Then, enable the bundle by adding it to the list of registered bundles +in the `config/bundles.php` file of your project: -``` php - ['all' => true], +]; ``` ## Documentation diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 26ef520..9fd609d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,10 +2,10 @@ - ./Tests/Unit + ./tests/Unit - ./Tests/Functional + ./tests/Functional diff --git a/psalm.xml b/psalm.xml index 94be006..ce09bf1 100644 --- a/psalm.xml +++ b/psalm.xml @@ -10,6 +10,7 @@ > + diff --git a/public/.gitignore b/public/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/src/ClientFactory.php b/src/ClientFactory.php index df86407..6ae3078 100644 --- a/src/ClientFactory.php +++ b/src/ClientFactory.php @@ -4,18 +4,23 @@ namespace Neo4j\Neo4jBundle; -use InvalidArgumentException; use Laudis\Neo4j\Authentication\Authenticate; use Laudis\Neo4j\ClientBuilder; use Laudis\Neo4j\Common\Uri; use Laudis\Neo4j\Contracts\AuthenticateInterface; use Laudis\Neo4j\Databags\DriverConfiguration; +use Laudis\Neo4j\Databags\HttpPsrBindings; use Laudis\Neo4j\Databags\SessionConfiguration; use Laudis\Neo4j\Databags\SslConfiguration; +use Laudis\Neo4j\Databags\SummarizedResult; use Laudis\Neo4j\Databags\TransactionConfiguration; use Laudis\Neo4j\Enum\AccessMode; use Laudis\Neo4j\Enum\SslMode; +use Laudis\Neo4j\Types\CypherMap; use Neo4j\Neo4jBundle\DependencyInjection\Configuration; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; /** * @psalm-import-type SessionConfigArray from Configuration @@ -39,10 +44,15 @@ public function __construct( private array|null $sessionConfiguration, private array|null $transactionConfiguration, private array $connections, - ) {} + private ClientInterface|null $client, + private StreamFactoryInterface|null $streamFactory, + private RequestFactoryInterface|null $requestFactory, + ) { + } public function create(): SymfonyClient { + /** @var ClientBuilder> $builder */ $builder = ClientBuilder::create(); if ($this->driverConfig) { @@ -66,13 +76,12 @@ public function create(): SymfonyClient ); } - /** @psalm-suppress InvalidArgument */ return new SymfonyClient($builder->build(), $this->eventHandler); } private function makeDriverConfig(): DriverConfiguration { - return new DriverConfiguration( + $config = new DriverConfiguration( userAgent: $this->driverConfig['user_agent'] ?? null, httpPsrBindings: null, sslConfig: $this->makeSslConfig($this->driverConfig['ssl'] ?? null), @@ -81,6 +90,21 @@ private function makeDriverConfig(): DriverConfiguration acquireConnectionTimeout: $this->driverConfig['acquire_connection_timeout'] ?? null, semaphore: null, ); + + $bindings = new HttpPsrBindings(); + if ($this->client) { + $config = $config->withHttpPsrBindings($bindings->withClient($this->client)); + } + + if ($this->streamFactory) { + $config = $config->withHttpPsrBindings($bindings->withStreamFactory($this->streamFactory)); + } + + if ($this->requestFactory) { + $config = $config->withHttpPsrBindings($bindings->withRequestFactory($this->requestFactory)); + } + + return $config; } private function makeSessionConfig(): SessionConfiguration @@ -107,19 +131,19 @@ private function makeTransactionConfig(): TransactionConfiguration */ private function createAuth(array|null $auth, string $dsn): AuthenticateInterface { - if ($auth === null) { + if (null === $auth) { return Authenticate::disabled(); } return match ($auth['type'] ?? null) { 'basic' => Authenticate::basic( - $auth['username'] ?? throw new InvalidArgumentException('Missing username for basic authentication'), - $auth['password'] ?? throw new InvalidArgumentException('Missing password for basic authentication') + $auth['username'] ?? throw new \InvalidArgumentException('Missing username for basic authentication'), + $auth['password'] ?? throw new \InvalidArgumentException('Missing password for basic authentication') ), - 'kerberos' => Authenticate::kerberos($auth['token'] ?? throw new InvalidArgumentException('Missing token for kerberos authentication')), + 'kerberos' => Authenticate::kerberos($auth['token'] ?? throw new \InvalidArgumentException('Missing token for kerberos authentication')), 'dsn', null => Authenticate::fromUrl(Uri::create($dsn)), 'none' => Authenticate::disabled(), - 'oid' => Authenticate::oidc($auth['token'] ?? throw new InvalidArgumentException('Missing token for oid authentication')), + 'oid' => Authenticate::oidc($auth['token'] ?? throw new \InvalidArgumentException('Missing token for oid authentication')), }; } @@ -128,7 +152,7 @@ private function createAuth(array|null $auth, string $dsn): AuthenticateInterfac */ private function makeSslConfig(array|null $ssl): SslConfiguration { - if ($ssl === null) { + if (null === $ssl) { return new SslConfiguration( mode: SslMode::DISABLE(), verifyPeer: false, diff --git a/src/Collector/Neo4jDataCollector.php b/src/Collector/Neo4jDataCollector.php index 10ffdbf..9014743 100644 --- a/src/Collector/Neo4jDataCollector.php +++ b/src/Collector/Neo4jDataCollector.php @@ -4,69 +4,80 @@ namespace Neo4j\Neo4jBundle\Collector; +use Laudis\Neo4j\Databags\ResultSummary; +use Neo4j\Neo4jBundle\EventListener\Neo4jProfileListener; +use Symfony\Bundle\FrameworkBundle\DataCollector\AbstractDataCollector; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\DataCollector\DataCollector; -use Throwable; /** - * @author Xavier Coureau + * @var array{ + * successful_statements: array>, + * failed_statements: list + * } $data */ -final class Neo4jDataCollector extends DataCollector +final class Neo4jDataCollector extends AbstractDataCollector { public function __construct( - private QueryLogger $queryLogger - ) {} - - public function collect(Request $request, Response $response, Throwable $exception = null): void - { - $this->data['time'] = $this->queryLogger->getElapsedTime(); - $this->data['nb_queries'] = count($this->queryLogger); - $this->data['statements'] = $this->queryLogger->getStatements(); - $this->data['failed_statements'] = array_filter($this->queryLogger->getStatements(), static function ($statement) { - return empty($statement['success']); - }); + private Neo4jProfileListener $subscriber + ) { } - public function reset(): void + public function collect(Request $request, Response $response, \Throwable $exception = null): void { - $this->data = []; - $this->queryLogger->reset(); + $this->data['successful_statements'] = array_map( + static fn (ResultSummary $summary) => $summary->toArray(), + $this->subscriber->getProfiledSummaries() + ); + + $this->data['failed_statements'] = array_map( + static fn (array $x) => [ + 'statement' => $x['statement']->toArray(), + 'exception' => [ + 'code' => $x['exception']->getErrors()[0]->getCode(), + 'message' => $x['exception']->getErrors()[0]->getMessage(), + 'classification' => $x['exception']->getErrors()[0]->getClassification(), + 'category' => $x['exception']->getErrors()[0]->getCategory(), + 'title' => $x['exception']->getErrors()[0]->getTitle(), + ], + 'alias' => $x['alias'], + ], + $this->subscriber->getProfiledFailures() + ); } - public function getQueryCount(): int + public function reset(): void { - return $this->data['nb_queries']; + parent::reset(); + $this->subscriber->reset(); } - /** - * Return all statements, successful and not successful. - */ - public function getStatements(): array + public function getName(): string { - return $this->data['statements']; + return 'neo4j'; } - /** - * Return not successful statements. - */ public function getFailedStatements(): array { return $this->data['failed_statements']; } - public function getTime(): float + public function getSuccessfulStatements(): array { - return $this->data['time']; + return $this->data['successful_statements']; } - public function getTimeForQuery(): float + public function getQueryCount(): int { - return $this->data['time']; + return count($this->data['successful_statements']) + count($this->data['failed_statements']); } - public function getName(): string + public static function getTemplate(): ?string { - return 'neo4j'; + return 'web_profiler.html.twig'; } } diff --git a/src/Collector/QueryLogger.php b/src/Collector/QueryLogger.php deleted file mode 100644 index 3bca3a3..0000000 --- a/src/Collector/QueryLogger.php +++ /dev/null @@ -1,129 +0,0 @@ - - * - * @psalm-type StatementInfo = array{ - * start_time: float, - * query: string, - * parameters: string, - * end_time: float, - * nb_results: int, - * statistics: array, - * scheme: string, - * success: bool, - * exceptionCode: string, - * exceptionMessage: string - * } - */ -class QueryLogger implements Countable -{ - private int $nbQueries = 0; - - /** - * @var list - */ - private array $statements = []; - - public function record(Statement $statement): void - { - $statementText = $statement->getText(); - $statementParams = json_encode($statement->getParameters(), JSON_THROW_ON_ERROR); - - $this->statements[] = [ - 'start_time' => microtime(true) * 1000, - 'query' => $statementText, - 'parameters' => $statementParams, - - // Add dummy data in case we never run logException or finish - 'end_time' => microtime(true) * 1000, // same - 'nb_results' => 0, - 'statistics' => [], - 'scheme' => '', - 'success' => false, - 'exceptionCode' => '', - 'exceptionMessage' => '', - ]; - } - - /** - * @param SummarizedResult $result - * - * @throws Exception - */ - public function finish(SummarizedResult $result): void - { - $id = count($this->statements) - 1; - if ($id < 0) { - return; - } - - $summary = $result->getSummary(); - $this->statements[$id] = array_merge($this->statements[$id], [ - 'end_time' => $summary->getResultConsumedAfter(), - 'nb_results' => $result->count(), - 'statistics' => iterator_to_array($summary->getCounters()->getIterator(), true), - 'scheme' => $summary->getServerInfo()->getAddress()->getScheme(), - 'success' => true, - ]); - } - - public function reset(): void - { - $this->statements = []; - } - - public function logException(Neo4jException $exception): void - { - $classification = explode('.', $exception->getNeo4jCode())[1] ?? ''; - $idx = count($this->statements) - 1; - if ($idx < 0) { - return; - } - - $this->statements[$idx] = array_merge($this->statements[$idx], [ - 'end_time' => microtime(true) * 1000, - 'exceptionCode' => $classification, - 'exceptionMessage' => $exception->getErrors()[0]->getMessage() ?? '', - 'success' => false, - ]); - } - - public function count(): int - { - return $this->nbQueries; - } - - /** - * @return array - */ - public function getStatements(): array - { - return $this->statements; - } - - public function getElapsedTime(): float - { - $time = 0; - - foreach ($this->statements as $statement) { - $time += $statement['end_time'] - $statement['start_time']; - } - - return $time; - } -} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index dad118d..a713ba2 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -20,6 +20,7 @@ * verify_peer?: bool|null, * } * @psalm-type DriverConfigArray = array{ + * profiling?: bool, * acquire_connection_timeout?: int|null, * user_agent?: string|null, * pool_size?: int|null, @@ -61,16 +62,6 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder->getRootNode() ->fixXmlConfig('driver') ->children() - ->arrayNode('profiling') - ->info('Profiling configuration') - ->canBeEnabled() - ->children() - ->booleanNode('enabled') - ->info('Enable profiling') - ->defaultTrue() - ->end() - ->end() - ->end() ->append($this->decorateDriverConfig()) ->append($this->decorateSessionConfig()) ->append($this->decorateTransactionConfig()) @@ -88,6 +79,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->info('The alias for this driver. Default is "default".') ->defaultValue('default') ->end() + ->scalarNode('profiling') + ->info('Enable profiling for requests on this driver. If no value is provided the default value will be equal to the kernel.debug parameter.') + ->defaultValue(null) + ->end() ->scalarNode('dsn') ->info('The DSN for the driver. Default is "bolt://localhost:7687".') ->defaultValue('bolt://localhost:7687') @@ -121,6 +116,7 @@ private function decorateSessionConfig(): ArrayNodeDefinition ->info('The default configuration for every session') ->children() ->scalarNode('fetch_size') + ->info('The amount of rows that are being fetched at once in the result cursor') ->end() ->enumNode('access_mode') ->values(['read', 'write', null]) diff --git a/src/DependencyInjection/Neo4jExtension.php b/src/DependencyInjection/Neo4jExtension.php index 1677d86..eee991d 100644 --- a/src/DependencyInjection/Neo4jExtension.php +++ b/src/DependencyInjection/Neo4jExtension.php @@ -4,39 +4,66 @@ namespace Neo4j\Neo4jBundle\DependencyInjection; +use Neo4j\Neo4jBundle\Collector\Neo4jDataCollector; +use Neo4j\Neo4jBundle\EventListener\Neo4jProfileListener; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; -use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\DependencyInjection\Reference; /** * @psalm-import-type NormalisedDriverConfig from Configuration */ -class Neo4jExtension extends ConfigurableExtension +class Neo4jExtension extends Extension { - protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void + public function load(array $configs, ContainerBuilder $container): ContainerBuilder { - $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../../config')); - $loader->load('services.xml'); - $loader->load('data-collector.xml'); - - $debug = $container->getParameter('kernel.debug'); - if ($debug === false) { - $container->removeDefinition('neo4j.collector.debug_collector'); - } elseif (($mergedConfig['profiling']['enabled'] ?? null) === false) { - $container->removeDefinition('neo4j.collector.debug_collector'); - } + $configuration = new Configuration(); + $mergedConfig = $this->processConfiguration($configuration, $configs); + + $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../../config')); + $loader->load('services.php'); $container->getDefinition('neo4j.client_factory') ->setArgument(1, $mergedConfig['default_driver_config'] ?? null) ->setArgument(2, $mergedConfig['default_session_config'] ?? null) ->setArgument(3, $mergedConfig['default_transaction_config'] ?? null) ->setArgument(4, $mergedConfig['drivers'] ?? []) + ->setArgument(5, new Reference(ClientInterface::class, ContainerInterface::NULL_ON_INVALID_REFERENCE)) + ->setArgument(6, new Reference(StreamFactoryInterface::class, ContainerInterface::NULL_ON_INVALID_REFERENCE)) + ->setArgument(7, new Reference(RequestFactoryInterface::class, ContainerInterface::NULL_ON_INVALID_REFERENCE)) ->setAbstract(false) ; $container->getDefinition('neo4j.driver') ->setArgument(0, $mergedConfig['drivers']['alias'] ?? 'default'); + + $enabledProfiles = []; + foreach ($mergedConfig['drivers'] as $driver) { + if (true === $driver['profiling'] || (null === $driver['profiling'] && $container->getParameter('kernel.debug'))) { + $enabledProfiles[] = $driver['alias']; + } + } + + if (0 !== count($enabledProfiles)) { + $container->setDefinition('neo4j.data_collector', (new Definition(Neo4jDataCollector::class)) + ->setArgument(0, new Reference('neo4j.client_factory')) + ->addTag('data_collector') + ); + + $container->setDefinition('neo4j.subscriber', (new Definition(Neo4jProfileListener::class)) + ->setArgument(0, $enabledProfiles) + ->addTag('kernel.event_subscriber') + ); + } + + return $container; } public function getConfiguration(array $config, ContainerBuilder $container): Configuration diff --git a/src/Events/FailureEvent.php b/src/Event/FailureEvent.php similarity index 60% rename from src/Events/FailureEvent.php rename to src/Event/FailureEvent.php index d7447e7..f5e1c61 100644 --- a/src/Events/FailureEvent.php +++ b/src/Event/FailureEvent.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Neo4j\Neo4jBundle\Events; +namespace Neo4j\Neo4jBundle\Event; +use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Exception\Neo4jException; use Symfony\Contracts\EventDispatcher\Event; @@ -11,13 +12,10 @@ class FailureEvent extends Event { public const EVENT_ID = 'neo4j.on_failure'; - protected Neo4jException $exception; - protected bool $shouldThrowException = true; - public function __construct(Neo4jException $exception) + public function __construct(private string|null $alias, private Statement $statement, private Neo4jException $exception) { - $this->exception = $exception; } public function getException(): Neo4jException @@ -34,4 +32,14 @@ public function shouldThrowException(): bool { return $this->shouldThrowException; } + + public function getAlias(): string|null + { + return $this->alias; + } + + public function getStatement(): Statement + { + return $this->statement; + } } diff --git a/src/Event/PostRunEvent.php b/src/Event/PostRunEvent.php new file mode 100644 index 0000000..75e9a96 --- /dev/null +++ b/src/Event/PostRunEvent.php @@ -0,0 +1,29 @@ +result; + } + + public function getAlias(): string|null + { + return $this->alias; + } +} diff --git a/src/Event/PreRunEvent.php b/src/Event/PreRunEvent.php new file mode 100644 index 0000000..cd59239 --- /dev/null +++ b/src/Event/PreRunEvent.php @@ -0,0 +1,27 @@ +statement; + } + + public function getAlias(): string|null + { + return $this->alias; + } +} diff --git a/src/EventHandler.php b/src/EventHandler.php index e2e5247..34579c5 100644 --- a/src/EventHandler.php +++ b/src/EventHandler.php @@ -4,14 +4,20 @@ namespace Neo4j\Neo4jBundle; +use Laudis\Neo4j\Common\Uri; +use Laudis\Neo4j\Databags\DatabaseInfo; +use Laudis\Neo4j\Databags\ResultSummary; +use Laudis\Neo4j\Databags\ServerInfo; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Databags\SummarizedResult; +use Laudis\Neo4j\Databags\SummaryCounters; +use Laudis\Neo4j\Enum\ConnectionProtocol; +use Laudis\Neo4j\Enum\QueryTypeEnum; use Laudis\Neo4j\Exception\Neo4jException; use Laudis\Neo4j\Types\CypherList; -use Laudis\Neo4j\Types\CypherMap; -use Neo4j\Neo4jBundle\Events\FailureEvent; -use Neo4j\Neo4jBundle\Events\PostRunEvent; -use Neo4j\Neo4jBundle\Events\PreRunEvent; +use Neo4j\Neo4jBundle\Event\FailureEvent; +use Neo4j\Neo4jBundle\Event\PostRunEvent; +use Neo4j\Neo4jBundle\Event\PreRunEvent; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; class EventHandler @@ -24,31 +30,47 @@ public function __construct(?EventDispatcherInterface $dispatcher) } /** - * @param callable():CypherList> $runHandler - * @param iterable $statements + * @template T * - * @return CypherList> + * @param callable(Statement):SummarizedResult $runHandler + * + * @return SummarizedResult */ - public function handle(callable $runHandler, iterable $statements): CypherList + public function handle(callable $runHandler, Statement $statement, string|null $alias): SummarizedResult { if (null === $this->dispatcher) { - return $runHandler(); + return $runHandler($statement); } - $this->dispatcher->dispatch(new PreRunEvent($statements), PreRunEvent::EVENT_ID); + $this->dispatcher->dispatch(new PreRunEvent($alias, $statement), PreRunEvent::EVENT_ID); try { - $tbr = $runHandler(); - $this->dispatcher->dispatch(new PostRunEvent($tbr), PostRunEvent::EVENT_ID); + $tbr = $runHandler($statement); + $this->dispatcher->dispatch(new PostRunEvent($alias, $tbr->getSummary()), PostRunEvent::EVENT_ID); } catch (Neo4jException $e) { - $event = new FailureEvent($e); + $event = new FailureEvent($alias, $statement, $e); $event = $this->dispatcher->dispatch($event, FailureEvent::EVENT_ID); if ($event->shouldThrowException()) { throw $e; } + + $summary = new ResultSummary( + new SummaryCounters(), + new DatabaseInfo('n/a'), + new CypherList([]), + null, + null, + $statement, + QueryTypeEnum::READ_ONLY(), + 0, + 0, + new ServerInfo(Uri::create(''), ConnectionProtocol::BOLT_V5(), 'n/a'), + ); + + $tbr = new SummarizedResult($summary); } - return new CypherList(); + return $tbr; } } diff --git a/src/EventListener/Neo4jProfileListener.php b/src/EventListener/Neo4jProfileListener.php new file mode 100644 index 0000000..b046b99 --- /dev/null +++ b/src/EventListener/Neo4jProfileListener.php @@ -0,0 +1,78 @@ + + */ + private array $profiledSummaries = []; + + /** + * @var list + */ + private array $profiledFailures = []; + + /** + * @param list $enabledProfiles + */ + public function __construct(private array $enabledProfiles = []) + { + } + + public static function getSubscribedEvents(): array + { + return [ + PostRunEvent::EVENT_ID => 'onPostRun', + FailureEvent::EVENT_ID => 'onFailure', + ]; + } + + public function onPostRun(PostRunEvent $event): void + { + if (in_array($event->getAlias(), $this->enabledProfiles)) { + $this->profiledSummaries[] = $event->getResult(); + } + } + + public function onFailure(FailureEvent $event): void + { + if (in_array($event->getAlias(), $this->enabledProfiles)) { + $this->profiledFailures[] = [ + 'exception' => $event->getException(), + 'statement' => $event->getStatement(), + 'alias' => $event->getAlias(), + ]; + } + } + + public function getProfiledSummaries(): array + { + return $this->profiledSummaries; + } + + /** + * @return list + */ + public function getProfiledFailures(): array + { + return $this->profiledFailures; + } + + public function reset(): void + { + $this->profiledFailures = []; + $this->profiledSummaries = []; + } +} diff --git a/src/EventSubscriber/LoggerSubscriber.php b/src/EventSubscriber/LoggerSubscriber.php deleted file mode 100644 index 88c9130..0000000 --- a/src/EventSubscriber/LoggerSubscriber.php +++ /dev/null @@ -1,49 +0,0 @@ -queryLogger = $queryLogger; - } - - public static function getSubscribedEvents(): array - { - return [ - PreRunEvent::EVENT_ID => 'onPreRun', - PostRunEvent::EVENT_ID => 'onPostRun', - FailureEvent::EVENT_ID => 'onFailure', - ]; - } - - public function onPreRun(PreRunEvent $event): void - { - foreach ($event->getStatements() as $statement) { - $this->queryLogger->record($statement); - } - } - - public function onPostRun(PostRunEvent $event): void - { - foreach ($event->getResults() as $result) { - $this->queryLogger->finish($result); - } - } - - public function onFailure(FailureEvent $event): void - { - $this->queryLogger->logException($event->getException()); - } -} diff --git a/src/Events/PostRunEvent.php b/src/Events/PostRunEvent.php deleted file mode 100644 index dea501d..0000000 --- a/src/Events/PostRunEvent.php +++ /dev/null @@ -1,30 +0,0 @@ -> $results - */ - public function __construct( - protected CypherList $results - ) {} - - /** - * @return CypherList> - */ - public function getResults(): CypherList - { - return $this->results; - } -} diff --git a/src/Events/PreRunEvent.php b/src/Events/PreRunEvent.php deleted file mode 100644 index e10ec55..0000000 --- a/src/Events/PreRunEvent.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ - private iterable $statements; - - /** - * @param iterable $statements - */ - public function __construct(iterable $statements) - { - $this->statements = $statements; - } - - /** - * @return iterable - */ - public function getStatements(): iterable - { - return $this->statements; - } -} diff --git a/src/Neo4jBundle.php b/src/Neo4jBundle.php index 1e5e711..03973ae 100644 --- a/src/Neo4jBundle.php +++ b/src/Neo4jBundle.php @@ -6,9 +6,6 @@ use Symfony\Component\HttpKernel\Bundle\Bundle; -/** - * @author Tobias Nyholm - */ class Neo4jBundle extends Bundle { } diff --git a/src/Resources/public/css/neo4j.css b/src/Resources/public/css/neo4j.css new file mode 100644 index 0000000..96de3b8 --- /dev/null +++ b/src/Resources/public/css/neo4j.css @@ -0,0 +1,3 @@ +.bg-red { + background-color: #B0413E; +} diff --git a/src/Resources/public/images/neo4j.svg b/src/Resources/public/images/neo4j.svg new file mode 100644 index 0000000..e77bcaa --- /dev/null +++ b/src/Resources/public/images/neo4j.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/tests/App/config/routing.yml b/src/Resources/public/js/neo4j.js similarity index 100% rename from tests/App/config/routing.yml rename to src/Resources/public/js/neo4j.js diff --git a/src/SymfonyClient.php b/src/SymfonyClient.php index 6ea616b..efceb9d 100644 --- a/src/SymfonyClient.php +++ b/src/SymfonyClient.php @@ -27,7 +27,8 @@ class SymfonyClient implements ClientInterface public function __construct( private ClientInterface $client, private EventHandler $handler - ) {} + ) { + } public function run(string $statement, iterable $parameters = [], string $alias = null): ?SummarizedResult { @@ -36,22 +37,28 @@ public function run(string $statement, iterable $parameters = [], string $alias public function runStatement(Statement $statement, string $alias = null): ?SummarizedResult { - return $this->runStatements([$statement], $alias)->first(); + return $this->handler->handle(fn (Statement $statement) => $this->client->runStatement($statement, $alias), $statement, $alias); } public function runStatements(iterable $statements, string $alias = null): CypherList { - return $this->handler->handle(fn () => $this->client->runStatements($statements, $alias), $statements); + $tbr = []; + foreach ($statements as $statement) { + $tbr[] = $this->runStatement($statement, $alias); + } + + return CypherList::fromIterable($tbr); } public function beginTransaction(iterable $statements = null, string $alias = null, TransactionConfiguration $config = null): UnmanagedTransactionInterface { - $tsx = new SymfonyTransaction($this->client->beginTransaction(null, $alias, $config), $this->handler); - /** - * @var callable():CypherList> $runHandler - */ - $runHandler = fn (): CypherList => $tsx->runStatements($statements ?? []); - $this->handler->handle($runHandler, $statements ?? []); + $tsx = new SymfonyTransaction($this->client->beginTransaction(null, $alias, $config), $this->handler, $alias); + + $runHandler = fn (Statement $statement): CypherList => $tsx->runStatement($statement); + + foreach (($statements ?? []) as $statement) { + $this->handler->handle($runHandler, $statement, $alias); + } return $tsx; } @@ -67,7 +74,7 @@ public function writeTransaction(callable $tsxHandler, string $alias = null, Tra $session = $this->client->getDriver($alias)->createSession($sessionConfig); return TransactionHelper::retry( - fn () => new SymfonyTransaction($session->beginTransaction([], $config), $this->handler), + fn () => new SymfonyTransaction($session->beginTransaction([], $config), $this->handler, $alias), $tsxHandler ); } @@ -78,7 +85,7 @@ public function readTransaction(callable $tsxHandler, string $alias = null, Tran $session = $this->client->getDriver($alias)->createSession($sessionConfig); return TransactionHelper::retry( - fn () => new SymfonyTransaction($session->beginTransaction([], $config), $this->handler), + fn () => new SymfonyTransaction($session->beginTransaction([], $config), $this->handler, $alias), $tsxHandler ); } @@ -107,4 +114,9 @@ public function rollbackBoundTransaction(string $alias = null, int $depth = 1): { $this->client->rollbackBoundTransaction($alias, $depth); } + + public function hasDriver(string $alias): bool + { + return $this->client->hasDriver($alias); + } } diff --git a/src/SymfonyTransaction.php b/src/SymfonyTransaction.php index 493841b..fee502b 100644 --- a/src/SymfonyTransaction.php +++ b/src/SymfonyTransaction.php @@ -15,17 +15,11 @@ */ class SymfonyTransaction implements UnmanagedTransactionInterface { - /** @var UnmanagedTransactionInterface> */ - private UnmanagedTransactionInterface $tsx; - private EventHandler $handler; - /** * @param UnmanagedTransactionInterface> $tsx */ - public function __construct(UnmanagedTransactionInterface $tsx, EventHandler $handler) + public function __construct(private UnmanagedTransactionInterface $tsx, private EventHandler $handler, private string|null $alias) { - $this->tsx = $tsx; - $this->handler = $handler; } public function run(string $statement, iterable $parameters = []): SummarizedResult @@ -35,7 +29,7 @@ public function run(string $statement, iterable $parameters = []): SummarizedRes public function runStatement(Statement $statement): SummarizedResult { - return $this->runStatements([$statement])->first(); + return $this->handler->handle(fn ($statement) => $this->tsx->runStatement($statement), $statement, $this->alias); } /** @@ -43,12 +37,21 @@ public function runStatement(Statement $statement): SummarizedResult */ public function runStatements(iterable $statements): CypherList { - return $this->handler->handle(fn () => $this->tsx->runStatements($statements), $statements); + $tbr = []; + foreach ($statements as $statement) { + $tbr[] = $this->runStatement($statement); + } + + return CypherList::fromIterable($tbr); } public function commit(iterable $statements = []): CypherList { - return $this->handler->handle(fn () => $this->tsx->commit($statements), $statements); + $tbr = $this->runStatements($statements); + + $this->tsx->commit(); + + return $tbr; } public function rollback(): void diff --git a/views/webprofiler.html.twig b/templates/webprofiler.html.twig similarity index 94% rename from views/webprofiler.html.twig rename to templates/webprofiler.html.twig index ac0d985..8393225 100644 --- a/views/webprofiler.html.twig +++ b/templates/webprofiler.html.twig @@ -1,9 +1,10 @@ +{# templates/data_collector/template.html.twig #} {% extends '@WebProfiler/Profiler/layout.html.twig' %} {% block toolbar %} {% if collector.queryCount > 0 %} {% set icon %} - {{ include('@Neo4j/Icon/neo4j.svg') }} + {{ include('@Neo4j/neo4j.svg') }} {{ collector.queryCount }} stmt. {% endset %} @@ -30,8 +31,8 @@ {% endblock %} {% block head %} - - + + {{ parent() }} {% endblock %} @@ -62,7 +63,7 @@ - {% for idx, statement in collector.statements %} + {% for idx, statement in collector.successfulStatements %} {% set start_time = statement.start_time|default(null) %} {% set end_time = statement.end_time|default(null) %} diff --git a/tests/App/config/default.yml b/tests/App/config/default.yml index 17c918b..45c21a9 100644 --- a/tests/App/config/default.yml +++ b/tests/App/config/default.yml @@ -1,6 +1,6 @@ -imports: - - { resource: framework.yml } - - { resource: services.yml } +framework: + secret: test + test: true neo4j: default_driver: neo4j-test diff --git a/tests/App/config/framework.yml b/tests/App/config/framework.yml deleted file mode 100644 index 5cfe332..0000000 --- a/tests/App/config/framework.yml +++ /dev/null @@ -1,13 +0,0 @@ -framework: - secret: test - test: ~ - session: -# storage_id: session.storage.mock_file - storage_factory_id: session.storage.factory.native - form: false - csrf_protection: false - validation: - enabled: false - router: - resource: "%kernel.project_dir%/config/routing.yml" - utf8: true diff --git a/tests/App/config/services.yml b/tests/App/config/services.yml deleted file mode 100644 index 0baad47..0000000 --- a/tests/App/config/services.yml +++ /dev/null @@ -1 +0,0 @@ -services: diff --git a/tests/Functional/BundleInitializationTest.php b/tests/Functional/BundleInitializationTest.php index fe43526..ff3ffc6 100644 --- a/tests/Functional/BundleInitializationTest.php +++ b/tests/Functional/BundleInitializationTest.php @@ -6,7 +6,6 @@ use Laudis\Neo4j\Contracts\ClientInterface; use Neo4j\Neo4jBundle\Tests\App\TestKernel; -use RuntimeException; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; /** @@ -40,7 +39,7 @@ public function testDefaultDsn(): void $container = static::getContainer(); $client = $container->get('neo4j.client'); - $this->expectException(RuntimeException::class); + $this->expectException(\RuntimeException::class); $this->expectExceptionMessage("Cannot connect to any server on alias: neo4j_undefined_configs with Uris: ('bolt://localhost')"); $client->getDriver('default'); } @@ -50,7 +49,7 @@ public function testDsn(): void static::bootKernel(); $container = static::getContainer(); - $this->expectException(RuntimeException::class); + $this->expectException(\RuntimeException::class); $this->expectExceptionMessage("Cannot connect to any server on alias: neo4j_undefined_configs with Uris: ('bolt://localhost')"); $container->get('neo4j.driver'); diff --git a/tests/Unit/DependencyInjection/Neo4jExtensionTest.php b/tests/Unit/DependencyInjection/Neo4jExtensionTest.php index 8c69e18..572535d 100644 --- a/tests/Unit/DependencyInjection/Neo4jExtensionTest.php +++ b/tests/Unit/DependencyInjection/Neo4jExtensionTest.php @@ -5,6 +5,7 @@ namespace Neo4j\Neo4jBundle\Tests\Unit\DependencyInjection; use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractExtensionTestCase; +use Neo4j\Neo4jBundle\Collector\Neo4jDataCollector; use Neo4j\Neo4jBundle\DependencyInjection\Neo4jExtension; /** @@ -24,9 +25,7 @@ public function testDataCollectorLoaded(): void $this->setParameter('kernel.debug', true); $this->load(); - $this->assertContainerBuilderHasService('neo4j.collector.debug_collector', - 'Neo4j\Neo4jBundle\Collector\Neo4jDataCollector' - ); + $this->assertContainerBuilderHasService('neo4j.data_collector', Neo4jDataCollector::class); } public function testDataCollectorNotLoadedInNonDebug(): void @@ -34,15 +33,19 @@ public function testDataCollectorNotLoadedInNonDebug(): void $this->setParameter('kernel.debug', false); $this->load(); - $this->assertContainerBuilderNotHasService('neo4j.collector.debug_collector'); + $this->assertContainerBuilderNotHasService('neo4j.data_collector'); } public function testDataCollectorNotLoadedWhenDisabled(): void { $this->setParameter('kernel.debug', true); - $this->load(['profiling' => ['enabled' => false]]); + $this->load(['drivers' => [ + 'default' => [ + 'profiling' => false, + ], + ]]); - $this->assertContainerBuilderNotHasService('neo4j.collector.debug_collector'); + $this->assertContainerBuilderNotHasService('neo4j.neo4j_data_collector'); } protected function getContainerExtensions(): array diff --git a/views/Icon/neo4j.svg b/views/Icon/neo4j.svg deleted file mode 100644 index ef6cbac..0000000 --- a/views/Icon/neo4j.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - -