aeatech / transaction-manager-postgresql
PostgreSQL dialect layer for AEATech Transaction Manager, providing PostgreSQL-specific SQL behavior and extensions not covered by the core or common packages.
Installs: 7
Dependents: 1
Suggesters: 1
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/aeatech/transaction-manager-postgresql
Requires
- php: >=8.2
- ext-pdo: *
- ext-pdo_pgsql: *
- aeatech/transaction-manager-common-transactions: ^1.0
- aeatech/transaction-manager-core: ^1.0
Requires (Dev)
- aeatech/transaction-manager-doctrine-adapter: ^1.0
- mockery/mockery: ^1.6.10
- phpstan/phpstan: ^2.1
- phpstan/phpstan-mockery: ^2.0
- phpunit/phpunit: ^11.5
- roave/security-advisories: dev-master
This package is not auto-updated.
Last update: 2026-01-12 10:30:55 UTC
README
Lightweight module for generating safe and efficient PostgreSQL statements:
- INSERT
- INSERT ... ON CONFLICT DO NOTHING (aka INSERT IGNORE)
- INSERT ... ON CONFLICT ... DO UPDATE (UPSERT)
- DELETE, DELETE with LIMIT (via
ctid) - UPDATE, UPDATE with CASE WHEN ... THEN ...
This package is an extension of aeatech/transaction-manager-core.
It only builds SQL and parameters; the core package handles execution, retries, and transaction boundaries.
For Doctrine DBAL users, there is an adapter package: aeatech/transaction-manager-doctrine-adapter.
System requirements:
- PHP >= 8.2
- ext-pdo
- ext-pdo_pgsql
- PostgreSQL 12+ (tested with 16;
ctid-based DELETE LIMIT is PostgreSQL-specific)
Installation (Composer):
composer require aeatech/transaction-manager-postgresql
Quick start
<?php use AEATech\TransactionManager\DoctrineAdapter\DbalPostgresConnectionAdapter; use AEATech\TransactionManager\ExecutionPlanBuilder; use AEATech\TransactionManager\ExponentialBackoff; use AEATech\TransactionManager\GenericErrorClassifier; use AEATech\TransactionManager\IsolationLevel; use AEATech\TransactionManager\PostgreSQL\PostgreSQLErrorHeuristics; use AEATech\TransactionManager\PostgreSQL\PostgreSQLIdentifierQuoter; use AEATech\TransactionManager\PostgreSQL\PostgreSQLTransactionsFactoryBuilder; use AEATech\TransactionManager\PostgreSQL\PostgreSQLTransactionsFactoryInterface as PgTxFactory; // Create the PostgreSQL transactions factory $txFactory = PostgreSQLTransactionsFactoryBuilder::build(); // Example: UPSERT by unique email $tx = $txFactory->createInsertOnConflictUpdate( tableName: 'users', rows: [ ['id' => 1, 'email' => 'foo@example.com', 'name' => 'Foo'], ], updateColumns: ['name'], conflictTarget: $txFactory->conflictTargetByColumns(['email']), columnTypes: [ 'id' => \PDO::PARAM_INT, ], isIdempotent: true, ); $options = new TxOptions( isolationLevel: IsolationLevel::ReadCommitted, retryPolicy: new RetryPolicy(3, new ExponentialBackoff()) ); $runResult = $tm->run($tx, $options);
Usage examples
1) INSERT
$tx = $txFactory->createInsert( tableName: 'audit_log', rows: [ ['id' => 1, 'event' => 'login', 'meta' => json_encode(['ip' => '1.1.1.1'])], ['id' => 2, 'event' => 'logout', 'meta' => null], ], columnTypes: [ 'id' => \PDO::PARAM_INT, 'event' => \PDO::PARAM_STR, // 'meta' type can be omitted; DBAL will infer ], isIdempotent: false, ); $tm->run($tx, $options);
Prepared Statement Reuse Hint
This package supports the optional prepared statement reuse hint via StatementReusePolicy from the Core package. It is a best‑effort performance hint that may be ignored by connection implementations.
Options:
StatementReusePolicy::None— no reuse (default)StatementReusePolicy::PerTransaction— attempt to reuse within a single DB transactionStatementReusePolicy::PerConnection— attempt to reuse across transactions while the physical connection remains open
Example with a PostgreSQL transaction factory:
use AEATech\TransactionManager\StatementReusePolicy; $tx = $txFactory->createInsert( tableName: 'users', rows: [ ['id' => 1, 'email' => 'foo@example.com', 'name' => 'Foo'], ], columnTypes: ['id' => \PDO::PARAM_INT], isIdempotent: false, statementReusePolicy: StatementReusePolicy::PerTransaction, ); $tm->run($tx, $options);
Notes:
- This is a performance hint only; do not depend on it for correctness or idempotency.
- A connection adapter may ignore the hint due to driver limitations, reconnections, or internal safety choices.
2) INSERT IGNORE (ON CONFLICT DO NOTHING)
$tx = $txFactory->createInsertIgnore( tableName: 'users', rows: [ ['id' => 1, 'email' => 'a@example.com'], ['id' => 1, 'email' => 'a@example.com'], // duplicate id — ignored ], // columnTypes optional isIdempotent: true, ); $tm->run($tx, $options);
3) UPSERT (ON CONFLICT DO UPDATE) by columns
$target = $txFactory->conflictTargetByColumns(['email']); $tx = $txFactory->createInsertOnConflictUpdate( tableName: 'users', rows: [ ['id' => 10, 'email' => 'x@example.com', 'name' => 'Alice'], ['id' => 11, 'email' => 'y@example.com', 'name' => 'Bob'], ], updateColumns: ['name'], conflictTarget: $target, isIdempotent: true, ); $tm->run($tx, $options);
4) UPSERT (ON CONFLICT DO UPDATE) by constraint name
$target = $txFactory->conflictTargetByConstraint('uniq_users_email'); $tx = $txFactory->createInsertOnConflictUpdate( tableName: 'users', rows: [ ['id' => 10, 'email' => 'x@example.com', 'name' => 'Alice'], ], updateColumns: ['name'], conflictTarget: $target, isIdempotent: true, ); $tm->run($tx, $options);
5) DELETE by identifiers
$tx = $txFactory->createDelete( tableName: 'users', identifierColumn: 'id', identifierColumnType: \PDO::PARAM_INT, identifiers: [1, 2, 3], isIdempotent: true, ); $tm->run($tx, $options);
6) DELETE with LIMIT (PostgreSQL only, via ctid)
$tx = $txFactory->createDeleteWithLimit( tableName: 'events', identifierColumn: 'account_id', identifierColumnType: \PDO::PARAM_INT, identifiers: [42], // delete rows for account_id=42 limit: 1000, // at most 1000 physical rows isIdempotent: true, ); $tm->run($tx, $options);
7) UPDATE by identifiers
$tx = $txFactory->createUpdate( tableName: 'users', rows: [ ['id' => 1, 'name' => 'Renamed'], ['id' => 2, 'name' => 'Also Renamed'], ], identifierColumn: 'id', identifierColumnType: \PDO::PARAM_INT, updateColumns: ['name'], updateColumnTypes: [ 'name' => \PDO::PARAM_STR, ], isIdempotent: true, ); $tm->run($tx, $options);
8) UPDATE WHEN ... THEN ...
$tx = $txFactory->createUpdateWhenThen( tableName: 'users', rows: [ ['id' => 1, 'quota' => 100, 'plan' => 'basic'], ['id' => 2, 'quota' => 250, 'plan' => 'pro'], ], identifierColumn: 'id', identifierColumnType: \PDO::PARAM_INT, updateColumns: ['quota', 'plan'], updateColumnTypes: [ 'quota' => \PDO::PARAM_INT, 'plan' => \PDO::PARAM_STR, ], isIdempotent: true, ); $tm->run($tx, $options);
9) Raw SQL
$sql = 'UPDATE "users" SET "name" = ? WHERE "id" = ?'; $params = ['John', 123]; $types = [\PDO::PARAM_STR, \PDO::PARAM_INT]; $tx = $txFactory->createSql($sql, $params, $types, isIdempotent: true); $tm->run($tx, $options);
Parameters and types
- rows: array of homogeneous associative arrays like
['column' => value, ...]. All rows must have the same set of keys (columns). - columnTypes:
array<string, int|string>— mappingcolumn => parameter type(PDO::PARAM_*,Doctrine\DBAL\ParameterType::*or string type names supported by DBAL). Optional — DBAL will try to infer types. - isIdempotent: a flag for the transaction manager indicating retry safety. Semantics depend on your retry policy:
- false (default): a re-run may change the outcome (e.g., plain INSERT).
- true: the statement is designed to be idempotent (e.g., UPSERT or DO NOTHING), allowing the manager to apply more aggressive retries.
Additional notes for delete/update:
- identifierColumn / identifiers:
- Use a primary key or another unique column to avoid unintended data changes.
- Provide a non-empty array of scalar identifiers.
- DELETE with LIMIT:
- Implemented via
ctidselection inside a subquery. Guarantees that at most N physical rows are deleted, even if the identifier column is not unique. limitmust be a positive integer; not all provided identifiers may be deleted in one run.
- Implemented via
- UPDATE by identifiers:
updateColumnTypesapply to the columns inSETclause; the identifier type is provided separately viaidentifierColumnType.
- UPDATE WHEN ... THEN:
rowsmust include the identifier column and all columns listed inupdateColumns.
PostgreSQL specifics and nuances
- ON CONFLICT targets:
conflictTargetByColumns([col1, col2, ...])generatesON CONFLICT (col1, col2, ...)— all listed columns must be present in each inserted row; otherwise anInvalidArgumentExceptionwill be thrown during validation.conflictTargetByConstraint('constraint_name')generatesON CONFLICT ON CONSTRAINT "constraint_name"— column presence is not validated by the builder, but the constraint must exist in the DB.
- Identifier quoting:
- Pass names without quotes — the library uses
PostgreSQLIdentifierQuoterto quote identifiers with double quotes.
- Pass names without quotes — the library uses
- Idempotency hints:
createInsertIgnoreandcreateInsertOnConflictUpdateare typically idempotent — passisIdempotent: trueif your business logic agrees.- Plain
createInsertis usually non-idempotent.
- Isolation level:
- Choose appropriate
TxOptions::isolationLevel. For UPSERT-heavy workloads,ReadCommittedis common, butSerializablecan be paired with retries for stricter guarantees.
- Choose appropriate
- Error classification & retries:
- Use
GenericErrorClassifier(new PostgreSQLErrorHeuristics())with the core TransactionManager. It classifies transient issues (e.g.,40001serialization failure,40P01deadlock,55P03lock not available) and connection losses for safe retries according to yourRetryPolicy.
- Use
- Large batches:
- Insert in batches (e.g., 100–1000 rows) to avoid driver limits and huge statements.
- JSON, arrays, custom types:
- For complex types, rely on your DBAL mapping and pass explicit
columnTypeswhen needed.
- For complex types, rely on your DBAL mapping and pass explicit
- DELETE with LIMIT (ctid) caveats:
- The physical row order is not guaranteed. If you need deterministic ordering, perform chunking at the application level with stable predicates and small limits.
How it works
- SQL and parameters are built inside transaction objects (
InsertTransaction,InsertIgnoreTransaction,InsertOnConflictUpdateTransaction, etc.). - Identifiers (table and column names) are quoted safely for PostgreSQL.
- The result is an
AEATech\TransactionManager\Query(from the core), which is executed via the provided connection adapter.
Running tests
1) Via Docker Compose (recommended for reproducibility)
Bring up services for your target PHP/PostgreSQL versions and run PHPUnit inside the PHP CLI containers.
Start services (PHP 8.2/8.3/8.4 with PostgreSQL 16, 17, 18 see docker/docker-compose.yml for details):
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml up -d --build
Install dependencies inside the PHP container (example for PHP 8.3):
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml exec -T php-cli-8.3-pg16 composer install
Run tests for PHP 8.2 and PostgreSQL 16:
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml exec -T php-cli-8.2-pg16 vendor/bin/phpunit
For PHP 8.3 and PostgreSQL 16:
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml exec -T php-cli-8.3-pg16 vendor/bin/phpunit
For PHP 8.4 and PostgreSQL 16:
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml exec -T php-cli-8.4-pg16 vendor/bin/phpunit
Run tests for PHP 8.2 and PostgreSQL 17:
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml exec -T php-cli-8.2-pg17 vendor/bin/phpunit
For PHP 8.3 and PostgreSQL 17:
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml exec -T php-cli-8.3-pg17 vendor/bin/phpunit
For PHP 8.4 and PostgreSQL 17:
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml exec -T php-cli-8.4-pg17 vendor/bin/phpunit
Run tests for PHP 8.2 and PostgreSQL 18:
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml exec -T php-cli-8.2-pg18 vendor/bin/phpunit
For PHP 8.3 and PostgreSQL 17:
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml exec -T php-cli-8.3-pg18 vendor/bin/phpunit
For PHP 8.4 and PostgreSQL 17:
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml exec -T php-cli-8.4-pg18 vendor/bin/phpunit
Run all configured variants:
for v in php-cli-8.2-pg16 php-cli-8.2-pg17 php-cli-8.2-pg18 php-cli-8.3-pg16 php-cli-8.3-pg17 php-cli-8.3-pg18 php-cli-8.4-pg16 php-cli-8.4-pg17 php-cli-8.4-pg18 ; do \ echo "Testing PHP $v..."; \ docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml exec -T $v vendor/bin/phpunit || break; \ done
Run phpstan
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml exec php-cli-8.3-pg16 vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G
To stop and remove containers:
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml down -v
License
This project is licensed under the MIT License. See the LICENSE file for details.