diff --git a/README.md b/README.md
index 7b4235d..7adf0d0 100644
--- a/README.md
+++ b/README.md
@@ -59,9 +59,12 @@ func main() {
- **Three validators** — fields selection, filtering, and sorting, each gated by
an allow-list of fields (nested dot-notation like `user.profile.age` supported).
-- **Rich, PostgreSQL-flavored filter grammar** — comparison, logical, `IN`,
- `BETWEEN [SYMMETRIC]`, `LIKE`/`ILIKE`, `SIMILAR TO`, POSIX regex, `IS NULL`,
- `IS [NOT] TRUE/FALSE/UNKNOWN`, and `IS [NOT] DISTINCT FROM`.
+- **PostgreSQL's filter grammar, or the SQL standard's** — comparison,
+ logical, `IN`, `BETWEEN [SYMMETRIC]`, `LIKE`/`ILIKE` with `ESCAPE`,
+ `SIMILAR TO`, POSIX regex, `IS NULL`, `IS [NOT] TRUE/FALSE/UNKNOWN`, and
+ `IS [NOT] DISTINCT FROM`. The default dialect is PostgreSQL 18's;
+ `WithDialect(DialectSQLStandard)` accepts SQL:2023 (ISO/IEC 9075:2023) and
+ refuses PostgreSQL's own spellings. See [the SQL dialect](docs/configuration.md#the-sql-dialect).
- **Configurable** — restrict which operators or sort directions are allowed.
- **Returns an AST** you can walk or render, with precise, aggregated errors.
- **Concurrency-safe** parsers.
diff --git a/dialect.go b/dialect.go
new file mode 100644
index 0000000..820bb4a
--- /dev/null
+++ b/dialect.go
@@ -0,0 +1,105 @@
+package qfv
+
+// Dialect is the SQL a filter is written in: what the parser accepts is what
+// that database, or that standard, runs.
+//
+// The parser checks an expression; it does not translate one. A caller that
+// hands the filter's text to its database, as the services built on this
+// package do, needs "accepted" to mean "the database runs it", so a filter is
+// validated in the grammar of the SQL it will run as. Each dialect's rules are
+// checked against its reference: PostgreSQL's against PostgreSQL 18 and its
+// manual's SQL Syntax chapter, the
+// standard's against PostgreSQL's SQL Conformance appendix, which names the
+// feature each predicate is
+// (https://www.postgresql.org/docs/current/features-sql-standard.html), and
+// against what its manual calls an extension.
+//
+// A Dialect is one of the values below; the zero value is DialectPostgreSQL,
+// the grammar this package has always parsed.
+type Dialect struct {
+ name string
+}
+
+var (
+ // DialectPostgreSQL is PostgreSQL's grammar, as PostgreSQL 18 runs it
+ // (https://www.postgresql.org/docs/18/sql-syntax.html): every operator
+ // this package knows. The default.
+ DialectPostgreSQL = Dialect{}
+
+ // DialectSQLStandard is the SQL standard, ISO/IEC 9075:2023 (SQL:2023),
+ // Part 2 (Foundation). It is PostgreSQL's grammar without what
+ // PostgreSQL's manual calls its own:
+ //
+ // - != (the standard's "not equal" is <>);
+ // - ILIKE and NOT ILIKE, and the ~~, ~~*, !~~ and !~~* operators;
+ // - the POSIX regular-expression operators ~, ~*, !~ and !~* (the
+ // standard's is LIKE_REGEX, which this package does not parse);
+ // - the ISNULL and NOTNULL shorthands (the standard writes IS [NOT] NULL);
+ // - an empty ESCAPE (the standard's escape character is one character).
+ //
+ // What it keeps is in the standard: the comparison predicate (E061-01),
+ // BETWEEN (E061-02) and its SYMMETRIC form (T461), IN with a list of
+ // values (E061-03), LIKE (E061-04) and its ESCAPE clause (E061-05), the
+ // NULL predicate (E061-06), SIMILAR TO (T141), IS [NOT] DISTINCT FROM
+ // (T151, T152), the truth value tests IS [NOT] TRUE/FALSE/UNKNOWN (F571),
+ // and AND, OR and NOT.
+ //
+ // One difference cannot be checked: without an ESCAPE clause the standard
+ // has no escape character, where PostgreSQL's is the backslash. A pattern
+ // with a backslash in it means one thing to each.
+ DialectSQLStandard = Dialect{name: "SQL:2023"}
+)
+
+// String is the dialect's name, as error messages write it.
+func (d Dialect) String() string {
+ if d.name == "" {
+ return "PostgreSQL"
+ }
+
+ return d.name
+}
+
+// standard reports whether the dialect is the SQL standard.
+func (d Dialect) standard() bool {
+ return d == DialectSQLStandard
+}
+
+// Operators returns the operators the dialect has. The returned slice is a
+// copy and safe to modify.
+func (d Dialect) Operators() []Operator {
+ var out []Operator
+
+ for _, op := range AllOperators() {
+ if d.has(op) {
+ out = append(out, op)
+ }
+ }
+
+ return out
+}
+
+// has reports whether the dialect has the operator at all. A spelling the
+// dialect does not have (!= for <>, ~~ for LIKE, ISNULL for IS NULL) is
+// refused where the parser reads it.
+func (d Dialect) has(op Operator) bool {
+ if !d.standard() {
+ return true
+ }
+
+ switch op {
+ case OpILike, OpRegexMatch:
+ return false
+ default:
+ return true
+ }
+}
+
+// WithDialect sets the SQL a filter is written in. Without it the dialect is
+// DialectPostgreSQL. It narrows what WithAllowedOperators and
+// WithAllowedOperatorGroups allow: an operator the dialect does not have is
+// refused whether it is allowed or not.
+func WithDialect(d Dialect) FilterOption {
+ return func(p *FilterParser) {
+ p.dialect = d
+ }
+}
diff --git a/dialect_test.go b/dialect_test.go
new file mode 100644
index 0000000..e73b435
--- /dev/null
+++ b/dialect_test.go
@@ -0,0 +1,136 @@
+package qfv
+
+import (
+ "slices"
+ "strings"
+ "testing"
+)
+
+// What PostgreSQL has and the SQL standard does not, each as an input the
+// standard's grammar refuses, with what the refusal says.
+var postgresOnly = []struct {
+ input string
+ says string
+}{
+ // "<> is the standard SQL notation for not equal. != is an alias"
+ {input: "id != 1", says: "!= is not part of the SQL:2023 dialect: write <>"},
+ // "ILIKE ... is not in the SQL standard but is a PostgreSQL extension"
+ {input: "name ILIKE 'x%'", says: `operator "ILIKE" is not part of the SQL:2023 dialect`},
+ {input: "name NOT ILIKE 'x%'", says: `operator "ILIKE" is not part of the SQL:2023 dialect`},
+ // "All of these operators are PostgreSQL-specific": ~~ ~~* !~~ !~~*
+ {input: "name ~~ 'x%'", says: "~~ is not part of the SQL:2023 dialect: write LIKE"},
+ {input: "name !~~ 'x%'", says: "!~~ is not part of the SQL:2023 dialect: write NOT LIKE"},
+ {input: "name ~~* 'x%'", says: `operator "ILIKE" is not part of the SQL:2023 dialect`},
+ {input: "name !~~* 'x%'", says: `operator "ILIKE" is not part of the SQL:2023 dialect`},
+ // The standard's regular expressions are LIKE_REGEX, not ~.
+ {input: "name ~ '^x'", says: `operator "~" is not part of the SQL:2023 dialect`},
+ {input: "name !~* '^x'", says: `operator "~" is not part of the SQL:2023 dialect`},
+ // "ISNULL ... NOTNULL ... (nonstandard syntax)"
+ {input: "name ISNULL", says: "ISNULL is not part of the SQL:2023 dialect: write IS NULL"},
+ {input: "name NOTNULL", says: "NOTNULL is not part of the SQL:2023 dialect: write IS NOT NULL"},
+ // "a zero-length ESCAPE value is disallowed" by the standard
+ {input: "name LIKE 'x%' ESCAPE ''", says: "an empty ESCAPE is not part of the SQL:2023 dialect"},
+ {input: "name SIMILAR TO 'x%' ESCAPE ''", says: "an empty ESCAPE is not part of the SQL:2023 dialect"},
+ // Inside an expression, the refusal is the same.
+ {input: "id = 1 AND (name ILIKE 'x%' OR id = 2)", says: `operator "ILIKE" is not part of the SQL:2023 dialect`},
+}
+
+// What both have: every predicate the parser knows, in its standard spelling.
+var inTheStandard = []string{
+ "id = 1", "id <> 1", "id < 1", "id <= 1", "id > 1", "id >= 1", // E061-01
+ "id BETWEEN 1 AND 2", "id NOT BETWEEN 1 AND 2", // E061-02
+ "id BETWEEN SYMMETRIC 2 AND 1", "id BETWEEN ASYMMETRIC 1 AND 2", // T461
+ "id IN (1, 2)", "id NOT IN (1, 2)", // E061-03
+ "name LIKE 'x%'", "name NOT LIKE 'x%'", // E061-04
+ "name LIKE 'x!%' ESCAPE '!'", "name NOT LIKE 'x!%' ESCAPE '!'", // E061-05
+ "name IS NULL", "name IS NOT NULL", // E061-06
+ "name SIMILAR TO '(x|y)%'", "name NOT SIMILAR TO 'x!%' ESCAPE '!'", // T141
+ "id IS DISTINCT FROM 1", "id IS NOT DISTINCT FROM 1", // T151, T152
+ "active IS TRUE", "active IS NOT FALSE", "active IS UNKNOWN", // F571
+ "active = TRUE", "price = 1.5", "price = -2", "name = 'it''s'", // literals
+ "id = 1 AND (name LIKE 'x%' OR NOT active IS TRUE)", // AND, OR, NOT, a group
+}
+
+func TestTheDefaultDialectIsPostgreSQL(t *testing.T) {
+ t.Parallel()
+
+ var zero Dialect
+ if zero != DialectPostgreSQL || zero.String() != "PostgreSQL" {
+ t.Errorf("the zero Dialect is %q, want PostgreSQL", zero)
+ }
+
+ if got := DialectPostgreSQL.Operators(); !slices.Equal(got, AllOperators()) {
+ t.Errorf("PostgreSQL's operators are %v, want every operator", got)
+ }
+
+ allowed := []string{"id", "name", "price", "active"}
+
+ // Without the option and with it, what PostgreSQL has is accepted, as it
+ // was before there were dialects.
+ for _, parser := range map[string]*FilterParser{
+ "no option": NewFilterParser(allowed),
+ "WithDialect(Postgres)": NewFilterParser(allowed, WithDialect(DialectPostgreSQL)),
+ } {
+ for _, tt := range postgresOnly {
+ if _, err := parser.Parse(tt.input); err != nil {
+ t.Errorf("Parse(%q): %v, want it accepted in PostgreSQL", tt.input, err)
+ }
+ }
+ }
+}
+
+func TestTheSQLStandardDialect(t *testing.T) {
+ t.Parallel()
+
+ allowed := []string{"id", "name", "price", "active"}
+ parser := NewFilterParser(allowed, WithDialect(DialectSQLStandard))
+
+ if DialectSQLStandard.String() != "SQL:2023" {
+ t.Errorf("String() = %q, want SQL:2023", DialectSQLStandard)
+ }
+
+ if got := DialectSQLStandard.Operators(); slices.Contains(got, OpILike) || slices.Contains(got, OpRegexMatch) || len(got) != len(AllOperators())-2 {
+ t.Errorf("the standard's operators are %v, want every operator but ILIKE and ~", got)
+ }
+
+ for _, input := range inTheStandard {
+ t.Run("accepted: "+input, func(t *testing.T) {
+ t.Parallel()
+
+ if _, err := parser.Parse(input); err != nil {
+ t.Errorf("Parse(%q): %v, want it accepted: the standard has it", input, err)
+ }
+ })
+ }
+
+ for _, tt := range postgresOnly {
+ t.Run("refused: "+tt.input, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := parser.Parse(tt.input)
+ if err == nil {
+ t.Fatalf("Parse(%q) accepted, want it refused: it is PostgreSQL's, not the standard's", tt.input)
+ }
+
+ if !strings.Contains(err.Error(), tt.says) {
+ t.Errorf("Parse(%q) = %q, want it to say %q", tt.input, err.Error(), tt.says)
+ }
+ })
+ }
+}
+
+// The dialect narrows the allowed operators: allowing ILIKE does not put it
+// in the standard.
+func TestTheDialectNarrowsTheAllowedOperators(t *testing.T) {
+ t.Parallel()
+
+ parser := NewFilterParser([]string{"name"}, WithAllowedOperators(OpILike, OpLike), WithDialect(DialectSQLStandard))
+
+ if _, err := parser.Parse("name ILIKE 'x%'"); err == nil || !strings.Contains(err.Error(), "not part of the SQL:2023 dialect") {
+ t.Errorf("ILIKE allowed in the standard dialect: %v", err)
+ }
+
+ if _, err := parser.Parse("name LIKE 'x%'"); err != nil {
+ t.Errorf("LIKE refused: %v", err)
+ }
+}
diff --git a/docs.go b/docs.go
index f2d9f2d..d619065 100644
--- a/docs.go
+++ b/docs.go
@@ -1,3 +1,15 @@
-// A Go library for parsing and validating query expressions commonly used in REST APIs and database queries.
-// This library provides robust parsing and validation for fields selection, filtering, and sorting operations.
+// Package qfv parses and validates the query expressions of REST APIs:
+// fields selection, filtering and sorting, each gated by an allow-list of
+// fields.
+//
+// The filter parser reads an SQL boolean expression and returns its AST. It
+// checks an expression and does not translate one, so what it accepts is
+// what the SQL it will run as accepts: by default PostgreSQL's grammar, as
+// PostgreSQL 18 runs it; with WithDialect(DialectSQLStandard), the SQL
+// standard's (ISO/IEC 9075:2023), which refuses PostgreSQL's own spellings
+// such as != and ILIKE. WithAllowedOperators and WithAllowedOperatorGroups
+// narrow the operators further.
+//
+// Every parser is safe for concurrent use. The documentation, with the full
+// grammar, is in the repository's docs directory.
package qfv
diff --git a/docs/README.md b/docs/README.md
index 600c7c8..3a2c08d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -24,21 +24,24 @@ flowchart TB
allow -. gates .-> FieldsParser
allow -. gates .-> FilterParser
allow -. gates .-> SortParser
+
+ dialect(["SQL dialect
PostgreSQL (default) · SQL:2023"])
+ dialect -. grammar .-> FilterParser
```
| Parser | Input example | Output | Complexity |
| --- | --- | --- | --- |
| `FieldsParser` | `first_name, email` | `FieldsNode` | comma-split validation |
| `SortParser` | `first_name ASC, created_at DESC` | `SortNode` | comma-split + direction check |
-| `FilterParser` | `age >= 18 AND status IN ('active')` | AST (`Node`) | full recursive-descent grammar |
+| `FilterParser` | `age >= 18 AND status IN ('active')` | AST (`Node`) | full recursive-descent grammar, in PostgreSQL's dialect or the SQL standard's |
## Guides
| Guide | What it covers |
| --- | --- |
| [Getting Started](getting-started.md) | Install, update, and a complete runnable example of the three parsers |
-| [Filtering](filtering.md) | The full filter grammar — every operator/predicate, dotted fields, and worked examples |
-| [Configuration](configuration.md) | Restricting which operators and sort directions are allowed |
+| [Filtering](filtering.md) | The full filter grammar — every operator/predicate, `ESCAPE`, dotted fields, and worked examples |
+| [Configuration](configuration.md) | The SQL dialect (PostgreSQL or the SQL standard), and restricting which operators and sort directions are allowed |
| [Error Handling](error-handling.md) | Error shapes and how to inspect validation failures |
| [Migration Guide](migration.md) | Breaking changes and how to upgrade between versions |
diff --git a/docs/configuration.md b/docs/configuration.md
index ce872b2..85b2cf8 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1,8 +1,59 @@
# Configuration
-By default the filter parser accepts every operator and the sort parser accepts
-both `ASC` and `DESC`. You can narrow either with functional options at
-construction time.
+By default the filter parser reads PostgreSQL's grammar and accepts every
+operator, and the sort parser accepts both `ASC` and `DESC`. You can choose
+the SQL dialect, and narrow either, with functional options at construction
+time.
+
+## The SQL dialect
+
+A filter is checked in the grammar of the SQL it will run as: the parser
+checks an expression, it does not translate one, so what it accepts has to
+be what the database runs. `WithDialect` chooses it.
+
+| Dialect | Reference | What it accepts |
+| --- | --- | --- |
+| `DialectPostgreSQL` (the default) | PostgreSQL 18, and its manual's [SQL Syntax](https://www.postgresql.org/docs/18/sql-syntax.html) chapter | every operator this package knows |
+| `DialectSQLStandard` | ISO/IEC 9075:2023 (SQL:2023), Part 2, as PostgreSQL's [SQL Conformance](https://www.postgresql.org/docs/18/features-sql-standard.html) appendix names its features | PostgreSQL's grammar without what PostgreSQL's manual calls its own |
+
+```go
+p := qfv.NewFilterParser(fields, qfv.WithDialect(qfv.DialectSQLStandard))
+
+p.Parse("id <> 1 AND name LIKE 'x!%' ESCAPE '!'") // ok
+p.Parse("id != 1") // error: != is not part of the SQL:2023 dialect: write <>
+p.Parse("name ILIKE 'x%'") // error: operator "ILIKE" is not part of the SQL:2023 dialect
+```
+
+What the SQL standard dialect refuses, and what the standard writes instead:
+
+| PostgreSQL's | The standard's | Why |
+| --- | --- | --- |
+| `!=` | `<>` | "`<>` is the standard SQL notation for not equal" |
+| `ILIKE`, `NOT ILIKE`, `~~*`, `!~~*` | — | "not in the SQL standard but is a PostgreSQL extension" |
+| `~~`, `!~~` | `LIKE`, `NOT LIKE` | "PostgreSQL-specific" |
+| `~`, `~*`, `!~`, `!~*` | `LIKE_REGEX` (not parsed by this package) | the standard's regular expressions are XQuery's |
+| `ISNULL`, `NOTNULL` | `IS NULL`, `IS NOT NULL` | "nonstandard syntax" |
+| `ESCAPE ''` | one character | "a zero-length `ESCAPE` value is disallowed" |
+
+What it keeps, each with the standard's feature: the comparison predicate
+(E061-01), `BETWEEN` (E061-02) and its `SYMMETRIC` form (T461), `IN` with a
+list of values (E061-03), `LIKE` (E061-04) and its `ESCAPE` clause (E061-05),
+the NULL predicate (E061-06), `SIMILAR TO` (T141), `IS [NOT] DISTINCT FROM`
+(T151, T152), the truth value tests `IS [NOT] TRUE/FALSE/UNKNOWN` (F571), and
+`AND`, `OR`, `NOT`.
+
+One difference cannot be checked: without an `ESCAPE` clause the standard has
+no escape character, where PostgreSQL's is the backslash, so a pattern with a
+backslash in it means one thing to each.
+
+The dialect comes before the allow-list: an operator the dialect does not have
+is refused whether `WithAllowedOperators` allows it or not.
+
+A dialect is a value of the `Dialect` type, and the zero value is
+`DialectPostgreSQL`; there is no way to name one that does not exist.
+Another database's grammar (MySQL's, for instance: `REGEXP`, `<=>`,
+backslash escapes in strings) is a dialect to add, with its own tests run
+against that database.
## Restricting filter operators
@@ -36,12 +87,15 @@ flowchart LR
### How gating decides
-Every operator the parser encounters is checked against the allowed set. A
-**nil** set (neither option supplied) is the default and permits everything.
+Every operator the parser encounters is checked against the dialect, and then
+against the allowed set. A **nil** set (neither option supplied) is the
+default and permits everything the dialect has.
```mermaid
flowchart TD
- T["operator token in input"] --> C{"allow-list
configured?"}
+ T["operator token in input"] --> D{"in the dialect,
in this spelling?"}
+ D -->|"no"| DERR["QFVFilterError:
not part of the dialect"]
+ D -->|"yes"| C{"allow-list
configured?"}
C -->|"no (nil) — default"| OK["allowed
(every operator permitted)"]
C -->|"yes"| M{"operator in set?"}
M -->|"yes"| OK
diff --git a/docs/error-handling.md b/docs/error-handling.md
index ab22c41..0f1d6a3 100644
--- a/docs/error-handling.md
+++ b/docs/error-handling.md
@@ -91,6 +91,10 @@ The filter parser rejects, among others:
- unknown fields (not in the allow-list),
- disallowed operators (see [Configuration](configuration.md)),
+- operators and spellings the dialect does not have (`!=` or `ILIKE` in the
+ SQL standard's; see [the SQL dialect](configuration.md#the-sql-dialect)),
+- a pattern that is not a string, and an `ESCAPE` that is not a string of
+ one character (empty is PostgreSQL's only),
- trailing or incomplete input — the **entire** string must form one expression
(`first_name = 'John' garbage` is an error),
- unterminated strings, unbalanced parentheses, and illegal characters.
diff --git a/docs/filtering.md b/docs/filtering.md
index 4fe6899..b1e7913 100644
--- a/docs/filtering.md
+++ b/docs/filtering.md
@@ -1,9 +1,13 @@
# Filtering
-The filter parser validates complex boolean expressions and returns an AST. It
-implements a PostgreSQL-flavored grammar; see the PostgreSQL 18 references for
+The filter parser validates complex boolean expressions and returns an AST. Its
+grammar is PostgreSQL's by default; see the PostgreSQL 18 references for
+[SQL Syntax](https://www.postgresql.org/docs/18/sql-syntax.html),
[Comparison Functions and Operators](https://www.postgresql.org/docs/18/functions-comparison.html)
and [Pattern Matching](https://www.postgresql.org/docs/18/functions-matching.html).
+With `WithDialect(qfv.DialectSQLStandard)` it is the SQL standard's (ISO/IEC
+9075:2023), which this page's PostgreSQL extensions are not part of: see
+[the SQL dialect](configuration.md#the-sql-dialect).
```go
filterParser := qfv.NewFilterParser(allowedFields)
@@ -20,8 +24,8 @@ The parser guarantees that:
Parsing is a two-stage pipeline. The `Lexer` first turns the raw string into a
flat list of tokens; the recursive-descent parser then consumes those tokens to
-build the AST, checking each field against the allow-list and each operator
-against the (optional) operator allow-list as it goes.
+build the AST, checking each field against the allow-list, and each operator
+against the dialect and then the (optional) operator allow-list, as it goes.
```mermaid
flowchart LR
@@ -35,6 +39,7 @@ flowchart LR
LEX --> tokens
tokens --> PAR["parseExpression()"]
PAR --> AST["AST root (Node)"]
+ DIA(["dialect · operator allow-list"]) -. "each operator" .-> PAR
PAR -. "every problem collected" .-> ERR["errors.Join(…)"]
```
diff --git a/docs/getting-started.md b/docs/getting-started.md
index d38eac5..4dfda0b 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -32,6 +32,11 @@ Every parser is built from an **allow-list of fields**, so only fields you
explicitly permit can appear in a query. This is the primary defense against
invalid or malicious input reaching your database.
+The filter parser checks an expression in the grammar of the SQL it will run
+as, and does not translate it: **PostgreSQL's by default**, or the SQL
+standard's with `WithDialect(qfv.DialectSQLStandard)`. See
+[the SQL dialect](configuration.md#the-sql-dialect).
+
All three parsers are **safe for concurrent use** — build them once and share
them across goroutines.
@@ -130,5 +135,5 @@ func main() {
## Next steps
- [Filtering](filtering.md) — the full set of operators and predicates
-- [Configuration](configuration.md) — restrict operators and sort directions
+- [Configuration](configuration.md) — the SQL dialect, and restricting operators and sort directions
- [Error Handling](error-handling.md) — inspecting validation failures
diff --git a/docs/migration.md b/docs/migration.md
index 8e2e3c1..607f6c8 100644
--- a/docs/migration.md
+++ b/docs/migration.md
@@ -2,6 +2,22 @@
## v1.0.5 → v1.1.0
+Two additions: the SQL dialect a filter is written in, and the `ESCAPE`
+clause. A caller that changes nothing gets PostgreSQL's grammar, as before,
+with `ESCAPE` accepted.
+
+### The SQL dialect
+
+`WithDialect` chooses the grammar a filter is checked in:
+`DialectPostgreSQL`, 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
+[configuration.md](configuration.md#the-sql-dialect). Nothing changes
+without the option.
+
+### The `ESCAPE` clause
+
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.
diff --git a/example_doc_test.go b/example_doc_test.go
index 998cfc6..dfb26a7 100644
--- a/example_doc_test.go
+++ b/example_doc_test.go
@@ -70,6 +70,26 @@ func ExampleFilterParser_postgres() {
// (((email ILIKE '%@EXAMPLE.com') AND age IS NOT DISTINCT FROM 30) OR active IS TRUE)
}
+// ExampleWithDialect checks a filter in the SQL standard's grammar: the
+// standard's spellings are accepted, PostgreSQL's own are refused, and the
+// refusal names what the standard writes.
+func ExampleWithDialect() {
+ parser := qfv.NewFilterParser(allowed, qfv.WithDialect(qfv.DialectSQLStandard))
+
+ node, err := parser.Parse("age <> 30 AND email LIKE '%!_x' ESCAPE '!'")
+ if err != nil {
+ fmt.Println("error:", err)
+ return
+ }
+ fmt.Println(node.String())
+
+ _, err = parser.Parse("age != 30")
+ fmt.Println(err)
+ // Output:
+ // ((age <> 30) AND (email LIKE '%!_x' ESCAPE '!'))
+ // error: != is not part of the SQL:2023 dialect: write <>
+}
+
// ExampleFilterParser_errorInspection shows how to inspect validation failures.
// Parse aggregates every problem with errors.Join, so errors.As extracts the
// first *QFVFilterError cause.
diff --git a/filter_parser.go b/filter_parser.go
index cd2533f..e58234d 100644
--- a/filter_parser.go
+++ b/filter_parser.go
@@ -33,6 +33,8 @@ type FilterParser struct {
// allowedOps is the set of permitted operators. A nil map means every
// operator is allowed (the default).
allowedOps map[Operator]struct{}
+ // dialect is the SQL the filter is written in (WithDialect).
+ dialect Dialect
}
// NewFilterParser creates a new parser with the allowed fields. By default every
@@ -60,6 +62,7 @@ func NewFilterParser(allowedFields []string, opts ...FilterOption) *FilterParser
type filterParseState struct {
allowedFields map[string]struct{}
allowedOps map[Operator]struct{}
+ dialect Dialect
lexer *Lexer
currentToken Token
errors []error
@@ -72,6 +75,7 @@ func (p *FilterParser) Parse(input string) (Node, error) {
st := &filterParseState{
allowedFields: p.allowedFields,
allowedOps: p.allowedOps,
+ dialect: p.dialect,
lexer: NewLexer(input),
}
st.lexer.Parse()
@@ -136,6 +140,12 @@ func (p *filterParseState) addError(err error) {
// allow-list means every operator is permitted. Parsing continues either way so
// that all problems are collected in a single pass.
func (p *filterParseState) requireOp(op Operator) {
+ if !p.dialect.has(op) {
+ p.addError(&QFVFilterError{Message: fmt.Sprintf("operator %q is not part of the %s dialect", op, p.dialect)})
+
+ return
+ }
+
if p.allowedOps == nil {
return
}
@@ -144,6 +154,14 @@ func (p *filterParseState) requireOp(op Operator) {
}
}
+// requireSpelling refuses a spelling of an operator the dialect does not
+// have, naming the one it does: PostgreSQL's != is the standard's <>.
+func (p *filterParseState) requireSpelling(spelling, standard string) {
+ if p.dialect.standard() {
+ p.addError(&QFVFilterError{Message: fmt.Sprintf("%s is not part of the %s dialect: write %s", spelling, p.dialect, standard)})
+ }
+}
+
// maxFilterDepth is how deep an expression may nest: parentheses inside
// parentheses, NOT applied to NOT. The parser is recursive, and a caller's
// input decides how deep it recurses: 250 000 opening parentheses, 250 KB of
@@ -284,11 +302,13 @@ func (p *filterParseState) parseComparison() Node {
case "ISNULL":
pos := p.currentToken.Pos
p.requireOp(OpIsNull)
+ p.requireSpelling("ISNULL", "IS NULL")
p.nextToken()
return &IsNullNode{baseNode: baseNode{pos: pos}, Field: field, IsNot: false}
case "NOTNULL":
pos := p.currentToken.Pos
p.requireOp(OpIsNull)
+ p.requireSpelling("NOTNULL", "IS NOT NULL")
p.nextToken()
return &IsNullNode{baseNode: baseNode{pos: pos}, Field: field, IsNot: true}
}
@@ -300,10 +320,16 @@ func (p *filterParseState) parseComparison() Node {
TokenOperatorLessThan, TokenOperatorLessThanOrEqualTo,
TokenOperatorGreaterThan, TokenOperatorGreaterThanOrEqualTo:
p.requireOp(comparisonOperator(p.currentToken.Type))
+ if p.currentToken.Type == TokenOperatorNotEqualAlias {
+ p.requireSpelling("!=", "<>")
+ }
return p.parseComparisonOperator(field)
case TokenOperatorLike:
p.requireOp(OpLike)
keyword := p.isKeyword("LIKE")
+ if !keyword {
+ p.requireSpelling("~~", "LIKE")
+ }
p.nextToken() // Consume LIKE (or ~~)
return p.parseLikeOperator(field, TokenOperatorLike, keyword)
case TokenOperatorILike:
@@ -313,6 +339,7 @@ func (p *filterParseState) parseComparison() Node {
return p.parseLikeOperator(field, TokenOperatorILike, keyword)
case TokenOperatorNotLike:
p.requireOp(OpLike)
+ p.requireSpelling("!~~", "NOT LIKE")
p.nextToken() // Consume !~~ (operator form of NOT LIKE)
return p.parseLikeOperator(field, TokenOperatorNotLike, false)
case TokenOperatorNotILike:
@@ -519,8 +546,15 @@ func (p *filterParseState) parseEscape(operator TokenType, keyword bool) Node {
return escape
}
- if text, _ := literal.Value.(string); utf8.RuneCountInString(text) > 1 {
+ text, _ := literal.Value.(string)
+
+ switch length := utf8.RuneCountInString(text); {
+ case length > 1:
p.addError(&QFVFilterError{Message: "the escape character after ESCAPE must be empty or one character"})
+ case length == 0 && p.dialect.standard():
+ // PostgreSQL's manual: "a zero-length ESCAPE value is disallowed"
+ // by the SQL standard.
+ p.addError(&QFVFilterError{Message: fmt.Sprintf("an empty ESCAPE is not part of the %s dialect: the escape character is one character", p.dialect)})
}
return escape