Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
105 changes: 105 additions & 0 deletions dialect.go
Original file line number Diff line number Diff line change
@@ -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
}
}
136 changes: 136 additions & 0 deletions dialect_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
16 changes: 14 additions & 2 deletions docs.go
Original file line number Diff line number Diff line change
@@ -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
9 changes: 6 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,24 @@ flowchart TB
allow -. gates .-> FieldsParser
allow -. gates .-> FilterParser
allow -. gates .-> SortParser

dialect(["SQL dialect<br/>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 |

Expand Down
66 changes: 60 additions & 6 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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<br/>configured?"}
T["operator token in input"] --> D{"in the dialect,<br/>in this spelling?"}
D -->|"no"| DERR["QFVFilterError:<br/>not part of the dialect"]
D -->|"yes"| C{"allow-list<br/>configured?"}
C -->|"no (nil) — default"| OK["allowed<br/>(every operator permitted)"]
C -->|"yes"| M{"operator in set?"}
M -->|"yes"| OK
Expand Down
4 changes: 4 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading