Skip to content

fix: the filter parser refuses what PostgreSQL refuses, and bounds its nesting - #19

Merged
christiangda merged 2 commits into
mainfrom
fix/filter-refuses-what-postgres-refuses
Oct 4, 2026
Merged

christiangda merged 2 commits into
mainfrom
fix/filter-refuses-what-postgres-refuses

Conversation

@christiangda

Copy link
Copy Markdown
Contributor

What

An expression the filter parser accepts is spliced into a WHERE clause by its callers, so it must not be one PostgreSQL calls a syntax error. This makes the parser refuse what PostgreSQL refuses, accept negative numbers, and bound how deep an expression may nest.

Input Before Now PostgreSQL 18
id DISTINCT FROM 1, id NOT DISTINCT FROM 1 accepted refused syntax error: SQL writes IS [NOT] DISTINCT FROM
price = 0x1p-2 (a hexadecimal float) accepted refused trailing junk after numeric literal
id=1AND name='x' (a word glued to a number) accepted refused trailing junk after numeric literal
active IS YES, active IS NOT NO accepted refused syntax error
name NOT ~~ 'x', name NOT ~~* 'x' accepted refused syntax error: NOT LIKE or !~~
a keyword spelled with a non-ASCII letter (lıke, Iſ, aſc in a sort) accepted refused syntax error: PostgreSQL folds case in ASCII only
a leading byte order mark accepted, the mark dropped refused syntax error or unknown column
price = 1_000.5 (a float with _) accepted refused runs, since PostgreSQL 16
id = -1, id IN (-1, 2), age BETWEEN -5 AND 5 refused accepted runs
nesting past 100 levels (((((…, NOT NOT NOT …) accepted, or a stack overflow refused

The first three and the negative number are the gaps found from svc-qu3ry-core.

How

  • DISTINCT FROM: the two branches that took it without IS are gone. IS is the only way in, as the docs already said.
  • Numbers: text/scanner reads Go numbers. A number is now a decimal literal, for integers and floats alike, and a word glued to it is one illegal token (1AND). 'x'AND stays accepted: a string ends at its quote, and PostgreSQL runs it.
  • A minus sign glued to a decimal literal is part of the literal (LiteralNode.Text is -1, Value is int64(-1)). There is still no arithmetic. A sign directly after an operator written with ! or ~ is refused, because PostgreSQL reads the longest operator and such an operator may end in - (!=-).
  • Keywords are ASCII. strings.ToUpper maps the dotless ı and the long ſ to I and S; asciiUpper does not. The sort parser's directions use it too.
  • Nesting is bounded at 100 levels. The parser is recursive and the input chose the depth; very deep nesting overflowed the goroutine stack, which is a fatal error no recover catches. A long flat expression is not limited by this. Callers should still bound the length of what they hand to Parse.

How it was checked

  • Every row of the new tests was run against PostgreSQL 18.3 as SELECT … WHERE (<input>); the comment on each row is what it answered.
  • Differential run against PostgreSQL. Nine million random token sequences (three seeds), every expression the parser accepts run against PostgreSQL on a table with an integer, a text, a float and a boolean column: none is a syntax error. What remains is type errors, which a parser that does not know the column types cannot see.
  • Reviewed by comparing this parser with the previous one on 43 million expressions and sending every difference to PostgreSQL. That found the float with _, the two letters, the byte order mark and the stack overflow; all four are in this change.
  • Eighteen mutants of the change: seventeen caught, the eighteenth equivalent.
  • FuzzFilterParser (new, the first fuzz target here): no panic, and a node or an error, never both.
  • go test -race -tags=unit ./... passes, 97.4% of statements.

For the release

  • Several accepted shapes are now refused in a v1 library. All but one could not run on PostgreSQL; the one that could is the float with _. I wrote the migration section as v1.0.2 → v1.0.3; rename it if you tag otherwise.
  • The message for 0x10 and 1_000 changes from invalid integer to illegal token (both were refused before and are still). Lexer users: - followed by a digit is now the start of an INT or FLOAT token.
  • Left alone, on purpose: YES/NO as boolean literals (active = yes is accepted and PostgreSQL reads yes as a column name; the docs now say so), lowercase is (refused, by an older special case in the lexer), and LIKE with a number as its pattern.

🤖 Generated with Claude Code

…s nesting

An expression the parser accepts is spliced into a WHERE clause, so it must
not be one PostgreSQL calls a syntax error. Eight shapes were accepted and are
one there, or an operator that does not exist; one that runs was refused; and
one input ended the process.

Refused now:
- `id DISTINCT FROM 1`, `id NOT DISTINCT FROM 1`: SQL writes
  `IS [NOT] DISTINCT FROM`, and IS is the only way in.
- a hexadecimal float (`0x1p-2`): text/scanner reads Go numbers. A number is
  a decimal literal, for integers and floats alike (so `1_000.5`, which ran,
  is refused as `1_000` already was).
- a word glued to a number (`id=1AND name='x'`): PostgreSQL's "trailing junk
  after numeric literal". The whole word is the illegal token.
- `active IS YES`: YES and NO are boolean literals, not truth values of IS.
- `name NOT ~~ 'x'`: NOT negates the keyword LIKE, not its symbol.
- a keyword spelled with a non-ASCII letter (`lıke`, `Iſ`, `aſc`):
  strings.ToUpper maps 'ı' and 'ſ' to 'I' and 'S', PostgreSQL folds case in
  ASCII only.
- a leading byte order mark, which text/scanner dropped without a token.
- more than 100 levels of nesting. The parser is recursive and the input
  chose the depth: about 250 000 opening parentheses ended the process with
  a stack overflow, a fatal error no recover catches.

Accepted now: a negative number (`id = -1`, `IN (-1, 2)`,
`BETWEEN -5 AND 5`). A minus sign glued to a decimal literal is part of it;
not after an operator written with `!` or `~`, which PostgreSQL reads as one
longer operator (`!=-`).

Every row of the new tests was run against PostgreSQL 18, and so was every
expression the parser accepts out of nine million random token sequences:
none is a syntax error. Reviewed by comparing this parser with the previous
one on 43 million expressions; that found the underscore float, the two
letters, the mark and the overflow. Eighteen mutants of the change, seventeen
caught; the eighteenth is equivalent.

Adds FuzzFilterParser: no panic, and a node or an error, never both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@christiangda christiangda self-assigned this Oct 4, 2026
The coverage step installed the tool with "@latest". v2.20.0 requires Go
1.27, this module builds with the Go of its go.mod (1.26, GOTOOLCHAIN=local),
and the job failed the day that version was published: on this pull request,
and it would have failed the release workflow the same way.

v2.19.0 is the last version whose go.mod says 1.26. Raise the pin together
with the go directive.

Checked with go1.26.8: the tool installs, the suite passes with the race
detector, and the coverage thresholds are met (97.5%).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@christiangda
christiangda merged commit 0e1ed1a into main Oct 4, 2026
1 check passed
@christiangda
christiangda deleted the fix/filter-refuses-what-postgres-refuses branch October 4, 2026 14:47
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