Pop PHP
Database

Query Builder

The query builder writes SQL for the adapter that is connected. The same PHP produces backticks and ? on MySQL, double quotes and $1 on PostgreSQL, double quotes and :name on SQLite — which is the whole point of it, and the reason a hand-written statement stops being portable the moment it needs a parameter. A record class drives this builder underneath; this page drives it directly, which is what you want for a query no findBy() shorthand expresses.

Select#

createSql() on the adapter returns a builder already bound to it. From there select() starts a statement and from() names the table:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select(['id', 'username'])
    ->from('users')
    ->where('id = :id');

On the three adapters that renders as:

SQL
-- MySQL
SELECT `id`, `username` FROM `users` WHERE (`id` = ?)

-- PostgreSQL
SELECT "id", "username" FROM "users" WHERE ("id" = $1)

-- SQLite
SELECT "id", "username" FROM "users" WHERE ("id" = :id)

You write :id in every case and the builder emits the placeholder the driver wants. Handing the builder to the adapter runs it, with the parameter array keyed by the name you wrote:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select(['id', 'username'])
    ->from('users')
    ->where('id = :id');

$users = $db->select($sql, ['id' => 1]);

Called with no argument, select() selects everything. An array of column names selects those columns; a string key in that array is an alias, and a value the builder recognizes as a SQL function is left unquoted:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select(['id', 'user' => 'username', 'total' => 'COUNT(*)'])
    ->from('users')
    ->distinct();

On MySQL that renders:

SQL
SELECT DISTINCT `id`, `username` AS `user`, COUNT(*) AS `total` FROM `users`

An array passed to from() aliases the table the same way — from(['u' => 'users']) gives FROM `users` AS `u` .

Capture the rendered SQL in a variable if you need it twice — render() clears the statement after building the string, and echo $sql; calls it through __toString().

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql    = $db->createSql();
$select = $sql->select(['id', 'username'])->from('users')->where('id = :id');

echo $select;                            // safe to read
echo $select;                            // and to read again

$users = $db->select($sql, ['id' => 1]); // still runnable

Insert, Update and Delete#

The other three verbs replace the select on the same builder object. values() takes a column => value map, and a value written as :name becomes a bound parameter:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->insert('users')->values([
    'username' => ':username',
    'email'    => ':email'
]);

$db->insert($sql, ['username' => 'testuser', 'email' => 'testuser@test.com']);

update() takes the same map plus a predicate, and delete() takes a predicate alone:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->update('users')->values(['email' => ':email'])->where('id = :id');

$db->update($sql, ['email' => 'new@test.com', 'id' => 1]);

$sql = $db->createSql();
$sql->delete('users')->where('id = :id');

$db->delete($sql, ['id' => 1]);

On MySQL those render as UPDATE ... SET ... = ? WHERE (... = ?) and DELETE FROM ... WHERE (... = ?), with $1/$2 on PostgreSQL and :email/:id on SQLite. The parameter array is keyed by name on every adapter; the positional forms are the builder's problem, not yours.

An insert can also be an upsert. onConflict() takes the columns to overwrite when the row already exists, plus the column the conflict is detected on:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->insert('users')
    ->values(['id' => ':id', 'username' => ':username', 'email' => ':email'])
    ->onConflict(['username', 'email'], 'id');

$db->insert($sql, ['id' => 1, 'username' => 'testuser', 'email' => 'testuser@test.com']);

That renders ON DUPLICATE KEY UPDATE on MySQL and ON CONFLICT ("id") DO UPDATE SET on PostgreSQL and SQLite, and inserts-or-updates correctly on all three. onDuplicateKeyUpdate() is the same call without the conflict column — it's MySQL's spelling, and it's MySQL-only in practice: on PostgreSQL and SQLite the missing column makes rendering fail with Pop\Db\Sql\AbstractSql::quoteId(): Argument #1 ($identifier) must be of type string, null given. Use onConflict() with both arguments and the statement travels.

Joins#

The join methods take the foreign table and an array mapping the two columns being matched:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select(['users.username', 'user_info.city'])
    ->from('users')
    ->leftJoin('user_info', ['user_info.user_id' => 'users.id'])
    ->where('users.id < :id');
SQL
-- MySQL
SELECT `users`.`username`, `user_info`.`city` FROM `users`
    LEFT JOIN `user_info` ON ((`user_info`.`user_id` = `users`.`id`))
    WHERE (`users`.`id` < ?)

Once a join is in play both tables are in scope, so qualify the columns in select() and in the predicate — an unqualified id is ambiguous to the database, not to the builder. Calling a join method more than once joins more than one table, in the order you called them.

Twelve join methods cover the SQL keywords:

Method Renders
join($table, $columns, $type) the $type you name, JOIN by default
leftJoin(), rightJoin(), fullJoin() LEFT JOIN, RIGHT JOIN, FULL JOIN
outerJoin(), leftOuterJoin(), rightOuterJoin(), fullOuterJoin() OUTER JOIN and its three qualified forms
innerJoin(), leftInnerJoin(), rightInnerJoin(), fullInnerJoin() INNER JOIN and its three qualified forms

join()'s third argument is checked against that list of keywords. A type outside it does not raise anything — join('orders', [...], 'CROSS JOIN') silently renders a plain JOIN, on all three adapters. Spell the type the way the table does, or call the named method.

The foreign "table" can be a select of its own, which is how you join against an aggregate:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$totals = $db->createSql()->select(['user_id', 'order_total' => 'SUM(total)'])
    ->from('orders')
    ->groupBy('user_id');
$totals->setAlias('totals');

$sql = $db->createSql();
$sql->select(['users.username', 'totals.order_total'])
    ->from('users')
    ->leftJoin($totals, ['totals.user_id' => 'users.id']);

The alias is required, and forgetting it is not caught. A joined subquery with no alias renders as LEFT JOIN SELECT "user_id" FROM "orders" ON (...) — unbracketed and unnamed, which no database accepts — and the builder raises nothing on any of the three adapters. The same aliased object works in from(), giving SELECT * FROM (SELECT ...) AS "totals".

Predicates#

where() accepts a SQL expression as a string and parses it into a predicate. Multiple conditions in one string are split on AND and OR:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select()->from('users')->where('id > :id AND logins > :logins');

andWhere() and orWhere() add to an existing predicate with an explicit conjunction, and both take a string or an array of them:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select()
    ->from('users')
    ->where('id > :id')
    ->orWhere('email LIKE :email');

That gives WHERE ((`id` > ?) OR (`email` LIKE ?)) on MySQL. Underneath the string form there is a typed API, reached through the where property rather than the method, and it's the one to use whenever the value is not a placeholder:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select()
    ->from('users')
    ->where->greaterThan('id', 1)->and()->lessThan('logins', 5);

Each method takes the column and the value; and() and or() set the conjunction for whatever comes next. The full set is equalTo(), notEqualTo(), greaterThan(), greaterThanOrEqualTo(), lessThan(), lessThanOrEqualTo(), like(), notLike(), between(), notBetween(), in(), notIn(), isNull() and isNotNull(), plus the exists()/notExists() and json*() families the sections below cover.

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select()->from('users')->where->between('id', 1, 10);

$sql = $db->createSql();
$sql->select()->from('users')->where->in('id', [1, 2, 3]);

$sql = $db->createSql();
$sql->select()->from('users')->where->isNotNull('email');

Those render BETWEEN 1 AND 10, IN (1, 2, 3) and IS NOT NULL. A literal value goes through the adapter's own quoting rather than being bound. The two styles mix on one statement: a where() string carrying :logins alongside where->equalTo('failed', 0) produces WHERE ((`logins` > ?) AND (`failed` = 0)), and the parameter array covers only the placeholder.

having(), andHaving() and orHaving() mirror the three where methods, and having is a property in the same way where is. groupBy() takes a column, a comma-separated string of them, or an array.

Write a HAVING clause with Pop\Db\Db::executeSql() or a hand-written statement when you also need GROUP BY; the builder renders HAVING before GROUP BY.

Nested Predicates#

nest() opens a sub-group inside the current predicate, so everything added after it is bracketed together:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select()
    ->from('users')
    ->where->greaterThan('id', ':id')
        ->nest()->greaterThan('logins', ':logins')
            ->or()->lessThanOrEqualTo('failed', ':failed');
SQL
-- MySQL
SELECT * FROM `users` WHERE ((`id` > ?) AND ((`logins` > ?) OR (`failed` <= ?)))

nest() joins the group to what came before with AND. andNest() and orNest() say so explicitly, and orNest() is the one worth reaching for by name — the difference between a AND (b OR c) and a OR (b AND c) is the difference between two quite different queries, and it's decided entirely by which of the three you call.

Inside the group, and() and or() behave as they do at the top level: they set the conjunction for the next predicate added, not for the group as a whole. A group is closed by the end of the chain rather than by a method, so to add another top-level condition after one, build the top-level conditions first and nest last.

Subqueries#

New in v7: the IN/NOT IN predicates and the six scalar comparisons take a Pop\Db\Sql\Select where they would otherwise take an array or a value. The subquery renders inline, in the dialect of the same adapter:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$subquery = $db->createSql()->select('user_id')->from('orders');
$subquery->where->greaterThanOrEqualTo('total', 100);

$sql = $db->createSql();
$sql->select()
    ->from('users')
    ->where->in('id', $subquery);
SQL
-- MySQL
SELECT * FROM `users` WHERE (`id` IN (SELECT `user_id` FROM `orders` WHERE (`total` >= 100)))

notIn() gives NOT IN (SELECT ...). The scalar predicates — equalTo(), notEqualTo(), greaterThan(), greaterThanOrEqualTo(), lessThan(), lessThanOrEqualTo() — take one the same way, producing col = (SELECT ...), so the subquery has to return a single value:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$subquery = $db->createSql()->select('MAX(user_id)')->from('orders');

$sql = $db->createSql();
$sql->select()
    ->from('users')
    ->where->equalTo('id', $subquery);

The shorthand array syntax a record class takes accepts the same objects, so a subquery reaches findBy() without dropping to the builder for the outer query:

PHP
use App\Table\Users;
use Pop\Db\Record;

$db = Record::getDb();

$subquery = $db->createSql()->select('user_id')->from('orders');
$subquery->where->greaterThanOrEqualTo('total', 100);

$buyers = Users::findBy(['id' => ['IN', $subquery]]);

Two constraints decide whether a subquery is usable at all, and both were confirmed by running them.

Its conditions must be literal values, not placeholders. A subquery is rendered to a string before the outer statement is prepared, so its placeholders are not part of the outer statement's parameter binding. Write $subquery->where->greaterThanOrEqualTo('total', 100), not $subquery->where('total >= :total'). MySQL and SQLite survive the mistake — the inner placeholder ends up in the outer statement's list and the parameter array happens to line up — but PostgreSQL does not: inner and outer both number from $1, the two collide, and the query fails with pg_fetch_array(): Argument #1 ($result) must be of type PgSql\Result, false given.

It cannot be correlated to the outer query. There is no way to express "this column of the outer row" — every value handed to a predicate is treated as a literal, so $subquery->where->equalTo('orders.user_id', 'users.id') renders `orders`.`user_id` = 'users.id', comparing the column to the string. MySQL and SQLite return zero rows without complaint; PostgreSQL raises invalid input syntax for type integer: "users.id". Write a join instead, which is what a correlated subquery usually wants to be anyway.

A Select used as a subquery value carries no alias — setAlias() renders (SELECT ...) AS alias, which is valid only in a FROM or JOIN. The alias is what separates the two uses of the same object.

EXISTS Predicates#

Also new in v7, exists() and notExists() test whether a subquery returns any rows at all. They take the Select and nothing else — there's no column argument, because EXISTS compares nothing:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$subquery = $db->createSql()->select('id')->from('orders');
$subquery->where->greaterThanOrEqualTo('total', 300);

$sql = $db->createSql();
$sql->select()->from('users')->where->exists($subquery);
SQL
-- MySQL
SELECT * FROM `users` WHERE (EXISTS (SELECT `id` FROM `orders` WHERE (`total` >= 300)))

notExists() renders NOT EXISTS (...). Both compose with the rest of the predicate API, so an EXISTS sits inside a nest or beside an ordinary comparison like anything else.

The shorthand array syntax reserves two top-level keys for them, in the same way it reserves OR and AND. The value is the Select:

PHP
use App\Table\Users;
use Pop\Db\Record;

$db = Record::getDb();

$subquery = $db->createSql()->select('id')->from('orders');
$subquery->where->greaterThanOrEqualTo('total', 300);

$users = Users::findBy(['EXISTS' => $subquery]);
$none  = Users::findBy(['NOT EXISTS' => $subquery]);

Because 'EXISTS' and 'NOT EXISTS' are reserved keys, a column genuinely named EXISTS cannot be addressed through the shorthand array at all — build that predicate on the query builder instead.

exists() subqueries are non-correlated, so write a correlated EXISTS — "users who have an order" — as a join, or as a hand-written statement.

Querying JSON Columns#

jsonExtract() on the builder reads a value out of a JSON column by path, and returns an expression object rather than a string. That object can be a select column, an orderBy() or a groupBy():

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select(['id', 'extracted_name' => $sql->jsonExtract('data', '$.name')])
    ->from('users');

The path is always written in MySQL's JSONPath spelling — '$.name', '$.address.city', '$.tags[0]' — and each adapter renders its own equivalent:

SQL
-- MySQL
SELECT `id`, JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.name')) AS `extracted_name` FROM `users`

-- PostgreSQL
SELECT "id", "data"->>'name' AS "extracted_name" FROM "users"

-- SQLite
SELECT "id", json_extract("data", '$.name') AS "extracted_name" FROM "users"

PostgreSQL parses the path into its own segment form, so a nested path comes out as "data"#>>'{address,city}'. All three return the same value for the same row.

Filtering is jsonEqualTo() and jsonNotEqualTo(), which take the column, the path and a scalar:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select()
    ->from('users')
    ->where->jsonEqualTo('data', '$.role', 'admin');
SQL
-- MySQL
SELECT * FROM `users` WHERE (JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.role')) = 'admin')

-- PostgreSQL
SELECT * FROM "users" WHERE ("data"->>'role' = 'admin')

-- SQLite
SELECT * FROM "users" WHERE (json_extract("data", '$.role') = 'admin')

jsonContains() asks whether the array at a path holds a given value. It renders JSON_CONTAINS() on MySQL and the #> / @> jsonb operators on PostgreSQL:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select()
    ->from('users')
    ->where->jsonContains('data', '$.roles', 'admin');

jsonContains() is MySQL and PostgreSQL only; neither SQLite nor SQL Server has a native JSON containment operator.

All three predicates are reachable from the record shorthand through a 'column->$.path' key, with =, != and CONTAINS as the operators:

PHP
use App\Table\Users;

$admins  = Users::findBy(['data->$.role'  => 'admin']);
$others  = Users::findBy(['data->$.role'  => ['!=', 'admin']]);
$editors = Users::findBy(['data->$.roles' => ['CONTAINS', 'editor']]);

A bare value with no operator is an equality match, the same as any other shorthand column.

PostgreSQL's JSON extraction yields text and will not compare it to a number implicitly, so jsonEqualTo() renders a quoted literal there: "data"->>'n' = '5'. That makes 5 and 5.0 equal on MySQL and SQLite and unequal on PostgreSQL. jsonContains() encodes its candidate into the query verbatim rather than binding it.

Sorting, Limits and Offsets#

orderBy() takes a column and a direction, groupBy() takes a column, and limit() and offset() take integers:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$sql = $db->createSql();
$sql->select()
    ->from('users')
    ->orderBy('id', 'DESC')
    ->limit(10)
    ->offset(20);

That renders ORDER BY `id` DESC LIMIT 10 OFFSET 20 on MySQL, and the same with double quotes on PostgreSQL and SQLite. orderBy() also takes an array of columns or a comma-separated string of them, but the direction is a single argument appended once at the end — orderBy(['logins', 'id'], 'DESC') gives ORDER BY `logins`, `id` DESC, which in SQL means logins ASC, id DESC. Per-column directions need one call per column.

limit() and offset() are typed int, so a string like '10, 20' is a TypeError rather than a MySQL-style limit clause.

Pair offset() with a limit() — only PostgreSQL accepts a bare OFFSET.

orderBy() quotes what it is given as an identifier, so pass a column name. For a SQL function, order in a hand-written statement instead.

See Also#