DialectMySQL: MySQL 8.4's grammar, with its default sql_mode. Nothing
changes for a caller that does not pass it.
- It reads strings as MySQL does, with backslash escapes. A parser that
reads them as PostgreSQL does, in front of MySQL, accepted
name = '\'' OR id > 0 #'as one comparison ofnamewith a string, and MySQL 8.4 runs it with a predicate onid, outside the allow-list. If your filters run on MySQL, passWithDialect(qfv.DialectMySQL). - It refuses what MySQL 8.4 does not run:
ILIKE, the~~operators,SIMILAR TO, the~operators,IS [NOT] DISTINCT FROM,BETWEEN SYMMETRIC/ASYMMETRIC,ISNULL/NOTNULL. It keeps!=and an emptyESCAPE, which MySQL runs. - It refuses a backslash right after a character outside ASCII in a string,
which MySQL reads as part of that character in a multi-byte connection
character set (
gbk,sjis): put an ASCII character between them. NewLexerreads strings as before, as PostgreSQL does.- In every dialect, an
ESCAPEstring with a byte that is not UTF-8 in it is refused, and in MySQL's,ESCAPE '\%'andESCAPE '\_'are two characters, as MySQL counts them. - A string's
LiteralNode.Valueis its text with one quote taken off each end. It took every quote, so a string ending on an escaped quote lost it. - An
ESCAPEstring is measured in characters with its doubled quotes counted once in every dialect: an escape string of four single quotes is one quote, where it was counted as empty.
Every row is in SQL Dialects, each one run against MySQL 8.4.13.
DialectPostgreSQL.String() is PostgreSQL 18, where it was PostgreSQL:
each dialect names its version, as DialectSQLStandard's SQL:2023 does.
Only a caller that compares the string changes; the grammar does not.
The dialects have a page of their own, SQL Dialects: why a filter parser needs one, how to choose it, how a filter is checked against it, and every construct in each.
Two additions: the SQL dialect a filter is written in, and the ESCAPE
clause. A caller that changes nothing gets PostgreSQL 18's grammar, as before,
with ESCAPE accepted.
WithDialect chooses the grammar a filter is checked in:
DialectPostgreSQL (PostgreSQL 18), the default and what every earlier version parsed, or
DialectSQLStandard, ISO/IEC 9075:2023, which refuses PostgreSQL's own
spellings (!=, ILIKE, the ~~ and ~ operators, ISNULL/NOTNULL, an
empty ESCAPE). The table is in
dialects.md. Nothing changes
without the option.
The ESCAPE clause of the pattern operators, which PostgreSQL writes
string LIKE pattern [ESCAPE escape-character] and the same after
SIMILAR TO. Each row was run against PostgreSQL 18.6.
Accepted now, and refused before:
| Input | v1.0.5 | v1.1.0 | PostgreSQL 18 |
|---|---|---|---|
name LIKE 'x!%' ESCAPE '!', after ILIKE, NOT LIKE, NOT ILIKE, SIMILAR TO and NOT SIMILAR TO too, the keyword in any case |
❌ error | ✅ accepted | runs |
an empty escape: name LIKE 'x%' ESCAPE '' |
❌ error | ✅ accepted | runs, with escaping off |
Refused, as PostgreSQL refuses them:
| Input | v1.1.0 | PostgreSQL 18 |
|---|---|---|
two characters: ESCAPE '!!' |
❌ error: must be empty or one character |
invalid escape string |
a number, a boolean or a column: ESCAPE 1, ESCAPE TRUE, ESCAPE name |
❌ error: expected a string escape character after ESCAPE |
function like_escape(unknown, integer) does not exist; invalid escape string per row |
after the operator spellings: name ~~ 'x!%' ESCAPE '!', and after ~~*, !~~, !~~* |
❌ error: ESCAPE follows a pattern written with LIKE, ILIKE or SIMILAR TO |
syntax error at or near "ESCAPE" |
For callers that walk the tree. BinaryOperatorNode and SimilarToNode
have a new field, Escape: the escape character, a string LiteralNode, or
nil when there is no clause, which is every filter v1.0.5 accepted. Their
String() writes the clause when there is one. Nothing else changes, so the
version is a minor one.
For callers that hand the filter to PostgreSQL as written: the clause
goes with it and runs as the database runs it. A caller that prefixes field
names in the text (name to t.name) leaves ESCAPE alone, since it is no
field.
Three places where the filter parser and PostgreSQL disagreed about an input. Each row was run against PostgreSQL 18; what the pattern operators take is from its manual, 9.7. Pattern Matching.
Refused now, and accepted before:
| Input | v1.0.3 | v1.0.4 | PostgreSQL 18 |
|---|---|---|---|
a number or a boolean as a pattern: name LIKE 1, name NOT ILIKE -1, name ~~ 1, name SIMILAR TO 1, name LIKE TRUE |
✅ accepted | ❌ error: expected string pattern for operator LIKE, got LITERAL |
operator does not exist: text ~~ integer, for every column type |
YES and NO as boolean literals: active = YES, active <> no, active IN (yes, no) |
✅ accepted, as true and false |
❌ error | a name: the comparison is with a column called yes, or column "yes" does not exist |
Accepted now, and refused before:
| Input | v1.0.3 | v1.0.4 | PostgreSQL 18 |
|---|---|---|---|
IS in lower case: id is null, id is not null, id is distinct from 1, active is true |
❌ error (Is, iS and IS were accepted) |
✅ accepted | runs |
a field called yes or no, when it is in the allow-list: yes = 1 |
❌ error | ✅ accepted | runs |
The POSIX operators (~, ~*, !~, !~*) already took a string and
nothing else, and the ESCAPE clause is still not part of the grammar.
For callers that walk the tree. A LiteralNode of Kind
reflect.Bool now comes from TRUE or FALSE only, and the right side of a
LIKE/ILIKE BinaryOperatorNode and a SimilarToNode's Pattern are
always a string LiteralNode. Lexer users: is in lower case is the IS
token like every other spelling, and YES/NO are IDENTIFIER tokens.
Migration: write TRUE and FALSE; quote a pattern (name LIKE '1').
A field that was called is cannot be filtered on, as it cannot be named
unquoted in PostgreSQL.
v1.0.3 makes the filter parser refuse what PostgreSQL refuses as a syntax
error. A caller that passed one of these inputs through now gets the parser's
error instead of the database's.
| Input | v1.0.2 | v1.0.3 | PostgreSQL 18 |
|---|---|---|---|
id DISTINCT FROM 1, id NOT DISTINCT FROM 1 |
✅ accepted | ❌ error | syntax error: write IS [NOT] DISTINCT FROM |
price = 0x1p-2 (a hexadecimal float) |
✅ accepted | ❌ error | trailing junk after numeric literal |
id=1AND name='x' (a word glued to a number) |
✅ accepted | ❌ error | trailing junk after numeric literal |
active IS YES, active IS NOT NO |
✅ accepted | ❌ error | syntax error: IS takes TRUE, FALSE, UNKNOWN, NULL |
name NOT ~~ 'x', name NOT ~~* 'x' |
✅ accepted | ❌ error | syntax error: write NOT LIKE or !~~ |
Three more refusals, of input no client writes by accident:
| Input | v1.0.2 | v1.0.3 |
|---|---|---|
a keyword spelled with a non-ASCII letter (name lıke 'x', id Iſ NULL, name aſc in a sort) |
✅ accepted | ❌ error: PostgreSQL folds case in ASCII only, and reads these as names |
| an expression that starts with a byte order mark (U+FEFF) | ✅ accepted, the mark dropped | ❌ error |
an expression nested more than 100 levels deep (((((…, NOT NOT NOT …) |
✅ accepted, or the process ended: about 250 000 opening parentheses overflow the stack, a fatal error no recover catches |
❌ error |
The last one is a reason to upgrade whatever else you do, if the expression
comes from a request: bound its length before calling Parse, too.
One input that ran is now refused: a float written with _ separators
(price = 1_000.5, 1e1_0). v1.0.2 refused the integer form (1_000) and
accepted the float by accident of how each was converted; PostgreSQL 16 and
later run both. A number is now decimal digits, a point and an exponent, for
integers and floats alike. Write 1000.5.
One input that was refused is now accepted: a negative number.
| Input | v1.0.2 | v1.0.3 |
|---|---|---|
id = -1, id IN (-1, 2), age BETWEEN -5 AND 5 |
❌ error | ✅ accepted |
The sign is part of the literal: LiteralNode.Text is -1 and Value is
int64(-1). It must be glued to the number, and not to an operator written
with ! or ~: id !=-1 is refused, because PostgreSQL reads !=- as one
operator. The parser still does not know a column's type, so a negative
number is accepted wherever a literal is (name LIKE -1, a bare -1), as a
positive one already was; PostgreSQL refuses those for their type. See
Filtering › Numbers.
Error messages and tokens. For an input that was and is refused, the text
can differ: 0x10, 0b101, 0o17 and 1_000 are illegal token (it was
invalid integer), and a word glued to a number is one illegal token named
whole (error on field '1name': illegal token). Lexer users: - followed
by a digit or a point is no longer its own illegal token but the start of an
INT or FLOAT whose Value carries the sign.
Migration: replace x [NOT] DISTINCT FROM y with x IS [NOT] DISTINCT FROM y, and write numbers without _.
v0.1.0 fixes correctness bugs in the filter parser, adds the PostgreSQL 18
predicate set, and introduces configurable operators/directions and dotted
field names. If you only call Parse and check the returned error, most
changes are transparent — the parser now rejects inputs it previously accepted
by mistake. If you inspect the returned AST, review the notes below.
Previously the parser stopped at the first complete expression and silently ignored the rest, so invalid input validated successfully. It now requires the entire input to be consumed.
| Input | Before | v0.1.0 |
|---|---|---|
first_name = 'John' garbage |
✅ accepted | ❌ error |
age > 30 40 |
✅ accepted | ❌ error |
first_name = 'John') |
✅ accepted | ❌ error |
1 = 1 |
✅ accepted | ❌ error |
Migration: none required for valid queries. If you relied on the lenient behavior, fix the offending inputs — they were never valid.
NOT IN, NOT BETWEEN, NOT SIMILAR TO, and NOT DISTINCT FROM previously
produced a UnaryOperatorNode{Operator: NOT} wrapping the inner node (whose own
IsNot was always false). They now return the operator node directly with
IsNot = true, matching how RegexMatchNode already worked. NOT LIKE now
produces a BinaryOperatorNode with Operator = TokenOperatorNotLike.
For example, status NOT IN ('archived') changed shape:
flowchart LR
subgraph before["Before (v0.0.x)"]
direction TB
U["UnaryOperatorNode<br/>Operator: NOT"] --> I1["InNode<br/>IsNot: false"]
end
subgraph after["v0.1.0"]
direction TB
I2["InNode<br/>IsNot: true"]
end
before ==>|"flattened"| after
// Before: type-switch had to unwrap the NOT node
if u, ok := node.(*qfv.UnaryOperatorNode); ok && u.Operator == qfv.TokenOperatorNot {
if in, ok := u.X.(*qfv.InNode); ok { /* negated IN */ }
}
// After: the node carries its own negation
if in, ok := node.(*qfv.InNode); ok && in.IsNot { /* negated IN */ }The IsNot fields on InNode, BetweenNode, SimilarToNode, and
DistinctNode are now meaningful, Type() returns the corresponding
NodeTypeNotIn / NodeTypeNotBetween / NodeTypeNotSimilarTo /
NodeTypeNotDistinct, and String() renders the NOT/IS NOT form.
A standalone leading
NOT(e.g.NOT (age > 30)) is unchanged — it still produces aUnaryOperatorNode.
IS [NOT] DISTINCT FROM <value> previously discarded the compared value.
DistinctNode now holds it:
type DistinctNode struct {
Field Node
Value Node // NEW: the right-hand side value (nil when absent)
IsNot bool
}Its String() now renders the canonical field IS [NOT] DISTINCT FROM value.
FilterParser.Parse returns errors.Join(...) of *QFVFilterError values
rather than a single formatted string. Use errors.As / errors.Is to inspect
causes — see Error Handling. Code that only checks
err != nil is unaffected.
A single *FilterParser (like *SortParser and *FieldsParser) can now be
created once and shared across goroutines; per-request state is no longer stored
on the parser. No API change — this removes a data race.
The grammar gained the following; existing queries keep parsing:
| Predicate / operator | AST |
|---|---|
ILIKE, NOT ILIKE (and ~~, ~~*, !~~, !~~*) |
BinaryOperatorNode with ILIKE/NOT ILIKE (or LIKE/NOT LIKE) |
IS [NOT] TRUE/FALSE/UNKNOWN |
new BooleanTestNode{Field, Value, IsNot} |
IS [NOT] DISTINCT FROM <value> |
DistinctNode via the standard IS form |
BETWEEN [NOT] SYMMETRIC (explicit ASYMMETRIC) |
BetweenNode.IsSymmetric |
ISNULL, NOTNULL shorthands |
IsNullNode (IsNot=true for NOTNULL) |
NewFilterParser(fields) and NewSortParser(fields) gained variadic options:
func NewFilterParser(allowedFields []string, opts ...FilterOption) *FilterParser
func NewSortParser(allowedFields []string, opts ...SortOption) *SortParserExisting calls compile unchanged. See Configuration for
WithAllowedOperators, WithAllowedOperatorGroups, and WithAllowedDirections.
Field names may now contain dots (user.profile.age) in all three parsers.
Numeric literals such as 3.14 are unaffected. See
Filtering › Nested field names.