From ea761866f589a2ce3f62adc671a6d09da47a4168 Mon Sep 17 00:00:00 2001 From: Petr Chromec Date: Thu, 6 May 2021 16:28:32 +0200 Subject: [PATCH 1/2] Initial implementation --- .github/workflows/tests.yaml | 75 ++ .gitignore | 6 + CHANGELOG.md | 6 + README.md | 257 +++++++ composer.json | 92 +++ dangerfile.js | 18 + ecs.php | 25 + phpstan.neon | 11 + phpunit.xml.dist | 34 + src/Command/DebugCqrsCommand.php | 162 ++++ src/Controller/CacheController.php | 48 ++ .../Compiler/HandlerPass.php | 102 +++ src/DependencyInjection/Configuration.php | 47 ++ src/DependencyInjection/LmcCqrsExtension.php | 119 +++ src/LmcCqrsBundle.php | 15 + src/Profiler/CqrsDataCollector.php | 182 +++++ src/Resources/config/routes.yaml | 3 + src/Resources/config/services-debug.yaml | 12 + src/Resources/config/services-handler.yaml | 56 ++ src/Resources/config/services-http.yaml | 34 + src/Resources/config/services-profiler.yaml | 30 + src/Resources/config/services-solr.yaml | 42 ++ src/Resources/views/Profiler/index.html.twig | 714 ++++++++++++++++++ src/Service/ErrorProfilerFormatter.php | 28 + tests/AbstractTestCase.php | 74 ++ .../Compiler/HandlerPassTest.php | 92 +++ .../DependencyInjection/ConfigurationTest.php | 32 + .../LmcCqrsExtensionTest.php | 491 ++++++++++++ tests/Profiler/CqrsDataCollectorTest.php | 294 ++++++++ tests/Service/ErrorProfilerFormatterTest.php | 62 ++ 30 files changed, 3163 insertions(+) create mode 100644 .github/workflows/tests.yaml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 dangerfile.js create mode 100644 ecs.php create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 src/Command/DebugCqrsCommand.php create mode 100644 src/Controller/CacheController.php create mode 100644 src/DependencyInjection/Compiler/HandlerPass.php create mode 100644 src/DependencyInjection/Configuration.php create mode 100644 src/DependencyInjection/LmcCqrsExtension.php create mode 100644 src/LmcCqrsBundle.php create mode 100644 src/Profiler/CqrsDataCollector.php create mode 100644 src/Resources/config/routes.yaml create mode 100644 src/Resources/config/services-debug.yaml create mode 100644 src/Resources/config/services-handler.yaml create mode 100644 src/Resources/config/services-http.yaml create mode 100644 src/Resources/config/services-profiler.yaml create mode 100644 src/Resources/config/services-solr.yaml create mode 100644 src/Resources/views/Profiler/index.html.twig create mode 100644 src/Service/ErrorProfilerFormatter.php create mode 100644 tests/AbstractTestCase.php create mode 100644 tests/DependencyInjection/Compiler/HandlerPassTest.php create mode 100644 tests/DependencyInjection/ConfigurationTest.php create mode 100644 tests/DependencyInjection/LmcCqrsExtensionTest.php create mode 100644 tests/Profiler/CqrsDataCollectorTest.php create mode 100644 tests/Service/ErrorProfilerFormatterTest.php diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..5290670 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,75 @@ +name: Tests and linting + +on: + push: + pull_request: + schedule: + - cron: '0 3 * * *' + +jobs: + unit-tests: + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: ['7.4'] + dependencies: [''] + include: + - { php-version: '7.4', dependencies: '--prefer-lowest --prefer-stable' } + + name: Unit tests - PHP ${{ matrix.dependencies }} + + steps: + - uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: json, mbstring + coverage: xdebug + + - name: Install dependencies + run: composer update --no-progress --no-interaction ${{ matrix.dependencies }} + + - name: Run tests + run: | + composer tests-ci + + - name: Submit coverage to Coveralls + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_PARALLEL: true + COVERALLS_FLAG_NAME: ${{ github.job }}-PHP-${{ matrix.php-version }} ${{ matrix.dependencies }} + run: | + composer global require php-coveralls/php-coveralls + ~/.composer/vendor/bin/php-coveralls --coverage_clover=./reports/clover.xml --json_path=./reports/coveralls-upload.json -v + + finish-tests: + name: Tests finished + needs: [unit-tests] + runs-on: ubuntu-latest + steps: + - name: Notify Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel-finished: true + + codestyle: + name: "Code style and static analysis" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + extensions: json, mbstring + + - name: Install dependencies + run: composer update --no-progress + + - name: Run checks + run: composer analyze diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f06a47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/bin/ +/vendor/ +composer.lock + +.phpunit.result.cache +/reports/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f9ec767 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + + + +## Unreleased +- Initial implementation diff --git a/README.md b/README.md new file mode 100644 index 0000000..f024d88 --- /dev/null +++ b/README.md @@ -0,0 +1,257 @@ +LMC CQRS Bundle +=============== + +[![cqrs-types](https://img.shields.io/badge/cqrs-types-purple.svg)](https://github.com/lmc-eu/cqrs-types) +[![Tests and linting](https://github.com/lmc-eu/cqrs-bundle/actions/workflows/tests.yaml/badge.svg)](https://github.com/lmc-eu/cqrs-bundle/actions/workflows/tests.yaml) +[![Coverage Status](https://coveralls.io/repos/github/lmc-eu/cqrs-bundle/badge.svg?branch=main)](https://coveralls.io/github/lmc-eu/cqrs-bundle?branch=main) + +> Symfony bundle for CQRS libraries and extensions. It registers services, data collectors etc. by a configuration. + +## Table of contents +- [Installation](#installation) +- [Configuration](#configuration) + - [Routes](#routes) + - [Tags](#tags) +- [Services](#services) + - [Handlers](#handlers) +- [Extensions](#extensions) + - [HTTP](#http) + - [Solr](#solr) +- [List of all predefined services](#list-of-all-predefined-services-and-their-priorities) + +## Installation +```shell +composer require lmc/cqrs-bundle +``` + +## Configuration + +```yaml +lmc_cqrs: + profiler: false # Whether to enable profiler and allow to profile queries and commands [default false] + debug: false # Whether to enable debug the CQRS by a console command [default false] + + cache: + enabled: false # Whether to use cache for Queries [default false (true, if you define cache_provider)] + cache_provider: '@my.cache.provider' # Service implementing a CacheItemPoolInterface. Required when cache is enabled [default null] + + extension: + http: false # Whether should http extension be active (requires a lmc/cqrs-http dependency) [default false] + solr: false # Whether should solr extension be active (requires a lmc/cqrs-solr dependency) [default false] +``` + +**TIPs**: +- it is advised to set `profiler: '%kernel.debug%'` so it profiles (and registers all services for profiling) only when it is really used +- you can define `profiler` and `debug` in your `dev/lmc_cqrs.yaml` to only allow it in `dev` Symfony environment + +**Note**: if you don't enable any of the extension, there will only be a `CallbackQueryHandler` and `CallbackSendCommandHandler`, so you probably need to register your own. + +### Routes +You must register the routes for a CQRS bundle if you enable a profiler. + +```yaml +# config/routes.yaml + +lmc_cqrs_bundle_routes: + resource: "@LmcCqrsBundle/Resources/config/routes.yaml" +``` + +### Tags: +> Tags are automatically registered, if your class implements an Interface and is registered in Symfony container as a service + +- `lmc_cqrs.query_handler` (`QueryHandlerInterface`) +- `lmc_cqrs.send_command_handler` (`SendCommandHandlerInterface`) +- `lmc_cqrs.profiler_formatter` (`ProfilerFormatterInterface`) +- `lmc_cqrs.response_decoder` (`ResponseDecoderInterface`) + +With priority +```yaml +services: + My\CustomQueryHandler: + tags: + - { name: 'lmc_cqrs.query_handler', priority: 80 } +``` + +**Note**: Default priority is `50` and none of the predefined handlers, profilers, etc. has priority higher than `90` (see [complete list below](#list-of-all-predefined-services-and-their-priorities)) + +## Services +Bundle registers all necessary services according to configuration (for example, if you set `http: true` it will automatically register all http handlers, etc.) + +Most of the services are registered both by an alias and a class name, so it will be available for autowiring. +All interfaces are automatically configured to have a tag (see [Tags](#tags) section above). + +### Handlers +There are 2 main services, which are essential to the library. +Both of them have its interface to represent it, and it is advised to use it via the interface. + +#### 1. Query Fetcher Interface +- implementation `Lmc\Cqrs\Handler\QueryFetcher` +- alias: `@lmc_cqrs.query_fetcher` +- it will find a handler for your query, handles it, decodes a response and caches the result (if cache is enabled) +- provides features: + - caching + - requires: + - cache_provider (set in the configuration) - service implements `Psr\Cache\CacheItemPoolInterface` + - Query to implement `Lmc\Cqrs\Types\Feature\CacheableInterface` + - it allows to cache a decoded result and load it again from cache + - profiling + - requires: + - enabled profiler (in the configuration) + - Query to implement `Lmc\Cqrs\Types\Feature\ProfileableInterface` + - it profiles a query, its execution time, response, applied handler and decoders and shows the info in the Symfony profiler + +Fetching a query + +You can do whatever you want with a response, we will persist a result into db, for an example or log an error. +```php +// with continuation +$this->queryFetcher->fetch( + $query, + fn ($response) => $this->repository->save($response), + fn (\Throwable $error) => $this->logger->critical($error->getMassage()) +); + +// with return +try { + $response = $this->queryFetcher->fetchAndReturn($query); + $this->repository->save($response); +} catch (\Throwable $error) { + $this->logger->critical($error->getMessage()); +} +``` + +#### 2. Command Sender Interface +- implementation `Lmc\Cqrs\Handler\CommandSender` +- alias: `@lmc_cqrs.command_sender` +- it will find a handler for your command, handles it, decodes a response +- provides features: + - profiling + - requires: + - enabled profiler (in the configuration) + - Command to implement `Lmc\Cqrs\Types\Feature\ProfileableInterface` + - it profiles a command, its execution time, response, applied handler and decoders and shows the info in the Symfony profiler + +Sending a command + +You can do whatever you want with a response, we will persist a result into db, for an example or log an error. +```php +// with continuation +$this->commandSender->send( + $command, + fn ($response) => $this->repository->save($response), + fn (\Throwable $error) => $this->logger->critical($error->getMassage()) +); + +// with return +try { + $response = $this->commandSender->sendAndReturn($query); + $this->repository->save($response); +} catch (\Throwable $error) { + $this->logger->critical($error->getMessage()); +} +``` + +**Note**: There is no logging feature in the CQRS library, if you need one, you have to implement it by yourself. + +### Profiler Bag +There is a `profiler bag` service, which is a collection of all profiler information in the current request. +The information inside are used by a `CqrsDataCollector`, which shows them in the Symfony profiler. + +It requires `profiler: true` in the configuration. + +You can access the profiler bag either by: +- `@lmc_cqrs.profiler_bag` (alias) +- `Lmc\Cqrs\Handler\ProfilerBag` (autowiring) +- or access a `CqrsDataCollector` programmatically (see [here](https://symfony.com/doc/current/profiler.html#accessing-profiling-data-programmatically)) + +## Extensions + +We offer a basic extensions for a common Commands & Queries +- [Http](#http) (using [PSR-7](https://www.php-fig.org/psr/psr-7/)) +- [SOLR](#solr) (using [Solarium](https://github.com/solariumphp/solarium)) + +### Http +> [Http extension repository](https://github.com/lmc-eu/cqrs-http) + +Installation +```shell +composer require lmc/cqrs-http +``` + +**NOTE**: You will also need an implementation for [PSR-7](https://packagist.org/providers/psr/http-message-implementation), [PSR-17](https://packagist.org/providers/psr/http-factory-implementation) and [PSR-18](https://packagist.org/providers/psr/http-client-implementation) for HTTP extensions to work. + +Configuration +```yaml +lmc_cqrs: + extension: + http: true +``` + +Enabling a Http extension will allow a `QueryFetcher` and `CommandSender` to handle a PSR-7 Requests/Response and decode it. + +### Solr +> [SOLR extension repository](https://github.com/lmc-eu/cqrs-solr) + +Installation +```shell +composer require lmc/cqrs-solr +``` + +Configuration +```yaml +lmc_cqrs: + extension: + solr: true +``` + +Enabling a Solr extension will allow a `QueryFetcher` and `CommandSender` to handle a Solarium Requests/Result and decode it. + +#### Solarium Query Builder +It allows you to build a Solarium request only by defining an Entity with all features you want to provide. +See [Solr extension readme](https://github.com/lmc-eu/cqrs-solr#query-builder) for more information. + +**Note**: You can specify a tag for your custom applicator by `lmc_cqrs.solr.query_builder_applicator` + +## List of all predefined services and their priorities + +**Note**: To see a list of all services really registered in your application use `bin/console debug:cqrs` (it requires `debug: true` in your configuration) + +### Top most handlers for Commands & Queries +| Interface | Class | Alias | +| --- | --- | --- | +| Lmc\Cqrs\Types\QueryFetcherInterface | Lmc\Cqrs\Handler\QueryFetcher | `@lmc_cqrs.query_fetcher` | +| Lmc\Cqrs\Types\CommandSenderInterface | Lmc\Cqrs\Handler\CommandSender | `@lmc_cqrs.command_sender` + +### Query Handlers +| Service | Alias | Tag | Priority | Enabled | +| --- | --- | --- | --- | --- | +| Lmc\Cqrs\Handler\Handler\GetCachedHandler | - | - | 80 | if `cache` is enabled | +| Lmc\Cqrs\Handler\Handler\CallbackQueryHandler | `@lmc_cqrs.query_handler.callback` | `lmc_cqrs.query_handler` | 50 | *always* | +| Lmc\Cqrs\Http\Handler\HttpQueryHandler | `@lmc_cqrs.query_handler.http` | `lmc_cqrs.query_handler` | 50 | if `http` extension is enabled | + +### Send Command Handlers +| Service | Alias | Tag | Priority | Enabled | +| --- | --- | --- | --- | --- | +| Lmc\Cqrs\Handler\Handler\CallbackSendCommandHandler | `@lmc_cqrs.send_command_handler.callback` | `lmc_cqrs.send_command_handler` | 50 | *always* | +| Lmc\Cqrs\Http\Handler\HttpSendCommandHandler | `@lmc_cqrs.send_command_handler.http` | `lmc_cqrs.send_command_handler` | 50 | if `http` extension is enabled | + +### Response decoders +| Service | Alias | Tag | Priority | Enabled | +| --- | --- | --- | --- | --- | +| Lmc\Cqrs\Http\Decoder\HttpMessageResponseDecoder | `@lmc_cqrs.response_decoder.http` | `lmc_cqrs.response_decoder` | 90 | if `http` extension is enabled | +| Lmc\Cqrs\Http\Decoder\StreamResponseDecoder | `@lmc_cqrs.response_decoder.stream` | `lmc_cqrs.response_decoder` | 70 | if `http` extension is enabled | +| Lmc\Cqrs\Types\Decoder\JsonResponseDecoder | `@lmc_cqrs.response_decoder.json` | `lmc_cqrs.response_decoder` | 20 | *always* | + +### Profiler formatters +| Service | Alias | Tag | Priority | Enabled | +| --- | --- | --- | --- | --- | +| Lmc\Cqrs\Http\Formatter\HttpProfilerFormatter | `@lmc_cqrs.profiler_formatter.http` | `lmc_cqrs.profiler_formatter` | -1 | if `http` extension is enabled | +| Lmc\Cqrs\Types\Formatter\JsonProfilerFormatter | - | `lmc_cqrs.profiler_formatter` | -1 | if `profiler` is enabled | +| Lmc\Cqrs\Bundle\Service\ErrorProfilerFormatter | - | `lmc_cqrs.profiler_formatter` | -1 | if `profiler` is enabled | + +### Other services +| Class | Alias | Purpose | Enabled | +| --- | --- | --- | --- | +| Lmc\Cqrs\Bundle\Controller\CacheController | - | Controller for invalidating cache from a profiler | if `profiler` is enabled | +| Lmc\Cqrs\Bundle\Profiler\CqrsDataCollector | - | Collects a data about Commands & Queries for a profiler | if `profiler` is enabled | +| Lmc\Cqrs\Handler\ProfilerBag | `@lmc_cqrs.profiler_bag` | A collection of all ProfilerItems, it is a main source of data for a CqrsDataCollector | if `profiler` is enabled | diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1a5584c --- /dev/null +++ b/composer.json @@ -0,0 +1,92 @@ +{ + "name": "lmc/cqrs-bundle", + "type": "symfony-bundle", + "description": "A symfony bundle for CQRS library and its extensions for Queries and Commands", + "license": "MIT", + "require": { + "php": "^7.4", + "ext-json": "*", + "ext-mbstring": "*", + "lmc/cqrs-handler": "dev-feature/initial-implementation", + "lmc/cqrs-types": "dev-feature/initial-implementation", + "symfony/config": "^4.4 || ^5.1", + "symfony/console": "^4.4 || ^5.1", + "symfony/dependency-injection": "^4.4 || ^5.1", + "symfony/error-handler": "^4.4 || ^5.1", + "symfony/framework-bundle": "^4.4 || ^5.1", + "symfony/http-foundation": "^4.4 || ^5.1", + "symfony/http-kernel": "^4.4 || ^5.1", + "twig/twig": "^2.0 || ^3.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.5", + "lmc/coding-standard": "^3.0", + "lmc/cqrs-http": "dev-feature/initial-implementation", + "lmc/cqrs-solr": "dev-feature/initial-implementation", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^0.12.83", + "phpstan/phpstan-phpunit": "^0.12.18", + "phpunit/phpunit": "^9.5", + "symfony/yaml": "^5.2" + }, + "suggest": { + "lmc/cqrs-http": "Provides http handler and base types for queries and commands.", + "lmc/cqrs-solr": "Provides solr handler and base types for queries and commands." + }, + "config": { + "sort-packages": true + }, + "autoload": { + "psr-4": { + "Lmc\\Cqrs\\Bundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Lmc\\Cqrs\\Bundle\\": "tests/" + } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/lmc-eu/cqrs-types" + }, + { + "type": "vcs", + "url": "https://github.com/lmc-eu/cqrs-handler" + }, + { + "type": "vcs", + "url": "https://github.com/lmc-eu/cqrs-http" + }, + { + "type": "vcs", + "url": "https://github.com/lmc-eu/cqrs-solr" + } + ], + "scripts": { + "all": [ + "@lint", + "@analyze", + "@tests" + ], + "analyze": [ + "@cs", + "@phpstan" + ], + "cs": "vendor/bin/ecs check --ansi src/ tests/ ecs.php", + "fix": [ + "vendor/bin/ecs check --ansi --clear-cache --fix src/ tests/ ecs.php", + "@composer normalize" + ], + "lint": [ + "vendor/bin/parallel-lint -j 10 ./src ./tests", + "@composer validate", + "@composer normalize --dry-run" + ], + "phpstan": "vendor/bin/phpstan analyze -c phpstan.neon --ansi", + "tests": "vendor/bin/phpunit", + "tests-ci": "mkdir -p reports && php -dxdebug.coverage_enable=1 vendor/bin/phpunit -c phpunit.xml.dist" + } +} diff --git a/dangerfile.js b/dangerfile.js new file mode 100644 index 0000000..9b56bbd --- /dev/null +++ b/dangerfile.js @@ -0,0 +1,18 @@ +'use strict'; + +const commits = danger.github.commits; +const pr = danger.github.pr; + +// Fail when there are fixup/squash commits in the PR +for (const commit of commits) { + const commitMessage = commit.commit.message; + if (commitMessage.startsWith('fixup!') || commitMessage.startsWith('squash!')) { + fail('There are some fixup/squash commits that needs to be applied before merge.'); + break; + } +} + +// Warn if PR is too big +if (pr.additions > 500) { + warn('This PR is way too big, consider splitting it up to smaller ones. Reviewers will be thankful 🙏.') +} diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..0327984 --- /dev/null +++ b/ecs.php @@ -0,0 +1,25 @@ +parameters(); + + $parameters->set( + Option::SKIP, + ['SlevomatCodingStandard\Sniffs\Exceptions\ReferenceThrowableOnlySniff.ReferencedGeneralException' => ['tests/Exception/*.php']] + ); + + $containerConfigurator->import(__DIR__ . '/vendor/lmc/coding-standard/ecs.php'); + + $services = $containerConfigurator->services(); + + $services->set(PhpUnitTestAnnotationFixer::class) + ->call('configure', [['style' => 'annotation']]); + + $services->set(NoSuperfluousPhpdocTagsFixer::class) + ->call('configure', [['allow_mixed' => true]]); +}; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..16dc09f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,11 @@ +parameters: + checkMissingIterableValueType: false + level: 7 + paths: + - src + - tests + + excludes_analyse: + - src/DependencyInjection/Configuration.php + + ignoreErrors: diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..a0aed18 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,34 @@ + + + + + + + + src + + + + + + + + + + + + + + + tests/ + + + + + + + diff --git a/src/Command/DebugCqrsCommand.php b/src/Command/DebugCqrsCommand.php new file mode 100644 index 0000000..246ced7 --- /dev/null +++ b/src/Command/DebugCqrsCommand.php @@ -0,0 +1,162 @@ + */ + private QueryFetcherInterface $queryFetcher; + /** @phpstan-var CommandSenderInterface */ + private CommandSenderInterface $commandSender; + private SymfonyStyle $io; + private ?string $cacheProvider; + private ?CqrsDataCollector $cqrsDataCollector; + private bool $isExtensionHttpEnabled; + private bool $isExtensionSolrEnabled; + + /** + * @phpstan-param QueryFetcherInterface $queryFetcher + * @phpstan-param CommandSenderInterface $commandSender + */ + public function __construct( + QueryFetcherInterface $queryFetcher, + CommandSenderInterface $commandSender, + ?string $cacheProvider, + ?CqrsDataCollector $cqrsDataCollector, + bool $isExtensionHttpEnabled, + bool $isExtensionSolrEnabled + ) { + $this->queryFetcher = $queryFetcher; + $this->commandSender = $commandSender; + $this->cacheProvider = $cacheProvider; + $this->cqrsDataCollector = $cqrsDataCollector; + $this->isExtensionHttpEnabled = $isExtensionHttpEnabled; + $this->isExtensionSolrEnabled = $isExtensionSolrEnabled; + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setName('debug:cqrs') + ->setDescription('Display configured handlers, decoders, formatters and other services for a cqrs library'); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $this->io = new SymfonyStyle($input, $output); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->io->title('Registered handlers, decoders, formatters and other services for a cqrs library'); + + $this->io->table( + ['Extension', 'Is Enabled'], + [ + ['http', $this->isExtensionHttpEnabled ? 'Yes' : 'No'], + ['solr', $this->isExtensionSolrEnabled ? 'Yes' : 'No'], + ] + ); + + $this->separator(); + + $this->io->title('QueryFetcherInterface'); + $this->io->definitionList( + ['Class' => get_class($this->queryFetcher)], + ['Cache (enabled)' => $this->queryFetcher->isCacheEnabled() ? 'Yes' : 'No'], + ['Cache (provider)' => $this->cacheProvider ?? '-'], + ); + + $this->io->section('Registered Query handlers'); + $this->io->table(...$this->formatPrioritizedItems($this->queryFetcher->getHandlers())); + + $this->io->section('Registered Response decoders'); + $this->io->table(...$this->formatPrioritizedItems($this->queryFetcher->getDecoders())); + + $this->separator(); + + $this->io->title('CommandSenderInterface'); + $this->io->definitionList(['Class' => get_class($this->commandSender)]); + + $this->io->section('Registered Send Command handlers'); + $this->io->table(...$this->formatPrioritizedItems($this->commandSender->getHandlers())); + + $this->io->section('Registered Response decoders'); + $this->io->table(...$this->formatPrioritizedItems($this->commandSender->getDecoders())); + + $this->separator(); + + $this->io->title('Profiler'); + if ($this->cqrsDataCollector !== null) { + $this->io->definitionList( + ['Is Enabled' => 'Yes'], + ['Data Collector' => get_class($this->cqrsDataCollector)], + ); + + $this->io->section('Registered Profiler formatters'); + $this->io->table(...$this->formatItems($this->cqrsDataCollector->getRegisteredFormatters())); + } else { + $this->io->definitionList( + ['Is Enabled' => 'No'], + ); + } + + return 0; + } + + /** + * @phpstan-param iterable> $prioritizedItems + * @param PrioritizedItem[] $prioritizedItems + */ + private function formatPrioritizedItems(iterable $prioritizedItems): array + { + $formatted = []; + $i = 1; + + foreach ($prioritizedItems as $item) { + $formatted[] = [ + sprintf('#%d', $i++), + get_class($item->getItem()), + $item->getPriority(), + ]; + } + + return [ + ['Order', 'Class', 'Priority'], + $formatted, + ]; + } + + private function formatItems(iterable $items): array + { + $formatted = []; + $i = 1; + + foreach ($items as $item) { + $formatted[] = [ + sprintf('#%d', $i++), + get_class($item), + ]; + } + + return [ + ['Order', 'Class'], + $formatted, + ]; + } + + private function separator(): void + { + $this->io->writeln(str_repeat('*', 120)); + } +} diff --git a/src/Controller/CacheController.php b/src/Controller/CacheController.php new file mode 100644 index 0000000..7564a5d --- /dev/null +++ b/src/Controller/CacheController.php @@ -0,0 +1,48 @@ + $queryFetcher + */ + public function invalidateQueryCacheAction( + QueryFetcherInterface $queryFetcher, + Request $request + ): JsonResponse { + /** @var string|array|null $keys */ + $keys = $request->query->get('key'); + $response = new JsonResponse(); + + $result = []; + + if (!$keys) { + return $response->setData(['status' => 'empty key']); + } + + if (!is_array($keys)) { + $keys = [$keys]; + } + + foreach ($keys as $key) { + $key = urldecode($key); + $isInvalidated = $queryFetcher->invalidateCacheItem($key); + + $result[] = [ + 'key' => $key, + 'isInvalidated' => $isInvalidated, + ]; + } + + return $response->setData($result); + } +} diff --git a/src/DependencyInjection/Compiler/HandlerPass.php b/src/DependencyInjection/Compiler/HandlerPass.php new file mode 100644 index 0000000..415d1d5 --- /dev/null +++ b/src/DependencyInjection/Compiler/HandlerPass.php @@ -0,0 +1,102 @@ +setUpQueryFetcher($container); + $this->setUpCommandSender($container); + } + + private function setUpQueryFetcher(ContainerBuilder $container): void + { + if (!$container->has(QueryFetcherInterface::class)) { + return; + } + + $queryFetcher = $container->findDefinition(QueryFetcherInterface::class); + $defaultPriority = $this->getDefaultPriority(); + + foreach ($this->iterateByTags( + $container, + LmcCqrsExtension::TAG_QUERY_HANDLER, + $defaultPriority + ) as $handlerId => $priority) { + $queryFetcher->addMethodCall('addHandler', [new Reference($handlerId), $priority]); + } + + foreach ($this->iterateByTags( + $container, + LmcCqrsExtension::TAG_RESPONSE_DECODER, + $defaultPriority + ) as $decoderId => $priority) { + $queryFetcher->addMethodCall('addDecoder', [new Reference($decoderId), $priority]); + } + } + + private function setUpCommandSender(ContainerBuilder $container): void + { + if (!$container->has(CommandSenderInterface::class)) { + return; + } + + $commandSender = $container->findDefinition(CommandSenderInterface::class); + $defaultPriority = $this->getDefaultPriority(); + + foreach ($this->iterateByTags( + $container, + LmcCqrsExtension::TAG_SEND_COMMAND_HANDLER, + $defaultPriority + ) as $handlerId => $priority) { + $commandSender->addMethodCall('addHandler', [new Reference($handlerId), $priority]); + } + + foreach ($this->iterateByTags( + $container, + LmcCqrsExtension::TAG_RESPONSE_DECODER, + $defaultPriority + ) as $decoderId => $priority) { + $commandSender->addMethodCall('addDecoder', [new Reference($decoderId), $priority]); + } + } + + private function getDefaultPriority(): int + { + return PrioritizedItem::PRIORITY_MEDIUM; + } + + private function iterateByTags(ContainerBuilder $container, string $tag, int $defaultPriority): iterable + { + foreach ($container->findTaggedServiceIds($tag) as $handlerId => $tags) { + foreach ($this->getPriorities($tags, $defaultPriority) as $priority) { + yield $handlerId => $priority; + } + } + } + + private function getPriorities(array $tags, int $defaultPriority): array + { + $priorities = []; + + foreach ($tags as $tag) { + if (array_key_exists('priority', $tag)) { + $priority = (int) $tag['priority']; + $priorities[$priority] = $priority; + } + } + + return empty($priorities) + ? [$defaultPriority] + : $priorities; + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..11c25c8 --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,47 @@ +getRootNode() + ->children() + ->booleanNode('profiler') + ->defaultFalse() + ->end() + ->booleanNode('debug') + ->defaultFalse() + ->end() + ->arrayNode('cache') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled') + ->defaultNull() + ->end() + ->scalarNode('cache_provider') + ->defaultNull() + ->end() + ->end() + ->end() + ->arrayNode('extension') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('http') + ->defaultFalse() + ->end() + ->scalarNode('solr') + ->defaultFalse() + ->end() + ->end() + ->end() + ->end(); + + return $treeBuilder; + } +} diff --git a/src/DependencyInjection/LmcCqrsExtension.php b/src/DependencyInjection/LmcCqrsExtension.php new file mode 100644 index 0000000..6601be0 --- /dev/null +++ b/src/DependencyInjection/LmcCqrsExtension.php @@ -0,0 +1,119 @@ +autoconfigureTags($container); + + $configuration = new Configuration(); + $config = $this->processConfiguration($configuration, $configs); + + $this->setUpCache($config, $container); + + // Load pre-defined services from YAML + $locator = new FileLocator(__DIR__ . '/../Resources/config'); + $loader = new Loader\YamlFileLoader($container, $locator); + + $loader->load('services-handler.yaml'); + + $this->tryRegisterProfiler($config, $container, $loader); + $this->tryRegisterDebug($config, $container, $loader); + $this->tryRegisterHttpExtension($config, $container, $loader); + $this->tryRegisterSolrExtension($config, $container, $loader); + } + + private function autoconfigureTags(ContainerBuilder $container): void + { + $container->registerForAutoconfiguration(QueryHandlerInterface::class) + ->addTag(self::TAG_QUERY_HANDLER); + + $container->registerForAutoconfiguration(SendCommandHandlerInterface::class) + ->addTag(self::TAG_SEND_COMMAND_HANDLER); + + $container->registerForAutoconfiguration(ProfilerFormatterInterface::class) + ->addTag(self::TAG_PROFILER_FORMATTER); + + $container->registerForAutoconfiguration(ResponseDecoderInterface::class) + ->addTag(self::TAG_RESPONSE_DECODER); + } + + private function setUpCache(array $config, ContainerBuilder $container): void + { + $cacheProvider = $config['cache']['cache_provider']; + $isCacheEnabled = + ($config['cache']['enabled'] === true) || + ($config['cache']['enabled'] === null && $cacheProvider !== null); + + $container->setParameter(self::PARAMETER_CACHE_ENABLED, $isCacheEnabled); + $container->setParameter(self::PARAMETER_CACHE_PROVIDER, $cacheProvider); + + if ($cacheProvider) { + $container->setAlias('lmc_cqrs.cache_provider', str_replace('@', '', $cacheProvider)); + } + } + + private function tryRegisterProfiler(array $config, ContainerBuilder $container, YamlFileLoader $loader): void + { + if ($config['profiler']) { + $loader->load('services-profiler.yaml'); + } + } + + private function tryRegisterDebug(array $config, ContainerBuilder $container, YamlFileLoader $loader): void + { + if ($config['debug']) { + $loader->load('services-debug.yaml'); + } + } + + private function tryRegisterHttpExtension(array $config, ContainerBuilder $container, YamlFileLoader $loader): void + { + if ($config['extension']['http']) { + $loader->load('services-http.yaml'); + $container->setParameter(self::PARAMETER_EXTENSION_HTTP, true); + } else { + $container->setParameter(self::PARAMETER_EXTENSION_HTTP, false); + } + } + + private function tryRegisterSolrExtension(array $config, ContainerBuilder $container, YamlFileLoader $loader): void + { + if ($config['extension']['solr']) { + $container->registerForAutoconfiguration(ApplicatorInterface::class) + ->addTag(self::TAG_SOLR_QUERY_BUILDER_APPLICATOR); + + $loader->load('services-solr.yaml'); + $container->setParameter(self::PARAMETER_EXTENSION_SOLR, true); + } else { + $container->setParameter(self::PARAMETER_EXTENSION_SOLR, false); + } + } +} diff --git a/src/LmcCqrsBundle.php b/src/LmcCqrsBundle.php new file mode 100644 index 0000000..2ca135c --- /dev/null +++ b/src/LmcCqrsBundle.php @@ -0,0 +1,15 @@ +addCompilerPass(new HandlerPass()); + } +} diff --git a/src/Profiler/CqrsDataCollector.php b/src/Profiler/CqrsDataCollector.php new file mode 100644 index 0000000..49f8944 --- /dev/null +++ b/src/Profiler/CqrsDataCollector.php @@ -0,0 +1,182 @@ + */ + private QueryFetcherInterface $queryFetcher; + /** @phpstan-var CommandSenderInterface */ + private CommandSenderInterface $commandSender; + /** @var ProfilerFormatterInterface[] */ + private array $formatters; + private ?string $cacheProvider; + + public static function getDefaultPriority(): int + { + return 0; + } + + /** + * @phpstan-param QueryFetcherInterface $queryFetcher + * @phpstan-param CommandSenderInterface $commandSender + * @param \Traversable $formatters + */ + public function __construct( + ProfilerBag $profilerBag, + QueryFetcherInterface $queryFetcher, + CommandSenderInterface $commandSender, + \Traversable $formatters, + ?string $cacheProvider + ) { + $this->profilerBag = $profilerBag; + $this->queryFetcher = $queryFetcher; + $this->commandSender = $commandSender; + $this->formatters = iterator_to_array($formatters); + $this->cacheProvider = $cacheProvider; + } + + /** + * Collects data for the given Request and Response. + */ + public function collect(Request $request, Response $response, \Throwable $exception = null): void + { + $this->data = [ + 'formatters' => $this->formatters, + 'items' => $this->formatItems($this->profilerBag->getBag()), + 'queryFetcher' => [ + 'class' => get_class($this->queryFetcher), + 'isCacheEnabled' => $this->queryFetcher->isCacheEnabled(), + 'cacheProvider' => $this->cacheProvider ?? '-', + 'handlers' => $this->mapPrioritizedItems($this->queryFetcher->getHandlers(), 'handler'), + 'decoders' => $this->mapPrioritizedItems($this->queryFetcher->getDecoders(), 'decoder'), + ], + 'commandSender' => [ + 'class' => get_class($this->commandSender), + 'handlers' => $this->mapPrioritizedItems($this->commandSender->getHandlers(), 'handler'), + 'decoders' => $this->mapPrioritizedItems($this->commandSender->getDecoders(), 'decoder'), + ], + ]; + } + + private function formatItems(array $profilerBag): array + { + return array_map( + fn (ProfilerItem $item) => array_reduce( + $this->formatters, + fn (ProfilerItem $item, ProfilerFormatterInterface $formatter) => $formatter->formatItem($item), + $item + ), + $profilerBag + ); + } + + private function mapPrioritizedItems(array $prioritizedItems, string $itemKey): array + { + return array_map( + fn (PrioritizedItem $item) => [ + $itemKey => get_class($item->getItem()), + 'priority' => $item->getPriority(), + ], + $prioritizedItems + ); + } + + public function reset(): void + { + $this->data = []; + } + + public function getRegisteredFormatters(): array + { + return $this->formatters; + } + + public function getFormatters(): array + { + return array_map( + fn (ProfilerFormatterInterface $formatter) => get_class($formatter), + $this->data['formatters'] ?? [] + ); + } + + public function getItems(): array + { + return array_values($this->data['items'] ?? []); + } + + public function getQueries(): array + { + return array_values(array_filter( + $this->getItems(), + fn (ProfilerItem $item) => $item->getItemType() === ProfilerItem::TYPE_QUERY + )); + } + + public function getCommands(): array + { + return array_values(array_filter( + $this->getItems(), + fn (ProfilerItem $item) => $item->getItemType() === ProfilerItem::TYPE_COMMAND + )); + } + + public function getOthers(): array + { + return array_values(array_filter( + $this->getItems(), + fn (ProfilerItem $item) => $item->getItemType() === ProfilerItem::TYPE_OTHER + )); + } + + public function countCachedQueries(): int + { + return $this->countCachedQueryItems(true); + } + + public function countUncachedQueries(): int + { + return $this->countCachedQueryItems(false); + } + + private function countCachedQueryItems(bool $cached): int + { + return array_reduce( + $this->getQueries(), + function (int $count, ProfilerItem $item) use ($cached) { + if ($item->isLoadedFromCache() === $cached) { + return $count + 1; + } + + return $count; + }, + 0 + ); + } + + public function getQueryFetcher(): array + { + return $this->data['queryFetcher'] ?? []; + } + + public function getCommandSender(): array + { + return $this->data['commandSender'] ?? []; + } + + public function getName(): string + { + return 'cqrs'; + } +} diff --git a/src/Resources/config/routes.yaml b/src/Resources/config/routes.yaml new file mode 100644 index 0000000..ebf5fda --- /dev/null +++ b/src/Resources/config/routes.yaml @@ -0,0 +1,3 @@ +Cache: + resource: '@LmcCqrsBundle/Controller/CacheController.php' + type: annotation diff --git a/src/Resources/config/services-debug.yaml b/src/Resources/config/services-debug.yaml new file mode 100644 index 0000000..75d883e --- /dev/null +++ b/src/Resources/config/services-debug.yaml @@ -0,0 +1,12 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + bind: + $cacheProvider: '%lmc_cqrs.cache.provider%' + $isExtensionHttpEnabled: '%lmc_cqrs.extension.http%' + $isExtensionSolrEnabled: '%lmc_cqrs.extension.solr%' + + Lmc\Cqrs\Bundle\Command\: + resource: '../../Command' diff --git a/src/Resources/config/services-handler.yaml b/src/Resources/config/services-handler.yaml new file mode 100644 index 0000000..70b031a --- /dev/null +++ b/src/Resources/config/services-handler.yaml @@ -0,0 +1,56 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + # + # Query Fetcher + # + + lmc_cqrs.query_fetcher: + class: Lmc\Cqrs\Handler\QueryFetcher + arguments: + $isCacheEnabled: '%lmc_cqrs.cache.enabled%' + $cache: '@?lmc_cqrs.cache_provider' + $profilerBag: '@?lmc_cqrs.profiler_bag' + + Lmc\Cqrs\Handler\QueryFetcher: '@lmc_cqrs.query_fetcher' + Lmc\Cqrs\Types\QueryFetcherInterface: '@lmc_cqrs.query_fetcher' + + # + # Command Sender + # + + lmc_cqrs.command_sender: + class: Lmc\Cqrs\Handler\CommandSender + arguments: + $profilerBag: '@?lmc_cqrs.profiler_bag' + + Lmc\Cqrs\Handler\CommandSender: '@lmc_cqrs.command_sender' + Lmc\Cqrs\Types\CommandSenderInterface: '@lmc_cqrs.command_sender' + + # + # Default handlers + # + + lmc_cqrs.query_handler.callback: + class: Lmc\Cqrs\Handler\Handler\CallbackQueryHandler + + Lmc\Cqrs\Handler\Handler\CallbackQueryHandler: '@lmc_cqrs.query_handler.callback' + + lmc_cqrs.send_command_handler.callback: + class: Lmc\Cqrs\Handler\Handler\CallbackSendCommandHandler + + Lmc\Cqrs\Handler\Handler\CallbackSendCommandHandler: '@lmc_cqrs.send_command_handler.callback' + + # + # Default response decoders + # + + lmc_cqrs.response_decoder.json: + class: Lmc\Cqrs\Types\Decoder\JsonResponseDecoder + tags: + - { name: lmc_cqrs.response_decoder, priority: 20 } + + Lmc\Cqrs\Types\Decoder\JsonResponseDecoder: '@lmc_cqrs.response_decoder.json' diff --git a/src/Resources/config/services-http.yaml b/src/Resources/config/services-http.yaml new file mode 100644 index 0000000..948aec4 --- /dev/null +++ b/src/Resources/config/services-http.yaml @@ -0,0 +1,34 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + lmc_cqrs.query_handler.http: + class: Lmc\Cqrs\Http\Handler\HttpQueryHandler + + Lmc\Cqrs\Http\Handler\HttpQueryHandler: '@lmc_cqrs.query_handler.http' + + lmc_cqrs.send_command_handler.http: + class: Lmc\Cqrs\Http\Handler\HttpSendCommandHandler + + Lmc\Cqrs\Http\Handler\HttpSendCommandHandler: '@lmc_cqrs.send_command_handler.http' + + lmc_cqrs.response_decoder.http: + class: Lmc\Cqrs\Http\Decoder\HttpMessageResponseDecoder + tags: + - { name: lmc_cqrs.response_decoder, priority: 90 } + + Lmc\Cqrs\Http\Decoder\HttpMessageResponseDecoder: '@lmc_cqrs.response_decoder.http' + + lmc_cqrs.response_decoder.stream: + class: Lmc\Cqrs\Http\Decoder\StreamResponseDecoder + tags: + - { name: lmc_cqrs.response_decoder, priority: 70 } + + Lmc\Cqrs\Http\Decoder\StreamResponseDecoder: '@lmc_cqrs.response_decoder.stream' + + lmc_cqrs.profiler_formatter.http: + class: Lmc\Cqrs\Http\Formatter\HttpProfilerFormatter + + Lmc\Cqrs\Http\Formatter\HttpProfilerFormatter: '@lmc_cqrs.profiler_formatter.http' diff --git a/src/Resources/config/services-profiler.yaml b/src/Resources/config/services-profiler.yaml new file mode 100644 index 0000000..ffd5505 --- /dev/null +++ b/src/Resources/config/services-profiler.yaml @@ -0,0 +1,30 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + bind: + $cacheProvider: '%lmc_cqrs.cache.provider%' + + Lmc\Cqrs\Bundle\Controller\: + resource: '../../Controller' + tags: ['controller.service_arguments'] + + Lmc\Cqrs\Bundle\Profiler\CqrsDataCollector: + arguments: + $formatters: !tagged_iterator { tag: lmc_cqrs.profiler_formatter, default_priority_method: getDefaultPriority } + tags: + - { name: data_collector, template: '@LmcCqrs/Profiler/index.html.twig', id: cqrs } + + lmc_cqrs.profiler_bag: + class: Lmc\Cqrs\Handler\ProfilerBag + + Lmc\Cqrs\Handler\ProfilerBag: '@lmc_cqrs.profiler_bag' + + Lmc\Cqrs\Types\Formatter\JsonProfilerFormatter: + tags: + - { name: lmc_cqrs.profiler_formatter, priority: -1 } + + Lmc\Cqrs\Bundle\Service\ErrorProfilerFormatter: + tags: + - { name: lmc_cqrs.profiler_formatter, priority: -1 } diff --git a/src/Resources/config/services-solr.yaml b/src/Resources/config/services-solr.yaml new file mode 100644 index 0000000..8fb2d13 --- /dev/null +++ b/src/Resources/config/services-solr.yaml @@ -0,0 +1,42 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + lmc_cqrs.query_handler.solr: + class: Lmc\Cqrs\Solr\Handler\SolrQueryHandler + arguments: + $client: '@?solarium.client' + + Lmc\Cqrs\Solr\Handler\SolrQueryHandler: '@lmc_cqrs.query_handler.solr' + + # + # Query Builder + # + + lmc_cqrs.query_builder: + class: Lmc\Cqrs\Solr\QueryBuilder\QueryBuilder + + Lmc\Cqrs\Solr\QueryBuilder\QueryBuilder: '@lmc_cqrs.query_builder' + + # + # Applicators + # + + Lmc\Cqrs\Solr\QueryBuilder\Applicator\ApplicatorFactory: + arguments: + $availableApplicators: !tagged_iterator { tag: lmc_cqrs.solr.query_builder_applicator, default_priority_method: getDefaultPriority } + + Lmc\Cqrs\Solr\QueryBuilder\Applicator\EntityApplicator: ~ + Lmc\Cqrs\Solr\QueryBuilder\Applicator\FacetsApplicator: ~ + Lmc\Cqrs\Solr\QueryBuilder\Applicator\FilterApplicator: ~ + Lmc\Cqrs\Solr\QueryBuilder\Applicator\FiltersApplicator: ~ + Lmc\Cqrs\Solr\QueryBuilder\Applicator\FulltextApplicator: ~ + Lmc\Cqrs\Solr\QueryBuilder\Applicator\FulltextBigramApplicator: ~ + Lmc\Cqrs\Solr\QueryBuilder\Applicator\FulltextBoostApplicator: ~ + Lmc\Cqrs\Solr\QueryBuilder\Applicator\GroupingApplicator: ~ + Lmc\Cqrs\Solr\QueryBuilder\Applicator\GroupingFacetApplicator: ~ + Lmc\Cqrs\Solr\QueryBuilder\Applicator\ParameterizedApplicator: ~ + Lmc\Cqrs\Solr\QueryBuilder\Applicator\SortApplicator: ~ + Lmc\Cqrs\Solr\QueryBuilder\Applicator\StatsApplicator: ~ diff --git a/src/Resources/views/Profiler/index.html.twig b/src/Resources/views/Profiler/index.html.twig new file mode 100644 index 0000000..9f33e96 --- /dev/null +++ b/src/Resources/views/Profiler/index.html.twig @@ -0,0 +1,714 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% set colors = { 'success': '#4F805D', 'warning': '#A46A1F', 'error': '#B0413E' } %} +{% import _self as format %} + +{% block toolbar %} + {% if collector.items|length %} + {% set queryColor = (collector.countUncachedQueries == 0) ? 'green' : 'yellow' %} + + {% set icon %} + CQRS + {{ collector.items|length }} + {% endset %} + + {% set text %} +
+
+ Commands + {{ collector.commands|length }} +
+ +
+ Queries + {{ collector.queries|length }} +
+
+ +
+
+ Queries (Cached) + {{ collector.countCachedQueries }} +
+
+ Queries (Uncached) + {{ collector.countUncachedQueries }} +
+
+ + {% if collector.others|length > 0 %} +
+ Others + {{ collector.others|length }} +
+ {% endif %} + {% endset %} + + {% include '@WebProfiler/Profiler/toolbar_item.html.twig' with { 'link': profiler_url } %} + {% endif %} +{% endblock %} + +{% block menu %} + + + CQRS + + CQRS + + {{ collector.items|length }} + + +{% endblock %} + +{% block panel %} +

