Database and code execution
Dynamic SQL: Identifiers, ORDER BY and IN Lists
The three query shapes that force dynamic SQL, and how to build each one without putting request data into the string: allowlists, %i, and a correct IN-list helper.
A filter bar on an orders screen offers a status dropdown, a column picker with an ascending and descending toggle, and a set of checkboxes that selects several references at once. Every one of those controls changes the shape of the query rather than the data in it, and a placeholder only helps with data. This is the point where people who learned "always use prepare()" start concatenating again, and the point where a code review has to know more than the rule.
Three shapes cover almost all of it. A name in a structure slot, such as a column
after ORDER BY or a table after FROM. A keyword that is not a
name at all, such as the ASC or DESC that follows a sort
column. And a list of values whose length is not known until the request arrives, which
is what IN (...) asks for. Each has a different correct answer, and only
two of the three involve a placeholder.
An identifier is bounded by choosing, not by escaping
The column picker is the easiest of the three to get right when you accept what it actually is. The reader is not sending a column name. The reader is sending a choice from a list your code wrote, and the browser is an unreliable narrator about which one it sent. So write the list down and match against it.
/**
* Maps a public sort key to a real column.
*
* @param string $key The value from the request.
* @return string A column that exists in the table.
*/
function m4_sort_column( string $key ): string {
$columns = array(
'reference' => 'reference',
'customer' => 'customer',
'total' => 'total',
'status' => 'status',
);
return $columns[ $key ] ?? 'reference';
}
The switch shape does something the array of allowed names does not: it lets the public key differ from the column, so you can keep a stable API while renaming a column later. It also makes the default explicit rather than implicit in a ternary, which is the difference between "sorted by reference because that is the default" and "sorted by whatever the first entry in the array happened to be".
Once the value is one of four strings you wrote, whether you also pass it through
%i is a question about future edits rather than about this one. Pass it
anyway. A later change that reads the column from an option, or that lets an
administrator define a custom column, keeps the %i escaping and loses only
the allowlist:
$column = m4_sort_column( $request['sort'] ?? 'reference' );
$sql = $wpdb->prepare(
"SELECT id, reference, total FROM {$wpdb->prefix}m4_orders ORDER BY %i DESC",
$column
);
The direction is a keyword, so it needs its own allowlist
This is the part that gets forgotten, because the direction looks like a small
detail attached to the column. It has no placeholder at all.
%i would turn DESC into the identifier
`DESC`, which is a column that does not exist, and %s would
turn it into the string constant 'DESC', which MySQL accepts and ignores.
Neither is what you want, so the direction has to become one of two literal keywords:
$direction = ( 'asc' === strtolower( (string) ( $request['order'] ?? 'asc' ) ) ) ? 'ASC' : 'DESC';
The ternary reads oddly the first time. It is an allowlist with two entries where
the fallback is also a valid entry, and it is correct: anything that is not
asc, including an empty string, a nested array cast to a string, or a
payload, becomes DESC. The output is one of two constants that the
developer typed, and the reader's bytes never appear in the query. Compare that with
strtoupper( $request['order'] ), which looks tidier and puts arbitrary
reader input into a structure slot.
Core WordPress handles the same problem the same way. The posts controller defines
its orderby REST argument with a closed list of permitted values, and its
order argument with a second list holding only asc and
desc. A request carrying anything else fails validation before a query is
built. The pattern is worth copying precisely because it is boring.
// From the posts controller in WordPress 7.0.7, abbreviated.
$query_params['order'] = array(
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
);
$query_params['orderby'] = array(
'default' => 'date',
'enum' => array( 'author', 'date', 'id', 'include', 'modified', 'parent', 'relevance', 'slug', 'title' ),
);
An IN list needs placeholders you generate from the count
The third shape is the one with a genuinely reusable answer, because the number of
values is unknown at the time you write the query. The mistake is to build the list by
joining escaped values. The correct move is to build the placeholders from the count
and let prepare() bind the values.
/**
* Builds a placeholder list and its arguments for a dynamic IN clause.
*
* @param array $values Values to bind. Re-indexed before use.
* @param string $type Placeholder for each value: %s, %d or %f.
* @return array{0:string,1:array} The SQL fragment and the arguments for it.
*/
function m4_in_list( array $values, string $type = '%s' ): array {
if ( array() === $values ) {
// "col IN ()" is a syntax error. NULL matches nothing, which is the
// honest result for an empty selection.
return array( 'NULL', array() );
}
return array(
implode( ', ', array_fill( 0, count( $values ), $type ) ),
array_values( $values ),
);
}
Three details in that function are doing work. array_fill( 0, count( $values ), $type )
produces an array whose length matches the values, so the placeholder count can never
drift from the argument count. array_values() drops the keys, which
matters because prepare() binds arguments by position and an associative
array inherited from a request can carry string keys that break the positional match.
And the empty case returns a fragment that is syntactically valid instead of a pair of
parentheses with nothing in them.
Used with a spread, the call site stays readable:
list( $placeholders, $args ) = m4_in_list( array( 'ORD-1002', 'ORD-1005' ) );
$sql = $wpdb->prepare(
"SELECT reference, customer FROM {$wpdb->prefix}m4_orders
WHERE reference IN ($placeholders) ORDER BY reference",
...$args
);
SELECT reference, customer FROM wp_m4_orders
WHERE reference IN ('ORD-1002', 'ORD-1005') ORDER BY reference
ORD-1002 | Bo Tran
ORD-1005 | Eli Farkas
With %d and a pair of integers the same helper produces
IN (2, 5) with the two numbers unquoted. The type argument is the caller's
promise about the data, and it is the one thing the helper cannot check for you: pass
strings to %d and MySQL will coerce them, so a reference of
ORD-1002 becomes 0 and silently matches nothing.
The list member that contains a quote
Building the same list by hand with esc_sql() produces the identical
fragment, IN ('ORD-1002', 'x\') OR 1=1 -- '), and the identical result:
one row and no error, because each value is still inside its own quotes and the stray
quote is escaped. The escaping underneath is the same call. What the helper buys you
is that WordPress decides where the quotes go for every member instead of a loop you
wrote at eleven at night, and that the placeholder count is derived from the value
count rather than from a foreach that someone later edits.
Several filters mean an array of conditions and an array of arguments
Real screens combine the shapes, which is where the argument order starts to matter. Build the conditions and the arguments together, in the same order, and concatenate the finished strings:
/**
* Builds a WHERE clause from optional filters.
*
* @return array{0:string,1:array} SQL conditions and their bound arguments.
*/
function m4_orders_where( array $statuses, array $refs ): array {
$conditions = array();
$args = array();
if ( $statuses ) {
list( $p, $a ) = m4_in_list( $statuses );
$conditions[] = "status IN ($p)";
$args = array_merge( $args, $a );
}
if ( $refs ) {
list( $p, $a ) = m4_in_list( $refs );
$conditions[] = "reference IN ($p)";
$args = array_merge( $args, $a );
}
return array( $conditions ? implode( ' AND ', $conditions ) : '1=1', $args );
}
list( $where, $args ) = m4_orders_where( array( 'shipped', 'pending' ), array( 'ORD-1002', 'ORD-1005' ) );
$sql = $wpdb->prepare(
"SELECT reference, status FROM {$wpdb->prefix}m4_orders
WHERE $where ORDER BY reference",
...$args
);
SELECT reference, status FROM wp_m4_orders
WHERE status IN ('shipped', 'pending') AND reference IN ('ORD-1002', 'ORD-1005') ORDER BY reference
The function returns SQL that already contains placeholders and an argument array
that matches them one for one. That pairing is the contract, and it is the thing to
check when this pattern is reviewed: if a branch adds a condition without adding its
arguments, or adds arguments in a different order to the conditions,
prepare() will report a count mismatch and hand back an empty string. The
failure is loud in development and silent in production, which is a good reason to
assert the count in a unit test rather than trusting the reading.
The '1=1' default deserves a word. Returning an empty string for "no
filters" forces every caller to branch on whether the clause is empty, and the branch
that forgets produces a query ending in WHERE ORDER BY. A clause that is
always true keeps the string shape constant and costs nothing measurable.
esc_sql returns escaped data, not a safe query
The reason esc_sql() cannot stand in for the patterns above is that it
solves half a problem. It escapes the characters that are special inside a MySQL
string literal. It does not add the surrounding quotes, and it does not know whether
the position it is being pasted into is a literal at all. Both gaps are real, and the
first one is the one that shows up in penetration tests.
The second gap is easier to demonstrate than to argue about. Under the
NO_BACKSLASH_ESCAPES SQL mode MySQL stops treating a backslash as an
escape character, so the backslash that esc_sql() added in front of a
quote is only a backslash. A value whose only special character is a trailing backslash
then closes the string literal:
global $wpdb;
$wpdb->query( "SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'" );
$hostile = "x\\";
$escaped = esc_sql( $hostile );
$sql = "SELECT COUNT(*) FROM {$wpdb->prefix}m4_orders WHERE customer = '$escaped' OR 1=1 -- '";
// SELECT COUNT(*) FROM wp_m4_orders WHERE customer = 'x\' OR 1=1 -- '
rows matched : 5 (of 5)
Five of five rows matched a query that was supposed to filter by customer, and no
error was returned. The reader's input was a single backslash. The same value through a
placeholder produces the finished string
WHERE customer = 'x\' with no boolean after it, matches nothing, and
returns zero rows with no error. The escaping in the two cases is identical, because
both call mysqli_real_escape_string(). The difference is that
prepare() decided where the quotes go, so the attacker cannot use the
quote that the developer was supposed to place.
That is the argument in one sentence: esc_sql() is a function that
returns escaped data, and the security property people want belongs to the composition
of the query. Keep the composition inside prepare() and every string your
code assembles is a fragment you wrote. Move it outside and the security of the query
is a property of your ability to remember quotes, in every branch, forever.
Write m4_find_orders() that takes a request array and returns the
SQL string and the arguments, without running the query. It supports four inputs:
an optional status that may be one value or several, an optional
min_total as a number, a sort column chosen from
reference, customer, total and
status, and an order of asc or
desc.
list( $sql, $args ) = m4_find_orders( array(
'status' => array( 'shipped', 'pending' ),
'min_total' => '100',
'sort' => 'total',
'order' => 'desc',
) );
Then answer four questions about your own function. What does it return when
status is an empty array? What happens when sort is
total; DROP TABLE wp_m4_orders? How many elements does
$args have in the first example, and how did you confirm it matches
the placeholder count? And what changes if a caller passes
'status' => 'shipped' as a string instead of an array?
Show the solution and the reasoning
The shape that keeps the answers obvious is to normalise at the top, build conditions and arguments in one pass, and hand the ordering to the two allowlists. Nothing in the function should look at reader input after normalisation.
function m4_find_orders( array $request ): array {
global $wpdb;
$conditions = array();
$values = array();
// One value and several values collapse into the same list.
if ( ! empty( $request['status'] ) ) {
$statuses = array_values( (array) $request['status'] );
$statuses = array_map( 'sanitize_key', $statuses );
list( $p, $a ) = m4_in_list( $statuses );
$conditions[] = "status IN ($p)";
$values = array_merge( $values, $a );
}
if ( isset( $request['min_total'] ) && '' !== $request['min_total'] ) {
$conditions[] = 'total >= %f';
$values[] = (float) $request['min_total'];
}
$where = $conditions ? implode( ' AND ', $conditions ) : '1=1';
$columns = array( 'reference', 'customer', 'total', 'status' );
$sort = (string) ( $request['sort'] ?? 'reference' );
$column = in_array( $sort, $columns, true ) ? $sort : 'reference';
$direction = ( 'asc' === strtolower( (string) ( $request['order'] ?? 'asc' ) ) ) ? 'ASC' : 'DESC';
// prepare() binds its arguments in the order the placeholders appear, and
// the identifier placeholder is last in this query, so it is last here.
$args = array_merge( array_values( $values ), array( $column ) );
$sql = $wpdb->prepare(
"SELECT id, reference, customer, total, status
FROM {$wpdb->prefix}m4_orders
WHERE $where
ORDER BY %i $direction",
...$args
);
return array( $sql, $args );
}
Called with status of array( 'shipped', 'pending' ),
min_total of '100', sort of
'total' and order of 'desc', it produces four
arguments and the rows below:
SELECT id, reference, customer, total, status FROM wp_m4_orders WHERE status IN ('shipped', 'pending') AND total >= 100.000000 ORDER BY `total` DESC
args (4): ["shipped","pending",100,"total"]
rows: 3 error: (none)
ORD-1003 410.75 shipped
ORD-1005 256.10 pending
ORD-1001 124.50 shipped
An empty status array disappears rather than becoming
IN (NULL), because ! empty() catches both an empty array
and an empty string before the helper is reached. The query keeps its shape and the
identifier argument is still last, which is what makes the two cases share one code
path. If you prefer to keep the filter visible in the query, allow the NULL
fragment through instead; either is defensible, and the one thing not to do is emit
IN (), which is a syntax error that surfaces as a database error rather
than as no results.
sort = "total; DROP TABLE wp_m4_orders" fails the
in_array() check and the query sorts by reference with an
empty error log. The table is not at risk from the %i either, since the
payload would be escaped as a single backticked identifier and rejected as an unknown
column, but the answer you want to give is the allowlist one, because a rejected
query is a failure the user sees and a silent fallback is not.
The argument order is the trap in this function, and it is why the identifier is
appended to $args rather than prepended. prepare() binds
arguments to placeholders by position in the finished string, so the sort column has
to be the last argument here even though %i reads as the last thing
before DESC in the query. Move it to the front and everything shifts one
place to the right:
SELECT 1 WHERE status IN ('total', 'shipped') AND total >= 0.000000 ORDER BY `100`
The first status landed in %f and PHP cast it to 0.0, the
second landed in %i as an identifier, and the float landed in the last
%s. The query still runs, because %f accepts a string and
%i accepts anything, so the only symptom is a result set that answers a
different question. That is the kind of bug that survives review and turns up in a
support ticket, which is a good argument for a test that asserts rows rather than
SQL.
'status' => 'shipped' as a string works because of the cast to
array at the top. Without it, count( 'shipped' ) would be a fatal error
on PHP 8, which is one of the few failures here that announces itself clearly. The
cast is cheaper than requiring every caller to remember the shape, and it is the same
reason array_values() is inside the helper rather than at every call
site.
A different reasonable design returns a prepared SQL string only, with the
arguments kept private and a separate method to run it. That removes the chance of a
caller reading $args and passing them in the wrong order, at the cost of
making the function harder to unit test without a database. Both are fine. What is
not fine is returning SQL with placeholders and no arguments, which produces the
empty-string query and an empty result set that looks like an empty table.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.