CDO (Connection Data Object) — an extended PDO wrapper for type-safe, parameterised database operations with a composable query builder.
Full documentation: https://winterframe.net/docs/cdo
- PHP >= 8.3
- ext-pdo
- psr/log ^3.0
composer require flytachi/winter-cdo| Database | insert | insertGroup | upsert | upsertGroup | update | delete |
|---|---|---|---|---|---|---|
| PostgreSQL | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| MySQL / MariaDB | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Oracle | ✅ | ❌ | ❌ | ✅ | ✅ |
Extend MySqlDbConfig or PgDbConfig and fill credentials 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', '');
}
}For a one-off connection without a dedicated class, use the inline PgDbCall /
MySqlDbCall / DbCall constructors — see Configuration docs.
$cdo = ConnectionPool::db(AppDb::class);use Flytachi\Winter\Cdo\Qb;
// Insert — returns the generated primary key:
$id = $cdo->insert('users', [
'name' => 'Alice',
'email' => 'alice@example.com',
]);
// Update — returns affected row count:
$cdo->update('users',
['name' => 'Alice Smith'],
Qb::eq('id', $id)
);
// Delete — returns deleted row count:
$cdo->delete('users', Qb::eq('id', $id));
// Batch insert:
$cdo->insertGroup('users', $usersArray, chunkSize: 500);
// Upsert (insert or update on conflict):
$cdo->upsert('products',
['sku' => 'ABC-001', 'price' => 9.99, 'stock' => 50],
conflictColumns: ['sku'],
updateColumns: ['price' => ':new', 'stock' => ':current + :new']
);Qb builds safe, parameterised SQL WHERE fragments. Every value is bound
via a named placeholder — no string interpolation, no injection risk.
Column names, however, are injected verbatim (they cannot be bound). Never pass user input as a column name:
Qb::eq('status', $userInput)is safe,Qb::eq($userInput, 'active')is a SQL-injection vector.
// Simple condition:
Qb::eq('status', 'active')
// → status = :iqb0
// Compound condition:
$where = Qb::and(
Qb::eq('status', 'active'),
Qb::gte('age', 18),
Qb::isNull('banned_at'),
);
// → status = :iqb0 AND age >= :iqb1 AND banned_at IS NULL| Category | Methods | SQL result |
|---|---|---|
| Comparison | eq, neq, gt, gte, lt, lte |
col = :x, col != :x, … |
| NULL | isNull, isNotNull |
col IS NULL, col IS NOT NULL |
| NULL-safe | nsEq |
col <=> :x (MySQL/MariaDB) |
| Set | in, notIn |
col IN (:a, :b), col NOT IN (…) |
| Pattern | like, notLike |
col LIKE :x, col NOT LIKE :x |
| Range | between, notBetween |
col BETWEEN :a AND :b |
| Range (inverted) | betweenBy, notBetweenBy |
:x BETWEEN col1 AND col2 |
| Logical | and, or, xor |
a AND b, a OR b, a XOR b |
| Grouping | clip |
(condition) |
| CASE | case |
CASE WHEN … THEN … END |
| Raw | raw |
verbatim SQL with optional binds |
// ❌ Wrong — SQL reads as (published AND role='editor') OR role='admin':
Qb::and(
Qb::eq('published', true),
Qb::or(Qb::eq('role', 'editor'), Qb::eq('role', 'admin')),
)
// ✅ Correct — clip enforces the right grouping:
Qb::and(
Qb::eq('published', true),
Qb::clip(
Qb::or(Qb::eq('role', 'editor'), Qb::eq('role', 'admin'))
),
)
// → published IS TRUE AND (role = :iqb0 OR role = :iqb1)// null conditions are silently skipped:
$where = Qb::and(
Qb::eq('status', 'active'),
$minAge !== null ? Qb::gte('age', $minAge) : null,
$country !== null ? Qb::eq('country', $country) : null,
Qb::in('tag_id', $tagIds), // skipped when $tagIds is []
);$uid = new CDOBind('uid', $currentUserId);
$where = Qb::or(
Qb::eq('author_id', $uid),
Qb::eq('reviewer_id', $uid),
Qb::eq('assignee_id', $uid),
);
// → author_id = :uid OR reviewer_id = :uid OR assignee_id = :uid| Token | PostgreSQL | MySQL / MariaDB |
|---|---|---|
:new |
EXCLUDED.column |
VALUES(column) |
:current |
table.column |
column |
$cdo->upsertGroup('inventory', $items,
conflictColumns: ['warehouse_id', 'product_id'],
updateColumns: [
'cost' => ':new',
'quantity' => ':current + :new',
'updated_at' => 'NOW()',
]
);All failures throw CDOException, which wraps the original PDOException as
its $previous cause (preserving SQLSTATE code and driver message):
use Flytachi\Winter\Cdo\Connection\CDOException;
try {
$cdo->insert('users', $data);
} catch (CDOException $e) {
$sqlstate = $e->getPrevious()?->getCode(); // e.g. "23505" (PG unique violation)
// handle or re-throw
}Full reference documentation is at https://winterframe.net/docs/cdo
Local docs in docs/:
| File | Topic |
|---|---|
| 01-configuration.md | Config classes, inline Call classes |
| 02-connection-pool.md | ConnectionPool, health checks |
| 03-cdo.md | All CDO DML methods |
| 04-cdo-statement.md | Type binding, object serialisation |
| 05-exceptions.md | CDOException, SQLSTATE reference |
| 06-cdobind.md | CDOBind — named parameters |
| 07-comparison-operators.md | eq, neq, gt, gte, lt, lte, nsEq |
| 08-null-checks.md | isNull, isNotNull |
| 09-set-operators.md | in, notIn |
| 10-pattern-matching.md | like, notLike |
| 11-range-operators.md | between, betweenBy, notBetween, notBetweenBy |
| 12-logical-operators.md | and, or, xor, clip |
| 13-mutable-methods.md | addAnd, addOr, addXor |
| 14-case-expression.md | CASE WHEN … END |
| 15-special.md | raw, empty |
| 16-advanced-examples.md | Real-world combinations |
MIT License. See LICENSE.