Skip to content

Parameters

Muhammet Şafak edited this page May 24, 2026 · 1 revision

Parameters

The parameter bag is what makes the builder safe to use with PDO without ever concatenating user input. This page covers the API in detail — collision auto-suffixing, the NULL short-circuit, RawQuery key hashing, and the value-inlining decision tree.

The contract

ParameterInterface exposes six methods:

Method Returns Purpose
set(string $key, mixed $value) self overwrite by key
add(string|RawQuery $key, mixed $value) placeholder name append, auto-suffix on collision
get(?string $key = null, mixed $default = null) value or full map read
all() array<string, mixed> PDO-ready map
merge(array|ParameterInterface ...$arrays) self bulk merge
reset() self empty the bag

The default implementation, Parameters, ships with the package.

Accessing the bag

$qb = new QueryBuilder('mysql');
$qb->from('users')->where('country', 'TR');

$bag = $qb->getParameter();
$bag->all();    // [':country' => 'TR']

set() vs add()

set() — overwrites by key

$bag->set('id', 1);
$bag->set('id', 2);
$bag->all();
// [':id' => 2]

Use set() when you control the key and want it to remain stable across rebinds. The builder's setParameter() delegates here.

add() — collision auto-suffix

$bag->add('id', 1);  // returns ':id'
$bag->add('id', 2);  // returns ':id_1'
$bag->add('id', 3);  // returns ':id_2'

This is what the clause builders use internally. Every value bound by where('id', ...), set('id', ...) etc. goes through add() so a chain mentioning the same column repeatedly still produces a valid SQL statement.

Key sanitization

Both set() and add() strip non-alphanumeric characters from the key before prefixing with ::

$bag->add('user.id', 1);     // returns ':userid'  (dot removed)
$bag->add('user-id', 2);     // returns ':userid_1' (dash removed)

PDO bind names only accept [A-Za-z0-9_]. The sanitization is silent — be aware of it if you reach for "exotic" key shapes.

The NULL short-circuit

add() does not register a binding when the value is null:

$placeholder = $bag->add('deleted_at', null);
// $placeholder === 'NULL'
$bag->all();
// []

This lets the compiler inline the literal NULL into the SQL. A parameterized :deleted_at bound to PHP null would compile to = NULL which is not the same as IS NULL. The short-circuit sidesteps that footgun. Use whereIsNull() / whereIsNotNull() for the correct SQL form.

RawQuery keys

When the key passed to add() is itself a RawQuery (used internally by batch UPDATE when the column reference is a complex expression), the implementation hashes it with md5() to produce a stable, opaque placeholder name:

$bag->add(new RawQuery('some expression'), 1);
// returns ':<32 hex chars>'

You won't usually trigger this directly — it's plumbing for the batch UPDATE compiler.

Reading values

get() is multi-purpose:

$bag->set('id', 99);

$bag->get();        // returns the whole map
$bag->get('id');    // 99
$bag->get(':id');   // 99 — leading colon is optional
$bag->get('missing'); // null
$bag->get('missing', 'fallback'); // 'fallback'
$bag->get('missing', fn () => 'lazy'); // 'lazy' — closure invoked lazily

A Closure default is invoked only when the key is missing — useful for expensive fallbacks.

Merging bags

merge() accepts plain arrays and other ParameterInterface instances:

$other = (new Parameters())->set('c', 3)->set('d', 4);

$bag = new Parameters();
$bag->merge(['a' => 1, 'b' => 2], $other);
$bag->all();
// [':a' => 1, ':b' => 2, ':c' => 3, ':d' => 4]

merge() uses set() semantics — colliding keys overwrite. If you need collision-safe merging, iterate the source manually and call add() for each entry.

Resetting

$bag->reset();   // empty the bag
$bag->all();     // []

QueryBuilder::resetStructure() does not reset the parameter bag. They are independent. If you reuse a builder for a fresh query and want a clean bag too, call both:

$qb->resetStructure();
$qb->getParameter()->reset();

When the builder DOES NOT parameterize

Not every value flows through the bag. The internal helper SqlValueDetector::isSqlParameterOrFunction() returns true (and the value is inlined instead of bound) for:

  • Integers5 becomes 5 in SQL.
  • ? — positional placeholder.
  • :foo shape — pre-formed named placeholder.
  • table.column shape — dotted column reference.
  • function() shape — parameterless SQL function call.
  • RawQuery — always inlined verbatim.
$qb->where('id', 5);
// WHERE `id` = 5      ← integer inlined

$qb->where('id', '?');
// WHERE `id` = ?      ← positional placeholder inlined

$qb->where('id', $qb->raw('NOW()'));
// WHERE `id` = NOW()  ← RawQuery inlined

Everything else — strings, booleans, floats, DateTime — goes through add().

🔐 The table.column and function() auto-detection is defensible for programmer-supplied strings but can be abused if user input happens to match those shapes. See Security §V1, §V2 — the application MUST coerce user input to a concrete type before passing it to a value slot.

Plugging the bag into PDO

The map returned by all() is already keyed for PDO:

$pdo  = new PDO(/* … */);
$qb->select('*')->from('users')->where('country', 'TR');

$stmt = $pdo->prepare($qb->generateSelectQuery());
$stmt->execute($qb->getParameter()->all());

PDO ignores the leading : on bind names, so either form (':country' or 'country') works at execute time. The bag always emits the colon-prefixed form.

Hoisting a value into the outer bag

Useful for sub-queries (see Sub Queries) or hand-rolled RawQuery:

$qb->setParameter('admin_role', 'admin');
$qb->where($qb->raw('role = :admin_role'));
// WHERE role = :admin_role
// Bag: [':admin_role' => 'admin']

setParameter() is a convenience for getParameter()->set(...).

A worked example

$qb = new QueryBuilder('mysql');
$qb->from('users')
   ->where('country', 'TR')
   ->where('country', 'US')           // collision → :country_1
   ->whereIn('role_id', [1, 2, 3])     // integers inlined
   ->set('updated_at', $qb->raw('NOW()')); // RawQuery inlined

$qb->getParameter()->all();
// [
//     ':country'   => 'TR',
//     ':country_1' => 'US',
// ]

Only the strings landed in the bag — integers and the NOW() call were inlined directly into the SQL.


Next: Drivers

Clone this wiki locally