Commands & Queries ({{ collector.items|length }})

+ + + +
+
+ {{ collector.commands|length }} + Commands +
+ +
+ {{ collector.queries|length }} + Queries +
+ +
+ +
+ {{ collector.countCachedQueries }} + Queries (cached) +
+ +
+ {{ collector.countUncachedQueries }} + Queries (uncached) +
+ + {% if collector.others|length > 0 %} +
+ +
+ {{ collector.others|length }} + Other +
+ {% endif %} +
+ + + + + + + +

Commands ({{ collector.commands|length }})

+ {% for commandItem in collector.commands %} + + + + + + + + + + + + + + + {{ format.colWrap(commandItem.profilerId, 3) }} + + + + {{ format.colWrap(commandItem.handledBy ?? '', 3) }} + + {% if commandItem.decodedBy is not empty %} + + + + + {% endif %} + {% if commandItem.error is not null %} + + + {{ format.colWrapDump(commandItem.error, 3) }} + + {% else %} + + + {{ format.colWrapDump(commandItem.response, 3) }} + + {% endif %} + {% if commandItem.additionalData %} + {% for key, value in commandItem.additionalData %} + + + {{ format.colWrapDump(value, 3) }} + + {% endfor %} + {% endif %} + +
ClassDuration
{{ commandItem.type }}{{ commandItem.duration }}ms
Command
Handled by
Decoded by +
    + {% for decodedBy in commandItem.decodedBy %} +
  1. {{ decodedBy }}
  2. + {% endfor %} +
