flytachi / winter-cdo
Extended, type-safe PDO wrapper for PostgreSQL, MySQL/MariaDB, SQLite and Oracle with a composable, injection-safe query builder.
Requires (Dev)
- phpunit/phpunit: @stable
- squizlabs/php_codesniffer: @stable
This package is auto-updated.
Last update: 2026-08-17 15:12:30 UTC
README
CDO (Connection Data Object) extends PDO with the write operations applications
actually perform โ insert, update, delete, upsert and their streaming batch
variants โ and with Qb, a composable builder for the WHERE clause.
Every value travels as a bound parameter, so SQL text and data never meet by string concatenation. The driver-specific dialect is generated for you (PostgreSQL, MySQL / MariaDB, SQLite, Oracle), so the same call works across all of them.
๐ Documentation ยท Quick start ยท CDO API ยท Qb operators
Installation
composer require flytachi/winter-cdo
Requires PHP 8.3+, ext-pdo and psr/log ^3.0.
Supported databases
| Database | insert | insertBatch | upsert | upsertBatch | update | delete |
|---|---|---|---|---|---|---|
| PostgreSQL | โ | โ | โ | โ | โ | โ |
| MySQL / MariaDB | โ | โ | โ | โ | โ | โ |
| SQLite | โ | โ | โ | โ | โ | โ |
| Oracle | โ ๏ธ | โ | โ | โ | โ | โ |
SQLite uses PostgreSQL-style ON CONFLICT upserts; insert() / upsert() return the
last inserted id via lastInsertId() rather than RETURNING, and timezone sync is a
no-op because SQLite has no session timezone.
Quick start
Declare a database by filling in setUp():
use Flytachi\Winter\Cdo\Config\PgDbConfig; class AppDb extends PgDbConfig { public function setUp(): void { $this->host = env('DB_HOST', 'localhost'); $this->port = (int) env('DB_PORT', 5432); $this->database = env('DB_NAME', 'myapp'); $this->username = env('DB_USER', 'postgres'); $this->password = env('DB_PASS', ''); } }
Then ask the pool for a connection and write:
use Flytachi\Winter\Cdo\ConnectionPool; use Flytachi\Winter\Cdo\Qb; $cdo = ConnectionPool::db(AppDb::class); $id = $cdo->insert('users', ['name' => 'Alice', 'email' => 'alice@example.com']); $cdo->update('users', ['name' => 'Alice Smith'], Qb::eq('id', $id)); $cdo->delete('users', Qb::eq('id', $id));
A one-off connection needs no class โ the inline Call variants take the credentials
directly, and SQLite needs none at all:
use Flytachi\Winter\Cdo\Config\Call\SqliteDbCall; $cdo = (new SqliteDbCall())->connection(); // in-memory, handy in tests
What you get
- Write operations, not a query language โ
insert,update,delete,upserttake a table, an entity and a condition; the SQL is generated per driver. - Streaming batches โ
insertBatch/upsertBatchaccept a generator, so peak memory follows the chunk size rather than the size of the job. - A composable
WHEREโQbfragments combine withand/or/xor, skipnull, and parenthesise groups so an innerORcannot break the surroundingAND. - Type-aware binding โ the
PDO::PARAM_*type is derived from the PHP value; objects go throughDateTimeInterface/JsonSerializable/__toString(). - Named binds โ one
CDOBindreused across several conditions stays a single placeholder. - Lazy connections โ a config is instantiated once and cached; the socket opens on
first use, with
ping()/reconnect()for long-lived workers. - PSR-3 logging โ give it a logger and each statement, its bindings and its timing are recorded.
A taste of Qb
Qb::and( Qb::eq('status', 'active'), Qb::gte('age', 18), Qb::or( Qb::like('email', '%@example.com'), Qb::in('role', ['admin', 'editor']), ), ); // (status = :iqb0 AND age >= :iqb1 AND (email LIKE :iqb2 OR role IN (:iqb3, :iqb4)))
Optional filters drop out by themselves, because logical operators skip null:
Qb::and( Qb::eq('published', true), $categoryId ? Qb::eq('category_id', $categoryId) : null, $tagIds ? Qb::in('tag_id', $tagIds) : null, // in() throws on [] );
Values are bound; column names are not. A column name cannot be a placeholder, so it goes into the SQL verbatim.
Qb::eq('status', $userInput)is safe;Qb::eq($userInput, 'active')is an injection vector โ never let user input choose a column without a whitelist.
Every operator with the SQL it emits: Qb operators.
Documentation
The user-facing documentation lives at winterframe.net/packages/cdo (the link picks your language; RU and EN are both complete).
Start here
| Page | What it answers |
|---|---|
| Introduction | What CDO is, and where it sits next to plain PDO |
| Installation | Requirements, install, driver extensions |
| Quick start | Config, connection, first write |
| Mental model | How config, pool, CDO and Qb relate |
Guides
| Page | What it answers |
|---|---|
| Inserting records | Single rows, returned ids, batches |
| Updating and deleting | Conditions, affected rows, staying safe |
| Upserts | Conflict columns and what gets updated |
| Building conditions | Composing Qb, optional filters, grouping |
| Logging and diagnostics | Seeing the SQL, the bindings and the timing |
Reference
| Page | What it answers |
|---|---|
| CDO API | Every method, its arguments and its return value |
| Qb operators | All operators with the SQL they emit |
| Configuration | Config classes, inline calls, driver options |
| Upsert placeholders | :new, :current, and expressions between them |
| Exceptions | What is thrown, and which SQLSTATE means what |
Deep dive
| Page | What it answers |
|---|---|
| Batches and chunking | Memory, partial failure, choosing a chunk size |
| Parameter binding | How a PHP value becomes a bound parameter |
| Driver detection | What changes per driver, and how it is decided |
Classes in this package carry an @link to their page, so the same documentation is one
click away from your IDE.
Contributing
Internal technical notes โ exact contracts, the SQL each operator emits, and the
reasoning behind decisions that are not obvious from the code โ live in
docs/. Read that before changing generated SQL.
composer test # phpunit composer test-detail # phpunit --testdox composer cs-check # phpcs composer cs-fix # phpcbf
- Changes and upgrade notes: CHANGELOG.md
- How to contribute (setup, tests, coding standard): CONTRIBUTING.md
- Reporting a vulnerability: SECURITY.md
License
MIT License. See LICENSE.