From 26647f839f7afeab3d9e50df803c8d64077e9631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 24 Oct 2026 19:20:43 +0200 Subject: [PATCH 1/2] feat: the SQL dialect a filter is written in The parser checks an expression and does not translate one: a caller hands the filter's text to its database, so what the parser accepts has to be what that database runs. It always read PostgreSQL's grammar and said so nowhere but in its tests. WithDialect names it: DialectPostgreSQL, the default and the zero value, every operator as before; or DialectSQLStandard, ISO/IEC 9075:2023, which refuses what PostgreSQL's manual calls its own (!=, ILIKE, the ~~ and ~ operators, ISNULL and NOTNULL, an empty ESCAPE), each refusal naming the standard's spelling. What it keeps is listed with the standard's feature ids, from PostgreSQL's SQL Conformance appendix. The dialect is checked before the allow-list: an operator it does not have is refused however allowed. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 ++- dialect.go | 105 ++++++++++++++++++++++++++++++++ dialect_test.go | 136 ++++++++++++++++++++++++++++++++++++++++++ docs/configuration.md | 66 ++++++++++++++++++-- docs/migration.md | 16 +++++ filter_parser.go | 36 ++++++++++- 6 files changed, 358 insertions(+), 10 deletions(-) create mode 100644 dialect.go create mode 100644 dialect_test.go 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/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/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/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 From 45129586ea346853eaac402d133e3378be5d8b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 24 Oct 2026 19:25:07 +0200 Subject: [PATCH 2/2] docs: every page says the dialect and ESCAPE The package doc (what pkg.go.dev shows) was two lines; it says the filter grammar is PostgreSQL's by default, or the standard's with WithDialect. The documentation index's diagram shows the dialect gating the filter parser, and its guide table names it; Getting Started, Error Handling and Filtering (its opening and its pipeline diagram) say it; ExampleWithDialect is runnable on pkg.go.dev. Every diagram was rendered. Co-Authored-By: Claude Opus 5.5 --- docs.go | 16 ++++++++++++++-- docs/README.md | 9 ++++++--- docs/error-handling.md | 4 ++++ docs/filtering.md | 13 +++++++++---- docs/getting-started.md | 7 ++++++- example_doc_test.go | 20 ++++++++++++++++++++ 6 files changed, 59 insertions(+), 10 deletions(-) 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/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/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.