+
Error
Response
{{ key }}
+ {% else %} +
+

There are no commands on this page.

+
+ {% endfor %} + +

Queries ({{ collector.queries|length }})

+ {% set keys = [] %} + {% for queryItem in collector.queries %} + {% if queryItem.cacheKey is not null %} + {% set currentCacheKey = queryItem.cacheKey.hashedKey %} + {% set keys = keys | merge([currentCacheKey | url_encode]) %} + {% else %} + {% set currentCacheKey = null %} + {% endif %} + + + + + + + + + + + + + + + + {% if queryItem.isLoadedFromCache %} + + {% elseif queryItem.isLoadedFromCache is not null %} + + {% else %} + + {% endif %} + {% if queryItem.isStoredInCache %} + + {% elseif queryItem.isStoredInCache is not null %} + + {% else %} + + {% endif %} + + + + + {{ format.colWrap(queryItem.profilerId, 5) }} + + + + {{ format.colWrap(queryItem.handledBy ?? '', 5) }} + + {% if queryItem.decodedBy is not empty %} + + + + + {% endif %} + {% if currentCacheKey %} + + + {{ format.colWrap(queryItem.cacheKey.key, 5) }} + + + + {{ format.colWrap(currentCacheKey, 5) }} + + {% endif %} + {% if queryItem.error is not null %} + + + {{ format.colWrapDump(queryItem.error, 5) }} + + {% else %} + + + {{ format.colWrapDump(queryItem.response, 5) }} + + {% endif %} + {% if queryItem.additionalData %} + {% for key, value in queryItem.additionalData %} + + + {{ format.colWrapDump(value, 5) }} + + {% endfor %} + {% endif %} + +
ClassDurationCache (hit)Cache (stored)Actions
{{ queryItem.type }}{{ queryItem.duration }}msYesNoYes (for {{ queryItem.storedInCacheFor }}s)No + {% if currentCacheKey is not null %} + + {% endif %} +
Query
Handled by
Decoded by +
    + {% for decodedBy in queryItem.decodedBy %} +
  1. {{ decodedBy }}
  2. + {% endfor %} +
