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
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,15 @@ 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).
- **PostgreSQL's filter grammar, or the SQL standard's** — comparison,
- **The filter grammar of the database that runs it** — 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/dialects.md).
- **Configurable** — choose the SQL dialect (PostgreSQL 18 or SQL:2023), and
`IS [NOT] DISTINCT FROM`, as PostgreSQL 18 runs them (the default), or as
SQL:2023 (ISO/IEC 9075:2023) or MySQL 8.4 does with `WithDialect`. MySQL's
dialect reads strings with MySQL's backslash escapes, so a filter cannot
hide a predicate from the allow-list. See [SQL Dialects](docs/dialects.md).
- **Configurable** — choose the SQL dialect (PostgreSQL 18, SQL:2023 or
MySQL 8.4), and
restrict which operators or sort directions are allowed.
- **Returns an AST** you can walk or render, with precise, aggregated errors.
- **Concurrency-safe** parsers.
Expand All @@ -88,7 +90,7 @@ flowchart LR
AL(["Allow-list of fields"]) -. gates .-> FP
AL -. gates .-> FI
AL -. gates .-> SP
DI(["SQL dialect<br/>PostgreSQL 18 (default) · SQL:2023"]) -. grammar .-> FI
DI(["SQL dialect<br/>PostgreSQL 18 (default) · SQL:2023 · MySQL 8.4"]) -. grammar .-> FI

FP --> FN["FieldsNode<br/>[]string"]
FI --> AST["Filter AST<br/>(Node tree)"]
Expand Down Expand Up @@ -121,7 +123,7 @@ Full documentation lives in [`docs/`](docs/README.md):

- [Getting Started](docs/getting-started.md) — install, update, complete example
- [Filtering](docs/filtering.md) — the full operator/predicate reference
- [SQL Dialects](docs/dialects.md) — PostgreSQL 18 (the default) or SQL:2023:
- [SQL Dialects](docs/dialects.md) — PostgreSQL 18 (the default), SQL:2023 or MySQL 8.4:
why a filter parser needs one, choosing it, and every construct in each
- [Configuration](docs/configuration.md) — choose the dialect, restrict
operators and sort directions
Expand Down
78 changes: 68 additions & 10 deletions dialect.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ package qfv
// against what its manual calls an extension.
//
// A Dialect is one of the values below, each a version of its SQL: PostgreSQL
// 18, SQL:2023. The zero value is DialectPostgreSQL, the grammar this package
// has always parsed.
// 18, SQL:2023, MySQL 8.4. The zero value is DialectPostgreSQL, the grammar
// this package has always parsed.
type Dialect struct {
name string
}
Expand Down Expand Up @@ -49,10 +49,39 @@ var (
// 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"}

// DialectMySQL is MySQL's grammar, as MySQL 8.4 runs it with its default
// sql_mode (https://dev.mysql.com/doc/refman/8.4/en/sql-statements.html).
// It is PostgreSQL's grammar without what MySQL 8.4 does not run:
//
// - ILIKE and NOT ILIKE, and the ~~, ~~*, !~~ and !~~* operators;
// - SIMILAR TO, and the POSIX operators ~, ~*, !~ and !~* (MySQL's
// regular expressions are REGEXP, which this package does not parse);
// - IS [NOT] DISTINCT FROM (MySQL's null-safe equality is <=>, which
// this package does not parse);
// - BETWEEN SYMMETRIC and BETWEEN ASYMMETRIC;
// - the ISNULL and NOTNULL shorthands (MySQL's ISNULL is a function).
//
// It keeps != and an empty ESCAPE, which MySQL 8.4 runs.
//
// And it reads a string as MySQL does: a backslash escapes the character
// after it, so 'a\'b' is one string, a'b. PostgreSQL's strings keep a
// backslash as it is, and read the same text as the string 'a\' followed
// by b'. A filter checked with PostgreSQL's rules can hide a predicate
// from the field allow-list when MySQL runs it: name = '\'' OR id > 0 #'
// is one comparison of name with a string to PostgreSQL's reading, and to
// MySQL 8.4 a comparison of name with a quote, then id > 0, then a
// comment to the end of the line. With this dialect it is refused: id is
// not allowed, and # is not part of the grammar.
//
// MySQL's sql_mode can turn backslash escapes off (NO_BACKSLASH_ESCAPES).
// The parser cannot see the server's mode: this dialect is MySQL 8.4's
// default, which has them on.
DialectMySQL = Dialect{name: "MySQL 8.4"}
)

