aeatech/transaction-manager-postgresql
Lightweight module for generating safe and efficient PostgreSQL statements:
ctid)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:
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 openExample 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:
$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);
['column' => value, ...]. All rows must have the same set of keys (columns).array<string, int|string> — mapping column => parameter type (PDO::PARAM_*, Doctrine\DBAL\ParameterType::* or string type names supported by DBAL). Optional — DBAL will try to infer types.Additional notes for delete/update:
ctid selection inside a subquery. Guarantees that at most N physical rows are deleted, even if the identifier column is not unique.limit must be a positive integer; not all provided identifiers may be deleted in one run.updateColumnTypes apply to the columns in SET clause; the identifier type is provided separately via identifierColumnType.rows must include the identifier column and all columns listed in updateColumns.conflictTargetByColumns([col1, col2, ...]) generates ON CONFLICT (col1, col2, ...) — all listed columns must be present in each inserted row; otherwise an InvalidArgumentException will be thrown during validation.conflictTargetByConstraint('constraint_name') generates ON CONFLICT ON CONSTRAINT "constraint_name" — column presence is not validated by the builder, but the constraint must exist in the DB.PostgreSQLIdentifierQuoter to quote identifiers with double quotes.createInsertIgnore and createInsertOnConflictUpdate are typically idempotent — pass isIdempotent: true if your business logic agrees.createInsert is usually non-idempotent.TxOptions::isolationLevel. For UPSERT-heavy workloads, ReadCommitted is common, but Serializable can be paired with retries for stricter guarantees.GenericErrorClassifier(new PostgreSQLErrorHeuristics()) with the core TransactionManager. It classifies transient issues (e.g., 40001 serialization failure, 40P01 deadlock, 55P03 lock not available) and connection losses for safe retries according to your RetryPolicy.columnTypes when needed.InsertTransaction, InsertIgnoreTransaction, InsertOnConflictUpdateTransaction, etc.).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 --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
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
docker-compose -p aeatech-transaction-manager-postgresql -f docker/docker-compose.yml down -v
This project is licensed under the MIT License. See the LICENSE file for details.
How can I help you explore Laravel packages today?