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