+
Cache key (original)
Cache key ({{ queryItem.cacheKey.algorithm }})
Error
Response
{{ key }}
+ + {% if loop.last and keys is not empty %} + + {% endif %} + {% else %} +
+

There are no queries on this page.

+
+ {% endfor %} + + + + {% if collector.others|length > 0 %} +

Others ({{ collector.others|length }})

+ {% for otherItem in collector.others %} + + + + + + + + + + + + + + + {{ format.colWrap(otherItem.profilerId, 2) }} + + + + {{ format.colWrap(otherItem.handledBy ?? '', 2) }} + + {% if otherItem.decodedBy is not empty %} + + + + + {% endif %} + {% if otherItem.error is not null %} + + + {{ format.colWrapDump(otherItem.error, 2) }} + + {% else %} + + + {{ format.colWrapDump(otherItem.response, 2) }} + + {% endif %} + {% if otherItem.additionalData %} + {% for key, value in otherItem.additionalData %} + + + {{ format.colWrapDump(value, 2) }} + + {% endfor %} + {% endif %} + +
ClassDuration
{{ otherItem.type }}{{ otherItem.duration }}ms
Other
Handled by
Decoded by +
    + {% for decodedBy in otherItem.decodedBy %} +
  1. {{ decodedBy }}
  2. + {% endfor %} +
