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<?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);$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);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.
$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);$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);$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);$tx = $txFactory->createDelete(
tableName: 'users',
identifierColumn: 'id',
identifierColumnType: \PDO::PARAM_INT,
identifiers: [1, 2, 3],
isIdempotent: true,
);
$tm->run($tx, $options);$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);$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);$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);$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);- 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.
- 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.
- 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.
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 --buildInstall 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 installRun 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/phpunitFor 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/phpunitFor 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/phpunitRun 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/phpunitFor 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/phpunitFor 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/phpunitRun 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/phpunitFor 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/phpunitFor 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/phpunitRun 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; \
donedocker-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=1Gdocker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml down -vThis project is licensed under the MIT License. See the LICENSE file for details.