Package · cdo

Building conditions

Qb builds parameterised WHERE fragments you can compose, nest, and reuse. Every value becomes a named bind, so values are injection-safe by construction. This guide covers the patterns you reach for daily; the full operator list is in the Qb operators reference.

Column names are injected verbatim

Only values are bound. The column name argument is interpolated into the SQL as-is, without quoting or escaping. Never pass user input as a column name — Qb::eq('status', $userInput) is safe, but Qb::eq($userInput, 'active') is a SQL-injection vector.

Compose with and / or

Start from leaf conditions and join them:

php
use Flytachi\Winter\Cdo\Qb;

$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

and / or / xor take any number of conditions and skip null or empty ones silently — which is exactly what makes optional filters clean.

Optional (dynamic) filters

Because null conditions are dropped, you can inline them with a ternary. Empty in() lists must be guarded, since in() throws on an empty array.

php
$where = Qb::and(
  Qb::eq('status', 'active'),
  $minAge  !== null ? Qb::gte('age', $minAge)     : null,
  $country !== null ? Qb::eq('country', $country)  : null,
  $tagIds          ? Qb::in('tag_id', $tagIds)     : null, // guard empty array
);

Guard against an all-empty condition

If every optional filter is skipped, Qb::and(...) yields an empty fragment. Passing that to update() or delete() produces a statement with no WHERE. Check $where->getQuery() !== '' before running a mutation.

Control precedence with clip

SQL binds AND tighter than OR. When you mix them, wrap the OR group in clip() to force the grouping you mean:

php
// Wrong: 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')),
);

// Right: clip makes the OR a single grouped term
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)

Build incrementally (mutable)

Qb is immutable by default, but the addAnd / addOr / addXor methods mutate in place — handy when you accumulate conditions in a loop:

php
$where = Qb::eq('status', 'active');

foreach ($filters as $column => $value) {
  $where->addAnd(Qb::eq($column, $value));
}

Reuse a value with a named bind

By default each value gets an opaque placeholder (:iqb0). To reference the same value across several conditions, build a CDOBind once and pass it in — it binds a single :name shared by every condition that uses it:

php
use Flytachi\Winter\Cdo\CDOBind;

$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

Escape hatch: raw

When no operator fits (vendor functions, subqueries), Qb::raw() injects SQL verbatim — but still lets you bind values through placeholders:

php
Qb::raw('JSON_CONTAINS(tags, :tag)', ['tag' => '"php"']);
// JSON_CONTAINS(tags, :tag)  — :tag is bound, the function text is verbatim

raw does not sanitise the query string

The SQL string in raw() is emitted exactly as written. Never interpolate user input into it — pass values through the $binds argument instead.

Using the fragment

update() and delete() consume a Qb directly. For reads, pull the pieces apart and bind them onto a PDO statement:

php
$stmt = $cdo->prepare("SELECT * FROM users WHERE " . $where->getQuery());
foreach ($where->getBinds() as $bind) {
  $stmt->bindValue($bind->getName(), $bind->getValue());
}
$stmt->execute();