+
Error
Response
{{ key }}
+ {% endfor %} + {% endif %} + +
+
+ +

Handlers

+ +
+ {% if collector.queryFetcher.handlers is defined %} +
+ {{ collector.queryFetcher.handlers|length }} + Query Fetcher Handlers +
+ +
+ {{ collector.queryFetcher.decoders|length }} + Response Decoders +
+ +
+ {% endif %} + + {% if collector.commandSender.handlers is defined %} +
+ {{ collector.commandSender.handlers|length }} + Command Sender Handlers +
+ +
+ {{ collector.commandSender.decoders|length }} + Response Decoders +
+ {% endif %} +
+ + {% if collector.queryFetcher is not empty %} + + + + + + + + + + + + + + + + + + + + + + + +
QueryFetcherInterfaceClassCache enabledCache provider
{{ collector.queryFetcher.class }}{% if collector.queryFetcher.isCacheEnabled %}Yes{% else %}No{% endif %}{{ collector.queryFetcher.cacheProvider ?? '' }}
+ + + + + + + + + {% for handler in collector.queryFetcher.handlers %} + + + + + {% else %} + + + + {% endfor %} + +
HandlerPriority
{{ handler.handler }}{{ handler.priority }}
+
+

No query handlers

+
+
+
+ + + + + + + + + {% for decoder in collector.queryFetcher.decoders %} + + + + + {% else %} + + + + {% endfor %} + +
Response DecoderPriority
{{ decoder.decoder }}{{ decoder.priority }}
+
+

No response decoders

+
+
+
+ {% endif %} + + {% if collector.commandSender is not empty %} + + + + + + + + + + + + + + + + + + + +
CommandSenderInterfaceClass
{{ collector.commandSender.class }}
+ + + + + + + + + {% for handler in collector.commandSender.handlers %} + + + + + {% else %} + + + + {% endfor %} + +
HandlerPriority
{{ handler.handler }}{{ handler.priority }}
+
+

No send command handlers

+
+
+
+ + + + + + + + + {% for decoder in collector.commandSender.decoders %} + + + + + {% else %} + + + + {% endfor %} + +
DecoderPriority
{{ decoder.decoder }}{{ decoder.priority }}
+
+

