Skip to content

Query: validate tuples, quote untyped values, require a condition - #15

Merged
daedeloth merged 4 commits into
masterfrom
fix/query-strict-values
Oct 7, 2026
Merged

daedeloth merged 4 commits into
masterfrom
fix/query-strict-values

Conversation

@daedeloth

Copy link
Copy Markdown
Member

Builds on #9 and #10 (this branch contains both) and should be merged after them. Until they are merged the diff below also shows their changes; the commit of this PR is ba8f0b9.

What changes

Tuples are validated. The builders read an array as a tuple: [ value, type, comparator ] in WHERE and [ value, type, nullOnEmpty ] in SET.

  • A tuple is a list with 1 to 3 elements (keys 0..2). An associative array or a longer list throws InvalidParameter.
  • The type has to be one of the Query::PARAM_* constants.
  • The comparator has to be one of =, !=, NOT, <, >, <=, >=, LIKE, IN (case-insensitive, as before). An unknown comparator such as <> or NOT LIKE used to be read as equality and now throws.
  • A list of values is accepted for IN (explicit, or implied by passing a list as the value) and holds scalars and nulls. IN with a single value, a list with another comparator, a nested list and a list as a SET value throw.
  • In SET the third element has to be a boolean.
  • Values bound on a written query (bindValue, bindValues) get the same checks on shape, type and lists when the query is parsed. Their third element keeps being read loosely as the "null on empty" flag.
  • PARAM_NULL writes NULL (and throws for another value); a type the class does not know throws instead of writing nothing.

Untyped values are quoted. A value without a type (PARAM_UNKNOWN) is always written as a quoted string, integers included: id = '5'. PARAM_NUMBER still writes a bare number. INF/NAN throw for every type, floats are written with a decimal point in every locale, and the coordinates of a Point have to be numeric.

Null conditions no longer shift later values. 'col' => null is written as col IS NULL, which has no placeholder, but it still collected a value: the next condition got that value and every value after it moved up one place, unless the null condition was the last one. It no longer collects a value, so a null condition can stand anywhere. The builders also check that they wrote as many placeholders as they collected values and throw InvalidParameter otherwise (for example for a ? in a column name).

update() and delete() require a condition. With an empty $where they throw InvalidParameter instead of addressing every row. select() is unchanged.

Compatibility

  • A query that binds an untyped integer where SQL needs a bare number has to bind it as PARAM_NUMBER. The common case is LIMIT ? / OFFSET ?: ->bindValue (1, $limit) becomes ->bindValue (1, $limit, Query::PARAM_NUMBER). Comparisons, assignments, arithmetic and IN lists read a quoted number as a number for numeric columns. Against a string column the value is now compared as a string. An ENUM column compared with an untyped integer is compared by value instead of by index.
  • Code that relied on an unknown comparator meaning "equals" has to pass a supported one.
  • Query::update ($table, $set, array ()) and Query::delete ($table, array ()) throw. A statement for every row is written as a query: new Query ('DELETE FROM ...').
  • Conditions after a null condition are now applied as written.
  • The placeholder check only covers the builders. A written query (new Query ($sql) plus bindValue) is not counted, because it may hold a literal question mark after its last placeholder.
  • IN is written in upper case whatever the case of the comparator.

Not touched: quoting of identifiers (column keys, selected columns, ORDER BY, LIMIT of select()), a literal question mark in written SQL, != NULL for a null value with another comparator than =, and IN () for an empty list.

Tests

tests/DbQueryStrictTest.php (new, runs on the TestDatabase stub). Two tests in tests/DbQueryInjectionTest.php pinned the old behaviour and were updated: testUnknownParamIntIsNotQuoted (now testUnknownParamIntIsQuoted) and testDeleteNoWhere (now testDeleteNoWhereThrows). The expected strings in tests/DbQueryTest.php (group database, not run in CI) follow the quoting.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DqZUi2UiBJP6rZoCSRcdpE

daedeloth and others added 4 commits October 7, 2026 08:36
A value starting with '!' used to turn the comparison into != and lose
its first character. Values are now always compared as they are; the
comparator is only taken from the third element ('NOT', '!=', ...).

Query::setLegacyNegationPrefix(true) switches the old behaviour back on
for a project that still relies on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqZUi2UiBJP6rZoCSRcdpE
Values were substituted one after another over the whole query, through
fixed marker strings. Text inside one value could therefore be taken
for the marker of another. All markers are now swapped for their values
in one strtr() pass that never rescans substituted text, and markers
carry a random part per query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqZUi2UiBJP6rZoCSRcdpE
The query builder is strict about what it is given.

- Tuples are validated. A WHERE tuple is [ value, type, comparator ], a
  SET tuple [ value, type, nullOnEmpty ]: a list with 1 to 3 elements, a
  Query::PARAM_* type, and a comparator out of =, !=, NOT, <, >, <=, >=,
  LIKE, IN (case-insensitive). Anything else throws InvalidParameter,
  where an unknown comparator used to be read as equality. A list of
  values is only accepted for IN and holds scalars and nulls. Values
  bound on a written query get the same checks on shape, type and lists.
- Untyped values are always written as quoted strings, integers
  included. PARAM_NUMBER still writes a bare number. Non-finite numbers
  throw, PARAM_NULL writes NULL and an unknown type throws.
- A null condition no longer shifts the values after it: `IS NULL` has
  no placeholder and no longer collects a value. The builders check
  that they wrote as many placeholders as they collected values.
- update() and delete() throw without a condition.

Compatibility: a query that binds an untyped integer where SQL needs a
bare number (LIMIT ?) has to bind it as PARAM_NUMBER.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqZUi2UiBJP6rZoCSRcdpE
@daedeloth
daedeloth merged commit 0a88c94 into master Oct 7, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant