├── .github └── workflows │ └── integration.yaml ├── .php-cs-fixer.php ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── composer.json ├── docker-compose-tests.yml ├── docs ├── bookdown.json ├── interop_factories.md ├── introduction.md └── variants.md ├── phpunit.mariadb.xml ├── phpunit.mysql.xml ├── phpunit.postgres.xml ├── scripts ├── mariadb │ ├── 01_event_streams_table.sql │ └── 02_projections_table.sql ├── mysql │ ├── 01_event_streams_table.sql │ └── 02_projections_table.sql └── postgres │ ├── 01_event_streams_table.sql │ └── 02_projections_table.sql └── src ├── Container ├── AbstractEventStoreFactory.php ├── AbstractProjectionManagerFactory.php ├── MariaDbEventStoreFactory.php ├── MariaDbProjectionManagerFactory.php ├── MySqlEventStoreFactory.php ├── MySqlProjectionManagerFactory.php ├── PdoConnectionFactory.php ├── PostgresEventStoreFactory.php └── PostgresProjectionManagerFactory.php ├── DefaultMessageConverter.php ├── Exception ├── ConcurrencyExceptionFactory.php ├── ExtensionNotLoaded.php ├── InvalidArgumentException.php ├── JsonException.php ├── PdoEventStoreException.php ├── ProjectionNotCreatedException.php └── RuntimeException.php ├── HasQueryHint.php ├── MariaDBIndexedPersistenceStrategy.php ├── MariaDbEventStore.php ├── MySqlEventStore.php ├── PdoEventStore.php ├── PdoStreamIterator.php ├── PersistenceStrategy.php ├── PersistenceStrategy ├── MariaDbAggregateStreamStrategy.php ├── MariaDbPersistenceStrategy.php ├── MariaDbSimpleStreamStrategy.php ├── MariaDbSingleStreamStrategy.php ├── MySqlAggregateStreamStrategy.php ├── MySqlPersistenceStrategy.php ├── MySqlSimpleStreamStrategy.php ├── MySqlSingleStreamStrategy.php ├── PostgresAggregateStreamStrategy.php ├── PostgresPersistenceStrategy.php ├── PostgresSimpleStreamStrategy.php └── PostgresSingleStreamStrategy.php ├── PostgresEventStore.php ├── Projection ├── GapDetection.php ├── MariaDbProjectionManager.php ├── MySqlProjectionManager.php ├── PdoEventStoreProjector.php ├── PdoEventStoreQuery.php ├── PdoEventStoreReadModelProjector.php └── PostgresProjectionManager.php ├── Util ├── Json.php └── PostgresHelper.php ├── WriteLockStrategy.php └── WriteLockStrategy ├── MariaDbMetadataLockStrategy.php ├── MysqlMetadataLockStrategy.php ├── NoLockStrategy.php └── PostgresAdvisoryLockStrategy.php /.github/workflows/integration.yaml: -------------------------------------------------------------------------------- 1 | name: integration 2 | on: [ push, pull_request ] 3 | 4 | jobs: 5 | code-standard: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - name: Setup PHP 10 | uses: shivammathur/setup-php@v2 11 | with: 12 | php-version: 8.3 13 | - name: Install dependencies 14 | run: composer update --prefer-dist 15 | - name: Testing 16 | run: ./vendor/bin/php-cs-fixer fix -v --diff --dry-run 17 | 18 | coverage: 19 | runs-on: ubuntu-latest 20 | services: 21 | postgres: 22 | image: postgres:9.5 23 | ports: 24 | - 5432:5432 25 | env: 26 | POSTGRES_DB: event_store_tests 27 | POSTGRES_USER: postgres 28 | POSTGRES_PASSWORD: password 29 | options: 30 | --health-cmd pg_isready 31 | --health-interval 10s 32 | --health-timeout 5s 33 | --health-retries 5 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Setup PHP 37 | uses: shivammathur/setup-php@v2 38 | with: 39 | php-version: 8.4 40 | - name: Install dependencies 41 | run: composer update --prefer-dist 42 | - name: Testing 43 | run: php -dxdebug.mode=coverage vendor/bin/phpunit -c phpunit.postgres.xml --coverage-clover tests/coverage.xml 44 | - if: ${{ github.event_name == 'pull_request' }} 45 | name: Download artifact 46 | uses: dawidd6/action-download-artifact@v9 47 | continue-on-error: true 48 | with: 49 | workflow: .github/workflows/integration.yml # this file 50 | branch: main 51 | name: coverage-report 52 | path: tests/base 53 | - if: ${{ github.event_name != 'pull_request' }} 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: coverage-report 57 | path: tests/coverage.xml 58 | - if: ${{ github.event_name == 'pull_request' }} 59 | name: Coverage Report as Comment (Clover) 60 | uses: lucassabreu/comment-coverage-clover@main 61 | with: 62 | file: tests/coverage.xml 63 | base-file: tests/base/coverage.xml 64 | 65 | bc-check: 66 | runs-on: ubuntu-latest 67 | steps: 68 | - uses: actions/checkout@v4 69 | with: 70 | fetch-depth: 0 71 | - name: "Check for BC breaks" 72 | run: | 73 | composer require --dev roave/backward-compatibility-check:^8.0 74 | vendor/bin/roave-backward-compatibility-check --install-development-dependencies --format=github-actions 75 | 76 | tests: 77 | runs-on: ubuntu-latest 78 | services: 79 | postgres: 80 | image: postgres:9.5 81 | ports: 82 | - 5432:5432 83 | env: 84 | POSTGRES_DB: event_store_tests 85 | POSTGRES_USER: postgres 86 | POSTGRES_PASSWORD: password 87 | options: 88 | --health-cmd pg_isready 89 | --health-interval 10s 90 | --health-timeout 5s 91 | --health-retries 5 92 | mariadb: 93 | image: mariadb:10.2 94 | ports: 95 | - 3307:3306 96 | env: 97 | MARIADB_DATABASE: event_store_tests 98 | MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1 99 | options: 100 | --health-cmd "mysqladmin ping" 101 | --health-interval 10s 102 | --health-timeout 5s 103 | --health-retries 5 104 | mysql: 105 | image: mysql:5.7 106 | ports: 107 | - 3306:3306 108 | env: 109 | MYSQL_DATABASE: event_store_tests 110 | MYSQL_ALLOW_EMPTY_PASSWORD: 1 111 | options: 112 | --health-cmd "mysqladmin ping" 113 | --health-interval 10s 114 | --health-timeout 5s 115 | --health-retries 5 116 | strategy: 117 | fail-fast: false 118 | matrix: 119 | dependencies: [ '', '--prefer-lowest --prefer-stable' ] 120 | php: [ 8.1, 8.2, 8.3, 8.4 ] 121 | database: 122 | - mariadb 123 | - mysql 124 | - postgres 125 | steps: 126 | - uses: actions/checkout@v4 127 | - name: Setup PHP 128 | uses: shivammathur/setup-php@v2 129 | with: 130 | php-version: ${{ matrix.php }} 131 | - name: Install dependencies 132 | run: composer update --prefer-dist ${{ matrix.dependencies }} 133 | - name: Testing 134 | run: php vendor/bin/phpunit --configuration phpunit.${{ matrix.database }}.xml 135 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | getFinder()->in(__DIR__); 5 | 6 | $cacheDir = getenv('TRAVIS') ? getenv('HOME') . '/.php-cs-fixer' : __DIR__; 7 | 8 | $config->setCacheFile($cacheDir . '/.php_cs.cache'); 9 | 10 | return $config; 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM composer AS composer 2 | 3 | FROM php:8.3-alpine 4 | 5 | RUN set -eux; \ 6 | apk add --no-cache --virtual .build-deps \ 7 | $PHPIZE_DEPS \ 8 | linux-headers \ 9 | libzip-dev \ 10 | postgresql-dev \ 11 | zlib-dev \ 12 | ; \ 13 | \ 14 | pecl install \ 15 | xdebug-3.4.1 \ 16 | \ 17 | docker-php-ext-configure zip; \ 18 | docker-php-ext-configure pcntl --enable-pcntl; \ 19 | docker-php-ext-install -j$(nproc) \ 20 | pcntl \ 21 | pdo_mysql \ 22 | pdo_pgsql \ 23 | zip \ 24 | ; \ 25 | docker-php-ext-enable \ 26 | pcntl \ 27 | xdebug \ 28 | zip \ 29 | ; \ 30 | \ 31 | \ 32 | runDeps="$( \ 33 | scanelf --needed --nobanner --format '%n#p' --recursive /usr/local/lib/php/extensions \ 34 | | tr ',' '\n' \ 35 | | sort -u \ 36 | | awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \ 37 | )"; \ 38 | apk add --no-cache --virtual .phpexts-rundeps $runDeps; \ 39 | \ 40 | apk del .build-deps 41 | 42 | COPY --from=composer /usr/bin/composer /usr/bin/composer 43 | 44 | WORKDIR /app 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2022, Alexander Miertsch 2 | Copyright (c) 2016-2022, Sascha-Oliver Prolic 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of prooph nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pdo-event-store 2 | 3 | [![Build Status](https://travis-ci.com/prooph/pdo-event-store.svg?branch=master)](https://travis-ci.com/prooph/pdo-event-store) 4 | [![Coverage Status](https://coveralls.io/repos/prooph/pdo-event-store/badge.svg?branch=master&service=github)](https://coveralls.io/github/prooph/pdo-event-store?branch=master) 5 | [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/prooph/improoph) 6 | 7 | PDO EventStore implementation for [Prooph EventStore](https://github.com/prooph/event-store) 8 | 9 | Requirements 10 | ------------ 11 | 12 | - PHP >= 7.3 13 | - PDO_MySQL Extension or PDO_PGSQL Extension 14 | 15 | For MariaDB you need server version >= 10.2.11. 16 | **Performance Impact**: see [MariaDB Indexes and Efficiency](docs/variants.md#MariaDB Indexes and Efficiency) 17 | 18 | For MySQL you need server version >= 5.7.9. 19 | 20 | For Postgres you need server version >= 9.4. 21 | 22 | Attention: Since v1.6.0 MariaDB Server has to be at least 10.2.11 due to a bugfix in MariaDB, see https://jira.mariadb.org/browse/MDEV-14402. 23 | 24 | Setup 25 | ----- 26 | 27 | For MariaDB run the script in `scripts/mariadb/01_event_streams_table.sql` on your server. 28 | 29 | For MySQL run the script in `scripts/mysql/01_event_streams_table.sql` on your server. 30 | 31 | For Postgres run the script in `scripts/postgres/01_event_streams_table.sql` on your server. 32 | 33 | This will setup the required event streams table. 34 | 35 | If you want to use the projections, run additionally the scripts `scripts/mariadb/02_projections_table.sql` 36 | (for MariaDB), `scripts/mysql/02_projections_table.sql` (for MySQL) or 37 | `scripts/postgres/02_projections_table.sql` (for Postgres) on your server. 38 | 39 | Upgrade from 1.6 to 1.7 40 | ----------------------- 41 | 42 | Starting from v1.7 the pdo-event-store uses optimized table schemas. 43 | The upgrade can be done in background with a script optimizing that process. 44 | A downtime for the database should not be needed. 45 | In order to upgrade your existing database, you have to execute: 46 | 47 | - MariaDB 48 | 49 | ``` 50 | ALTER TABLE `event_streams` MODIFY `metadata` LONGTEXT NOT NULL; 51 | ALTER TABLE `projections` MODIFY `position` LONGTEXT; 52 | ALTER TABLE `projections` MODIFY `state` LONGTEXT; 53 | ``` 54 | 55 | Then for all event-streams (`SELECT stream_name FROM event_streams`) 56 | 57 | ``` 58 | ALTER TABLE MODIFY `payload` LONGTEXT NOT NULL; 59 | ALTER TABLE MODIFY `metadata` LONGTEXT NOT NULL, 60 | ``` 61 | 62 | - MySQL 63 | 64 | nothing to upgrade 65 | 66 | - Postgres 67 | 68 | For all event-streams (`SELECT stream_name FROM event_streams`) 69 | 70 | ``` 71 | ALTER TABLE MODIFY event_id UUID NOT NULL; 72 | ``` 73 | 74 | Additional note: 75 | 76 | When using Postgres, the event_id has to be a valid uuid, so be careful when using a custom MetadataMatcher, as the 77 | event-store could throw an exception when passing a non-valid uuid (f.e. "foo") as uuid. 78 | 79 | The migration is strongly recommended, but not required. It's fully backward-compatible. The change on Postgres is 80 | only a microoptimization, the change on MariaDB prevents errors, when the stored json gets too big. 81 | 82 | Introduction 83 | ------------ 84 | 85 | [![Prooph Event Store v7](https://img.youtube.com/vi/QhpDIqYQzg0/0.jpg)](https://www.youtube.com/watch?v=QhpDIqYQzg0) 86 | 87 | Tests 88 | ----- 89 | If you want to run the unit tests locally you need a runnging MySql server listening on port `3306` 90 | and a running Postgres server listening on port `5432`. Both should contain an empty database `event_store_tests`. 91 | 92 | ## Run Tests With Composer 93 | 94 | ### MariaDb 95 | 96 | `$ vendor/bin/phpunit -c phpunit.xml.mariadb` 97 | 98 | ### MySql 99 | 100 | `$ vendor/bin/phpunit -c phpunit.xml.mysql` 101 | 102 | ### Postgres 103 | 104 | `$ vendor/bin/phpunit -c phpunit.xml.postgres` 105 | 106 | ## Run Tests With Docker Compose 107 | 108 | ### MariaDb 109 | 110 | ```bash 111 | docker-compose -f docker-compose-tests.yml run tests composer run-script test-mariadb --timeout 0; \ 112 | docker-compose -f docker-compose-tests.yml stop 113 | ``` 114 | 115 | ### MySql 116 | 117 | ```bash 118 | docker-compose -f docker-compose-tests.yml run tests composer run-script test-mysql --timeout 0; \ 119 | docker-compose -f docker-compose-tests.yml stop 120 | ``` 121 | 122 | ### Postgres 123 | 124 | ```bash 125 | docker-compose -f docker-compose-tests.yml run tests composer run-script test-postgres --timeout 0; \ 126 | docker-compose -f docker-compose-tests.yml stop 127 | ``` 128 | 129 | ## Support 130 | 131 | - Ask questions on Stack Overflow tagged with [#prooph](https://stackoverflow.com/questions/tagged/prooph). 132 | - File issues at [https://github.com/prooph/event-store/issues](https://github.com/prooph/event-store/issues). 133 | - Say hello in the [prooph gitter](https://gitter.im/prooph/improoph) chat. 134 | 135 | ## Contribute 136 | 137 | Please feel free to fork and extend existing or add new plugins and send a pull request with your changes! 138 | To establish a consistent code quality, please provide unit tests for all your changes and may adapt the documentation. 139 | 140 | ## License 141 | 142 | Released under the [New BSD License](LICENSE). 143 | 144 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prooph/pdo-event-store", 3 | "description": "Prooph PDO EventStore", 4 | "homepage": "http://getprooph.org/", 5 | "license": "BSD-3-Clause", 6 | "authors": [ 7 | { 8 | "name": "Alexander Miertsch", 9 | "email": "kontakt@codeliner.ws" 10 | }, 11 | { 12 | "name": "Sascha-Oliver Prolic", 13 | "email": "saschaprolic@googlemail.com" 14 | } 15 | ], 16 | "minimum-stability": "dev", 17 | "prefer-stable": true, 18 | "require": { 19 | "php": "^8.1", 20 | "ext-pdo": "*", 21 | "prooph/event-store": "^v7.12.3" 22 | }, 23 | "require-dev": { 24 | "ext-pcntl": "*", 25 | "sandrokeil/interop-config": "^2.0.1", 26 | "phpunit/php-invoker": "^3.1 || ^4.0 || ^5.0 || ^6.0", 27 | "phpunit/phpunit": "^10.0", 28 | "phpspec/prophecy": "^1.9", 29 | "phpspec/prophecy-phpunit": "^2.0", 30 | "prooph/bookdown-template": "^0.2.3", 31 | "psr/container": "^1.0", 32 | "php-coveralls/php-coveralls": "^2.7", 33 | "prooph/php-cs-fixer-config": "^0.7.0" 34 | }, 35 | "suggest": { 36 | "psr/container": "^1.0 for usage of provided factories", 37 | "sandrokeil/interop-config": "^2.0.1 for usage of provided factories", 38 | "ext-pdo_mysql": "For usage with MySQL", 39 | "ext-pdo_pgsql": "For usage with PostgreSQL" 40 | }, 41 | "conflict": { 42 | "sandrokeil/interop-config": "<2.0.1" 43 | }, 44 | "autoload": { 45 | "psr-4": { 46 | "Prooph\\EventStore\\Pdo\\": "src/" 47 | } 48 | }, 49 | "autoload-dev": { 50 | "psr-4": { 51 | "ProophTest\\EventStore\\Pdo\\": "tests/" 52 | } 53 | }, 54 | "extra": { 55 | "branch-alias": { 56 | "dev-master": "1.7-dev" 57 | } 58 | }, 59 | "config": { 60 | "preferred-install": { 61 | "prooph/*": "source" 62 | } 63 | }, 64 | "scripts": { 65 | "cs": "php-cs-fixer fix -v --diff --dry-run", 66 | "cs-fix": "php-cs-fixer fix -v --diff", 67 | "test-postgres": "DB_HOST=postgres vendor/bin/phpunit -c phpunit.postgres.xml", 68 | "test-mariadb": "DB_HOST=mariadb DB_PORT=3306 vendor/bin/phpunit -c phpunit.mariadb.xml", 69 | "test-mysql": "DB_HOST=mysql vendor/bin/phpunit -c phpunit.mysql.xml", 70 | "test-all": [ 71 | "@test-postgres", 72 | "@test-mariadb", 73 | "@test-mysql" 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /docker-compose-tests.yml: -------------------------------------------------------------------------------- 1 | services: 2 | tests: 3 | build: . 4 | volumes: 5 | - .:/app 6 | environment: 7 | - COMPOSER_ALLOW_SUPERUSER=1 8 | depends_on: 9 | postgres: 10 | condition: service_healthy 11 | restart: true 12 | mariadb: 13 | condition: service_healthy 14 | restart: true 15 | mysql: 16 | condition: service_healthy 17 | restart: true 18 | 19 | postgres: 20 | image: postgres:alpine 21 | environment: 22 | - POSTGRES_DB=event_store_tests 23 | - POSTGRES_USER=postgres 24 | - POSTGRES_PASSWORD=password 25 | # - POSTGRES_HOST_AUTH_METHOD=trust 26 | healthcheck: 27 | test: [ "CMD-SHELL", "sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'" ] 28 | start_period: 30s 29 | start_interval: 1s 30 | mariadb: 31 | image: mariadb:10.3 32 | environment: 33 | - MYSQL_DATABASE=event_store_tests 34 | - MYSQL_ALLOW_EMPTY_PASSWORD=1 35 | healthcheck: 36 | test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] 37 | start_period: 30s 38 | start_interval: 1s 39 | mysql: 40 | image: mysql:5.7 41 | # until we go to mysql 8.0 we need to use this on arm64 42 | platform: linux/amd64 43 | environment: 44 | - MYSQL_DATABASE=event_store_tests 45 | - MYSQL_ALLOW_EMPTY_PASSWORD=1 46 | healthcheck: 47 | test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] 48 | start_period: 30s 49 | start_interval: 1s 50 | -------------------------------------------------------------------------------- /docs/bookdown.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "PDO Event Store", 3 | "content": [ 4 | {"intro": "introduction.md"}, 5 | {"variants": "variants.md"}, 6 | {"interop_factories": "interop_factories.md"} 7 | ], 8 | "tocDepth": 1, 9 | "numbering": false, 10 | "target": "./html", 11 | "template": "../vendor/prooph/bookdown-template/templates/main.php" 12 | } 13 | -------------------------------------------------------------------------------- /docs/interop_factories.md: -------------------------------------------------------------------------------- 1 | # Interop Factories 2 | 3 | Instead of providing a module, a bundle, a bridge or similar framework integration prooph/event-store ships with `interop factories`. 4 | 5 | ## Factory-Driven Creation 6 | 7 | The concept behind these factories (see `src/Container` folder) is simple but powerful. It allows us to provide you with bootstrapping logic for the event store and related components 8 | without the need to rely on a specific framework. However, the factories have three requirements. 9 | 10 | ### Requirements 11 | 12 | 1. Your Inversion of Control container must implement the [PSR Container interface](https://github.com/php-fig/container). 13 | 2. [interop-config](https://github.com/sandrokeil/interop-config) must be installed 14 | 3. The application configuration should be registered with the service id `config` in the container. 15 | 16 | *Note: Don't worry, if your environment doesn't provide these requirements, you can 17 | always bootstrap the components by hand. Just look at the factories for inspiration in this case.* 18 | 19 | ### MariaDbEventStoreFactory 20 | 21 | If the requirements are met you just need to add a new section in your application config ... 22 | 23 | ```php 24 | [ 25 | 'prooph' => [ 26 | 'event_store' => [ 27 | 'default' => [ 28 | 'wrap_action_event_emitter' => true, 29 | 'metadata_enrichers' => [ 30 | // The factory will get the metadata enrichers and inject them in the MetadataEnricherPlugin. 31 | // Note: you can obtain the same result by instanciating the plugin yourself 32 | // and pass it to the 'plugin' section bellow. 33 | 'metadata_enricher_1', 34 | 'metadata_enricher_2', 35 | // ... 36 | ], 37 | 'plugins' => [ 38 | //And again the factory will use each service id to get the plugin from the container 39 | //Plugin::attachToEventStore($eventStore) is then invoked by the factory so your plugins 40 | // get attached automatically 41 | //Awesome, isn't it? 42 | 'plugin_1_service_id', 43 | 'plugin_2_service_id', 44 | //... 45 | ], 46 | 'connection' => 'my_pdo_connection', // service id for the used pdo connection 47 | 'persistence_strategy' => MariaDbSingleStreamStrategy::class, // service id for the used persistance strategy 48 | 'load_batch_size' => 1000, // how many events a query should return in one batch, defaults to 1000 49 | 'event_streams_table' => 'event_streams', // event stream table to use, defaults to `event_streams` 50 | 'message_factory' => FQCNMessageFactory::class, // message factory to use, defauls to `FQCNMessageFactory::class` 51 | ], 52 | ], 53 | ], 54 | 'dependencies' => [ 55 | 'factories' => [ 56 | 'MariaDbEventStore' => [ 57 | \Prooph\EventStore\Container\MariaDbEventStoreFactory::class, 58 | 'default', 59 | ], 60 | ], 61 | ], 62 | //... other application config here 63 | ] 64 | ``` 65 | 66 | $eventStore = $container->get('MariaDbEventStore'); 67 | 68 | ### MariaDbProjectionManagerFactory 69 | 70 | ```php 71 | [ 72 | 'prooph' => [ 73 | 'projection_manager' => [ 74 | 'default' => [ 75 | 'event_store' => 'MariaDbEventStore', 76 | 'connection' => 'my_pdo_connection', // service id for the used pdo connection 77 | 'event_streams_table' => 'event_streams', // event stream table to use, defaults to `event_streams` 78 | 'projections_table' => 'projections', // projection table to use, defaults to `projections` 79 | ], 80 | ], 81 | ], 82 | 'dependencies' => [ 83 | 'factories' => [ 84 | 'MariaDbProjectionManager' => [ 85 | \Prooph\EventStore\Container\MariaDbProjectionManagerFactory::class, 86 | 'default', 87 | ], 88 | ], 89 | ], 90 | //... other application config here 91 | ] 92 | ``` 93 | 94 | $projectionManager = $container->get('MariaDbProjectionManager'); 95 | 96 | ### MySqlEventStoreFactory 97 | 98 | If the requirements are met you just need to add a new section in your application config ... 99 | 100 | ```php 101 | [ 102 | 'prooph' => [ 103 | 'event_store' => [ 104 | 'default' => [ 105 | 'wrap_action_event_emitter' => true, 106 | 'metadata_enrichers' => [ 107 | // The factory will get the metadata enrichers and inject them in the MetadataEnricherPlugin. 108 | // Note: you can obtain the same result by instanciating the plugin yourself 109 | // and pass it to the 'plugin' section bellow. 110 | 'metadata_enricher_1', 111 | 'metadata_enricher_2', 112 | // ... 113 | ], 114 | 'plugins' => [ 115 | //And again the factory will use each service id to get the plugin from the container 116 | //Plugin::attachToEventStore($eventStore) is then invoked by the factory so your plugins 117 | // get attached automatically 118 | //Awesome, isn't it? 119 | 'plugin_1_service_id', 120 | 'plugin_2_service_id', 121 | //... 122 | ], 123 | 'connection' => 'my_pdo_connection', // service id for the used pdo connection 124 | 'persistence_strategy' => MySqlSingleStreamStrategy::class, // service id for the used persistance strategy 125 | 'load_batch_size' => 1000, // how many events a query should return in one batch, defaults to 1000 126 | 'event_streams_table' => 'event_streams', // event stream table to use, defaults to `event_streams` 127 | 'message_factory' => FQCNMessageFactory::class, // message factory to use, defauls to `FQCNMessageFactory::class` 128 | ], 129 | ], 130 | ], 131 | 'dependencies' => [ 132 | 'factories' => [ 133 | 'MySqlEventStore' => [ 134 | \Prooph\EventStore\Container\MySqlEventStoreFactory::class, 135 | 'default', 136 | ], 137 | ], 138 | ], 139 | //... other application config here 140 | ] 141 | ``` 142 | 143 | $eventStore = $container->get('MySqlEventStore'); 144 | 145 | ### MySqlProjectionManagerFactory 146 | 147 | ```php 148 | [ 149 | 'prooph' => [ 150 | 'projection_manager' => [ 151 | 'default' => [ 152 | 'event_store' => 'MySqlEventStore', 153 | 'connection' => 'my_pdo_connection', // service id for the used pdo connection 154 | 'event_streams_table' => 'event_streams', // event stream table to use, defaults to `event_streams` 155 | 'projections_table' => 'projections', // projection table to use, defaults to `projections` 156 | ], 157 | ], 158 | ], 159 | 'dependencies' => [ 160 | 'factories' => [ 161 | 'MySqlProjectionManager' => [ 162 | \Prooph\EventStore\Container\MySqlProjectionManagerFactory::class, 163 | 'default', 164 | ], 165 | ], 166 | ], 167 | //... other application config here 168 | ] 169 | ``` 170 | 171 | $projectionManager = $container->get('MySqlProjectionManager'); 172 | 173 | ### PostgresEventStoreFactory 174 | 175 | If the requirements are met you just need to add a new section in your application config ... 176 | 177 | ```php 178 | [ 179 | 'prooph' => [ 180 | 'event_store' => [ 181 | 'default' => [ 182 | 'wrap_action_event_emitter' => true, 183 | 'metadata_enrichers' => [ 184 | // The factory will get the metadata enrichers and inject them in the MetadataEnricherPlugin. 185 | // Note: you can obtain the same result by instanciating the plugin yourself 186 | // and pass it to the 'plugin' section bellow. 187 | 'metadata_enricher_1', 188 | 'metadata_enricher_2', 189 | // ... 190 | ], 191 | 'plugins' => [ 192 | //And again the factory will use each service id to get the plugin from the container 193 | //Plugin::attachToEventStore($eventStore) is then invoked by the factory so your plugins 194 | // get attached automatically 195 | //Awesome, isn't it? 196 | 'plugin_1_service_id', 197 | 'plugin_2_service_id', 198 | //... 199 | ], 200 | 'connection' => 'my_pdo_connection', // service id for the used pdo connection 201 | 'persistence_strategy' => PostgresSingleStreamStrategy::class, // service id for the used persistance strategy 202 | 'load_batch_size' => 1000, // how many events a query should return in one batch, defaults to 1000 203 | 'event_streams_table' => 'event_streams', // event stream table to use, defaults to `event_streams` 204 | 'message_factory' => FQCNMessageFactory::class, // message factory to use, defauls to `FQCNMessageFactory::class` 205 | ], 206 | ], 207 | ], 208 | 'dependencies' => [ 209 | 'factories' => [ 210 | 'PostgresEventStore' => [ 211 | \Prooph\EventStore\Container\PostgresEventStoreFactory::class, 212 | 'default', 213 | ], 214 | ], 215 | ], 216 | //... other application config here 217 | ] 218 | ``` 219 | 220 | $eventStore = $container->get('PostgresEventStore'); 221 | 222 | ### PostgresProjectionManagerFactory 223 | 224 | ```php 225 | [ 226 | 'prooph' => [ 227 | 'projection_manager' => [ 228 | 'default' => [ 229 | 'event_store' => 'PostgresEventStore', 230 | 'connection' => 'my_pdo_connection', // service id for the used pdo connection 231 | 'event_streams_table' => 'event_streams', // event stream table to use, defaults to `event_streams` 232 | 'projections_table' => 'projections', // projection table to use, defaults to `projections` 233 | ], 234 | ], 235 | ], 236 | 'dependencies' => [ 237 | 'factories' => [ 238 | 'PostgresProjectionManager' => [ 239 | \Prooph\EventStore\Container\PostgresProjectionManagerFactory::class, 240 | 'default', 241 | ], 242 | ], 243 | ], 244 | //... other application config here 245 | ] 246 | ``` 247 | 248 | $projectionManager = $container->get('PostgresProjectionManager'); 249 | 250 | ### PDO-based event stores 251 | 252 | The three PDO-based event stores (`MySqlEventStoreFactory`, `MariaDbEventStoreFactory`and `PostgresEventStoreFactory`) 253 | share the same config options: 254 | 255 | * `connection` (**required**): The ID of your PDO service 256 | * `persistence_strategy` (**required**): The ID of your persistence strategy service. You can learn more about 257 | persistence strategies [here](/event-store/implementations/pdo_event_store/variants.html#persistence-strategies). 258 | * `load_batch_size` (*default: 1000*): This is the maximum number of events retrieved when calling the `load` method. 259 | * `event_streams_table` (*default: `event_streams`*): The name of the table where event streams metadata are persisted. Note that this is not the 260 | table where the stream of events is persisted. 261 | * `message_factory` (*default: [FQCNMessageFactory](https://github.com/prooph/common/blob/master/src/Messaging/FQCNMessageFactory.php)*): 262 | The ID of a service implementing [`MessageFactory` interface](https://github.com/prooph/common/blob/master/src/Messaging/MessageFactory.php#L15). 263 | * `wrap_action_event_emitter` (*default: true*): Decorate the event store with an `ActionEventEmitterEventStore`. 264 | This is needed if you want to use plugins (see more details [here](http://docs.getprooph.org/event-store/event_store_plugins.html)). 265 | * `metadata_enrichers` (*default: []*): A list of IDs of services implementing the [`MetadataEnricher` interface](https://github.com/prooph/event-store/blob/master/src/Metadata/MetadataEnricher.php). 266 | `wrap_action_event_emitter` has to be enabled. For more details about metadata enrichers, see [here](event_store_plugins.html#metadata-enrichier). 267 | * `plugins` (*default: []*): A list of IDs of services implementing the [`Plugin` interface](https://github.com/prooph/event-store/blob/master/src/Plugin/Plugin.php). 268 | `wrap_action_event_emitter` has to be enabled. For more details about plugins, see [here](event_store_plugins.html). 269 | * `disable_transaction_handling` (*default: false*): Disable SQL transactions. 270 | 271 | #### PDOConnectionFactory 272 | 273 | In addition to event store factories, a `PDOConnectionFactory` is also provided. It supports following config options, 274 | under `prooph.pdo_connection` dimension: 275 | 276 | * `schema` (**required**): Type of the database (either `mysql` or `pgsql`). 277 | * `host` (*default: 127.0.0.1*) 278 | * `port` (**required**) 279 | * `user` (**required**) 280 | * `password` (**required**) 281 | * `dbname` (*default: event_store*) 282 | * `charset` (*default: utf8*) 283 | 284 | ```php 285 | [ 286 | 'prooph' => [ 287 | 'pdo_connection' => [ 288 | 'default' => [ 289 | 'schema', // one of mysql or pgsql 290 | 'user', // username to use 291 | 'password', // password to use 292 | 'port', // port to use 293 | 'host' => '127.0.0.1', // host name, defaults to `127.0.0.1` 294 | 'dbname' => 'event_store', // database name, defaults to `event_store` 295 | 'charset' => 'utf8', // chartset, defaults to `UTF-8`, 296 | ], 297 | ], 298 | ], 299 | 'dependencies' => [ 300 | 'factories' => [ 301 | 'my_pdo_connection' => [ 302 | \Prooph\EventStore\Container\PdoConnectionFactory::class, 303 | 'default', 304 | ], 305 | ], 306 | ], 307 | //... other application config here 308 | ] 309 | ``` 310 | 311 | $pdoConnection = $container->get('my_pdo_connection'); 312 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | The PDO Event Store is an implementation of [prooph/event-store](https://github.com/prooph/event-store) that supports 4 | MySQL and MariaDB as well as PostgreSQL. 5 | 6 | ## Video Introduction 7 | 8 | [![Prooph Event Store v7](https://img.youtube.com/vi/QhpDIqYQzg0/0.jpg)](https://www.youtube.com/watch?v=QhpDIqYQzg0) 9 | 10 | ## Installation 11 | 12 | ```php 13 | composer require prooph/pdo-event-store 14 | ``` 15 | 16 | ## Requirements 17 | 18 | - PHP >= 7.1 19 | - PDO_MySQL Extension or PDO_PGSQL Extension 20 | 21 | For MariaDB you need server version >= 10.2.11. 22 | 23 | For MySQL you need server version >= 5.7.9. 24 | 25 | For Postgres you need server version >= 9.4. 26 | 27 | Attention: Since v1.6.0 MariaDB Server has to be at least 10.2.11 due to a bugfix in MariaDB, see https://jira.mariadb.org/browse/MDEV-14402. 28 | 29 | ## Setup 30 | 31 | For MariaDB run the script in `scripts/mariadb/01_event_streams_table.sql` on your server. 32 | 33 | For MySQL run the script in `scripts/mysql/01_event_streams_table.sql` on your server. 34 | 35 | For Postgres run the script in `scripts/postgres/01_event_streams_table.sql` on your server. 36 | 37 | This will setup the required event streams table. 38 | 39 | If you want to use the projections, run additionally the scripts `scripts/mariadb/02_projections_table.sql` 40 | (for MariaDB), `scripts/mysql/02_projections_table.sql` (for MySQL) or 41 | `scripts/postgres/02_projections_table.sql` (for Postgres) on your server. 42 | -------------------------------------------------------------------------------- /docs/variants.md: -------------------------------------------------------------------------------- 1 | # Variants 2 | 3 | The PDO Event Store is an implementation of [prooph/event-store](https://github.com/prooph/event-store) that supports 4 | MySQL and MariaDB as well as PostgreSQL. 5 | 6 | For a better understanding, we recommend to read the event-store docs, first. 7 | 8 | ## Differences of MariaDb-/MySql- & PostgresEventStore 9 | 10 | The PostgresEventStore has a better performance (at least with default database configuration) and implements the 11 | `TransactionalEventStore` interface. If you need maximum performance or transaction support, we recommend to use the 12 | PostgresEventStore instead of the MariaDb-/MySqlEventStore. 13 | 14 | ## Event streams / projections table 15 | 16 | All known event streams are stored in an event stream table, so with a simple table lookup, you can find out what streams 17 | are available in your store. 18 | 19 | Same goes for the projections, all known projections are stored in a single table, so you can see what projections are 20 | available, and what their current state / stream position / status is. 21 | 22 | ## Load batch size 23 | 24 | When reading from an event streams with multiple aggregates (especially when using projections), you could end of with 25 | millions of events loaded in memory. Therefor the pdo-event-store will load events only in batches of 10000 by default. 26 | You can change to value to something higher to achieve even more performance with higher memory usage, or decrease it 27 | to reduce memory usage even more, with the drawback of having a not as good performance. 28 | 29 | ## PDO Connection for event-store and projection manager 30 | 31 | It is important to use the same database for event-store and projection manager, you could use distinct pdo connections 32 | if you want to, but they should be both connected to the same database. Otherwise you will run into issues, because the 33 | projection manager needs to query the underlying database table of the event-store for its querying API. 34 | 35 | It's recommended to just use the same pdo connection instance for both. 36 | 37 | ## Persistence Strategies 38 | 39 | This component ships with 9 default persistence strategies: 40 | 41 | - MariaDbAggregateStreamStrategy 42 | - MariaDbSimpleStreamStrategy 43 | - MariaDbSingleStreamStrategy 44 | - MySqlAggregateStreamStrategy 45 | - MySqlSimpleStreamStrategy 46 | - MySqlSingleStreamStrategy 47 | - PostgresAggregateStreamStrategy 48 | - PostgresSimpleStreamStrategy 49 | - PostgresSingleStreamStrategy 50 | 51 | All persistance strategies have the following in common: 52 | 53 | The generated table name for a given stream is: 54 | 55 | `'_' . sha1($streamName->toString()` 56 | 57 | so a sha1 hash of the stream name, prefixed with an underscore is the used table name. 58 | You can query the `event_streams` table to get real stream name to stream name mapping. 59 | 60 | You can implement your own persistence strategy by implementing the `Prooph\EventStore\Pdo\PersistenceStrategy` interface. 61 | 62 | ### AggregateStreamStrategy 63 | 64 | This stream strategy should be used together with event-sourcing, if you use one stream per aggregate. For example, you have 2 instances of two 65 | different aggregates named `user-123`, `user-234`, `todo-345` and `todo-456`, you would have 4 different event streams, 66 | one for each aggregate. 67 | 68 | This stream strategy is the most performant of all (with downsides, see notes), but it will create a lot of database tables, which is something not 69 | everyone likes (especially DB admins). 70 | 71 | All needed database tables will be created automatically for you. 72 | 73 | Note: For event-store projections the aggregate stream strategy is not that performant anymore, consider using [CategoryStreamProjectionRunner](https://github.com/prooph/standard-projections/blob/master/src/CategoryStreamProjectionRunner.php) from the [standard-projections](https://github.com/prooph/standard-projections) repository. 74 | But even than, the projections would be slow, because the projector needs to check all the streams one-by-one for any new events. Because of this speed of finding and projecting any new events depends on the number of streams which means it would rapidly decrease as you add more data to your event store. 75 | 76 | You could however drastically improve the projections, if you would add a category stream projection as event-store-plugin. (This doesn't exist, yet) 77 | 78 | ### SingleStreamStrategy 79 | 80 | This stream strategy should be used together with event-sourcing, if you want to store all events of an aggregate type into a single stream, for example 81 | `user-123` and `user-234` should both be stored into a stream called `user`. 82 | 83 | You can also store all stream of all aggregate types into a single stream, for example your aggregates `user-123`, 84 | `user-234`, `todo-345` and `todo-456` can all be stored into a stream called `event_stream`. 85 | 86 | This stream strategy is slightly less performant then the aggregate stream strategy. 87 | 88 | You need to setup the database table yourself when using this strategy. An example script to do that can be [found here](https://github.com/prooph/proophessor-do/blob/master/scripts/create_event_streams.php). 89 | 90 | ### SimpleStreamStrategy 91 | 92 | This stream strategy is not meant to be used for event-sourcing. It will create simple event streams without any constraints 93 | at all, so having two events of the same aggregate with the same version will not rise any error. 94 | 95 | This is very useful for projections, where you copy events from one stream to another (the resulting stream may need to use 96 | the simple stream strategy) or when you want to use the event-store outside the scope of event-sourcing. 97 | 98 | You need to setup the database table yourself when using this strategy. An example script to do that can be [found here](https://github.com/prooph/proophessor-do/blob/master/scripts/create_event_streams.php). 99 | 100 | ### Using custom stream strategies 101 | 102 | When you query the event streams a lot, it might be a good idea to create your own stream strategy, so you can add 103 | custom indexes to your database tables. When using with the MetadataMatcher, take care that you add the metadata 104 | matches in the right order, so they can match your indexes. 105 | 106 | ### MariaDB Indexes and Efficiency 107 | 108 | Unlike MySQL, MariaDB does not use indexed generated columns on the json document, leading to queries not using the 109 | pre-created indexes and causing a performance drawback. 110 | To fix that, make sure that your `CustomMariaDBPersistencyStrategy` implements the newly introduced 111 | [`MariaDBIndexedPersistenceStrategy`](https://github.com/prooph/pdo-event-store/blob/master/src/MariaDBIndexedPersistenceStrategy.php) 112 | 113 | ### Disable transaction handling 114 | 115 | You can configure the event store to disable transaction handling completely. In order to do this, set the last parameter 116 | in the constructor to true (or configure your interop config factory accordingly, key is `disable_transaction_handling`). 117 | 118 | Enabling this feature will disable all transaction handling and you have to take care yourself to start, commit and rollback 119 | transactions. 120 | 121 | Note: This could lead to problems using the event store, if you did not manage to handle the transaction handling accordingly. 122 | This is your problem and we will not provide any support for problems you encounter while doing so. 123 | 124 | ### Using write locks 125 | 126 | In high-concurrent write scenarios it is possible that events are skipped when reading from the stream simultaneously. 127 | [Issue #189](https://github.com/prooph/pdo-event-store/issues/189) explains the background in more detail on why this 128 | is happening. 129 | 130 | In order to prevent this it is possible to apply a locking strategy when writing to an event stream. 131 | By default the `NoLockStrategy` does not conduct any form of locking. The `MysqlMetadataLockStrategy` uses MySQL 132 | metadata locks to ensure only one client at a time can write at the same stream. All default locking strategies 133 | `MysqlMetadataLockStrategy`, `MariaDbMetadataLockStrategy` and `PostgresAdvisoryLockStrategy` have an estimated loss 134 | of 50% write throughput in peak times compared to the `NoLockStrategy`. 135 | 136 | Changing the locking strategy depends on a few factors: 137 | - The selected stream strategy: the more events are written to the same database table the more likely this is an issue. 138 | For example when using the `AggregateStreamStrategy` each aggregate has its own table, making this less likely an 139 | issue (only when having lots of writes on the same aggregate respectively). 140 | - How the stream is read: if it is read at the same time as it is written, is it fine to miss a few events, etc ... 141 | 142 | ### Gap Detection 143 | 144 | `v1.12` introduces a second mechanism to tackle skipped events in projections - "Gap Detection". 145 | You can use it as an alternative to write locks or in combination. 146 | 147 | #### Write Lock + Gap Detection 148 | 149 | When using a write lock you have a performance penalty while writing. Often this is not a problem, because 150 | most systems have to deal with much more reads than writes. However, write locks can not fully prevent skipped events, 151 | because in case an aggregate records multiple events within the same transaction and the events have different payload size, 152 | it can still happen that a projection sees events too early and skip the ones with a big payload size. 153 | 154 | Combining Gap Detection with a short sleep time ensures that no events are skipped. 155 | 156 | *Note: By default, Gap Detection uses 4 retries with the last sleep time set to 500ms.* 157 | 158 | #### Only Gap Detection 159 | 160 | If write performance is too slow with a write lock, you can fallback to only use Gap Detection. But you should 161 | configure a much higher sleep time or multiple retries with increasing sleep time. 162 | 163 | For more details you can read the discussion in the [Gap Detection PR](https://github.com/prooph/pdo-event-store/pull/221). 164 | 165 | #### Enabling gap detection for a projection: 166 | 167 | ```php 168 | $gapDetection = new GapDetection(); 169 | 170 | $projection = $projectionManager->createProjection('test_projection', [ 171 | PdoEventStoreProjector::OPTION_GAP_DETECTION => $gapDetection, 172 | ]); 173 | ``` 174 | 175 | #### Enabling gap detection for a read model projection with custom configuration: 176 | 177 | ```php 178 | $gapDetection = new GapDetection( 179 | // Configure retries in case a gap is detected 180 | [ 181 | 0, // First retry without any sleep time 182 | 10, // Second retry after 10 ms 183 | 30, // Wait another 30 ms before performing a third retry 184 | ], 185 | \DateInterval('PT60S') //Set detection window to 60s 186 | ); 187 | 188 | $projection = $projectionManager->createReadModelProjection('test_projection', [ 189 | PdoEventStoreReadModelProjector::OPTION_GAP_DETECTION => $gapDetection, 190 | ]); 191 | ``` 192 | 193 | #### Detection Window 194 | 195 | If you set a detection window, gap detection is only performed on events not older than `NOW - window` 196 | If the detection window is set to `PT60S`, only events created within the last 60 seconds are checked. 197 | Be careful when setting `Event::createdAt` manually (for example during a migration). 198 | 199 | 200 | ### A note on Json 201 | 202 | MySql differs from the other vendors in a subtile manner which basicly is a result of the json specification itself. Json 203 | does not distuinguish between *integers* and *floats*, it just knowns a *number*. This means that when you send a float 204 | such as `2.0` to the store it will be stored by MySQL as integer `2`. While we have looked at ways to prevent this we 205 | decided it would become too complicated to support that (could be done with nested JSON_OBJECT calls, which strangely 206 | does store such value as-is). 207 | 208 | We think you can easily avoid this from becoming an issue by ensuring your events handle such differences. 209 | 210 | Example 211 | 212 | ```php 213 | final class MySliderChanged extends AggregateChanged 214 | { 215 | public static function with(MySlider $slider): self { 216 | return self::occur((string) $dossierId, [ 217 | 'value' => $slider->toNative(), // float 218 | ]); 219 | } 220 | 221 | public function mySlider(): MySlider 222 | { 223 | return MySlider::from((float) $this->payload['value']); // use casting 224 | } 225 | } 226 | ``` 227 | -------------------------------------------------------------------------------- /phpunit.mariadb.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | ./tests/ 15 | 16 | 17 | 18 | mysql 19 | postgres 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ./src/ 37 | 38 | 39 | ./src/Container/MySqlEventStoreFactory.php 40 | ./src/Container/MySqlProjectionManagerFactory.php 41 | ./src/PersistenceStrategy/MySqlAggregateStreamStrategy.php 42 | ./src/PersistenceStrategy/MySqlSimpleStreamStrategy.php 43 | ./src/PersistenceStrategy/MySqlSingleStreamStrategy.php 44 | ./src/Projection/MySqlProjectionManager.php 45 | ./src/MySqlEventStore.php 46 | ./src/Container/PostgresEventStoreFactory.php 47 | ./src/Container/PostgresProjectionManagerFactory.php 48 | ./src/PersistenceStrategy/PostgresAggregateStreamStrategy.php 49 | ./src/PersistenceStrategy/PostgresSimpleStreamStrategy.php 50 | ./src/PersistenceStrategy/PostgresSingleStreamStrategy.php 51 | ./src/Projection/PostgresProjectionManager.php 52 | ./src/PostgresEventStore.php 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /phpunit.mysql.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | ./tests/ 15 | 16 | 17 | 18 | mariadb 19 | postgres 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ./src/ 37 | 38 | 39 | ./src/Container/MariaDbEventStoreFactory.php 40 | ./src/Container/MariaDbProjectionManagerFactory.php 41 | ./src/PersistenceStrategy/MariaDbAggregateStreamStrategy.php 42 | ./src/PersistenceStrategy/MariaDbSimpleStreamStrategy.php 43 | ./src/PersistenceStrategy/MariaDbSingleStreamStrategy.php 44 | ./src/Projection/MariaDbProjectionManager.php 45 | ./src/MariaDbEventStore.php 46 | ./src/Container/PostgresEventStoreFactory.php 47 | ./src/Container/PostgresProjectionManagerFactory.php 48 | ./src/PersistenceStrategy/PostgresAggregateStreamStrategy.php 49 | ./src/PersistenceStrategy/PostgresSimpleStreamStrategy.php 50 | ./src/PersistenceStrategy/PostgresSingleStreamStrategy.php 51 | ./src/Projection/PostgresProjectionManager.php 52 | ./src/PostgresEventStore.php 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /phpunit.postgres.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | ./tests/ 14 | 15 | 16 | 17 | mariadb 18 | mysql 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ./src/ 36 | 37 | 38 | ./src/Container/MariaDbEventStoreFactory.php 39 | ./src/Container/MariaDbProjectionManagerFactory.php 40 | ./src/PersistenceStrategy/MariaDbAggregateStreamStrategy.php 41 | ./src/PersistenceStrategy/MariaDbSimpleStreamStrategy.php 42 | ./src/PersistenceStrategy/MariaDbSingleStreamStrategy.php 43 | ./src/Projection/MariaDbProjectionManager.php 44 | ./src/MariaDbEventStore.php 45 | ./src/Container/MySqlEventStoreFactory.php 46 | ./src/Container/MySqlProjectionManagerFactory.php 47 | ./src/PersistenceStrategy/MySqlAggregateStreamStrategy.php 48 | ./src/PersistenceStrategy/MySqlSimpleStreamStrategy.php 49 | ./src/PersistenceStrategy/MySqlSingleStreamStrategy.php 50 | ./src/Projection/MySqlProjectionManager.php 51 | ./src/MySqlEventStore.php 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /scripts/mariadb/01_event_streams_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `event_streams` ( 2 | `no` BIGINT(20) NOT NULL AUTO_INCREMENT, 3 | `real_stream_name` VARCHAR(150) NOT NULL, 4 | `stream_name` CHAR(41) NOT NULL, 5 | `metadata` LONGTEXT NOT NULL, 6 | `category` VARCHAR(150), 7 | CHECK (`metadata` IS NOT NULL OR JSON_VALID(`metadata`)), 8 | PRIMARY KEY (`no`), 9 | UNIQUE KEY `ix_rsn` (`real_stream_name`), 10 | KEY `ix_cat` (`category`) 11 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin; 12 | -------------------------------------------------------------------------------- /scripts/mariadb/02_projections_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `projections` ( 2 | `no` BIGINT(20) NOT NULL AUTO_INCREMENT, 3 | `name` VARCHAR(150) NOT NULL, 4 | `position` LONGTEXT, 5 | `state` LONGTEXT, 6 | `status` VARCHAR(28) NOT NULL, 7 | `locked_until` CHAR(26), 8 | CHECK (`position` IS NULL OR JSON_VALID(`position`)), 9 | CHECK (`state` IS NULL OR JSON_VALID(`state`)), 10 | PRIMARY KEY (`no`), 11 | UNIQUE KEY `ix_name` (`name`) 12 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin; 13 | -------------------------------------------------------------------------------- /scripts/mysql/01_event_streams_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `event_streams` ( 2 | `no` BIGINT(20) NOT NULL AUTO_INCREMENT, 3 | `real_stream_name` VARCHAR(150) NOT NULL, 4 | `stream_name` CHAR(41) NOT NULL, 5 | `metadata` JSON, 6 | `category` VARCHAR(150), 7 | PRIMARY KEY (`no`), 8 | UNIQUE KEY `ix_rsn` (`real_stream_name`), 9 | KEY `ix_cat` (`category`) 10 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin; 11 | -------------------------------------------------------------------------------- /scripts/mysql/02_projections_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `projections` ( 2 | `no` BIGINT(20) NOT NULL AUTO_INCREMENT, 3 | `name` VARCHAR(150) NOT NULL, 4 | `position` JSON, 5 | `state` JSON, 6 | `status` VARCHAR(28) NOT NULL, 7 | `locked_until` CHAR(26), 8 | PRIMARY KEY (`no`), 9 | UNIQUE KEY `ix_name` (`name`) 10 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin; 11 | -------------------------------------------------------------------------------- /scripts/postgres/01_event_streams_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE event_streams ( 2 | no BIGSERIAL, 3 | real_stream_name VARCHAR(150) NOT NULL, 4 | stream_name CHAR(41) NOT NULL, 5 | metadata JSONB, 6 | category VARCHAR(150), 7 | PRIMARY KEY (no), 8 | UNIQUE (stream_name) 9 | ); 10 | CREATE INDEX on event_streams (category); 11 | -------------------------------------------------------------------------------- /scripts/postgres/02_projections_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE projections ( 2 | no BIGSERIAL, 3 | name VARCHAR(150) NOT NULL, 4 | position JSONB, 5 | state JSONB, 6 | status VARCHAR(28) NOT NULL, 7 | locked_until CHAR(26), 8 | PRIMARY KEY (no), 9 | UNIQUE (name) 10 | ); 11 | -------------------------------------------------------------------------------- /src/Container/AbstractEventStoreFactory.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Container; 15 | 16 | use Interop\Config\ConfigurationTrait; 17 | use Interop\Config\ProvidesDefaultOptions; 18 | use Interop\Config\RequiresConfigId; 19 | use Interop\Config\RequiresMandatoryOptions; 20 | use Prooph\EventStore\ActionEventEmitterEventStore; 21 | use Prooph\EventStore\EventStore; 22 | use Prooph\EventStore\Exception\ConfigurationException; 23 | use Prooph\EventStore\Metadata\MetadataEnricher; 24 | use Prooph\EventStore\Metadata\MetadataEnricherAggregate; 25 | use Prooph\EventStore\Metadata\MetadataEnricherPlugin; 26 | use Prooph\EventStore\Pdo\Exception\InvalidArgumentException; 27 | use Prooph\EventStore\Pdo\WriteLockStrategy\NoLockStrategy; 28 | use Prooph\EventStore\Plugin\Plugin; 29 | use Psr\Container\ContainerInterface; 30 | 31 | abstract class AbstractEventStoreFactory implements 32 | ProvidesDefaultOptions, 33 | RequiresConfigId, 34 | RequiresMandatoryOptions 35 | { 36 | use ConfigurationTrait; 37 | 38 | /** 39 | * @var string 40 | */ 41 | private $configId; 42 | 43 | /** 44 | * Creates a new instance from a specified config, specifically meant to be used as static factory. 45 | * 46 | * In case you want to use another config key than provided by the factories, you can add the following factory to 47 | * your config: 48 | * 49 | * 50 | * [MySqlEventStoreFactory::class, 'service_name'], 53 | * PostgresEventStore::class => [PostgresEventStoreFactory::class, 'service_name'], 54 | * ]; 55 | * 56 | * 57 | * @throws InvalidArgumentException 58 | */ 59 | public static function __callStatic(string $name, array $arguments): EventStore 60 | { 61 | if (! isset($arguments[0]) || ! $arguments[0] instanceof ContainerInterface) { 62 | throw new InvalidArgumentException( 63 | \sprintf('The first argument must be of type %s', ContainerInterface::class) 64 | ); 65 | } 66 | 67 | return (new static($name))->__invoke($arguments[0]); 68 | } 69 | 70 | public function __construct(string $configId = 'default') 71 | { 72 | $this->configId = $configId; 73 | } 74 | 75 | public function __invoke(ContainerInterface $container): EventStore 76 | { 77 | $config = $container->get('config'); 78 | $config = $this->options($config, $this->configId); 79 | 80 | if (isset($config['write_lock_strategy'])) { 81 | $writeLockStrategy = $container->get($config['write_lock_strategy']); 82 | } else { 83 | $writeLockStrategy = new NoLockStrategy(); 84 | } 85 | 86 | $eventStoreClassName = $this->eventStoreClassName(); 87 | 88 | $eventStore = new $eventStoreClassName( 89 | $container->get($config['message_factory']), 90 | $container->get($config['connection']), 91 | $container->get($config['persistence_strategy']), 92 | $config['load_batch_size'], 93 | $config['event_streams_table'], 94 | $config['disable_transaction_handling'], 95 | $writeLockStrategy 96 | ); 97 | 98 | if (! $config['wrap_action_event_emitter']) { 99 | return $eventStore; 100 | } 101 | 102 | $wrapper = $this->createActionEventEmitterEventStore($eventStore); 103 | 104 | foreach ($config['plugins'] as $pluginAlias) { 105 | $plugin = $container->get($pluginAlias); 106 | 107 | if (! $plugin instanceof Plugin) { 108 | throw ConfigurationException::configurationError(\sprintf( 109 | 'Plugin %s does not implement the Plugin interface', 110 | $pluginAlias 111 | )); 112 | } 113 | 114 | $plugin->attachToEventStore($wrapper); 115 | } 116 | 117 | $metadataEnrichers = []; 118 | 119 | foreach ($config['metadata_enrichers'] as $metadataEnricherAlias) { 120 | $metadataEnricher = $container->get($metadataEnricherAlias); 121 | 122 | if (! $metadataEnricher instanceof MetadataEnricher) { 123 | throw ConfigurationException::configurationError(\sprintf( 124 | 'Metadata enricher %s does not implement the MetadataEnricher interface', 125 | $metadataEnricherAlias 126 | )); 127 | } 128 | 129 | $metadataEnrichers[] = $metadataEnricher; 130 | } 131 | 132 | if (\count($metadataEnrichers) > 0) { 133 | $plugin = new MetadataEnricherPlugin( 134 | new MetadataEnricherAggregate($metadataEnrichers) 135 | ); 136 | 137 | $plugin->attachToEventStore($wrapper); 138 | } 139 | 140 | return $wrapper; 141 | } 142 | 143 | abstract protected function createActionEventEmitterEventStore(EventStore $eventStore): ActionEventEmitterEventStore; 144 | 145 | abstract protected function eventStoreClassName(): string; 146 | 147 | public function dimensions(): iterable 148 | { 149 | return ['prooph', 'event_store']; 150 | } 151 | 152 | public function mandatoryOptions(): iterable 153 | { 154 | return [ 155 | 'connection', 156 | 'persistence_strategy', 157 | ]; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Container/AbstractProjectionManagerFactory.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Container; 15 | 16 | use Interop\Config\ConfigurationTrait; 17 | use Interop\Config\ProvidesDefaultOptions; 18 | use Interop\Config\RequiresConfigId; 19 | use Interop\Config\RequiresMandatoryOptions; 20 | use Prooph\EventStore\EventStore; 21 | use Prooph\EventStore\Pdo\Exception\InvalidArgumentException; 22 | use Prooph\EventStore\Projection\ProjectionManager; 23 | use Psr\Container\ContainerInterface; 24 | 25 | abstract class AbstractProjectionManagerFactory implements 26 | ProvidesDefaultOptions, 27 | RequiresConfigId, 28 | RequiresMandatoryOptions 29 | { 30 | use ConfigurationTrait; 31 | 32 | private $configId; 33 | 34 | /** 35 | * Creates a new instance from a specified config, specifically meant to be used as static factory. 36 | * 37 | * In case you want to use another config key than provided by the factories, you can add the following factory to 38 | * your config: 39 | * 40 | * 41 | * [MySqlProjectionManagerFactory::class, 'service_name'], 44 | * // or 45 | * ProjectionManager::class => [PostgresProjectionManagerFactory::class, 'service_name'], 46 | * ]; 47 | * 48 | * 49 | * @throws InvalidArgumentException 50 | */ 51 | public static function __callStatic(string $name, array $arguments): ProjectionManager 52 | { 53 | if (! isset($arguments[0]) || ! $arguments[0] instanceof ContainerInterface) { 54 | throw new InvalidArgumentException( 55 | \sprintf('The first argument must be of type %s', ContainerInterface::class) 56 | ); 57 | } 58 | 59 | return (new static($name))->__invoke($arguments[0]); 60 | } 61 | 62 | public function __construct(string $configId = 'default') 63 | { 64 | $this->configId = $configId; 65 | } 66 | 67 | public function __invoke(ContainerInterface $container): ProjectionManager 68 | { 69 | $config = $container->get('config'); 70 | $config = $this->options($config, $this->configId); 71 | 72 | $projectionManagerClassName = $this->projectionManagerClassName(); 73 | 74 | return new $projectionManagerClassName( 75 | $container->get($config['event_store']), 76 | $container->get($config['connection']), 77 | $config['event_streams_table'], 78 | $config['projections_table'] 79 | ); 80 | } 81 | 82 | abstract protected function projectionManagerClassName(): string; 83 | 84 | public function dimensions(): iterable 85 | { 86 | return ['prooph', 'projection_manager']; 87 | } 88 | 89 | public function mandatoryOptions(): iterable 90 | { 91 | return ['connection']; 92 | } 93 | 94 | public function defaultOptions(): iterable 95 | { 96 | return [ 97 | 'event_store' => EventStore::class, 98 | 'event_streams_table' => 'event_streams', 99 | 'projections_table' => 'projections', 100 | ]; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Container/MariaDbEventStoreFactory.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Container; 15 | 16 | use Prooph\Common\Event\ProophActionEventEmitter; 17 | use Prooph\Common\Messaging\FQCNMessageFactory; 18 | use Prooph\EventStore\ActionEventEmitterEventStore; 19 | use Prooph\EventStore\EventStore; 20 | use Prooph\EventStore\Pdo\MariaDbEventStore; 21 | 22 | final class MariaDbEventStoreFactory extends AbstractEventStoreFactory 23 | { 24 | protected function createActionEventEmitterEventStore(EventStore $eventStore): ActionEventEmitterEventStore 25 | { 26 | return new ActionEventEmitterEventStore( 27 | $eventStore, 28 | new ProophActionEventEmitter([ 29 | ActionEventEmitterEventStore::EVENT_APPEND_TO, 30 | ActionEventEmitterEventStore::EVENT_CREATE, 31 | ActionEventEmitterEventStore::EVENT_LOAD, 32 | ActionEventEmitterEventStore::EVENT_LOAD_REVERSE, 33 | ActionEventEmitterEventStore::EVENT_DELETE, 34 | ActionEventEmitterEventStore::EVENT_HAS_STREAM, 35 | ActionEventEmitterEventStore::EVENT_FETCH_STREAM_METADATA, 36 | ActionEventEmitterEventStore::EVENT_UPDATE_STREAM_METADATA, 37 | ActionEventEmitterEventStore::EVENT_FETCH_STREAM_NAMES, 38 | ActionEventEmitterEventStore::EVENT_FETCH_STREAM_NAMES_REGEX, 39 | ActionEventEmitterEventStore::EVENT_FETCH_CATEGORY_NAMES, 40 | ActionEventEmitterEventStore::EVENT_FETCH_CATEGORY_NAMES_REGEX, 41 | ]) 42 | ); 43 | } 44 | 45 | protected function eventStoreClassName(): string 46 | { 47 | return MariaDbEventStore::class; 48 | } 49 | 50 | public function defaultOptions(): iterable 51 | { 52 | return [ 53 | 'load_batch_size' => 1000, 54 | 'event_streams_table' => 'event_streams', 55 | 'message_factory' => FQCNMessageFactory::class, 56 | 'wrap_action_event_emitter' => true, 57 | 'metadata_enrichers' => [], 58 | 'plugins' => [], 59 | 'disable_transaction_handling' => false, 60 | 'write_lock_strategy' => null, 61 | ]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Container/MariaDbProjectionManagerFactory.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Container; 15 | 16 | use Prooph\EventStore\Pdo\Projection\MariaDbProjectionManager; 17 | 18 | class MariaDbProjectionManagerFactory extends AbstractProjectionManagerFactory 19 | { 20 | protected function projectionManagerClassName(): string 21 | { 22 | return MariaDbProjectionManager::class; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Container/MySqlEventStoreFactory.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Container; 15 | 16 | use Prooph\Common\Event\ProophActionEventEmitter; 17 | use Prooph\Common\Messaging\FQCNMessageFactory; 18 | use Prooph\EventStore\ActionEventEmitterEventStore; 19 | use Prooph\EventStore\EventStore; 20 | use Prooph\EventStore\Pdo\MySqlEventStore; 21 | 22 | final class MySqlEventStoreFactory extends AbstractEventStoreFactory 23 | { 24 | protected function createActionEventEmitterEventStore(EventStore $eventStore): ActionEventEmitterEventStore 25 | { 26 | return new ActionEventEmitterEventStore( 27 | $eventStore, 28 | new ProophActionEventEmitter([ 29 | ActionEventEmitterEventStore::EVENT_APPEND_TO, 30 | ActionEventEmitterEventStore::EVENT_CREATE, 31 | ActionEventEmitterEventStore::EVENT_LOAD, 32 | ActionEventEmitterEventStore::EVENT_LOAD_REVERSE, 33 | ActionEventEmitterEventStore::EVENT_DELETE, 34 | ActionEventEmitterEventStore::EVENT_HAS_STREAM, 35 | ActionEventEmitterEventStore::EVENT_FETCH_STREAM_METADATA, 36 | ActionEventEmitterEventStore::EVENT_UPDATE_STREAM_METADATA, 37 | ActionEventEmitterEventStore::EVENT_FETCH_STREAM_NAMES, 38 | ActionEventEmitterEventStore::EVENT_FETCH_STREAM_NAMES_REGEX, 39 | ActionEventEmitterEventStore::EVENT_FETCH_CATEGORY_NAMES, 40 | ActionEventEmitterEventStore::EVENT_FETCH_CATEGORY_NAMES_REGEX, 41 | ]) 42 | ); 43 | } 44 | 45 | protected function eventStoreClassName(): string 46 | { 47 | return MySqlEventStore::class; 48 | } 49 | 50 | public function defaultOptions(): iterable 51 | { 52 | return [ 53 | 'load_batch_size' => 1000, 54 | 'event_streams_table' => 'event_streams', 55 | 'message_factory' => FQCNMessageFactory::class, 56 | 'wrap_action_event_emitter' => true, 57 | 'metadata_enrichers' => [], 58 | 'plugins' => [], 59 | 'disable_transaction_handling' => false, 60 | 'write_lock_strategy' => null, 61 | ]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Container/MySqlProjectionManagerFactory.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Container; 15 | 16 | use Prooph\EventStore\Pdo\Projection\MySqlProjectionManager; 17 | 18 | class MySqlProjectionManagerFactory extends AbstractProjectionManagerFactory 19 | { 20 | protected function projectionManagerClassName(): string 21 | { 22 | return MySqlProjectionManager::class; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Container/PdoConnectionFactory.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Container; 15 | 16 | use Interop\Config\ConfigurationTrait; 17 | use Interop\Config\ProvidesDefaultOptions; 18 | use Interop\Config\RequiresConfigId; 19 | use Interop\Config\RequiresMandatoryOptions; 20 | use PDO; 21 | use Prooph\EventStore\Pdo\Exception\InvalidArgumentException; 22 | use Psr\Container\ContainerInterface; 23 | 24 | class PdoConnectionFactory implements ProvidesDefaultOptions, RequiresConfigId, RequiresMandatoryOptions 25 | { 26 | use ConfigurationTrait; 27 | 28 | /** 29 | * @var string 30 | */ 31 | private $configId; 32 | 33 | /** 34 | * Creates a new instance from a specified config, specifically meant to be used as static factory. 35 | * 36 | * In case you want to use another config key than provided by the factories, you can add the following factory to 37 | * your config: 38 | * 39 | * 40 | * [PdoConnectionFactory::class, 'mysql'], 43 | * ]; 44 | * 45 | * 46 | * @throws InvalidArgumentException 47 | */ 48 | public static function __callStatic(string $name, array $arguments): PDO 49 | { 50 | if (! isset($arguments[0]) || ! $arguments[0] instanceof ContainerInterface) { 51 | throw new InvalidArgumentException( 52 | \sprintf('The first argument must be of type %s', ContainerInterface::class) 53 | ); 54 | } 55 | 56 | return (new static($name))->__invoke($arguments[0]); 57 | } 58 | 59 | public function __construct(string $configId = 'default') 60 | { 61 | $this->configId = $configId; 62 | } 63 | 64 | public function __invoke(ContainerInterface $container): PDO 65 | { 66 | $config = $container->get('config'); 67 | $config = $this->options($config, $this->configId); 68 | 69 | return new PDO( 70 | $this->buildConnectionDns($config), 71 | $config['user'], 72 | $config['password'] 73 | ); 74 | } 75 | 76 | public function dimensions(): iterable 77 | { 78 | return [ 79 | 'prooph', 80 | 'pdo_connection', 81 | ]; 82 | } 83 | 84 | public function defaultOptions(): iterable 85 | { 86 | return [ 87 | 'host' => '127.0.0.1', 88 | 'dbname' => 'event_store', 89 | 'charset' => 'utf8', 90 | ]; 91 | } 92 | 93 | public function mandatoryOptions(): iterable 94 | { 95 | return [ 96 | 'schema', 97 | 'user', 98 | 'password', 99 | 'port', 100 | ]; 101 | } 102 | 103 | private function buildConnectionDns(array $params): string 104 | { 105 | $dsn = $params['schema'] . ':'; 106 | 107 | if ($params['host'] !== '') { 108 | $dsn .= 'host=' . $params['host'] . ';'; 109 | } 110 | 111 | if ($params['port'] !== '') { 112 | $dsn .= 'port=' . $params['port'] . ';'; 113 | } 114 | 115 | $dsn .= 'dbname=' . $params['dbname'] . ';'; 116 | 117 | if ('mysql' === $params['schema']) { 118 | $dsn .= 'charset=' . $params['charset'] . ';'; 119 | } elseif ('pgsql' === $params['schema']) { 120 | $dsn .= "options='--client_encoding=".$params['charset']."'"; 121 | } 122 | 123 | return $dsn; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Container/PostgresEventStoreFactory.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Container; 15 | 16 | use Prooph\Common\Event\ProophActionEventEmitter; 17 | use Prooph\Common\Messaging\FQCNMessageFactory; 18 | use Prooph\EventStore\ActionEventEmitterEventStore; 19 | use Prooph\EventStore\EventStore; 20 | use Prooph\EventStore\Pdo\PostgresEventStore; 21 | use Prooph\EventStore\TransactionalActionEventEmitterEventStore; 22 | 23 | final class PostgresEventStoreFactory extends AbstractEventStoreFactory 24 | { 25 | protected function createActionEventEmitterEventStore(EventStore $eventStore): ActionEventEmitterEventStore 26 | { 27 | return new TransactionalActionEventEmitterEventStore( 28 | $eventStore, 29 | new ProophActionEventEmitter([ 30 | TransactionalActionEventEmitterEventStore::EVENT_APPEND_TO, 31 | TransactionalActionEventEmitterEventStore::EVENT_CREATE, 32 | TransactionalActionEventEmitterEventStore::EVENT_LOAD, 33 | TransactionalActionEventEmitterEventStore::EVENT_LOAD_REVERSE, 34 | TransactionalActionEventEmitterEventStore::EVENT_DELETE, 35 | TransactionalActionEventEmitterEventStore::EVENT_HAS_STREAM, 36 | TransactionalActionEventEmitterEventStore::EVENT_FETCH_STREAM_METADATA, 37 | TransactionalActionEventEmitterEventStore::EVENT_UPDATE_STREAM_METADATA, 38 | TransactionalActionEventEmitterEventStore::EVENT_FETCH_STREAM_NAMES, 39 | TransactionalActionEventEmitterEventStore::EVENT_FETCH_STREAM_NAMES_REGEX, 40 | TransactionalActionEventEmitterEventStore::EVENT_FETCH_CATEGORY_NAMES, 41 | TransactionalActionEventEmitterEventStore::EVENT_FETCH_CATEGORY_NAMES_REGEX, 42 | TransactionalActionEventEmitterEventStore::EVENT_BEGIN_TRANSACTION, 43 | TransactionalActionEventEmitterEventStore::EVENT_COMMIT, 44 | TransactionalActionEventEmitterEventStore::EVENT_ROLLBACK, 45 | ]) 46 | ); 47 | } 48 | 49 | protected function eventStoreClassName(): string 50 | { 51 | return PostgresEventStore::class; 52 | } 53 | 54 | public function defaultOptions(): iterable 55 | { 56 | return [ 57 | 'load_batch_size' => 1000, 58 | 'event_streams_table' => 'event_streams', 59 | 'message_factory' => FQCNMessageFactory::class, 60 | 'wrap_action_event_emitter' => true, 61 | 'metadata_enrichers' => [], 62 | 'plugins' => [], 63 | 'disable_transaction_handling' => false, 64 | 'write_lock_strategy' => null, 65 | ]; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Container/PostgresProjectionManagerFactory.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Container; 15 | 16 | use Prooph\EventStore\Pdo\Projection\PostgresProjectionManager; 17 | 18 | class PostgresProjectionManagerFactory extends AbstractProjectionManagerFactory 19 | { 20 | protected function projectionManagerClassName(): string 21 | { 22 | return PostgresProjectionManager::class; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/DefaultMessageConverter.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo; 15 | 16 | use Prooph\Common\Messaging\Message; 17 | use Prooph\Common\Messaging\MessageConverter; 18 | 19 | class DefaultMessageConverter implements MessageConverter 20 | { 21 | public function convertToArray(Message $message): array 22 | { 23 | return [ 24 | 'message_name' => $message->messageName(), 25 | 'uuid' => $message->uuid()->toString(), 26 | 'payload' => $message->payload(), 27 | 'metadata' => $message->metadata(), 28 | 'created_at' => $message->createdAt(), 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Exception/ConcurrencyExceptionFactory.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Exception; 15 | 16 | use Prooph\EventStore\Exception\ConcurrencyException; 17 | 18 | class ConcurrencyExceptionFactory 19 | { 20 | public static function fromStatementErrorInfo(array $errorInfo): ConcurrencyException 21 | { 22 | return new ConcurrencyException( 23 | \sprintf( 24 | "Some of the aggregate IDs or event IDs have already been used in the same stream. The PDO error should contain more information:\nError %s.\nError-Info: %s", 25 | $errorInfo[0], 26 | $errorInfo[2] 27 | ) 28 | ); 29 | } 30 | 31 | public static function failedToAcquireLock(): ConcurrencyException 32 | { 33 | return new ConcurrencyException('Failed to acquire lock for writing to stream'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Exception/ExtensionNotLoaded.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Exception; 15 | 16 | class ExtensionNotLoaded extends RuntimeException 17 | { 18 | public static function with(string $extensionNme): ExtensionNotLoaded 19 | { 20 | return new self(\sprintf('Extension "%s" is not loaded', $extensionNme)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Exception; 15 | 16 | use Prooph\EventStore\Exception\InvalidArgumentException as EventStoreInvalidArgumentException; 17 | 18 | class InvalidArgumentException extends EventStoreInvalidArgumentException implements PdoEventStoreException 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/Exception/JsonException.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Exception; 15 | 16 | use Prooph\EventStore\Exception\RuntimeException as EventStoreRuntimeException; 17 | 18 | class JsonException extends EventStoreRuntimeException implements PdoEventStoreException 19 | { 20 | /** @deprecated */ 21 | public static function whileDecode(string $msg, int $code, string $json): JsonException 22 | { 23 | return new self( 24 | \sprintf( 25 | "Error while decoding JSON. \nMessage: %s \nJSON: %s", 26 | $msg, 27 | $json 28 | ), 29 | $code 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Exception/PdoEventStoreException.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Exception; 15 | 16 | use Prooph\EventStore\Exception\EventStoreException; 17 | 18 | interface PdoEventStoreException extends EventStoreException 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/Exception/ProjectionNotCreatedException.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Exception; 15 | 16 | class ProjectionNotCreatedException extends RuntimeException 17 | { 18 | public static function with(string $projectionName): ProjectionNotCreatedException 19 | { 20 | return new self(\sprintf('Projection "%s" was not created', $projectionName)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Exception; 15 | 16 | use Prooph\EventStore\Exception\RuntimeException as EventStoreRuntimeException; 17 | 18 | class RuntimeException extends EventStoreRuntimeException implements PdoEventStoreException 19 | { 20 | public static function fromStatementErrorInfo(array $errorInfo): RuntimeException 21 | { 22 | return new self( 23 | \sprintf( 24 | "Error %s. \nError-Info: %s", 25 | $errorInfo[0], 26 | $errorInfo[2] 27 | ) 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/HasQueryHint.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo; 15 | 16 | /** 17 | * Additional interface to be implemented for persistence strategies 18 | * to specify a query hint being used when loading events 19 | */ 20 | interface HasQueryHint 21 | { 22 | public function indexName(): string; 23 | } 24 | -------------------------------------------------------------------------------- /src/MariaDBIndexedPersistenceStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo; 15 | 16 | interface MariaDBIndexedPersistenceStrategy 17 | { 18 | /** 19 | * Return an array of indexed columns to enable the use of indexes in MariaDB 20 | * 21 | * @example 22 | * [ 23 | * '_aggregate_id' => 'aggregate_id', 24 | * '_aggregate_type' => 'aggregate_type', 25 | * '_aggregate_version' => 'aggregate_version', 26 | * ] 27 | */ 28 | public function indexedMetadataFields(): array; 29 | } 30 | -------------------------------------------------------------------------------- /src/PdoEventStore.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo; 15 | 16 | use Prooph\EventStore\EventStore; 17 | 18 | interface PdoEventStore extends EventStore 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/PdoStreamIterator.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo; 15 | 16 | use DateTimeImmutable; 17 | use DateTimeZone; 18 | use PDO; 19 | use PDOException; 20 | use PDOStatement; 21 | use Prooph\Common\Messaging\Message; 22 | use Prooph\Common\Messaging\MessageFactory; 23 | use Prooph\EventStore\Pdo\Exception\RuntimeException; 24 | use Prooph\EventStore\Pdo\Util\Json; 25 | use Prooph\EventStore\StreamIterator\StreamIterator; 26 | 27 | final class PdoStreamIterator implements StreamIterator 28 | { 29 | /** 30 | * @var PDOStatement 31 | */ 32 | private $selectStatement; 33 | 34 | /** 35 | * @var PDOStatement 36 | */ 37 | private $countStatement; 38 | 39 | /** 40 | * @var MessageFactory 41 | */ 42 | private $messageFactory; 43 | 44 | /** 45 | * @var \stdClass|false 46 | */ 47 | private $currentItem = null; 48 | 49 | /** 50 | * @var int 51 | */ 52 | private $currentKey = -1; 53 | 54 | /** 55 | * @var int 56 | */ 57 | private $batchPosition = 0; 58 | 59 | /** 60 | * @var int 61 | */ 62 | private $batchSize; 63 | 64 | /** 65 | * @var int 66 | */ 67 | private $fromNumber; 68 | 69 | /** 70 | * @var int 71 | */ 72 | private $currentFromNumber; 73 | 74 | /** 75 | * @var int|null 76 | */ 77 | private $count; 78 | 79 | /** 80 | * @var bool 81 | */ 82 | private $forward; 83 | 84 | public function __construct( 85 | PDOStatement $selectStatement, 86 | PDOStatement $countStatement, 87 | MessageFactory $messageFactory, 88 | int $batchSize, 89 | int $fromNumber, 90 | ?int $count, 91 | bool $forward 92 | ) { 93 | $this->selectStatement = $selectStatement; 94 | $this->countStatement = $countStatement; 95 | $this->messageFactory = $messageFactory; 96 | $this->batchSize = $batchSize; 97 | $this->fromNumber = $fromNumber; 98 | $this->currentFromNumber = $fromNumber; 99 | $this->count = $count; 100 | $this->forward = $forward; 101 | 102 | $this->next(); 103 | } 104 | 105 | /** 106 | * @return null|Message 107 | */ 108 | public function current(): ?Message 109 | { 110 | if (false === $this->currentItem) { 111 | return null; 112 | } 113 | 114 | $createdAt = $this->currentItem->created_at; 115 | 116 | if (\strlen($createdAt) === 19) { 117 | $createdAt = $createdAt . '.000'; 118 | } 119 | 120 | $createdAt = DateTimeImmutable::createFromFormat( 121 | 'Y-m-d H:i:s.u', 122 | $createdAt, 123 | new DateTimeZone('UTC') 124 | ); 125 | 126 | $metadata = Json::decode($this->currentItem->metadata); 127 | 128 | if (! \array_key_exists('_position', $metadata)) { 129 | $metadata['_position'] = $this->currentItem->no; 130 | } 131 | 132 | $payload = Json::decode($this->currentItem->payload); 133 | 134 | return $this->messageFactory->createMessageFromArray($this->currentItem->event_name, [ 135 | 'uuid' => $this->currentItem->event_id, 136 | 'created_at' => $createdAt, 137 | 'payload' => $payload, 138 | 'metadata' => $metadata, 139 | ]); 140 | } 141 | 142 | public function next(): void 143 | { 144 | if ($this->count && ($this->count - 1) === $this->currentKey) { 145 | $this->currentKey = -1; 146 | $this->currentItem = false; 147 | 148 | return; 149 | } 150 | 151 | $this->currentItem = $this->selectStatement->fetch(); 152 | 153 | if (false !== $this->currentItem) { 154 | $this->currentKey++; 155 | $this->currentItem->no = (int) $this->currentItem->no; 156 | $this->currentFromNumber = $this->currentItem->no; 157 | } else { 158 | $this->batchPosition++; 159 | if ($this->forward) { 160 | $from = $this->currentFromNumber + 1; 161 | } else { 162 | $from = $this->currentFromNumber - 1; 163 | } 164 | $this->selectStatement = $this->buildSelectStatement($from); 165 | 166 | try { 167 | $this->selectStatement->execute(); 168 | } catch (PDOException $exception) { 169 | // ignore and check error code 170 | } 171 | 172 | if ($this->selectStatement->errorCode() !== '00000') { 173 | throw RuntimeException::fromStatementErrorInfo($this->selectStatement->errorInfo()); 174 | } 175 | 176 | $this->selectStatement->setFetchMode(PDO::FETCH_OBJ); 177 | 178 | $this->currentItem = $this->selectStatement->fetch(); 179 | 180 | if (false === $this->currentItem) { 181 | $this->currentKey = -1; 182 | } else { 183 | $this->currentKey++; 184 | $this->currentItem->no = (int) $this->currentItem->no; 185 | $this->currentFromNumber = $this->currentItem->no; 186 | } 187 | } 188 | } 189 | 190 | /** 191 | * @return bool|int 192 | */ 193 | #[\ReturnTypeWillChange] 194 | public function key() 195 | { 196 | if (null === $this->currentItem) { 197 | return false; 198 | } 199 | 200 | return $this->currentItem->no; 201 | } 202 | 203 | /** 204 | * @return bool 205 | */ 206 | public function valid(): bool 207 | { 208 | return false !== $this->currentItem; 209 | } 210 | 211 | public function rewind(): void 212 | { 213 | //Only perform rewind if current item is not the first element 214 | if ($this->currentKey !== 0) { 215 | $this->batchPosition = 0; 216 | 217 | $this->selectStatement = $this->buildSelectStatement($this->fromNumber); 218 | 219 | try { 220 | $this->selectStatement->execute(); 221 | } catch (PDOException $exception) { 222 | // ignore and check error code 223 | } 224 | 225 | if ($this->selectStatement->errorCode() !== '00000') { 226 | throw RuntimeException::fromStatementErrorInfo($this->selectStatement->errorInfo()); 227 | } 228 | 229 | $this->currentItem = null; 230 | $this->currentKey = -1; 231 | $this->currentFromNumber = $this->fromNumber; 232 | 233 | $this->next(); 234 | } 235 | } 236 | 237 | public function count(): int 238 | { 239 | $this->countStatement->bindValue(':fromNumber', $this->fromNumber, PDO::PARAM_INT); 240 | 241 | try { 242 | if ($this->countStatement->execute()) { 243 | $count = (int) $this->countStatement->fetchColumn(); 244 | 245 | return null === $this->count ? $count : \min($count, $this->count); 246 | } 247 | } catch (PDOException $exception) { 248 | // ignore 249 | } 250 | 251 | return 0; 252 | } 253 | 254 | private function buildSelectStatement(int $fromNumber): PDOStatement 255 | { 256 | if (null === $this->count 257 | || $this->count < ($this->batchSize * ($this->batchPosition + 1)) 258 | ) { 259 | $limit = $this->batchSize; 260 | } else { 261 | $limit = $this->count - ($this->batchSize * ($this->batchPosition + 1)); 262 | } 263 | 264 | $this->selectStatement->bindValue(':fromNumber', $fromNumber, PDO::PARAM_INT); 265 | $this->selectStatement->bindValue(':limit', $limit, PDO::PARAM_INT); 266 | 267 | return $this->selectStatement; 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/PersistenceStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo; 15 | 16 | use Iterator; 17 | use Prooph\EventStore\StreamName; 18 | 19 | interface PersistenceStrategy 20 | { 21 | /** 22 | * @param string $tableName 23 | * @return string[] 24 | */ 25 | public function createSchema(string $tableName): array; 26 | 27 | public function columnNames(): array; 28 | 29 | public function prepareData(Iterator $streamEvents): array; 30 | 31 | public function generateTableName(StreamName $streamName): string; 32 | } 33 | -------------------------------------------------------------------------------- /src/PersistenceStrategy/MariaDbAggregateStreamStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\PersistenceStrategy; 15 | 16 | use Iterator; 17 | use Prooph\Common\Messaging\MessageConverter; 18 | use Prooph\EventStore\Pdo\DefaultMessageConverter; 19 | use Prooph\EventStore\Pdo\Exception; 20 | use Prooph\EventStore\Pdo\MariaDBIndexedPersistenceStrategy; 21 | use Prooph\EventStore\Pdo\Util\Json; 22 | use Prooph\EventStore\StreamName; 23 | 24 | final class MariaDbAggregateStreamStrategy implements MariaDbPersistenceStrategy, MariaDBIndexedPersistenceStrategy 25 | { 26 | /** 27 | * @var MessageConverter 28 | */ 29 | private $messageConverter; 30 | 31 | public function __construct(?MessageConverter $messageConverter = null) 32 | { 33 | $this->messageConverter = $messageConverter ?? new DefaultMessageConverter(); 34 | } 35 | 36 | /** 37 | * @param string $tableName 38 | * @return string[] 39 | */ 40 | public function createSchema(string $tableName): array 41 | { 42 | $statement = <<messageConverter->convertToArray($event); 80 | 81 | if (! isset($eventData['metadata']['_aggregate_version'])) { 82 | throw new Exception\RuntimeException('_aggregate_version is missing in metadata'); 83 | } 84 | 85 | $data[] = $eventData['metadata']['_aggregate_version']; 86 | $data[] = $eventData['uuid']; 87 | $data[] = $eventData['message_name']; 88 | $data[] = Json::encode($eventData['payload']); 89 | $data[] = Json::encode($eventData['metadata']); 90 | $data[] = $eventData['created_at']->format('Y-m-d\TH:i:s.u'); 91 | } 92 | 93 | return $data; 94 | } 95 | 96 | public function indexedMetadataFields(): array 97 | { 98 | return [ 99 | '_aggregate_version' => 'aggregate_version', 100 | ]; 101 | } 102 | 103 | public function generateTableName(StreamName $streamName): string 104 | { 105 | return '_' . \sha1($streamName->toString()); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/PersistenceStrategy/MariaDbPersistenceStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\PersistenceStrategy; 15 | 16 | use Prooph\EventStore\Pdo\PersistenceStrategy; 17 | 18 | interface MariaDbPersistenceStrategy extends PersistenceStrategy 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/PersistenceStrategy/MariaDbSimpleStreamStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\PersistenceStrategy; 15 | 16 | use Iterator; 17 | use Prooph\Common\Messaging\MessageConverter; 18 | use Prooph\EventStore\Pdo\DefaultMessageConverter; 19 | use Prooph\EventStore\Pdo\Util\Json; 20 | use Prooph\EventStore\StreamName; 21 | 22 | final class MariaDbSimpleStreamStrategy implements MariaDbPersistenceStrategy 23 | { 24 | /** 25 | * @var MessageConverter 26 | */ 27 | private $messageConverter; 28 | 29 | public function __construct(?MessageConverter $messageConverter = null) 30 | { 31 | $this->messageConverter = $messageConverter ?? new DefaultMessageConverter(); 32 | } 33 | 34 | /** 35 | * @param string $tableName 36 | * @return string[] 37 | */ 38 | public function createSchema(string $tableName): array 39 | { 40 | $statement = <<messageConverter->convertToArray($event); 75 | 76 | $data[] = $eventData['uuid']; 77 | $data[] = $eventData['message_name']; 78 | $data[] = Json::encode($eventData['payload']); 79 | $data[] = Json::encode($eventData['metadata']); 80 | $data[] = $eventData['created_at']->format('Y-m-d\TH:i:s.u'); 81 | } 82 | 83 | return $data; 84 | } 85 | 86 | public function generateTableName(StreamName $streamName): string 87 | { 88 | return '_' . \sha1($streamName->toString()); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/PersistenceStrategy/MariaDbSingleStreamStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\PersistenceStrategy; 15 | 16 | use Iterator; 17 | use Prooph\Common\Messaging\MessageConverter; 18 | use Prooph\EventStore\Pdo\DefaultMessageConverter; 19 | use Prooph\EventStore\Pdo\HasQueryHint; 20 | use Prooph\EventStore\Pdo\MariaDBIndexedPersistenceStrategy; 21 | use Prooph\EventStore\Pdo\Util\Json; 22 | use Prooph\EventStore\StreamName; 23 | 24 | final class MariaDbSingleStreamStrategy implements MariaDbPersistenceStrategy, HasQueryHint, MariaDBIndexedPersistenceStrategy 25 | { 26 | /** 27 | * @var MessageConverter 28 | */ 29 | private $messageConverter; 30 | 31 | public function __construct(?MessageConverter $messageConverter = null) 32 | { 33 | $this->messageConverter = $messageConverter ?? new DefaultMessageConverter(); 34 | } 35 | 36 | /** 37 | * @param string $tableName 38 | * @return string[] 39 | */ 40 | public function createSchema(string $tableName): array 41 | { 42 | $statement = << 'aggregate_id', 80 | '_aggregate_type' => 'aggregate_type', 81 | '_aggregate_version' => 'aggregate_version', 82 | ]; 83 | } 84 | 85 | public function prepareData(Iterator $streamEvents): array 86 | { 87 | $data = []; 88 | 89 | foreach ($streamEvents as $event) { 90 | $eventData = $this->messageConverter->convertToArray($event); 91 | 92 | $data[] = $eventData['uuid']; 93 | $data[] = $eventData['message_name']; 94 | $data[] = Json::encode($eventData['payload']); 95 | $data[] = Json::encode($eventData['metadata']); 96 | $data[] = $eventData['created_at']->format('Y-m-d\TH:i:s.u'); 97 | } 98 | 99 | return $data; 100 | } 101 | 102 | public function generateTableName(StreamName $streamName): string 103 | { 104 | return '_' . \sha1($streamName->toString()); 105 | } 106 | 107 | public function indexName(): string 108 | { 109 | return 'ix_query_aggregate'; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/PersistenceStrategy/MySqlAggregateStreamStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\PersistenceStrategy; 15 | 16 | use Iterator; 17 | use Prooph\Common\Messaging\MessageConverter; 18 | use Prooph\EventStore\Pdo\DefaultMessageConverter; 19 | use Prooph\EventStore\Pdo\Exception; 20 | use Prooph\EventStore\Pdo\Util\Json; 21 | use Prooph\EventStore\StreamName; 22 | 23 | final class MySqlAggregateStreamStrategy implements MySqlPersistenceStrategy 24 | { 25 | /** 26 | * @var MessageConverter 27 | */ 28 | private $messageConverter; 29 | 30 | public function __construct(?MessageConverter $messageConverter = null) 31 | { 32 | $this->messageConverter = $messageConverter ?? new DefaultMessageConverter(); 33 | } 34 | 35 | /** 36 | * @param string $tableName 37 | * @return string[] 38 | */ 39 | public function createSchema(string $tableName): array 40 | { 41 | $statement = <<messageConverter->convertToArray($event); 76 | 77 | if (! isset($eventData['metadata']['_aggregate_version'])) { 78 | throw new Exception\RuntimeException('_aggregate_version is missing in metadata'); 79 | } 80 | 81 | $data[] = $eventData['metadata']['_aggregate_version']; 82 | $data[] = $eventData['uuid']; 83 | $data[] = $eventData['message_name']; 84 | $data[] = Json::encode($eventData['payload']); 85 | $data[] = Json::encode($eventData['metadata']); 86 | $data[] = $eventData['created_at']->format('Y-m-d\TH:i:s.u'); 87 | } 88 | 89 | return $data; 90 | } 91 | 92 | public function generateTableName(StreamName $streamName): string 93 | { 94 | return '_' . \sha1($streamName->toString()); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/PersistenceStrategy/MySqlPersistenceStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\PersistenceStrategy; 15 | 16 | use Prooph\EventStore\Pdo\PersistenceStrategy; 17 | 18 | interface MySqlPersistenceStrategy extends PersistenceStrategy 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/PersistenceStrategy/MySqlSimpleStreamStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\PersistenceStrategy; 15 | 16 | use Iterator; 17 | use Prooph\Common\Messaging\MessageConverter; 18 | use Prooph\EventStore\Pdo\DefaultMessageConverter; 19 | use Prooph\EventStore\Pdo\Util\Json; 20 | use Prooph\EventStore\StreamName; 21 | 22 | final class MySqlSimpleStreamStrategy implements MySqlPersistenceStrategy 23 | { 24 | /** 25 | * @var MessageConverter 26 | */ 27 | private $messageConverter; 28 | 29 | public function __construct(?MessageConverter $messageConverter = null) 30 | { 31 | $this->messageConverter = $messageConverter ?? new DefaultMessageConverter(); 32 | } 33 | 34 | /** 35 | * @param string $tableName 36 | * @return string[] 37 | */ 38 | public function createSchema(string $tableName): array 39 | { 40 | $statement = <<messageConverter->convertToArray($event); 73 | 74 | $data[] = $eventData['uuid']; 75 | $data[] = $eventData['message_name']; 76 | $data[] = Json::encode($eventData['payload']); 77 | $data[] = Json::encode($eventData['metadata']); 78 | $data[] = $eventData['created_at']->format('Y-m-d\TH:i:s.u'); 79 | } 80 | 81 | return $data; 82 | } 83 | 84 | public function generateTableName(StreamName $streamName): string 85 | { 86 | return '_' . \sha1($streamName->toString()); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/PersistenceStrategy/MySqlSingleStreamStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\PersistenceStrategy; 15 | 16 | use Iterator; 17 | use Prooph\Common\Messaging\MessageConverter; 18 | use Prooph\EventStore\Pdo\DefaultMessageConverter; 19 | use Prooph\EventStore\Pdo\HasQueryHint; 20 | use Prooph\EventStore\Pdo\Util\Json; 21 | use Prooph\EventStore\StreamName; 22 | 23 | final class MySqlSingleStreamStrategy implements MySqlPersistenceStrategy, HasQueryHint 24 | { 25 | /** 26 | * @var MessageConverter 27 | */ 28 | private $messageConverter; 29 | 30 | public function __construct(?MessageConverter $messageConverter = null) 31 | { 32 | $this->messageConverter = $messageConverter ?? new DefaultMessageConverter(); 33 | } 34 | 35 | /** 36 | * @param string $tableName 37 | * @return string[] 38 | */ 39 | public function createSchema(string $tableName): array 40 | { 41 | $statement = <<messageConverter->convertToArray($event); 79 | 80 | $data[] = $eventData['uuid']; 81 | $data[] = $eventData['message_name']; 82 | $data[] = Json::encode($eventData['payload']); 83 | $data[] = Json::encode($eventData['metadata']); 84 | $data[] = $eventData['created_at']->format('Y-m-d\TH:i:s.u'); 85 | } 86 | 87 | return $data; 88 | } 89 | 90 | public function generateTableName(StreamName $streamName): string 91 | { 92 | return '_' . \sha1($streamName->toString()); 93 | } 94 | 95 | public function indexName(): string 96 | { 97 | return 'ix_query_aggregate'; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/PersistenceStrategy/PostgresAggregateStreamStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\PersistenceStrategy; 15 | 16 | use Iterator; 17 | use Prooph\Common\Messaging\MessageConverter; 18 | use Prooph\EventStore\Pdo\DefaultMessageConverter; 19 | use Prooph\EventStore\Pdo\Exception; 20 | use Prooph\EventStore\Pdo\Util\Json; 21 | use Prooph\EventStore\Pdo\Util\PostgresHelper; 22 | use Prooph\EventStore\StreamName; 23 | 24 | final class PostgresAggregateStreamStrategy implements PostgresPersistenceStrategy 25 | { 26 | use PostgresHelper; 27 | 28 | /** 29 | * @var MessageConverter 30 | */ 31 | private $messageConverter; 32 | 33 | public function __construct(?MessageConverter $messageConverter = null) 34 | { 35 | $this->messageConverter = $messageConverter ?? new DefaultMessageConverter(); 36 | } 37 | 38 | /** 39 | * @param string $tableName 40 | * @return string[] 41 | */ 42 | public function createSchema(string $tableName): array 43 | { 44 | $tableName = $this->quoteIdent($tableName); 45 | 46 | $statement = <<getSchemaCreationSchema($tableName), [ 60 | $statement, 61 | "CREATE UNIQUE INDEX on $tableName ((metadata->>'_aggregate_version'));", 62 | ]); 63 | } 64 | 65 | public function columnNames(): array 66 | { 67 | return [ 68 | 'no', 69 | 'event_id', 70 | 'event_name', 71 | 'payload', 72 | 'metadata', 73 | 'created_at', 74 | ]; 75 | } 76 | 77 | public function prepareData(Iterator $streamEvents): array 78 | { 79 | $data = []; 80 | 81 | foreach ($streamEvents as $event) { 82 | $eventData = $this->messageConverter->convertToArray($event); 83 | 84 | if (! isset($eventData['metadata']['_aggregate_version'])) { 85 | throw new Exception\RuntimeException('_aggregate_version is missing in metadata'); 86 | } 87 | 88 | $data[] = $eventData['metadata']['_aggregate_version']; 89 | $data[] = $eventData['uuid']; 90 | $data[] = $eventData['message_name']; 91 | $data[] = Json::encode($eventData['payload']); 92 | $data[] = Json::encode($eventData['metadata']); 93 | $data[] = $eventData['created_at']->format('Y-m-d\TH:i:s.u'); 94 | } 95 | 96 | return $data; 97 | } 98 | 99 | public function generateTableName(StreamName $streamName): string 100 | { 101 | $streamName = $streamName->toString(); 102 | $table = '_' . \sha1($streamName); 103 | 104 | if ($schema = $this->extractSchema($streamName)) { 105 | $table = $schema . '.' . $table; 106 | } 107 | 108 | return $table; 109 | } 110 | 111 | private function getSchemaCreationSchema(string $tableName): array 112 | { 113 | if (! $schemaName = $this->extractSchema($tableName)) { 114 | return []; 115 | } 116 | 117 | return [\sprintf( 118 | 'CREATE SCHEMA IF NOT EXISTS %s', 119 | $schemaName 120 | )]; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/PersistenceStrategy/PostgresPersistenceStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\PersistenceStrategy; 15 | 16 | use Prooph\EventStore\Pdo\PersistenceStrategy; 17 | 18 | interface PostgresPersistenceStrategy extends PersistenceStrategy 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/PersistenceStrategy/PostgresSimpleStreamStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\PersistenceStrategy; 15 | 16 | use Iterator; 17 | use Prooph\Common\Messaging\MessageConverter; 18 | use Prooph\EventStore\Pdo\DefaultMessageConverter; 19 | use Prooph\EventStore\Pdo\Util\Json; 20 | use Prooph\EventStore\Pdo\Util\PostgresHelper; 21 | use Prooph\EventStore\StreamName; 22 | 23 | final class PostgresSimpleStreamStrategy implements PostgresPersistenceStrategy 24 | { 25 | use PostgresHelper; 26 | 27 | /** 28 | * @var MessageConverter 29 | */ 30 | private $messageConverter; 31 | 32 | public function __construct(?MessageConverter $messageConverter = null) 33 | { 34 | $this->messageConverter = $messageConverter ?? new DefaultMessageConverter(); 35 | } 36 | 37 | /** 38 | * @param string $tableName 39 | * @return string[] 40 | */ 41 | public function createSchema(string $tableName): array 42 | { 43 | $tableName = $this->quoteIdent($tableName); 44 | 45 | $statement = <<getSchemaCreationSchema($tableName), [ 59 | $statement, 60 | ]); 61 | } 62 | 63 | public function columnNames(): array 64 | { 65 | return [ 66 | 'event_id', 67 | 'event_name', 68 | 'payload', 69 | 'metadata', 70 | 'created_at', 71 | ]; 72 | } 73 | 74 | public function prepareData(Iterator $streamEvents): array 75 | { 76 | $data = []; 77 | 78 | foreach ($streamEvents as $event) { 79 | $eventData = $this->messageConverter->convertToArray($event); 80 | 81 | $data[] = $eventData['uuid']; 82 | $data[] = $eventData['message_name']; 83 | $data[] = Json::encode($eventData['payload']); 84 | $data[] = Json::encode($eventData['metadata']); 85 | $data[] = $eventData['created_at']->format('Y-m-d\TH:i:s.u'); 86 | } 87 | 88 | return $data; 89 | } 90 | 91 | public function generateTableName(StreamName $streamName): string 92 | { 93 | $streamName = $streamName->toString(); 94 | $table = '_' . \sha1($streamName); 95 | 96 | if ($schema = $this->extractSchema($streamName)) { 97 | $table = $schema . '.' . $table; 98 | } 99 | 100 | return $table; 101 | } 102 | 103 | private function getSchemaCreationSchema(string $tableName): array 104 | { 105 | if (! $schemaName = $this->extractSchema($tableName)) { 106 | return []; 107 | } 108 | 109 | return [\sprintf( 110 | 'CREATE SCHEMA IF NOT EXISTS %s', 111 | $schemaName 112 | )]; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/PersistenceStrategy/PostgresSingleStreamStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\PersistenceStrategy; 15 | 16 | use Iterator; 17 | use Prooph\Common\Messaging\MessageConverter; 18 | use Prooph\EventStore\Pdo\DefaultMessageConverter; 19 | use Prooph\EventStore\Pdo\Util\Json; 20 | use Prooph\EventStore\Pdo\Util\PostgresHelper; 21 | use Prooph\EventStore\StreamName; 22 | 23 | final class PostgresSingleStreamStrategy implements PostgresPersistenceStrategy 24 | { 25 | use PostgresHelper; 26 | 27 | /** 28 | * @var MessageConverter 29 | */ 30 | private $messageConverter; 31 | 32 | public function __construct(?MessageConverter $messageConverter = null) 33 | { 34 | $this->messageConverter = $messageConverter ?? new DefaultMessageConverter(); 35 | } 36 | 37 | /** 38 | * @param string $tableName 39 | * @return string[] 40 | */ 41 | public function createSchema(string $tableName): array 42 | { 43 | $tableName = $this->quoteIdent($tableName); 44 | 45 | $statement = <<>'_aggregate_version') IS NOT NULL), 55 | CONSTRAINT aggregate_type_not_null CHECK ((metadata->>'_aggregate_type') IS NOT NULL), 56 | CONSTRAINT aggregate_id_not_null CHECK ((metadata->>'_aggregate_id') IS NOT NULL), 57 | UNIQUE (event_id) 58 | ); 59 | EOT; 60 | 61 | $index1 = <<>'_aggregate_type'), (metadata->>'_aggregate_id'), (metadata->>'_aggregate_version')); 64 | EOT; 65 | 66 | $index2 = <<>'_aggregate_type'), (metadata->>'_aggregate_id'), no); 69 | EOT; 70 | 71 | return \array_merge($this->getSchemaCreationSchema($tableName), [ 72 | $statement, 73 | $index1, 74 | $index2, 75 | ]); 76 | } 77 | 78 | public function columnNames(): array 79 | { 80 | return [ 81 | 'event_id', 82 | 'event_name', 83 | 'payload', 84 | 'metadata', 85 | 'created_at', 86 | ]; 87 | } 88 | 89 | public function prepareData(Iterator $streamEvents): array 90 | { 91 | $data = []; 92 | 93 | foreach ($streamEvents as $event) { 94 | $eventData = $this->messageConverter->convertToArray($event); 95 | 96 | $data[] = $eventData['uuid']; 97 | $data[] = $eventData['message_name']; 98 | $data[] = Json::encode($eventData['payload']); 99 | $data[] = Json::encode($eventData['metadata']); 100 | $data[] = $eventData['created_at']->format('Y-m-d\TH:i:s.u'); 101 | } 102 | 103 | return $data; 104 | } 105 | 106 | public function generateTableName(StreamName $streamName): string 107 | { 108 | $streamName = $streamName->toString(); 109 | $table = '_' . \sha1($streamName); 110 | 111 | if ($schema = $this->extractSchema($streamName)) { 112 | $table = $schema . '.' . $table; 113 | } 114 | 115 | return $table; 116 | } 117 | 118 | private function getSchemaCreationSchema(string $tableName): array 119 | { 120 | if (! $schemaName = $this->extractSchema($tableName)) { 121 | return []; 122 | } 123 | 124 | return [\sprintf( 125 | 'CREATE SCHEMA IF NOT EXISTS %s', 126 | $schemaName 127 | )]; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Projection/GapDetection.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Projection; 15 | 16 | use Prooph\Common\Messaging\Message; 17 | 18 | /** 19 | * Class GapDetection 20 | * 21 | * In high load scenarios it is possible that projections skip events due to newer events becoming visible before old ones. 22 | * For details you can take a look at this issue: https://github.com/prooph/pdo-event-store/issues/189 23 | * 24 | * The GapDetection class helps projectors to detect gaps while processing events and coordinates retries to fill gaps. 25 | * 26 | * @package Prooph\EventStore\Pdo\Projection 27 | */ 28 | final class GapDetection 29 | { 30 | /** 31 | * If gap still exists after all retries, projector moves on 32 | * 33 | * You can set your own sleep times. Each number is the sleep time in milliseconds before the next retry is performed 34 | * 35 | * @var array 36 | */ 37 | private $retryConfig = [ 38 | 0, // First retry is triggered immediately 39 | 5, // Second retry is triggered after 5ms 40 | 50, // Third retry with much longer sleep time 41 | 500, // Either DB is really busy or we have a real gap, wait another 500ms and run a last try 42 | // Add more ms values if projection should perform more retries 43 | ]; 44 | 45 | /** 46 | * There are two reasons for gaps in event streams: 47 | * 48 | * 1. A transaction rollback caused a gap 49 | * 2. Transaction visibility problem described in https://github.com/prooph/pdo-event-store/issues/189 50 | * 51 | * The latter can only occur if a projection processes events near realtime. 52 | * When configured, a detection window ensures that during a projection replay gap retries are not performed. During replays 53 | * only gaps caused by transaction rollbacks are possible. This avoids unnecessary retries. 54 | * 55 | * @var \DateInterval|null 56 | */ 57 | private $detectionWindow; 58 | 59 | /** 60 | * @var int 61 | */ 62 | private $retries = 0; 63 | 64 | public function __construct(?array $retryConfig = null, ?\DateInterval $detectionWindow = null) 65 | { 66 | if ($retryConfig) { 67 | $this->retryConfig = $retryConfig; 68 | } 69 | 70 | $this->detectionWindow = $detectionWindow; 71 | } 72 | 73 | public function isRetrying(): bool 74 | { 75 | return $this->retries > 0; 76 | } 77 | 78 | public function trackRetry(): void 79 | { 80 | $this->retries++; 81 | } 82 | 83 | public function resetRetries(): void 84 | { 85 | $this->retries = 0; 86 | } 87 | 88 | public function getSleepForNextRetry(): int 89 | { 90 | return (int) $this->retryConfig[$this->retries] ?? 0; 91 | } 92 | 93 | public function isGapInStreamPosition(int $streamPosition, int $eventPosition): bool 94 | { 95 | return $streamPosition + 1 !== $eventPosition; 96 | } 97 | 98 | public function shouldRetryToFillGap(\DateTimeImmutable $now, Message $currentMessage): bool 99 | { 100 | //This check avoids unnecessary retries when replaying projections 101 | if ($this->detectionWindow && $now->sub($this->detectionWindow) > $currentMessage->createdAt()) { 102 | return false; 103 | } 104 | 105 | return \array_key_exists($this->retries, $this->retryConfig); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Projection/MariaDbProjectionManager.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Projection; 15 | 16 | use PDO; 17 | use PDOException; 18 | use Prooph\EventStore\EventStore; 19 | use Prooph\EventStore\EventStoreDecorator; 20 | use Prooph\EventStore\Exception\OutOfRangeException; 21 | use Prooph\EventStore\Exception\ProjectionNotFound; 22 | use Prooph\EventStore\Pdo\Exception; 23 | use Prooph\EventStore\Pdo\MariaDbEventStore; 24 | use Prooph\EventStore\Pdo\Util\Json; 25 | use Prooph\EventStore\Projection\ProjectionManager; 26 | use Prooph\EventStore\Projection\ProjectionStatus; 27 | use Prooph\EventStore\Projection\Projector; 28 | use Prooph\EventStore\Projection\Query; 29 | use Prooph\EventStore\Projection\ReadModel; 30 | use Prooph\EventStore\Projection\ReadModelProjector; 31 | 32 | final class MariaDbProjectionManager implements ProjectionManager 33 | { 34 | /** 35 | * @var EventStore 36 | */ 37 | private $eventStore; 38 | 39 | /** 40 | * @var PDO 41 | */ 42 | private $connection; 43 | 44 | /** 45 | * @var string 46 | */ 47 | private $eventStreamsTable; 48 | 49 | /** 50 | * @var string 51 | */ 52 | private $projectionsTable; 53 | 54 | public function __construct( 55 | EventStore $eventStore, 56 | PDO $connection, 57 | string $eventStreamsTable = 'event_streams', 58 | string $projectionsTable = 'projections' 59 | ) { 60 | $this->eventStore = $eventStore; 61 | $this->connection = $connection; 62 | $this->eventStreamsTable = $eventStreamsTable; 63 | $this->projectionsTable = $projectionsTable; 64 | 65 | while ($eventStore instanceof EventStoreDecorator) { 66 | $eventStore = $eventStore->getInnerEventStore(); 67 | } 68 | 69 | if (! $eventStore instanceof MariaDbEventStore) { 70 | throw new Exception\InvalidArgumentException('Unknown event store instance given'); 71 | } 72 | } 73 | 74 | public function createQuery(array $options = []): Query 75 | { 76 | return new PdoEventStoreQuery( 77 | $this->eventStore, 78 | $this->connection, 79 | $this->eventStreamsTable, 80 | $options[Query::OPTION_PCNTL_DISPATCH] ?? Query::DEFAULT_PCNTL_DISPATCH 81 | ); 82 | } 83 | 84 | public function createProjection( 85 | string $name, 86 | array $options = [] 87 | ): Projector { 88 | return new PdoEventStoreProjector( 89 | $this->eventStore, 90 | $this->connection, 91 | $name, 92 | $this->eventStreamsTable, 93 | $this->projectionsTable, 94 | $options[PdoEventStoreProjector::OPTION_LOCK_TIMEOUT_MS] ?? PdoEventStoreProjector::DEFAULT_LOCK_TIMEOUT_MS, 95 | $options[PdoEventStoreProjector::OPTION_CACHE_SIZE] ?? PdoEventStoreProjector::DEFAULT_CACHE_SIZE, 96 | $options[PdoEventStoreProjector::OPTION_PERSIST_BLOCK_SIZE] ?? PdoEventStoreProjector::DEFAULT_PERSIST_BLOCK_SIZE, 97 | $options[PdoEventStoreProjector::OPTION_SLEEP] ?? PdoEventStoreProjector::DEFAULT_SLEEP, 98 | $options[PdoEventStoreProjector::OPTION_LOAD_COUNT] ?? PdoEventStoreProjector::DEFAULT_LOAD_COUNT, 99 | $options[PdoEventStoreProjector::OPTION_PCNTL_DISPATCH] ?? PdoEventStoreProjector::DEFAULT_PCNTL_DISPATCH, 100 | $options[PdoEventStoreProjector::OPTION_UPDATE_LOCK_THRESHOLD] ?? PdoEventStoreProjector::DEFAULT_UPDATE_LOCK_THRESHOLD, 101 | $options[PdoEventStoreProjector::OPTION_GAP_DETECTION] ?? null 102 | ); 103 | } 104 | 105 | public function createReadModelProjection( 106 | string $name, 107 | ReadModel $readModel, 108 | array $options = [] 109 | ): ReadModelProjector { 110 | return new PdoEventStoreReadModelProjector( 111 | $this->eventStore, 112 | $this->connection, 113 | $name, 114 | $readModel, 115 | $this->eventStreamsTable, 116 | $this->projectionsTable, 117 | $options[PdoEventStoreReadModelProjector::OPTION_LOCK_TIMEOUT_MS] ?? PdoEventStoreReadModelProjector::DEFAULT_LOCK_TIMEOUT_MS, 118 | $options[PdoEventStoreReadModelProjector::OPTION_PERSIST_BLOCK_SIZE] ?? PdoEventStoreReadModelProjector::DEFAULT_PERSIST_BLOCK_SIZE, 119 | $options[PdoEventStoreReadModelProjector::OPTION_SLEEP] ?? PdoEventStoreReadModelProjector::DEFAULT_SLEEP, 120 | $options[PdoEventStoreReadModelProjector::OPTION_LOAD_COUNT] ?? PdoEventStoreReadModelProjector::DEFAULT_LOAD_COUNT, 121 | $options[PdoEventStoreReadModelProjector::OPTION_PCNTL_DISPATCH] ?? PdoEventStoreReadModelProjector::DEFAULT_PCNTL_DISPATCH, 122 | $options[PdoEventStoreReadModelProjector::OPTION_UPDATE_LOCK_THRESHOLD] ?? PdoEventStoreReadModelProjector::DEFAULT_UPDATE_LOCK_THRESHOLD, 123 | $options[PdoEventStoreReadModelProjector::OPTION_GAP_DETECTION] ?? null 124 | ); 125 | } 126 | 127 | public function deleteProjection(string $name, bool $deleteEmittedEvents): void 128 | { 129 | $sql = <<projectionsTable` SET status = ? WHERE name = ? LIMIT 1; 131 | EOT; 132 | 133 | if ($deleteEmittedEvents) { 134 | $status = ProjectionStatus::DELETING_INCL_EMITTED_EVENTS()->getValue(); 135 | } else { 136 | $status = ProjectionStatus::DELETING()->getValue(); 137 | } 138 | 139 | $statement = $this->connection->prepare($sql); 140 | 141 | try { 142 | $statement->execute([ 143 | $status, 144 | $name, 145 | ]); 146 | } catch (PDOException $exception) { 147 | // ignore and check error code 148 | } 149 | 150 | if ($statement->errorCode() !== '00000') { 151 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 152 | } 153 | 154 | if (0 === $statement->rowCount()) { 155 | $sql = <<projectionsTable` WHERE name = ? LIMIT 1; 157 | EOT; 158 | $statement = $this->connection->prepare($sql); 159 | 160 | try { 161 | $statement->execute([$name]); 162 | } catch (PDOException $exception) { 163 | // ignore and check error code 164 | } 165 | 166 | if ($statement->errorCode() !== '00000') { 167 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 168 | } 169 | 170 | if (0 === $statement->rowCount()) { 171 | throw ProjectionNotFound::withName($name); 172 | } 173 | } 174 | } 175 | 176 | public function resetProjection(string $name): void 177 | { 178 | $sql = <<projectionsTable` SET status = ? WHERE name = ? LIMIT 1; 180 | EOT; 181 | 182 | $statement = $this->connection->prepare($sql); 183 | 184 | try { 185 | $statement->execute([ 186 | ProjectionStatus::RESETTING()->getValue(), 187 | $name, 188 | ]); 189 | } catch (PDOException $exception) { 190 | // ignore and check error code 191 | } 192 | 193 | if ($statement->errorCode() !== '00000') { 194 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 195 | } 196 | 197 | if (0 === $statement->rowCount()) { 198 | $sql = <<projectionsTable` WHERE name = ? LIMIT 1; 200 | EOT; 201 | $statement = $this->connection->prepare($sql); 202 | 203 | try { 204 | $statement->execute([$name]); 205 | } catch (PDOException $exception) { 206 | // ignore and check error code 207 | } 208 | 209 | if ($statement->errorCode() !== '00000') { 210 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 211 | } 212 | 213 | if (0 === $statement->rowCount()) { 214 | throw ProjectionNotFound::withName($name); 215 | } 216 | } 217 | } 218 | 219 | public function stopProjection(string $name): void 220 | { 221 | $sql = <<projectionsTable` SET status = ? WHERE name = ? LIMIT 1; 223 | EOT; 224 | 225 | $statement = $this->connection->prepare($sql); 226 | 227 | try { 228 | $statement->execute([ 229 | ProjectionStatus::STOPPING()->getValue(), 230 | $name, 231 | ]); 232 | } catch (PDOException $exception) { 233 | // ignore and check error code 234 | } 235 | 236 | if ($statement->errorCode() !== '00000') { 237 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 238 | } 239 | 240 | if (0 === $statement->rowCount()) { 241 | $sql = <<projectionsTable` WHERE name = ? LIMIT 1; 243 | EOT; 244 | $statement = $this->connection->prepare($sql); 245 | 246 | try { 247 | $statement->execute([$name]); 248 | } catch (PDOException $exception) { 249 | // ignore and check error code 250 | } 251 | 252 | if ($statement->errorCode() !== '00000') { 253 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 254 | } 255 | 256 | if (0 === $statement->rowCount()) { 257 | throw ProjectionNotFound::withName($name); 258 | } 259 | } 260 | } 261 | 262 | public function fetchProjectionNames(?string $filter, int $limit = 20, int $offset = 0): array 263 | { 264 | if (1 > $limit) { 265 | throw new OutOfRangeException( 266 | 'Invalid limit "'.$limit.'" given. Must be greater than 0.' 267 | ); 268 | } 269 | 270 | if (0 > $offset) { 271 | throw new OutOfRangeException( 272 | 'Invalid offset "'.$offset.'" given. Must be greater or equal than 0.' 273 | ); 274 | } 275 | 276 | $values = []; 277 | $whereCondition = ''; 278 | 279 | if (null !== $filter) { 280 | $values[':filter'] = $filter; 281 | 282 | $whereCondition = 'WHERE `name` = :filter'; 283 | } 284 | 285 | $query = <<projectionsTable` 287 | $whereCondition 288 | ORDER BY `name` ASC 289 | LIMIT $offset, $limit 290 | SQL; 291 | 292 | $statement = $this->connection->prepare($query); 293 | $statement->setFetchMode(PDO::FETCH_OBJ); 294 | 295 | try { 296 | $statement->execute($values); 297 | } catch (PDOException $exception) { 298 | // ignore and check error code 299 | } 300 | 301 | if ($statement->errorCode() !== '00000') { 302 | $errorCode = $statement->errorCode(); 303 | $errorInfo = $statement->errorInfo()[2]; 304 | 305 | throw new Exception\RuntimeException( 306 | "Error $errorCode. Maybe the event streams table is not setup?\nError-Info: $errorInfo" 307 | ); 308 | } 309 | 310 | $result = $statement->fetchAll(); 311 | 312 | $projectionNames = []; 313 | 314 | foreach ($result as $projectionName) { 315 | $projectionNames[] = $projectionName->name; 316 | } 317 | 318 | return $projectionNames; 319 | } 320 | 321 | public function fetchProjectionNamesRegex(string $filter, int $limit = 20, int $offset = 0): array 322 | { 323 | if (1 > $limit) { 324 | throw new OutOfRangeException( 325 | 'Invalid limit "'.$limit.'" given. Must be greater than 0.' 326 | ); 327 | } 328 | 329 | if (0 > $offset) { 330 | throw new OutOfRangeException( 331 | 'Invalid offset "'.$offset.'" given. Must be greater or equal than 0.' 332 | ); 333 | } 334 | 335 | if (empty($filter) || false === @\preg_match("/$filter/", '')) { 336 | throw new Exception\InvalidArgumentException('Invalid regex pattern given'); 337 | } 338 | 339 | $values = []; 340 | 341 | $values[':filter'] = $filter; 342 | 343 | $whereCondition = 'WHERE `name` REGEXP :filter'; 344 | 345 | $query = <<projectionsTable` 347 | $whereCondition 348 | ORDER BY `name` ASC 349 | LIMIT $offset, $limit 350 | SQL; 351 | 352 | $statement = $this->connection->prepare($query); 353 | $statement->setFetchMode(PDO::FETCH_OBJ); 354 | 355 | try { 356 | $statement->execute($values); 357 | } catch (PDOException $exception) { 358 | // ignore and check error code 359 | } 360 | 361 | if ($statement->errorCode() !== '00000') { 362 | $errorCode = $statement->errorCode(); 363 | $errorInfo = $statement->errorInfo()[2]; 364 | 365 | throw new Exception\RuntimeException( 366 | "Error $errorCode. Maybe the event streams table is not setup?\nError-Info: $errorInfo" 367 | ); 368 | } 369 | 370 | $result = $statement->fetchAll(); 371 | 372 | $projectionNames = []; 373 | 374 | foreach ($result as $projectionName) { 375 | $projectionNames[] = $projectionName->name; 376 | } 377 | 378 | return $projectionNames; 379 | } 380 | 381 | public function fetchProjectionStatus(string $name): ProjectionStatus 382 | { 383 | $query = <<projectionsTable` 385 | WHERE `name` = ? 386 | LIMIT 1 387 | SQL; 388 | 389 | $statement = $this->connection->prepare($query); 390 | $statement->setFetchMode(PDO::FETCH_OBJ); 391 | 392 | try { 393 | $statement->execute([$name]); 394 | } catch (PDOException $exception) { 395 | // ignore and check error code 396 | } 397 | 398 | if ($statement->errorCode() !== '00000') { 399 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 400 | } 401 | 402 | $result = $statement->fetch(); 403 | 404 | if (false === $result) { 405 | throw ProjectionNotFound::withName($name); 406 | } 407 | 408 | return ProjectionStatus::byValue($result->status); 409 | } 410 | 411 | public function fetchProjectionStreamPositions(string $name): array 412 | { 413 | $query = <<projectionsTable` 415 | WHERE `name` = ? 416 | LIMIT 1 417 | SQL; 418 | 419 | $statement = $this->connection->prepare($query); 420 | $statement->setFetchMode(PDO::FETCH_OBJ); 421 | 422 | try { 423 | $statement->execute([$name]); 424 | } catch (PDOException $exception) { 425 | // ignore and check error code 426 | } 427 | 428 | if ($statement->errorCode() !== '00000') { 429 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 430 | } 431 | 432 | $result = $statement->fetch(); 433 | 434 | if (false === $result) { 435 | throw ProjectionNotFound::withName($name); 436 | } 437 | 438 | return Json::decode($result->position); 439 | } 440 | 441 | public function fetchProjectionState(string $name): array 442 | { 443 | $query = <<projectionsTable` 445 | WHERE `name` = ? 446 | LIMIT 1 447 | SQL; 448 | 449 | $statement = $this->connection->prepare($query); 450 | $statement->setFetchMode(PDO::FETCH_OBJ); 451 | 452 | try { 453 | $statement->execute([$name]); 454 | } catch (PDOException $exception) { 455 | // ignore and check error code 456 | } 457 | 458 | if ($statement->errorCode() !== '00000') { 459 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 460 | } 461 | 462 | $result = $statement->fetch(); 463 | 464 | if (false === $result) { 465 | throw ProjectionNotFound::withName($name); 466 | } 467 | 468 | return Json::decode($result->state); 469 | } 470 | } 471 | -------------------------------------------------------------------------------- /src/Projection/MySqlProjectionManager.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Projection; 15 | 16 | use PDO; 17 | use PDOException; 18 | use Prooph\EventStore\EventStore; 19 | use Prooph\EventStore\EventStoreDecorator; 20 | use Prooph\EventStore\Exception\OutOfRangeException; 21 | use Prooph\EventStore\Exception\ProjectionNotFound; 22 | use Prooph\EventStore\Pdo\Exception; 23 | use Prooph\EventStore\Pdo\MySqlEventStore; 24 | use Prooph\EventStore\Pdo\Util\Json; 25 | use Prooph\EventStore\Projection\ProjectionManager; 26 | use Prooph\EventStore\Projection\ProjectionStatus; 27 | use Prooph\EventStore\Projection\Projector; 28 | use Prooph\EventStore\Projection\Query; 29 | use Prooph\EventStore\Projection\ReadModel; 30 | use Prooph\EventStore\Projection\ReadModelProjector; 31 | 32 | final class MySqlProjectionManager implements ProjectionManager 33 | { 34 | /** 35 | * @var EventStore 36 | */ 37 | private $eventStore; 38 | 39 | /** 40 | * @var PDO 41 | */ 42 | private $connection; 43 | 44 | /** 45 | * @var string 46 | */ 47 | private $eventStreamsTable; 48 | 49 | /** 50 | * @var string 51 | */ 52 | private $projectionsTable; 53 | 54 | public function __construct( 55 | EventStore $eventStore, 56 | PDO $connection, 57 | string $eventStreamsTable = 'event_streams', 58 | string $projectionsTable = 'projections' 59 | ) { 60 | $this->eventStore = $eventStore; 61 | $this->connection = $connection; 62 | $this->eventStreamsTable = $eventStreamsTable; 63 | $this->projectionsTable = $projectionsTable; 64 | 65 | while ($eventStore instanceof EventStoreDecorator) { 66 | $eventStore = $eventStore->getInnerEventStore(); 67 | } 68 | 69 | if (! $eventStore instanceof MySqlEventStore) { 70 | throw new Exception\InvalidArgumentException('Unknown event store instance given'); 71 | } 72 | } 73 | 74 | public function createQuery(array $options = []): Query 75 | { 76 | return new PdoEventStoreQuery( 77 | $this->eventStore, 78 | $this->connection, 79 | $this->eventStreamsTable, 80 | $options[Query::OPTION_PCNTL_DISPATCH] ?? Query::DEFAULT_PCNTL_DISPATCH 81 | ); 82 | } 83 | 84 | public function createProjection( 85 | string $name, 86 | array $options = [] 87 | ): Projector { 88 | return new PdoEventStoreProjector( 89 | $this->eventStore, 90 | $this->connection, 91 | $name, 92 | $this->eventStreamsTable, 93 | $this->projectionsTable, 94 | $options[PdoEventStoreProjector::OPTION_LOCK_TIMEOUT_MS] ?? PdoEventStoreProjector::DEFAULT_LOCK_TIMEOUT_MS, 95 | $options[PdoEventStoreProjector::OPTION_CACHE_SIZE] ?? PdoEventStoreProjector::DEFAULT_CACHE_SIZE, 96 | $options[PdoEventStoreProjector::OPTION_PERSIST_BLOCK_SIZE] ?? PdoEventStoreProjector::DEFAULT_PERSIST_BLOCK_SIZE, 97 | $options[PdoEventStoreProjector::OPTION_SLEEP] ?? PdoEventStoreProjector::DEFAULT_SLEEP, 98 | $options[PdoEventStoreProjector::OPTION_LOAD_COUNT] ?? PdoEventStoreProjector::DEFAULT_LOAD_COUNT, 99 | $options[PdoEventStoreProjector::OPTION_PCNTL_DISPATCH] ?? PdoEventStoreProjector::DEFAULT_PCNTL_DISPATCH, 100 | $options[PdoEventStoreProjector::OPTION_UPDATE_LOCK_THRESHOLD] ?? PdoEventStoreProjector::DEFAULT_UPDATE_LOCK_THRESHOLD, 101 | $options[PdoEventStoreProjector::OPTION_GAP_DETECTION] ?? null 102 | ); 103 | } 104 | 105 | public function createReadModelProjection( 106 | string $name, 107 | ReadModel $readModel, 108 | array $options = [] 109 | ): ReadModelProjector { 110 | return new PdoEventStoreReadModelProjector( 111 | $this->eventStore, 112 | $this->connection, 113 | $name, 114 | $readModel, 115 | $this->eventStreamsTable, 116 | $this->projectionsTable, 117 | $options[PdoEventStoreReadModelProjector::OPTION_LOCK_TIMEOUT_MS] ?? PdoEventStoreReadModelProjector::DEFAULT_LOCK_TIMEOUT_MS, 118 | $options[PdoEventStoreReadModelProjector::OPTION_PERSIST_BLOCK_SIZE] ?? PdoEventStoreReadModelProjector::DEFAULT_PERSIST_BLOCK_SIZE, 119 | $options[PdoEventStoreReadModelProjector::OPTION_SLEEP] ?? PdoEventStoreReadModelProjector::DEFAULT_SLEEP, 120 | $options[PdoEventStoreReadModelProjector::OPTION_LOAD_COUNT] ?? PdoEventStoreReadModelProjector::DEFAULT_LOAD_COUNT, 121 | $options[PdoEventStoreReadModelProjector::OPTION_PCNTL_DISPATCH] ?? PdoEventStoreReadModelProjector::DEFAULT_PCNTL_DISPATCH, 122 | $options[PdoEventStoreReadModelProjector::OPTION_UPDATE_LOCK_THRESHOLD] ?? PdoEventStoreReadModelProjector::DEFAULT_UPDATE_LOCK_THRESHOLD, 123 | $options[PdoEventStoreReadModelProjector::OPTION_GAP_DETECTION] ?? null 124 | ); 125 | } 126 | 127 | public function deleteProjection(string $name, bool $deleteEmittedEvents): void 128 | { 129 | $sql = <<projectionsTable` SET status = ? WHERE name = ? LIMIT 1; 131 | EOT; 132 | 133 | if ($deleteEmittedEvents) { 134 | $status = ProjectionStatus::DELETING_INCL_EMITTED_EVENTS()->getValue(); 135 | } else { 136 | $status = ProjectionStatus::DELETING()->getValue(); 137 | } 138 | 139 | $statement = $this->connection->prepare($sql); 140 | 141 | try { 142 | $statement->execute([ 143 | $status, 144 | $name, 145 | ]); 146 | } catch (PDOException $exception) { 147 | // ignore and check error code 148 | } 149 | 150 | if ($statement->errorCode() !== '00000') { 151 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 152 | } 153 | 154 | if (0 === $statement->rowCount()) { 155 | $sql = <<projectionsTable` WHERE name = ? LIMIT 1; 157 | EOT; 158 | $statement = $this->connection->prepare($sql); 159 | 160 | try { 161 | $statement->execute([$name]); 162 | } catch (PDOException $exception) { 163 | // ignore and check error code 164 | } 165 | 166 | if ($statement->errorCode() !== '00000') { 167 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 168 | } 169 | 170 | if (0 === $statement->rowCount()) { 171 | throw ProjectionNotFound::withName($name); 172 | } 173 | } 174 | } 175 | 176 | public function resetProjection(string $name): void 177 | { 178 | $sql = <<projectionsTable` SET status = ? WHERE name = ? LIMIT 1; 180 | EOT; 181 | 182 | $statement = $this->connection->prepare($sql); 183 | 184 | try { 185 | $statement->execute([ 186 | ProjectionStatus::RESETTING()->getValue(), 187 | $name, 188 | ]); 189 | } catch (PDOException $exception) { 190 | // ignore and check error code 191 | } 192 | 193 | if ($statement->errorCode() !== '00000') { 194 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 195 | } 196 | 197 | if (0 === $statement->rowCount()) { 198 | $sql = <<projectionsTable` WHERE name = ? LIMIT 1; 200 | EOT; 201 | $statement = $this->connection->prepare($sql); 202 | 203 | try { 204 | $statement->execute([$name]); 205 | } catch (PDOException $exception) { 206 | // ignore and check error code 207 | } 208 | 209 | if ($statement->errorCode() !== '00000') { 210 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 211 | } 212 | 213 | if (0 === $statement->rowCount()) { 214 | throw ProjectionNotFound::withName($name); 215 | } 216 | } 217 | } 218 | 219 | public function stopProjection(string $name): void 220 | { 221 | $sql = <<projectionsTable` SET status = ? WHERE name = ? LIMIT 1; 223 | EOT; 224 | 225 | $statement = $this->connection->prepare($sql); 226 | 227 | try { 228 | $statement->execute([ 229 | ProjectionStatus::STOPPING()->getValue(), 230 | $name, 231 | ]); 232 | } catch (PDOException $exception) { 233 | // ignore and check error code 234 | } 235 | 236 | if ($statement->errorCode() !== '00000') { 237 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 238 | } 239 | 240 | if (0 === $statement->rowCount()) { 241 | $sql = <<projectionsTable` WHERE name = ? LIMIT 1; 243 | EOT; 244 | $statement = $this->connection->prepare($sql); 245 | 246 | try { 247 | $statement->execute([$name]); 248 | } catch (PDOException $exception) { 249 | // ignore and check error code 250 | } 251 | 252 | if ($statement->errorCode() !== '00000') { 253 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 254 | } 255 | 256 | if (0 === $statement->rowCount()) { 257 | throw ProjectionNotFound::withName($name); 258 | } 259 | } 260 | } 261 | 262 | public function fetchProjectionNames(?string $filter, int $limit = 20, int $offset = 0): array 263 | { 264 | if (1 > $limit) { 265 | throw new OutOfRangeException( 266 | 'Invalid limit "'.$limit.'" given. Must be greater than 0.' 267 | ); 268 | } 269 | 270 | if (0 > $offset) { 271 | throw new OutOfRangeException( 272 | 'Invalid offset "'.$offset.'" given. Must be greater or equal than 0.' 273 | ); 274 | } 275 | 276 | $values = []; 277 | $whereCondition = ''; 278 | 279 | if (null !== $filter) { 280 | $values[':filter'] = $filter; 281 | 282 | $whereCondition = 'WHERE `name` = :filter'; 283 | } 284 | 285 | $query = <<projectionsTable` 287 | $whereCondition 288 | ORDER BY `name` ASC 289 | LIMIT $offset, $limit 290 | SQL; 291 | 292 | $statement = $this->connection->prepare($query); 293 | $statement->setFetchMode(PDO::FETCH_OBJ); 294 | 295 | try { 296 | $statement->execute($values); 297 | } catch (PDOException $exception) { 298 | // ignore and check error code 299 | } 300 | 301 | if ($statement->errorCode() !== '00000') { 302 | $errorCode = $statement->errorCode(); 303 | $errorInfo = $statement->errorInfo()[2]; 304 | 305 | throw new Exception\RuntimeException( 306 | "Error $errorCode. Maybe the event streams table is not setup?\nError-Info: $errorInfo" 307 | ); 308 | } 309 | 310 | $result = $statement->fetchAll(); 311 | 312 | $projectionNames = []; 313 | 314 | foreach ($result as $projectionName) { 315 | $projectionNames[] = $projectionName->name; 316 | } 317 | 318 | return $projectionNames; 319 | } 320 | 321 | public function fetchProjectionNamesRegex(string $filter, int $limit = 20, int $offset = 0): array 322 | { 323 | if (1 > $limit) { 324 | throw new OutOfRangeException( 325 | 'Invalid limit "'.$limit.'" given. Must be greater than 0.' 326 | ); 327 | } 328 | 329 | if (0 > $offset) { 330 | throw new OutOfRangeException( 331 | 'Invalid offset "'.$offset.'" given. Must be greater or equal than 0.' 332 | ); 333 | } 334 | 335 | if (empty($filter) || false === @\preg_match("/$filter/", '')) { 336 | throw new Exception\InvalidArgumentException('Invalid regex pattern given'); 337 | } 338 | 339 | $values = []; 340 | 341 | $values[':filter'] = $filter; 342 | 343 | $whereCondition = 'WHERE `name` REGEXP :filter'; 344 | 345 | $query = <<projectionsTable` 347 | $whereCondition 348 | ORDER BY `name` ASC 349 | LIMIT $offset, $limit 350 | SQL; 351 | 352 | $statement = $this->connection->prepare($query); 353 | $statement->setFetchMode(PDO::FETCH_OBJ); 354 | 355 | try { 356 | $statement->execute($values); 357 | } catch (PDOException $exception) { 358 | // ignore and check error code 359 | } 360 | 361 | if ($statement->errorCode() !== '00000') { 362 | $errorCode = $statement->errorCode(); 363 | $errorInfo = $statement->errorInfo()[2]; 364 | 365 | throw new Exception\RuntimeException( 366 | "Error $errorCode. Maybe the event streams table is not setup?\nError-Info: $errorInfo" 367 | ); 368 | } 369 | 370 | $result = $statement->fetchAll(); 371 | 372 | $projectionNames = []; 373 | 374 | foreach ($result as $projectionName) { 375 | $projectionNames[] = $projectionName->name; 376 | } 377 | 378 | return $projectionNames; 379 | } 380 | 381 | public function fetchProjectionStatus(string $name): ProjectionStatus 382 | { 383 | $query = <<projectionsTable` 385 | WHERE `name` = ? 386 | LIMIT 1 387 | SQL; 388 | 389 | $statement = $this->connection->prepare($query); 390 | $statement->setFetchMode(PDO::FETCH_OBJ); 391 | 392 | try { 393 | $statement->execute([$name]); 394 | } catch (PDOException $exception) { 395 | // ignore and check error code 396 | } 397 | 398 | if ($statement->errorCode() !== '00000') { 399 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 400 | } 401 | 402 | $result = $statement->fetch(); 403 | 404 | if (false === $result) { 405 | throw ProjectionNotFound::withName($name); 406 | } 407 | 408 | return ProjectionStatus::byValue($result->status); 409 | } 410 | 411 | public function fetchProjectionStreamPositions(string $name): array 412 | { 413 | $query = <<projectionsTable` 415 | WHERE `name` = ? 416 | LIMIT 1 417 | SQL; 418 | 419 | $statement = $this->connection->prepare($query); 420 | $statement->setFetchMode(PDO::FETCH_OBJ); 421 | 422 | try { 423 | $statement->execute([$name]); 424 | } catch (PDOException $exception) { 425 | // ignore and check error code 426 | } 427 | 428 | if ($statement->errorCode() !== '00000') { 429 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 430 | } 431 | 432 | $result = $statement->fetch(); 433 | 434 | if (false === $result) { 435 | throw ProjectionNotFound::withName($name); 436 | } 437 | 438 | return Json::decode($result->position); 439 | } 440 | 441 | public function fetchProjectionState(string $name): array 442 | { 443 | $query = <<projectionsTable` 445 | WHERE `name` = ? 446 | LIMIT 1 447 | SQL; 448 | 449 | $statement = $this->connection->prepare($query); 450 | $statement->setFetchMode(PDO::FETCH_OBJ); 451 | 452 | try { 453 | $statement->execute([$name]); 454 | } catch (PDOException $exception) { 455 | // ignore and check error code 456 | } 457 | 458 | if ($statement->errorCode() !== '00000') { 459 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 460 | } 461 | 462 | $result = $statement->fetch(); 463 | 464 | if (false === $result) { 465 | throw ProjectionNotFound::withName($name); 466 | } 467 | 468 | return Json::decode($result->state); 469 | } 470 | } 471 | -------------------------------------------------------------------------------- /src/Projection/PdoEventStoreQuery.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Projection; 15 | 16 | use Closure; 17 | use PDO; 18 | use PDOException; 19 | use Prooph\Common\Messaging\Message; 20 | use Prooph\EventStore\EventStore; 21 | use Prooph\EventStore\EventStoreDecorator; 22 | use Prooph\EventStore\Exception; 23 | use Prooph\EventStore\Metadata\MetadataMatcher; 24 | use Prooph\EventStore\Pdo\Exception\RuntimeException; 25 | use Prooph\EventStore\Pdo\PdoEventStore; 26 | use Prooph\EventStore\Pdo\Util\PostgresHelper; 27 | use Prooph\EventStore\Projection\Query; 28 | use Prooph\EventStore\StreamIterator\MergedStreamIterator; 29 | use Prooph\EventStore\StreamName; 30 | 31 | final class PdoEventStoreQuery implements Query 32 | { 33 | use PostgresHelper { 34 | quoteIdent as pgQuoteIdent; 35 | extractSchema as pgExtractSchema; 36 | } 37 | 38 | /** 39 | * @var EventStore 40 | */ 41 | private $eventStore; 42 | 43 | /** 44 | * @var PDO 45 | */ 46 | private $connection; 47 | 48 | /** 49 | * @var string 50 | */ 51 | private $eventStreamsTable; 52 | 53 | /** 54 | * @var array 55 | */ 56 | private $streamPositions = []; 57 | 58 | /** 59 | * @var array 60 | */ 61 | private $state = []; 62 | 63 | /** 64 | * @var callable|null 65 | */ 66 | private $initCallback; 67 | 68 | /** 69 | * @var Closure|null 70 | */ 71 | private $handler; 72 | 73 | /** 74 | * @var array 75 | */ 76 | private $handlers = []; 77 | 78 | /** 79 | * @var boolean 80 | */ 81 | private $isStopped = false; 82 | 83 | /** 84 | * @var ?string 85 | */ 86 | private $currentStreamName = null; 87 | 88 | /** 89 | * @var array|null 90 | */ 91 | private $query; 92 | 93 | /** 94 | * @var string 95 | */ 96 | private $vendor; 97 | 98 | /** 99 | * @var bool 100 | */ 101 | private $triggerPcntlSignalDispatch; 102 | 103 | /** 104 | * @var MetadataMatcher|null 105 | */ 106 | private $metadataMatcher; 107 | 108 | public function __construct(EventStore $eventStore, PDO $connection, string $eventStreamsTable, bool $triggerPcntlSignalDispatch = false) 109 | { 110 | $this->eventStore = $eventStore; 111 | $this->connection = $connection; 112 | $this->eventStreamsTable = $eventStreamsTable; 113 | $this->vendor = $this->connection->getAttribute(PDO::ATTR_DRIVER_NAME); 114 | $this->triggerPcntlSignalDispatch = $triggerPcntlSignalDispatch; 115 | 116 | while ($eventStore instanceof EventStoreDecorator) { 117 | $eventStore = $eventStore->getInnerEventStore(); 118 | } 119 | 120 | if (! $eventStore instanceof PdoEventStore) { 121 | throw new Exception\InvalidArgumentException('Unknown event store instance given'); 122 | } 123 | } 124 | 125 | public function init(Closure $callback): Query 126 | { 127 | if (null !== $this->initCallback) { 128 | throw new Exception\RuntimeException('Projection already initialized'); 129 | } 130 | 131 | $callback = Closure::bind($callback, $this->createHandlerContext($this->currentStreamName)); 132 | 133 | $result = $callback(); 134 | 135 | if (\is_array($result)) { 136 | $this->state = $result; 137 | } 138 | 139 | $this->initCallback = $callback; 140 | 141 | return $this; 142 | } 143 | 144 | public function fromStream(string $streamName, ?MetadataMatcher $metadataMatcher = null): Query 145 | { 146 | if (null !== $this->query) { 147 | throw new Exception\RuntimeException('From was already called'); 148 | } 149 | 150 | $this->query['streams'][] = $streamName; 151 | $this->metadataMatcher = $metadataMatcher; 152 | 153 | return $this; 154 | } 155 | 156 | public function fromStreams(string ...$streamNames): Query 157 | { 158 | if (null !== $this->query) { 159 | throw new Exception\RuntimeException('From was already called'); 160 | } 161 | 162 | foreach ($streamNames as $streamName) { 163 | $this->query['streams'][] = $streamName; 164 | } 165 | 166 | return $this; 167 | } 168 | 169 | public function fromCategory(string $name): Query 170 | { 171 | if (null !== $this->query) { 172 | throw new Exception\RuntimeException('From was already called'); 173 | } 174 | 175 | $this->query['categories'][] = $name; 176 | 177 | return $this; 178 | } 179 | 180 | public function fromCategories(string ...$names): Query 181 | { 182 | if (null !== $this->query) { 183 | throw new Exception\RuntimeException('From was already called'); 184 | } 185 | 186 | foreach ($names as $name) { 187 | $this->query['categories'][] = $name; 188 | } 189 | 190 | return $this; 191 | } 192 | 193 | public function fromAll(): Query 194 | { 195 | if (null !== $this->query) { 196 | throw new Exception\RuntimeException('From was already called'); 197 | } 198 | 199 | $this->query['all'] = true; 200 | 201 | return $this; 202 | } 203 | 204 | public function when(array $handlers): Query 205 | { 206 | if (null !== $this->handler || ! empty($this->handlers)) { 207 | throw new Exception\RuntimeException('When was already called'); 208 | } 209 | 210 | foreach ($handlers as $eventName => $handler) { 211 | if (! \is_string($eventName)) { 212 | throw new Exception\InvalidArgumentException('Invalid event name given, string expected'); 213 | } 214 | 215 | if (! $handler instanceof Closure) { 216 | throw new Exception\InvalidArgumentException('Invalid handler given, Closure expected'); 217 | } 218 | 219 | $this->handlers[$eventName] = Closure::bind($handler, $this->createHandlerContext($this->currentStreamName)); 220 | } 221 | 222 | return $this; 223 | } 224 | 225 | public function whenAny(Closure $handler): Query 226 | { 227 | if (null !== $this->handler || ! empty($this->handlers)) { 228 | throw new Exception\RuntimeException('When was already called'); 229 | } 230 | 231 | $this->handler = Closure::bind($handler, $this->createHandlerContext($this->currentStreamName)); 232 | 233 | return $this; 234 | } 235 | 236 | public function reset(): void 237 | { 238 | $this->streamPositions = []; 239 | 240 | $callback = $this->initCallback; 241 | 242 | if (\is_callable($callback)) { 243 | $result = $callback(); 244 | 245 | if (\is_array($result)) { 246 | $this->state = $result; 247 | 248 | return; 249 | } 250 | } 251 | 252 | $this->state = []; 253 | } 254 | 255 | public function stop(): void 256 | { 257 | $this->isStopped = true; 258 | } 259 | 260 | public function getState(): array 261 | { 262 | return $this->state; 263 | } 264 | 265 | public function run(): void 266 | { 267 | if (null === $this->query 268 | || (null === $this->handler && empty($this->handlers)) 269 | ) { 270 | throw new Exception\RuntimeException('No handlers configured'); 271 | } 272 | 273 | $singleHandler = null !== $this->handler; 274 | 275 | $this->isStopped = false; 276 | $this->prepareStreamPositions(); 277 | 278 | $eventStreams = []; 279 | 280 | foreach ($this->streamPositions as $streamName => $position) { 281 | try { 282 | $eventStreams[$streamName] = $this->eventStore->load(new StreamName($streamName), $position + 1, null, $this->metadataMatcher); 283 | } catch (Exception\StreamNotFound $e) { 284 | // ignore 285 | continue; 286 | } 287 | } 288 | 289 | $streamEvents = new MergedStreamIterator(\array_keys($eventStreams), ...\array_values($eventStreams)); 290 | 291 | if ($singleHandler) { 292 | $this->handleStreamWithSingleHandler($streamEvents); 293 | } else { 294 | $this->handleStreamWithHandlers($streamEvents); 295 | } 296 | } 297 | 298 | private function handleStreamWithSingleHandler(MergedStreamIterator $events): void 299 | { 300 | $handler = $this->handler; 301 | 302 | // @var Message $event 303 | foreach ($events as $key => $event) { 304 | if ($this->triggerPcntlSignalDispatch) { 305 | \pcntl_signal_dispatch(); 306 | } 307 | 308 | $this->currentStreamName = $events->streamName(); 309 | $this->streamPositions[$this->currentStreamName] = $key; 310 | 311 | $result = $handler($this->state, $event); 312 | 313 | if (\is_array($result)) { 314 | $this->state = $result; 315 | } 316 | 317 | if ($this->isStopped) { 318 | break; 319 | } 320 | } 321 | } 322 | 323 | private function handleStreamWithHandlers(MergedStreamIterator $events): void 324 | { 325 | // @var Message $event 326 | foreach ($events as $key => $event) { 327 | if ($this->triggerPcntlSignalDispatch) { 328 | \pcntl_signal_dispatch(); 329 | } 330 | 331 | $this->currentStreamName = $events->streamName(); 332 | $this->streamPositions[$this->currentStreamName] = $key; 333 | 334 | if (! isset($this->handlers[$event->messageName()])) { 335 | if ($this->isStopped) { 336 | break; 337 | } 338 | 339 | continue; 340 | } 341 | 342 | $handler = $this->handlers[$event->messageName()]; 343 | $result = $handler($this->state, $event); 344 | 345 | if (\is_array($result)) { 346 | $this->state = $result; 347 | } 348 | 349 | if ($this->isStopped) { 350 | break; 351 | } 352 | } 353 | } 354 | 355 | private function createHandlerContext(?string &$streamName) 356 | { 357 | return new class($this, $streamName) { 358 | /** 359 | * @var Query 360 | */ 361 | private $query; 362 | 363 | /** 364 | * @var ?string 365 | */ 366 | private $streamName; 367 | 368 | public function __construct(Query $query, ?string &$streamName) 369 | { 370 | $this->query = $query; 371 | $this->streamName = &$streamName; 372 | } 373 | 374 | public function stop(): void 375 | { 376 | $this->query->stop(); 377 | } 378 | 379 | public function streamName(): ?string 380 | { 381 | return $this->streamName; 382 | } 383 | }; 384 | } 385 | 386 | private function prepareStreamPositions(): void 387 | { 388 | $streamPositions = []; 389 | 390 | if (isset($this->query['all'])) { 391 | $eventStreamsTable = $this->quoteTableName($this->eventStreamsTable); 392 | $sql = <<connection->prepare($sql); 396 | 397 | try { 398 | $statement->execute(); 399 | } catch (PDOException $exception) { 400 | // ignore and check error code 401 | } 402 | 403 | if ($statement->errorCode() !== '00000') { 404 | throw RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 405 | } 406 | 407 | while ($row = $statement->fetch(PDO::FETCH_OBJ)) { 408 | $streamPositions[$row->real_stream_name] = 0; 409 | } 410 | 411 | $this->streamPositions = \array_merge($streamPositions, $this->streamPositions); 412 | 413 | return; 414 | } 415 | 416 | if (isset($this->query['categories'])) { 417 | $rowPlaces = \implode(', ', \array_fill(0, \count($this->query['categories']), '?')); 418 | 419 | $eventStreamsTable = $this->quoteTableName($this->eventStreamsTable); 420 | $sql = <<connection->prepare($sql); 424 | 425 | try { 426 | $statement->execute($this->query['categories']); 427 | } catch (PDOException $exception) { 428 | // ignore and check error code 429 | } 430 | 431 | if ($statement->errorCode() !== '00000') { 432 | throw RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 433 | } 434 | 435 | while ($row = $statement->fetch(PDO::FETCH_OBJ)) { 436 | $streamPositions[$row->real_stream_name] = 0; 437 | } 438 | 439 | $this->streamPositions = \array_merge($streamPositions, $this->streamPositions); 440 | 441 | return; 442 | } 443 | 444 | // stream names given 445 | foreach ($this->query['streams'] as $streamName) { 446 | $streamPositions[$streamName] = 0; 447 | } 448 | 449 | $this->streamPositions = \array_merge($streamPositions, $this->streamPositions); 450 | } 451 | 452 | private function quoteTableName(string $tableName): string 453 | { 454 | switch ($this->vendor) { 455 | case 'pgsql': 456 | return $this->pgQuoteIdent($tableName); 457 | default: 458 | return "`$tableName`"; 459 | } 460 | } 461 | } 462 | -------------------------------------------------------------------------------- /src/Projection/PostgresProjectionManager.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Projection; 15 | 16 | use PDO; 17 | use PDOException; 18 | use Prooph\EventStore\EventStore; 19 | use Prooph\EventStore\EventStoreDecorator; 20 | use Prooph\EventStore\Exception\OutOfRangeException; 21 | use Prooph\EventStore\Exception\ProjectionNotFound; 22 | use Prooph\EventStore\Pdo\Exception; 23 | use Prooph\EventStore\Pdo\PostgresEventStore; 24 | use Prooph\EventStore\Pdo\Util\Json; 25 | use Prooph\EventStore\Pdo\Util\PostgresHelper; 26 | use Prooph\EventStore\Projection\ProjectionManager; 27 | use Prooph\EventStore\Projection\ProjectionStatus; 28 | use Prooph\EventStore\Projection\Projector; 29 | use Prooph\EventStore\Projection\Query; 30 | use Prooph\EventStore\Projection\ReadModel; 31 | use Prooph\EventStore\Projection\ReadModelProjector; 32 | 33 | final class PostgresProjectionManager implements ProjectionManager 34 | { 35 | use PostgresHelper; 36 | 37 | /** 38 | * @var EventStore 39 | */ 40 | private $eventStore; 41 | 42 | /** 43 | * @var PDO 44 | */ 45 | private $connection; 46 | 47 | /** 48 | * @var string 49 | */ 50 | private $eventStreamsTable; 51 | 52 | /** 53 | * @var string 54 | */ 55 | private $projectionsTable; 56 | 57 | public function __construct( 58 | EventStore $eventStore, 59 | PDO $connection, 60 | string $eventStreamsTable = 'event_streams', 61 | string $projectionsTable = 'projections' 62 | ) { 63 | $this->eventStore = $eventStore; 64 | $this->connection = $connection; 65 | $this->eventStreamsTable = $eventStreamsTable; 66 | $this->projectionsTable = $projectionsTable; 67 | 68 | while ($eventStore instanceof EventStoreDecorator) { 69 | $eventStore = $eventStore->getInnerEventStore(); 70 | } 71 | 72 | if (! $eventStore instanceof PostgresEventStore) { 73 | throw new Exception\InvalidArgumentException('Unknown event store instance given'); 74 | } 75 | } 76 | 77 | public function createQuery(array $options = []): Query 78 | { 79 | return new PdoEventStoreQuery( 80 | $this->eventStore, 81 | $this->connection, 82 | $this->eventStreamsTable, 83 | $options[Query::OPTION_PCNTL_DISPATCH] ?? Query::DEFAULT_PCNTL_DISPATCH 84 | ); 85 | } 86 | 87 | public function createProjection( 88 | string $name, 89 | array $options = [] 90 | ): Projector { 91 | return new PdoEventStoreProjector( 92 | $this->eventStore, 93 | $this->connection, 94 | $name, 95 | $this->eventStreamsTable, 96 | $this->projectionsTable, 97 | $options[PdoEventStoreProjector::OPTION_LOCK_TIMEOUT_MS] ?? PdoEventStoreProjector::DEFAULT_LOCK_TIMEOUT_MS, 98 | $options[PdoEventStoreProjector::OPTION_CACHE_SIZE] ?? PdoEventStoreProjector::DEFAULT_CACHE_SIZE, 99 | $options[PdoEventStoreProjector::OPTION_PERSIST_BLOCK_SIZE] ?? PdoEventStoreProjector::DEFAULT_PERSIST_BLOCK_SIZE, 100 | $options[PdoEventStoreProjector::OPTION_SLEEP] ?? PdoEventStoreProjector::DEFAULT_SLEEP, 101 | $options[PdoEventStoreProjector::OPTION_LOAD_COUNT] ?? PdoEventStoreProjector::DEFAULT_LOAD_COUNT, 102 | $options[PdoEventStoreProjector::OPTION_PCNTL_DISPATCH] ?? PdoEventStoreProjector::DEFAULT_PCNTL_DISPATCH, 103 | $options[PdoEventStoreProjector::OPTION_UPDATE_LOCK_THRESHOLD] ?? PdoEventStoreProjector::DEFAULT_UPDATE_LOCK_THRESHOLD, 104 | $options[PdoEventStoreProjector::OPTION_GAP_DETECTION] ?? null 105 | ); 106 | } 107 | 108 | public function createReadModelProjection( 109 | string $name, 110 | ReadModel $readModel, 111 | array $options = [] 112 | ): ReadModelProjector { 113 | return new PdoEventStoreReadModelProjector( 114 | $this->eventStore, 115 | $this->connection, 116 | $name, 117 | $readModel, 118 | $this->eventStreamsTable, 119 | $this->projectionsTable, 120 | $options[PdoEventStoreReadModelProjector::OPTION_LOCK_TIMEOUT_MS] ?? PdoEventStoreReadModelProjector::DEFAULT_LOCK_TIMEOUT_MS, 121 | $options[PdoEventStoreReadModelProjector::OPTION_PERSIST_BLOCK_SIZE] ?? PdoEventStoreReadModelProjector::DEFAULT_PERSIST_BLOCK_SIZE, 122 | $options[PdoEventStoreReadModelProjector::OPTION_SLEEP] ?? PdoEventStoreReadModelProjector::DEFAULT_SLEEP, 123 | $options[PdoEventStoreReadModelProjector::OPTION_LOAD_COUNT] ?? PdoEventStoreReadModelProjector::DEFAULT_LOAD_COUNT, 124 | $options[PdoEventStoreReadModelProjector::OPTION_PCNTL_DISPATCH] ?? PdoEventStoreReadModelProjector::DEFAULT_PCNTL_DISPATCH, 125 | $options[PdoEventStoreReadModelProjector::OPTION_UPDATE_LOCK_THRESHOLD] ?? PdoEventStoreReadModelProjector::DEFAULT_UPDATE_LOCK_THRESHOLD, 126 | $options[PdoEventStoreReadModelProjector::OPTION_GAP_DETECTION] ?? null 127 | ); 128 | } 129 | 130 | public function deleteProjection(string $name, bool $deleteEmittedEvents): void 131 | { 132 | $sql = <<quoteIdent($this->projectionsTable)} SET status = ? WHERE name = ?; 134 | EOT; 135 | 136 | if ($deleteEmittedEvents) { 137 | $status = ProjectionStatus::DELETING_INCL_EMITTED_EVENTS()->getValue(); 138 | } else { 139 | $status = ProjectionStatus::DELETING()->getValue(); 140 | } 141 | 142 | $statement = $this->connection->prepare($sql); 143 | 144 | try { 145 | $statement->execute([ 146 | $status, 147 | $name, 148 | ]); 149 | } catch (PDOException $exception) { 150 | // ignore and check error code 151 | } 152 | 153 | if ($statement->errorCode() !== '00000') { 154 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 155 | } 156 | 157 | if (0 === $statement->rowCount()) { 158 | throw ProjectionNotFound::withName($name); 159 | } 160 | } 161 | 162 | public function resetProjection(string $name): void 163 | { 164 | $sql = <<quoteIdent($this->projectionsTable)} SET status = ? WHERE name = ?; 166 | EOT; 167 | 168 | $statement = $this->connection->prepare($sql); 169 | 170 | try { 171 | $statement->execute([ 172 | ProjectionStatus::RESETTING()->getValue(), 173 | $name, 174 | ]); 175 | } catch (PDOException $exception) { 176 | // ignore and check error code 177 | } 178 | 179 | if ($statement->errorCode() !== '00000') { 180 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 181 | } 182 | 183 | if (0 === $statement->rowCount()) { 184 | throw ProjectionNotFound::withName($name); 185 | } 186 | } 187 | 188 | public function stopProjection(string $name): void 189 | { 190 | $sql = <<quoteIdent($this->projectionsTable)} SET status = ? WHERE name = ?; 192 | EOT; 193 | 194 | $statement = $this->connection->prepare($sql); 195 | 196 | try { 197 | $statement->execute([ 198 | ProjectionStatus::STOPPING()->getValue(), 199 | $name, 200 | ]); 201 | } catch (PDOException $exception) { 202 | // ignore and check error code 203 | } 204 | 205 | if ($statement->errorCode() !== '00000') { 206 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 207 | } 208 | 209 | if (0 === $statement->rowCount()) { 210 | throw ProjectionNotFound::withName($name); 211 | } 212 | } 213 | 214 | public function fetchProjectionNames(?string $filter, int $limit = 20, int $offset = 0): array 215 | { 216 | if (1 > $limit) { 217 | throw new OutOfRangeException( 218 | 'Invalid limit "'.$limit.'" given. Must be greater than 0.' 219 | ); 220 | } 221 | 222 | if (0 > $offset) { 223 | throw new OutOfRangeException( 224 | 'Invalid offset "'.$offset.'" given. Must be greater or equal than 0.' 225 | ); 226 | } 227 | 228 | $values = []; 229 | $whereCondition = ''; 230 | 231 | if (null !== $filter) { 232 | $values[':filter'] = $filter; 233 | 234 | $whereCondition = 'WHERE name = :filter'; 235 | } 236 | 237 | $query = <<quoteIdent($this->projectionsTable)} 239 | $whereCondition 240 | ORDER BY name ASC 241 | LIMIT $limit OFFSET $offset 242 | SQL; 243 | 244 | $statement = $this->connection->prepare($query); 245 | $statement->setFetchMode(PDO::FETCH_OBJ); 246 | 247 | try { 248 | $statement->execute($values); 249 | } catch (PDOException $exception) { 250 | // ignore and check error code 251 | } 252 | 253 | if ($statement->errorCode() !== '00000') { 254 | $errorCode = $statement->errorCode(); 255 | $errorInfo = $statement->errorInfo()[2]; 256 | 257 | throw new Exception\RuntimeException( 258 | "Error $errorCode. Maybe the event streams table is not setup?\nError-Info: $errorInfo" 259 | ); 260 | } 261 | 262 | $result = $statement->fetchAll(); 263 | 264 | $projectionNames = []; 265 | 266 | foreach ($result as $projectionName) { 267 | $projectionNames[] = $projectionName->name; 268 | } 269 | 270 | return $projectionNames; 271 | } 272 | 273 | public function fetchProjectionNamesRegex(string $filter, int $limit = 20, int $offset = 0): array 274 | { 275 | if (1 > $limit) { 276 | throw new OutOfRangeException( 277 | 'Invalid limit "'.$limit.'" given. Must be greater than 0.' 278 | ); 279 | } 280 | 281 | if (0 > $offset) { 282 | throw new OutOfRangeException( 283 | 'Invalid offset "'.$offset.'" given. Must be greater or equal than 0.' 284 | ); 285 | } 286 | 287 | $values[':filter'] = $filter; 288 | 289 | $whereCondition = 'WHERE name ~ :filter'; 290 | $query = <<quoteIdent($this->projectionsTable)} 292 | $whereCondition 293 | ORDER BY name ASC 294 | LIMIT $limit OFFSET $offset 295 | SQL; 296 | 297 | $statement = $this->connection->prepare($query); 298 | $statement->setFetchMode(PDO::FETCH_OBJ); 299 | 300 | try { 301 | $statement->execute($values); 302 | } catch (PDOException $exception) { 303 | // ignore and check error code 304 | } 305 | 306 | if ($statement->errorCode() === '2201B') { 307 | throw new Exception\InvalidArgumentException('Invalid regex pattern given'); 308 | } elseif ($statement->errorCode() !== '00000') { 309 | $errorCode = $statement->errorCode(); 310 | $errorInfo = $statement->errorInfo()[2]; 311 | 312 | throw new Exception\RuntimeException( 313 | "Error $errorCode. Maybe the event streams table is not setup?\nError-Info: $errorInfo" 314 | ); 315 | } 316 | 317 | $result = $statement->fetchAll(); 318 | 319 | $projectionNames = []; 320 | 321 | foreach ($result as $projectionName) { 322 | $projectionNames[] = $projectionName->name; 323 | } 324 | 325 | return $projectionNames; 326 | } 327 | 328 | public function fetchProjectionStatus(string $name): ProjectionStatus 329 | { 330 | $query = <<quoteIdent($this->projectionsTable)} 332 | WHERE name = ? 333 | LIMIT 1 334 | SQL; 335 | 336 | $statement = $this->connection->prepare($query); 337 | $statement->setFetchMode(PDO::FETCH_OBJ); 338 | 339 | try { 340 | $statement->execute([$name]); 341 | } catch (PDOException $exception) { 342 | // ignore and check error code 343 | } 344 | 345 | if ($statement->errorCode() !== '00000') { 346 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 347 | } 348 | 349 | $result = $statement->fetch(); 350 | 351 | if (false === $result) { 352 | throw ProjectionNotFound::withName($name); 353 | } 354 | 355 | return ProjectionStatus::byValue($result->status); 356 | } 357 | 358 | public function fetchProjectionStreamPositions(string $name): array 359 | { 360 | $query = <<quoteIdent($this->projectionsTable)} 362 | WHERE name = ? 363 | LIMIT 1 364 | SQL; 365 | 366 | $statement = $this->connection->prepare($query); 367 | $statement->setFetchMode(PDO::FETCH_OBJ); 368 | 369 | try { 370 | $statement->execute([$name]); 371 | } catch (PDOException $exception) { 372 | // ignore and check error code 373 | } 374 | 375 | if ($statement->errorCode() !== '00000') { 376 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 377 | } 378 | 379 | $result = $statement->fetch(); 380 | 381 | if (false === $result) { 382 | throw ProjectionNotFound::withName($name); 383 | } 384 | 385 | return Json::decode($result->position); 386 | } 387 | 388 | public function fetchProjectionState(string $name): array 389 | { 390 | $query = <<quoteIdent($this->projectionsTable)} 392 | WHERE name = ? 393 | LIMIT 1 394 | SQL; 395 | 396 | $statement = $this->connection->prepare($query); 397 | $statement->setFetchMode(PDO::FETCH_OBJ); 398 | 399 | try { 400 | $statement->execute([$name]); 401 | } catch (PDOException $exception) { 402 | // ignore and check error code 403 | } 404 | 405 | if ($statement->errorCode() !== '00000') { 406 | throw Exception\RuntimeException::fromStatementErrorInfo($statement->errorInfo()); 407 | } 408 | 409 | $result = $statement->fetch(); 410 | 411 | if (false === $result) { 412 | throw ProjectionNotFound::withName($name); 413 | } 414 | 415 | return Json::decode($result->state); 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /src/Util/Json.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Util; 15 | 16 | use Prooph\EventStore\Pdo\Exception\JsonException; 17 | 18 | class Json 19 | { 20 | /** 21 | * @param mixed $value 22 | * 23 | * @return string 24 | * 25 | * @throws JsonException 26 | */ 27 | public static function encode($value): string 28 | { 29 | $flags = \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES | \JSON_PRESERVE_ZERO_FRACTION; 30 | 31 | $json = \json_encode($value, $flags); 32 | 33 | if (JSON_ERROR_NONE !== $error = \json_last_error()) { 34 | throw new JsonException(\json_last_error_msg(), $error); 35 | } 36 | 37 | return $json; 38 | } 39 | 40 | /** 41 | * @param string $json 42 | * 43 | * @return mixed 44 | * 45 | * @throws JsonException 46 | */ 47 | public static function decode(string $json) 48 | { 49 | $data = \json_decode($json, true, 512, \JSON_BIGINT_AS_STRING); 50 | 51 | if (JSON_ERROR_NONE !== $error = \json_last_error()) { 52 | throw new JsonException(\json_last_error_msg(), $error); 53 | } 54 | 55 | return $data; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Util/PostgresHelper.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\Util; 15 | 16 | /** 17 | * PostgreSQL helper to work with fully qualified table name. 18 | */ 19 | trait PostgresHelper 20 | { 21 | /** 22 | * @param string $ident 23 | * @return string 24 | */ 25 | private function quoteIdent(string $ident): string 26 | { 27 | $pos = \strpos($ident, '.'); 28 | 29 | if (false === $pos) { 30 | return '"' . $ident . '"'; 31 | } 32 | 33 | $schema = \substr($ident, 0, $pos); 34 | $table = \substr($ident, $pos + 1); 35 | 36 | return '"' . $schema . '"."' . $table . '"'; 37 | } 38 | 39 | /** 40 | * Extracts schema name as string before the first dot. 41 | * @param string $ident 42 | * @return string|null 43 | */ 44 | private function extractSchema(string $ident): ?string 45 | { 46 | if (false === ($pos = \strpos($ident, '.'))) { 47 | return null; 48 | } 49 | 50 | return \substr($ident, 0, $pos); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/WriteLockStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo; 15 | 16 | interface WriteLockStrategy 17 | { 18 | public function getLock(string $name): bool; 19 | 20 | public function releaseLock(string $name): bool; 21 | } 22 | -------------------------------------------------------------------------------- /src/WriteLockStrategy/MariaDbMetadataLockStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\WriteLockStrategy; 15 | 16 | use Prooph\EventStore\Pdo\WriteLockStrategy; 17 | 18 | final class MariaDbMetadataLockStrategy implements WriteLockStrategy 19 | { 20 | /** 21 | * @var \PDO 22 | */ 23 | private $connection; 24 | 25 | /** 26 | * @var int 27 | */ 28 | private $timeout; 29 | 30 | public function __construct(\PDO $connection, int $timeout = 0xffffff) 31 | { 32 | if ($timeout < 0) { 33 | throw new \InvalidArgumentException('$timeout must be greater or equal to 0.'); 34 | } 35 | 36 | $this->connection = $connection; 37 | $this->timeout = $timeout; 38 | } 39 | 40 | public function getLock(string $name): bool 41 | { 42 | try { 43 | $res = $this->connection->query('SELECT GET_LOCK(\'' . $name . '\', ' . $this->timeout . ') as \'get_lock\''); 44 | } catch (\PDOException $e) { 45 | // ER_USER_LOCK_DEADLOCK: we only care for deadlock errors and fail locking 46 | if ('3058' === $this->connection->errorCode()) { 47 | return false; 48 | } 49 | 50 | throw $e; 51 | } 52 | 53 | if (! $res) { 54 | return false; 55 | } 56 | 57 | $lockStatus = $res->fetchAll(); 58 | if ('1' === $lockStatus[0]['get_lock'] || 1 === $lockStatus[0]['get_lock']) { 59 | return true; 60 | } 61 | 62 | return false; 63 | } 64 | 65 | public function releaseLock(string $name): bool 66 | { 67 | $res = $this->connection->query('SELECT RELEASE_LOCK(\'' . $name . '\') as \'release_lock\''); 68 | 69 | if ($res) { 70 | $res->fetchAll(); 71 | } 72 | 73 | return true; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/WriteLockStrategy/MysqlMetadataLockStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\WriteLockStrategy; 15 | 16 | use Prooph\EventStore\Pdo\WriteLockStrategy; 17 | 18 | final class MysqlMetadataLockStrategy implements WriteLockStrategy 19 | { 20 | /** 21 | * @var \PDO 22 | */ 23 | private $connection; 24 | 25 | /** 26 | * @var int 27 | */ 28 | private $timeout; 29 | 30 | public function __construct(\PDO $connection, int $timeout = -1) 31 | { 32 | $this->connection = $connection; 33 | $this->timeout = $timeout; 34 | } 35 | 36 | public function getLock(string $name): bool 37 | { 38 | try { 39 | $res = $this->connection->query('SELECT GET_LOCK(\'' . $name . '\', ' . $this->timeout . ') as \'get_lock\''); 40 | } catch (\PDOException $e) { 41 | // ER_USER_LOCK_DEADLOCK: we only care for deadlock errors and fail locking 42 | if ('3058' === $this->connection->errorCode()) { 43 | return false; 44 | } 45 | 46 | throw $e; 47 | } 48 | 49 | if (! $res) { 50 | return false; 51 | } 52 | 53 | $lockStatus = $res->fetchAll(); 54 | if ('1' === $lockStatus[0]['get_lock'] || 1 === $lockStatus[0]['get_lock']) { 55 | return true; 56 | } 57 | 58 | return false; 59 | } 60 | 61 | public function releaseLock(string $name): bool 62 | { 63 | $this->connection->exec('DO RELEASE_LOCK(\'' . $name . '\') as \'release_lock\''); 64 | 65 | return true; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/WriteLockStrategy/NoLockStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\WriteLockStrategy; 15 | 16 | use Prooph\EventStore\Pdo\WriteLockStrategy; 17 | 18 | final class NoLockStrategy implements WriteLockStrategy 19 | { 20 | public function getLock(string $name): bool 21 | { 22 | return true; 23 | } 24 | 25 | public function releaseLock(string $name): bool 26 | { 27 | return true; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/WriteLockStrategy/PostgresAdvisoryLockStrategy.php: -------------------------------------------------------------------------------- 1 | 6 | * (c) 2016-2025 Sascha-Oliver Prolic 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Prooph\EventStore\Pdo\WriteLockStrategy; 15 | 16 | use Prooph\EventStore\Pdo\WriteLockStrategy; 17 | 18 | final class PostgresAdvisoryLockStrategy implements WriteLockStrategy 19 | { 20 | /** 21 | * @var \PDO 22 | */ 23 | private $connection; 24 | 25 | public function __construct(\PDO $connection) 26 | { 27 | $this->connection = $connection; 28 | } 29 | 30 | public function getLock(string $name): bool 31 | { 32 | $this->connection->exec('select pg_advisory_lock( hashtext(\'' . $name . '\') );'); 33 | 34 | return true; 35 | } 36 | 37 | public function releaseLock(string $name): bool 38 | { 39 | $this->connection->exec('select pg_advisory_unlock( hashtext(\'' . $name . '\') );'); 40 | 41 | return true; 42 | } 43 | } 44 | --------------------------------------------------------------------------------