No response decoders

+
+
+
+ {% endif %} + +
+
+ +

Profiler Formatters

+ + {% if collector.formatters|length > 0 %} + + + + + + + + {% for formatter in collector.formatters %} + + + + {% endfor %} + +
ProfilerFormatterInterface
{{ formatter }}
+ {% else %} +
+

No profiler formatters

+
+ {% endif %} +{% endblock %} + +{% macro colWrap(value, colspan = null) %} + {% if value is defined and value.formatted is defined and value.isWide %} + +
{{ value.original }}
+ + + + + {{ value.formatted }} + + {% elseif value is defined and value.formatted is defined and value.original is null %} + +
{{ value.formatted }}
+ + {% elseif value is defined and value.formatted is defined and colspan is not null %} + +
{{ value.original }}
+ + +
{{ value.formatted }}
+ + {% else %} + +
{{ value }}
+ + {% endif %} +{% endmacro %} + +{% macro colWrapDump(value, colspan = null) %} + {% if value is defined and value.formatted is defined and value.isWide %} + +
{{ dump(value.original) }}
+ + + + + {{ dump(value.formatted) }} + + {% elseif value is defined and value.formatted is defined and value.original is null %} + +
{{ dump(value.formatted) }}
+ + {% elseif value is defined and value.formatted is defined and colspan is not null %} + +
{{ dump(value.original) }}
+ + +
{{ dump(value.formatted) }}
+ + {% else %} + +
{{ dump(value) }}
+ + {% endif %} +{% endmacro %} diff --git a/src/Service/ErrorProfilerFormatter.php b/src/Service/ErrorProfilerFormatter.php new file mode 100644 index 0000000..f9589cc --- /dev/null +++ b/src/Service/ErrorProfilerFormatter.php @@ -0,0 +1,28 @@ +getError() instanceof \Throwable) { + $item->setError($this->formatError($item->getError())); + } + + return $item; + } + + /** @phpstan-return FormattedValue */ + private function formatError(\Throwable $error): FormattedValue + { + $flatten = FlattenException::createFromThrowable($error); + + return new FormattedValue($error->getMessage(), $flatten, true); + } +} diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php new file mode 100644 index 0000000..7aec541 --- /dev/null +++ b/tests/AbstractTestCase.php @@ -0,0 +1,74 @@ +assertTrue($containerBuilder->hasDefinition($alias)); + $this->assertTrue($containerBuilder->has($class)); + } + + protected function assertHasDefinitionWithPriority( + string $class, + string $tag, + int $priority, + ContainerBuilder $containerBuilder + ): void { + $definition = $containerBuilder->findDefinition($class); + + $this->assertTrue($definition->hasTag($tag)); + $tagAttributes = $definition->getTag($tag)[0]; + $this->assertSame($priority, $tagAttributes['priority']); + } + + protected function assertHasServiceWithAliasTagAndPriority( + string $alias, + string $class, + string $tag, + int $priority, + ContainerBuilder $containerBuilder + ): void { + $this->assertHasServiceWithAlias($alias, $class, $containerBuilder); + $this->assertHasDefinitionWithPriority($class, $tag, $priority, $containerBuilder); + } + + /** @param mixed $reference */ + protected function assertReference(string $expectedReferenceId, $reference): void + { + $this->assertInstanceOf(Reference::class, $reference); + $this->assertSame($expectedReferenceId, (string) $reference); + } + + /** @param mixed $argument */ + protected function assertTaggedIterator( + string $expectedTag, + string $expectedDefaultPriorityMethod, + $argument + ): void { + $this->assertInstanceOf(TaggedIteratorArgument::class, $argument); + + if ($argument instanceof TaggedIteratorArgument) { + $this->assertSame($expectedTag, $argument->getTag()); + $this->assertSame($expectedDefaultPriorityMethod, $argument->getDefaultPriorityMethod()); + } + } + + protected function assertAutoconfiguredTags( + array $expectedAutoconfiguredTags, + ContainerBuilder $containerBuilder + ): void { + $definitions = $containerBuilder->getAutoconfiguredInstanceof(); + + foreach ($expectedAutoconfiguredTags as $tag => $interface) { + $this->assertArrayHasKey($interface, $definitions); + $this->assertArrayHasKey($tag, $definitions[$interface]->getTags()); + } + } +} diff --git a/tests/DependencyInjection/Compiler/HandlerPassTest.php b/tests/DependencyInjection/Compiler/HandlerPassTest.php new file mode 100644 index 0000000..50454fe --- /dev/null +++ b/tests/DependencyInjection/Compiler/HandlerPassTest.php @@ -0,0 +1,92 @@ +register(QueryFetcherInterface::class); + $container->register('query_handler.first')->addTag('lmc_cqrs.query_handler', ['priority' => 80]); + $container->register('query_handler.second')->addTag('lmc_cqrs.query_handler'); + + $container->register('response_decoder.first')->addTag('lmc_cqrs.response_decoder', ['priority' => 70]); + $container->register('response_decoder.second')->addTag('lmc_cqrs.response_decoder'); + + $expectedMethods = [ + 'addHandler' => [ + ['query_handler.first', 80], + ['query_handler.second', 50], + ], + 'addDecoder' => [ + ['response_decoder.first', 70], + ['response_decoder.second', 50], + ], + ]; + + $compilerPass = new HandlerPass(); + $compilerPass->process($container); + + $methodCalls = $container->getDefinition(QueryFetcherInterface::class)->getMethodCalls(); + + $this->assertCalledMethods($expectedMethods, $methodCalls); + } + + /** + * @test + */ + public function shouldSetUpCommandSender(): void + { + $container = new ContainerBuilder(); + $container->register(CommandSenderInterface::class); + $container->register('send_command_handler.first')->addTag('lmc_cqrs.send_command_handler', ['priority' => 80]); + $container->register('send_command_handler.second')->addTag('lmc_cqrs.send_command_handler'); + + $container->register('response_decoder.first')->addTag('lmc_cqrs.response_decoder', ['priority' => 70]); + $container->register('response_decoder.second')->addTag('lmc_cqrs.response_decoder'); + + $expectedMethods = [ + 'addHandler' => [ + ['send_command_handler.first', 80], + ['send_command_handler.second', 50], + ], + 'addDecoder' => [ + ['response_decoder.first', 70], + ['response_decoder.second', 50], + ], + ]; + + $compilerPass = new HandlerPass(); + $compilerPass->process($container); + + $methodCalls = $container->getDefinition(CommandSenderInterface::class)->getMethodCalls(); + + $this->assertCalledMethods($expectedMethods, $methodCalls); + } + + private function assertCalledMethods(array $expectedMethods, array $methodCalls): void + { + foreach ($expectedMethods as $method => $expectedParameters) { + $currentMethodCalls = array_values( + array_filter( + $methodCalls, + fn (array $called) => $called[0] === $method + ) + ); + + foreach ($currentMethodCalls as $i => [1 => $calledWith]) { + $this->assertReference($expectedParameters[$i][0], $calledWith[0]); + $this->assertSame($expectedParameters[$i][1], $calledWith[1]); + } + } + } +} diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..099102b --- /dev/null +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,32 @@ +assertStringMatchesFormat($reference, $dumper->dump(new Configuration())); + } +} diff --git a/tests/DependencyInjection/LmcCqrsExtensionTest.php b/tests/DependencyInjection/LmcCqrsExtensionTest.php new file mode 100644 index 0000000..6f1f707 --- /dev/null +++ b/tests/DependencyInjection/LmcCqrsExtensionTest.php @@ -0,0 +1,491 @@ +extension = new LmcCqrsExtension(); + + $this->containerBuilder = new ContainerBuilder(); + $this->containerBuilder->registerExtension($this->extension); + } + + /** + * @test + */ + public function shouldSetUpDefaultServices(): void + { + $configs = []; + + $this->extension->load($configs, $this->containerBuilder); + + $this->assertDefaultSettings($this->containerBuilder); + $this->assertNoCacheSettings($this->containerBuilder); + $this->assertNoProfilerSettings($this->containerBuilder); + $this->assertNoDebugSettings($this->containerBuilder); + $this->assertNoHttpExtensionSettings($this->containerBuilder); + $this->assertNoSolrExtensionSettings($this->containerBuilder); + } + + private function assertDefaultSettings(ContainerBuilder $containerBuilder): void + { + $this->assertQueryFetcher($containerBuilder); + $this->assertCommandSender($containerBuilder); + + $this->assertHasServiceWithAlias( + 'lmc_cqrs.query_handler.callback', + CallbackQueryHandler::class, + $containerBuilder + ); + $this->assertHasServiceWithAlias( + 'lmc_cqrs.send_command_handler.callback', + CallbackSendCommandHandler::class, + $containerBuilder + ); + + $this->assertHasServiceWithAliasTagAndPriority( + 'lmc_cqrs.response_decoder.json', + JsonResponseDecoder::class, + 'lmc_cqrs.response_decoder', + 20, + $containerBuilder + ); + + $expectedAutoconfiguredTags = [ + 'lmc_cqrs.query_handler' => QueryHandlerInterface::class, + 'lmc_cqrs.send_command_handler' => SendCommandHandlerInterface::class, + 'lmc_cqrs.profiler_formatter' => ProfilerFormatterInterface::class, + 'lmc_cqrs.response_decoder' => ResponseDecoderInterface::class, + ]; + + $this->assertAutoconfiguredTags($expectedAutoconfiguredTags, $containerBuilder); + } + + private function assertNoCacheSettings(ContainerBuilder $containerBuilder): void + { + $this->assertFalse($containerBuilder->getParameter('lmc_cqrs.cache.enabled')); + $this->assertNull($containerBuilder->getParameter('lmc_cqrs.cache.provider')); + + $this->assertFalse($containerBuilder->has('lmc_cqrs.cache_provider')); + } + + private function assertNoProfilerSettings(ContainerBuilder $containerBuilder): void + { + $this->assertFalse($containerBuilder->has(CacheController::class)); + $this->assertFalse($containerBuilder->has(CqrsDataCollector::class)); + + $this->assertFalse($containerBuilder->has('lmc_cqrs.profiler_bag')); + $this->assertFalse($containerBuilder->has(ProfilerBag::class)); + + $this->assertFalse($containerBuilder->has(JsonProfilerFormatter::class)); + $this->assertFalse($containerBuilder->has(ErrorProfilerFormatter::class)); + } + + private function assertNoDebugSettings(ContainerBuilder $containerBuilder): void + { + $this->assertFalse($containerBuilder->has(DebugCqrsCommand::class)); + } + + private function assertNoHttpExtensionSettings(ContainerBuilder $containerBuilder): void + { + $this->assertFalse($containerBuilder->has('lmc_cqrs.query_handler.http')); + $this->assertFalse($containerBuilder->has(HttpQueryHandler::class)); + + $this->assertFalse($containerBuilder->has('lmc_cqrs.send_command_handler.http')); + $this->assertFalse($containerBuilder->has(HttpSendCommandHandler::class)); + + $this->assertFalse($containerBuilder->has('lmc_cqrs.response_decoder.http')); + $this->assertFalse($containerBuilder->has(HttpMessageResponseDecoder::class)); + + $this->assertFalse($containerBuilder->has('lmc_cqrs.response_decoder.stream')); + $this->assertFalse($containerBuilder->has(StreamResponseDecoder::class)); + + $this->assertFalse($containerBuilder->has('lmc_cqrs.profiler_formatter.http')); + $this->assertFalse($containerBuilder->has(HttpProfilerFormatter::class)); + } + + private function assertNoSolrExtensionSettings(ContainerBuilder $containerBuilder): void + { + $this->assertFalse($containerBuilder->has('lmc_cqrs.query_handler.solr')); + $this->assertFalse($containerBuilder->has(SolrQueryHandler::class)); + + $this->assertFalse($containerBuilder->has('lmc_cqrs.query_builder')); + $this->assertFalse($containerBuilder->has(QueryBuilder::class)); + + $this->assertFalse($containerBuilder->has(ApplicatorFactory::class)); + + $expectedApplicators = [ + EntityApplicator::class, + FacetsApplicator::class, + FilterApplicator::class, + FiltersApplicator::class, + FulltextApplicator::class, + FulltextBigramApplicator::class, + FulltextBoostApplicator::class, + GroupingApplicator::class, + GroupingFacetApplicator::class, + ParameterizedApplicator::class, + SortApplicator::class, + StatsApplicator::class, + ]; + + foreach ($expectedApplicators as $expectedApplicator) { + $this->assertFalse($containerBuilder->has($expectedApplicator)); + } + } + + /** + * @test + */ + public function shouldSetUpCacheForServices(): void + { + $configs = [ + [ + 'cache' => [ + 'enabled' => true, + 'cache_provider' => '@my.cache_data', + ], + ], + ]; + + $this->extension->load($configs, $this->containerBuilder); + + $this->assertDefaultSettings($this->containerBuilder); + $this->assertCacheSettings($this->containerBuilder, 'my.cache_data', '@my.cache_data'); + $this->assertNoProfilerSettings($this->containerBuilder); + $this->assertNoDebugSettings($this->containerBuilder); + $this->assertNoHttpExtensionSettings($this->containerBuilder); + $this->assertNoSolrExtensionSettings($this->containerBuilder); + } + + private function assertCacheSettings( + ContainerBuilder $containerBuilder, + string $providerAlias, + string $expectedProvider + ): void { + $containerBuilder + ->register($providerAlias) + ->setSynthetic(true) + ->setClass('cache_class'); + + $this->assertTrue($containerBuilder->getParameter('lmc_cqrs.cache.enabled')); + $this->assertSame($expectedProvider, $containerBuilder->getParameter('lmc_cqrs.cache.provider')); + + $this->assertTrue($containerBuilder->has('lmc_cqrs.cache_provider')); + $this->assertSame('cache_class', $containerBuilder->findDefinition('lmc_cqrs.cache_provider')->getClass()); + } + + /** + * @test + */ + public function shouldSetUpAllServices(): void + { + $configs = [ + [ + 'profiler' => true, + 'debug' => true, + + 'cache' => [ + 'enabled' => true, + 'cache_provider' => 'my.cache.provider', + ], + + 'extension' => [ + 'http' => true, + 'solr' => true, + ], + ], + ]; + + $this->extension->load($configs, $this->containerBuilder); + + $this->assertDefaultSettings($this->containerBuilder); + $this->assertCacheSettings($this->containerBuilder, 'my.cache.provider', 'my.cache.provider'); + $this->assertProfilerSettings($this->containerBuilder); + $this->assertDebugSettings($this->containerBuilder); + $this->assertHttpExtensionSettings($this->containerBuilder); + $this->assertSolrExtensionSettings($this->containerBuilder); + } + + /** + * @test + */ + public function shouldSetUpServicesForProfiler(): void + { + $configs = [ + [ + 'profiler' => true, + ], + ]; + + $this->extension->load($configs, $this->containerBuilder); + + $this->assertDefaultSettings($this->containerBuilder); + $this->assertNoCacheSettings($this->containerBuilder); + $this->assertProfilerSettings($this->containerBuilder); + $this->assertNoDebugSettings($this->containerBuilder); + $this->assertNoHttpExtensionSettings($this->containerBuilder); + $this->assertNoSolrExtensionSettings($this->containerBuilder); + } + + private function assertProfilerSettings(ContainerBuilder $containerBuilder): void + { + $this->assertTrue($containerBuilder->has(CacheController::class)); + + $this->assertTrue($containerBuilder->has(CqrsDataCollector::class)); + $cqrsDataCollectorDefinition = $containerBuilder->findDefinition(CqrsDataCollector::class); + $this->assertTaggedIterator( + 'lmc_cqrs.profiler_formatter', + 'getDefaultPriority', + $cqrsDataCollectorDefinition->getArgument('$formatters') + ); + $this->assertTrue($cqrsDataCollectorDefinition->hasTag('data_collector')); + $dataCollectorTag = $cqrsDataCollectorDefinition->getTag('data_collector')[0]; + $this->assertSame('@LmcCqrs/Profiler/index.html.twig', $dataCollectorTag['template']); + $this->assertSame('cqrs', $dataCollectorTag['id']); + + $this->assertHasServiceWithAlias('lmc_cqrs.profiler_bag', ProfilerBag::class, $containerBuilder); + + $this->assertHasDefinitionWithPriority( + JsonProfilerFormatter::class, + 'lmc_cqrs.profiler_formatter', + -1, + $containerBuilder + ); + $this->assertHasDefinitionWithPriority( + ErrorProfilerFormatter::class, + 'lmc_cqrs.profiler_formatter', + -1, + $containerBuilder + ); + } + + /** + * @test + */ + public function shouldSetUpServicesForDebug(): void + { + $configs = [ + [ + 'debug' => true, + ], + ]; + + $this->extension->load($configs, $this->containerBuilder); + + $this->assertDefaultSettings($this->containerBuilder); + $this->assertNoCacheSettings($this->containerBuilder); + $this->assertNoProfilerSettings($this->containerBuilder); + $this->assertDebugSettings($this->containerBuilder); + $this->assertNoHttpExtensionSettings($this->containerBuilder); + $this->assertNoSolrExtensionSettings($this->containerBuilder); + } + + private function assertDebugSettings(ContainerBuilder $containerBuilder): void + { + $this->assertTrue($containerBuilder->has(DebugCqrsCommand::class)); + $definition = $containerBuilder->findDefinition(DebugCqrsCommand::class); + $bindings = $definition->getBindings(); + + $expectedBoundArgs = [ + '$cacheProvider' => '%lmc_cqrs.cache.provider%', + '$isExtensionHttpEnabled' => '%lmc_cqrs.extension.http%', + '$isExtensionSolrEnabled' => '%lmc_cqrs.extension.solr%', + ]; + + foreach ($expectedBoundArgs as $arg => $value) { + $this->assertArrayHasKey($arg, $bindings); + $this->assertSame($value, $bindings[$arg]->getValues()[0]); + } + } + + /** + * @test + */ + public function shouldSetUpServicesForHttpExtension(): void + { + $configs = [ + [ + 'extension' => [ + 'http' => true, + ], + ], + ]; + + $this->extension->load($configs, $this->containerBuilder); + + $this->assertDefaultSettings($this->containerBuilder); + $this->assertNoCacheSettings($this->containerBuilder); + $this->assertNoProfilerSettings($this->containerBuilder); + $this->assertNoDebugSettings($this->containerBuilder); + $this->assertHttpExtensionSettings($this->containerBuilder); + $this->assertNoSolrExtensionSettings($this->containerBuilder); + } + + private function assertHttpExtensionSettings(ContainerBuilder $containerBuilder): void + { + $this->assertHasServiceWithAlias('lmc_cqrs.query_handler.http', HttpQueryHandler::class, $containerBuilder); + $this->assertHasServiceWithAlias( + 'lmc_cqrs.send_command_handler.http', + HttpSendCommandHandler::class, + $containerBuilder + ); + $this->assertHasServiceWithAliasTagAndPriority( + 'lmc_cqrs.response_decoder.http', + HttpMessageResponseDecoder::class, + 'lmc_cqrs.response_decoder', + 90, + $containerBuilder + ); + $this->assertHasServiceWithAliasTagAndPriority( + 'lmc_cqrs.response_decoder.stream', + StreamResponseDecoder::class, + 'lmc_cqrs.response_decoder', + 70, + $containerBuilder + ); + $this->assertHasServiceWithAlias( + 'lmc_cqrs.profiler_formatter.http', + HttpProfilerFormatter::class, + $containerBuilder + ); + } + + /** + * @test + */ + public function shouldSetUpServicesForSolrExtension(): void + { + $configs = [ + [ + 'extension' => [ + 'solr' => true, + ], + ], + ]; + + $this->extension->load($configs, $this->containerBuilder); + + $this->containerBuilder->register('solarium.client')->setSynthetic(true); + + $this->assertDefaultSettings($this->containerBuilder); + $this->assertNoCacheSettings($this->containerBuilder); + $this->assertNoProfilerSettings($this->containerBuilder); + $this->assertNoDebugSettings($this->containerBuilder); + $this->assertNoHttpExtensionSettings($this->containerBuilder); + $this->assertSolrExtensionSettings($this->containerBuilder); + } + + private function assertSolrExtensionSettings(ContainerBuilder $containerBuilder): void + { + $this->assertHasServiceWithAlias('lmc_cqrs.query_handler.solr', SolrQueryHandler::class, $containerBuilder); + $this->assertReference( + 'solarium.client', + $containerBuilder->findDefinition(SolrQueryHandler::class)->getArgument('$client') + ); + + $this->assertHasServiceWithAlias('lmc_cqrs.query_builder', QueryBuilder::class, $containerBuilder); + + $this->assertTrue($containerBuilder->has(ApplicatorFactory::class)); + $this->assertTaggedIterator( + 'lmc_cqrs.solr.query_builder_applicator', + 'getDefaultPriority', + $containerBuilder->findDefinition(ApplicatorFactory::class)->getArgument('$availableApplicators') + ); + + $expectedApplicators = [ + EntityApplicator::class, + FacetsApplicator::class, + FilterApplicator::class, + FiltersApplicator::class, + FulltextApplicator::class, + FulltextBigramApplicator::class, + FulltextBoostApplicator::class, + GroupingApplicator::class, + GroupingFacetApplicator::class, + ParameterizedApplicator::class, + SortApplicator::class, + StatsApplicator::class, + ]; + + foreach ($expectedApplicators as $expectedApplicator) { + $this->assertTrue($containerBuilder->has($expectedApplicator)); + } + + $this->assertAutoconfiguredTags( + ['lmc_cqrs.solr.query_builder_applicator' => ApplicatorInterface::class], + $containerBuilder + ); + } + + private function assertQueryFetcher(ContainerBuilder $containerBuilder): void + { + $this->assertTrue($containerBuilder->hasDefinition('lmc_cqrs.query_fetcher')); + $this->assertTrue($containerBuilder->has(QueryFetcherInterface::class)); + $this->assertTrue($containerBuilder->has(QueryFetcher::class)); + + $queryFetcherDefinition = $containerBuilder->findDefinition(QueryFetcherInterface::class); + + $this->assertSame('%lmc_cqrs.cache.enabled%', $queryFetcherDefinition->getArgument('$isCacheEnabled')); + + $this->assertReference('lmc_cqrs.cache_provider', $queryFetcherDefinition->getArgument('$cache')); + $this->assertReference('lmc_cqrs.profiler_bag', $queryFetcherDefinition->getArgument('$profilerBag')); + } + + private function assertCommandSender(ContainerBuilder $containerBuilder): void + { + $this->assertTrue($containerBuilder->hasDefinition('lmc_cqrs.command_sender')); + $this->assertTrue($containerBuilder->has(CommandSenderInterface::class)); + $this->assertTrue($containerBuilder->has(CommandSender::class)); + + $commandSenderDefinition = $containerBuilder->findDefinition(CommandSenderInterface::class); + + $this->assertReference('lmc_cqrs.profiler_bag', $commandSenderDefinition->getArgument('$profilerBag')); + } +} diff --git a/tests/Profiler/CqrsDataCollectorTest.php b/tests/Profiler/CqrsDataCollectorTest.php new file mode 100644 index 0000000..98be757 --- /dev/null +++ b/tests/Profiler/CqrsDataCollectorTest.php @@ -0,0 +1,294 @@ + */ + private QueryFetcherInterface $queryFetcher; + /** @phpstan-var CommandSenderInterface */ + private CommandSenderInterface $commandSender; + + protected function setUp(): void + { + $this->queryFetcher = new QueryFetcher(false, null, null); + $this->commandSender = new CommandSender(null); + } + + private function setUpCollectorWithData( + iterable $queries, + iterable $commands, + iterable $others, + iterable $formatters + ): CqrsDataCollector { + $this->queryFetcher->addHandler(new CallbackQueryHandler(), 50); + $this->queryFetcher->addDecoder(new JsonResponseDecoder(), 50); + + $this->commandSender->addHandler(new CallbackSendCommandHandler(), 50); + $this->commandSender->addDecoder(new JsonResponseDecoder(), 50); + + $profilerBag = new ProfilerBag(); + + foreach ($queries as $query) { + $profilerBag->add(Uuid::uuid4(), $query); + } + foreach ($commands as $command) { + $profilerBag->add(Uuid::uuid4(), $command); + } + foreach ($others as $other) { + $profilerBag->add(Uuid::uuid4(), $other); + } + + return new CqrsDataCollector( + $profilerBag, + $this->queryFetcher, + $this->commandSender, + (function () use ($formatters): \Generator { + yield from $formatters; + })(), + '@cache.provider' + ); + } + + /** + * @test + */ + public function shouldCollectsQueriesAndCommands(): CqrsDataCollector + { + $queries = [ + new ProfilerItem('q1', null, ProfilerItem::TYPE_QUERY, 'test'), + new ProfilerItem('q2', null, ProfilerItem::TYPE_QUERY, 'test'), + ]; + + $commands = [ + new ProfilerItem('c1', null, ProfilerItem::TYPE_COMMAND, 'test'), + new ProfilerItem('c2', null, ProfilerItem::TYPE_COMMAND, 'test'), + ]; + + $others = [ + new ProfilerItem('o1', null, 'foo', 'test'), + new ProfilerItem('o2', null, 'bar', 'test'), + ]; + + $collector = $this->setUpCollectorWithData($queries, $commands, $others, []); + + $this->assertSame('cqrs', $collector->getName()); + $this->assertEmpty($collector->getItems()); + $this->assertEmpty($collector->getQueries()); + $this->assertEmpty($collector->getCommands()); + $this->assertEmpty($collector->getOthers()); + $this->assertEmpty($collector->getFormatters()); + $this->assertEmpty($collector->getCommandSender()); + $this->assertEmpty($collector->getQueryFetcher()); + $this->assertEmpty($collector->getRegisteredFormatters()); + + $collector->collect(new Request(), new Response()); + + $this->assertSame(array_values([...$queries, ...$commands, ...$others]), $collector->getItems()); + $this->assertSame($queries, $collector->getQueries()); + $this->assertSame($commands, $collector->getCommands()); + $this->assertSame($others, $collector->getOthers()); + $this->assertSame([], $collector->getFormatters()); + + $this->assertEquals( + [ + 'class' => get_class($this->commandSender), + 'handlers' => [ + [ + 'handler' => CallbackSendCommandHandler::class, + 'priority' => 50, + ], + ], + 'decoders' => [ + [ + 'decoder' => JsonResponseDecoder::class, + 'priority' => 50, + ], + ], + ], + $collector->getCommandSender() + ); + + $this->assertSame( + [ + 'class' => get_class($this->queryFetcher), + 'isCacheEnabled' => false, + 'cacheProvider' => '@cache.provider', + 'handlers' => [ + [ + 'handler' => CallbackQueryHandler::class, + 'priority' => 50, + ], + ], + 'decoders' => [ + [ + 'decoder' => JsonResponseDecoder::class, + 'priority' => 50, + ], + ], + ], + $collector->getQueryFetcher() + ); + + $this->assertSame([], $collector->getRegisteredFormatters()); + + return $collector; + } + + /** + * @depends shouldCollectsQueriesAndCommands + * + * @test + */ + public function shouldResetCollectedQueries(CqrsDataCollector $collector): void + { + $collector->reset(); + + $this->assertEmpty($collector->getItems()); + $this->assertEmpty($collector->getQueries()); + $this->assertEmpty($collector->getCommands()); + $this->assertEmpty($collector->getOthers()); + $this->assertEmpty($collector->getFormatters()); + $this->assertEmpty($collector->getCommandSender()); + $this->assertEmpty($collector->getQueryFetcher()); + $this->assertEmpty($collector->getRegisteredFormatters()); + } + + /** + * @test + */ + public function countsCachedAndUncachedQueries(): void + { + $queries = [ + new ProfilerItem('q1', null, ProfilerItem::TYPE_QUERY, 'test'), + new ProfilerItem('q2', null, ProfilerItem::TYPE_QUERY, 'test'), + new ProfilerItem( + 'q3-cached', + null, + ProfilerItem::TYPE_QUERY, + 'test', + 'response', + null, + new CacheKey('q3-key'), + false, + true, + 100 + ), + new ProfilerItem( + 'q3-from-cache', + null, + ProfilerItem::TYPE_QUERY, + 'test', + 'response', + null, + new CacheKey('q3-key'), + true, + false + ), + ]; + + $commands = [ + new ProfilerItem('c1', null, ProfilerItem::TYPE_COMMAND, 'test'), + new ProfilerItem('c2', null, ProfilerItem::TYPE_COMMAND, 'test'), + ]; + + $others = [ + new ProfilerItem('o1', null, 'foo', 'test'), + new ProfilerItem('o2', null, 'bar', 'test'), + ]; + + $collector = $this->setUpCollectorWithData($queries, $commands, $others, []); + + $collector->collect(new Request(), new Response()); + + $this->assertCount(4, $collector->getQueries()); + $this->assertSame(1, $collector->countCachedQueries()); + $this->assertSame(1, $collector->countUncachedQueries()); + } + + /** + * @test + */ + public function shouldFormatCollectedItems(): void + { + $error = new \Exception('error message'); + + $queries = [ + new ProfilerItem( + 'query', + [ + 'body' => '{"body": "value"}', + ], + ProfilerItem::TYPE_QUERY, + 'test', + '{"data": {"response": "value"}}', + $error + ), + ]; + + $expected = [ + new ProfilerItem( + 'query', + [ + 'body' => new FormattedValue( + '{"body": "value"}', + ['body' => 'value'] + ), + ], + ProfilerItem::TYPE_QUERY, + 'test', + new FormattedValue( + '{"data": {"response": "value"}}', + ['data' => ['response' => 'value']] + ), + new FormattedValue( + 'error message', + FlattenException::createFromThrowable($error), + true + ) + ), + ]; + + $collector = $this->setUpCollectorWithData($queries, [], [], [ + new JsonProfilerFormatter(), + new ErrorProfilerFormatter(), + ]); + + $collector->collect(new Request(), new Response()); + + $this->assertEquals($expected, $collector->getQueries()); + $this->assertEquals( + [ + JsonProfilerFormatter::class, + ErrorProfilerFormatter::class, + ], + $collector->getFormatters() + ); + $this->assertEquals( + [ + new JsonProfilerFormatter(), + new ErrorProfilerFormatter(), + ], + $collector->getRegisteredFormatters() + ); + } +} diff --git a/tests/Service/ErrorProfilerFormatterTest.php b/tests/Service/ErrorProfilerFormatterTest.php new file mode 100644 index 0000000..e7c4758 --- /dev/null +++ b/tests/Service/ErrorProfilerFormatterTest.php @@ -0,0 +1,62 @@ +formatter = new ErrorProfilerFormatter(); + } + + /** + * @dataProvider provideError + * + * @test + */ + public function shouldFormatErrors(ProfilerItem $item, ProfilerItem $expected): void + { + $formatted = $this->formatter->formatItem($item); + + $this->assertEquals($expected, $formatted); + } + + public function provideError(): array + { + return [ + 'without any error' => [ + new ProfilerItem('id', null, 'test'), + new ProfilerItem('id', null, 'test'), + ], + 'with error' => [ + new ProfilerItem( + 'id', + null, + 'test', + '', + null, + $error = new \Exception('error message') + ), + new ProfilerItem( + 'id', + null, + 'test', + '', + null, + new FormattedValue( + 'error message', + FlattenException::createFromThrowable($error), + true + ) + ), + ], + ]; + } +} From e31cf0f00f15ae5c05409fe19b2ffb758792c34b Mon Sep 17 00:00:00 2001 From: Petr Chromec Date: Mon, 10 May 2021 16:47:11 +0200 Subject: [PATCH 2/2] fixup! Initial implementation --- phpunit.xml.dist | 4 ++++ tests/LmcCqrsBundleTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 tests/LmcCqrsBundleTest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a0aed18..4365213 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,10 @@ src + + src/Command + src/Controller + diff --git a/tests/LmcCqrsBundleTest.php b/tests/LmcCqrsBundleTest.php new file mode 100644 index 0000000..5fc5112 --- /dev/null +++ b/tests/LmcCqrsBundleTest.php @@ -0,0 +1,26 @@ +build($containerBuilder); + + $containsHandlerPass = false; + foreach ($containerBuilder->getCompilerPassConfig()->getPasses() as $pass) { + if ($pass instanceof HandlerPass) { + $containsHandlerPass = true; + } + } + + $this->assertTrue($containsHandlerPass); + } +}