// String is the dialect's name with its version, as error messages write it:
// "PostgreSQL 18", "SQL:2023".
// "PostgreSQL 18", "SQL:2023", "MySQL 8.4".
func (d Dialect) String() string {
if d.name == "" {
return "PostgreSQL 18"
Expand All @@ -66,6 +95,37 @@ func (d Dialect) standard() bool {
return d == DialectSQLStandard
}

// backslashEscapes reports whether a backslash in a string escapes the
// character after it, as it does in MySQL 8.4's default sql_mode.
func (d Dialect) backslashEscapes() bool {
return d == DialectMySQL
}

// hasSpelling reports whether the dialect has this spelling of an operator it
// has. PostgreSQL 18 has every one.
func (d Dialect) hasSpelling(spelling string) bool {
switch d {
case DialectSQLStandard:
switch spelling {
case "!=", "~~", "!~~", "ISNULL", "NOTNULL":
return false
}
case DialectMySQL:
switch spelling {
case "~~", "!~~", "ISNULL", "NOTNULL", "BETWEEN SYMMETRIC", "BETWEEN ASYMMETRIC":
return false
}
}

return true
}

// emptyEscape reports whether the dialect takes an empty ESCAPE string, which
// turns escaping off. PostgreSQL 18 and MySQL 8.4 do; the SQL standard does not.
func (d Dialect) emptyEscape() bool {
return !d.standard()
}

// Operators returns the operators the dialect has. The returned slice is a
// copy and safe to modify.
func (d Dialect) Operators() []Operator {
Expand All @@ -84,13 +144,11 @@ func (d Dialect) Operators() []Operator {
// 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
switch d {
case DialectSQLStandard:
return op != OpILike && op != OpRegexMatch
case DialectMySQL:
return op != OpILike && op != OpSimilarTo && op != OpRegexMatch && op != OpIsDistinctFrom
default:
return true
}
Expand Down
60 changes: 42 additions & 18 deletions dialect_docs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
"testing"
)

// docs/dialects.md quotes, word for word, what Parse answers in the SQL:2023
// dialect. A message that changes here and not there would leave the page
// telling clients to look for an error they never get, so each row of its
// table is parsed and held to the parser.
// docs/dialects.md quotes, word for word, what Parse answers in each dialect.
// A message that changes here and not there would leave the page telling
// clients to look for an error they never get, so each row of its tables is
// parsed and held to the parser: the "### SQL:2023" table in the SQL standard
// dialect, the "### MySQL 8.4" one in MySQL's. A row that names an allow-list
// ("with the allow-list `["name"]`") is parsed with it.
func TestTheDialectsPageQuotesTheParser(t *testing.T) {
t.Parallel()

Expand All @@ -31,30 +33,52 @@ func TestTheDialectsPageQuotesTheParser(t *testing.T) {
section = section[:end+1]
}

row := regexp.MustCompile("(?m)^\\| (`[^|]+`(?:, `[^|]+`)*) \\| `([^`]+)` \\|$")
row := regexp.MustCompile("(?m)^\\| (`[^|]+`(?:, `[^|]+`)*) \\| `([^`]+)`(?: \\(with the allow-list `\\[([^]]*)\\]`\\))? \\|$")
code := regexp.MustCompile("`([^`]+)`")
parser := NewFilterParser([]string{"id", "name"}, WithDialect(DialectSQLStandard))
quoted := regexp.MustCompile(`"([^"]+)"`)

checked := 0
dialects := map[string]Dialect{"SQL:2023": DialectSQLStandard, "MySQL 8.4": DialectMySQL}
checked := map[string]int{}

for _, m := range row.FindAllStringSubmatch(section, -1) {
for _, input := range code.FindAllStringSubmatch(m[1], -1) {
checked++
for _, table := range strings.Split(section, "\n### ")[1:] {
heading, body, _ := strings.Cut(table, "\n")

_, err := parser.Parse(input[1])
if err == nil {
t.Errorf("the page says %q is refused, and Parse accepts it", input[1])
dialect, ok := dialects[heading]
if !ok {
t.Errorf("docs/dialects.md has a refusals table for %q, a dialect this test does not know", heading)

continue
continue
}

for _, m := range row.FindAllStringSubmatch(body, -1) {
allowed := []string{"id", "name"}
if m[3] != "" {
allowed = nil
for _, f := range quoted.FindAllStringSubmatch(m[3], -1) {
allowed = append(allowed, f[1])
}
}

if !strings.Contains(err.Error(), m[2]) {
t.Errorf("the page says %q answers %q; Parse answers %q", input[1], m[2], err.Error())
parser := NewFilterParser(allowed, WithDialect(dialect))

for _, input := range code.FindAllStringSubmatch(m[1], -1) {
checked[heading]++

_, err := parser.Parse(input[1])
if err == nil {
t.Errorf("%s: the page says %q is refused, and Parse accepts it", heading, input[1])

continue
}

if !strings.Contains(err.Error(), m[2]) {
t.Errorf("%s: the page says %q answers %q; Parse answers %q", heading, input[1], m[2], err.Error())
}
}
}
}

if checked < 9 {
t.Fatalf("read %d inputs from the page's table, want at least 9: the table's shape changed and this test reads nothing", checked)
if checked["SQL:2023"] < 9 || checked["MySQL 8.4"] < 12 {
t.Fatalf("read %v inputs from the page's tables, want at least 9 for SQL:2023 and 12 for MySQL 8.4: the tables' shape changed and this test reads nothing", checked)
}
}
Loading
Loading