From 3591ae8b18f1860256926b7be274256c367a60fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Mon, 12 Oct 2026 14:32:21 +0200 Subject: [PATCH] fix: a pattern is a string, IS is a keyword in any case, YES and NO are names Three places where the filter parser and PostgreSQL disagreed. LIKE, ILIKE, SIMILAR TO and the ~~ operators take a string pattern, as the POSIX operators already did: PostgreSQL has no ~~ that takes a number or a boolean, whatever the column's type is. `is` in lower case is the IS keyword, like every other spelling of it; the lexer kept it back as an identifier. YES and NO are no boolean literals: SQL has TRUE and FALSE, and PostgreSQL reads the other two as names. A field may be called yes. Co-Authored-By: Claude Opus 5.5 --- docs/filtering.md | 48 +++++-- docs/migration.md | 34 +++++ filter_lexer.go | 72 +++++----- filter_lexer_test.go | 4 +- filter_nodes_string_test.go | 2 +- filter_parser.go | 36 ++++- filter_pg_patterns_test.go | 234 +++++++++++++++++++++++++++++++ filter_postgres_literals_test.go | 6 - 8 files changed, 377 insertions(+), 59 deletions(-) create mode 100644 filter_pg_patterns_test.go diff --git a/docs/filtering.md b/docs/filtering.md index ffbf6f0..8f57764 100644 --- a/docs/filtering.md +++ b/docs/filtering.md @@ -74,7 +74,36 @@ binds tighter than `OR`. | Grouping | `( ... )` | **Literals**: single-quoted strings (`''` escapes a quote), integers, floats, -and booleans (`TRUE`/`FALSE`/`YES`/`NO`). +and the two booleans SQL has, `TRUE` and `FALSE`. `YES` and `NO` are not +literals: PostgreSQL reads them as names, and so does this parser (a field +may be called `yes`). + +### Patterns + +What the pattern operators take is PostgreSQL's, from its manual, +[9.7. Pattern Matching](https://www.postgresql.org/docs/current/functions-matching.html): + +```text +string LIKE pattern [ESCAPE escape-character] +string SIMILAR TO pattern [ESCAPE escape-character] +text ~ text → boolean +``` + +`ILIKE` is `LIKE` without regard to case, and `~~`, `~~*`, `!~~` and `!~~*` +are the operators PostgreSQL translates `LIKE`, `ILIKE` and their negations +to. + +- **A pattern is a string literal**, for every one of them: `name LIKE 'x%'`, + `name ~~* 'X%'`, `name SIMILAR TO '(x|y)%'`, `name ~ '^x'`. A number or a + boolean there is refused (`name LIKE 1`, `name SIMILAR TO TRUE`): + PostgreSQL has no `~~` that takes one, whatever the column's type is, and + answers "operator does not exist". A number written as a string is a + pattern: `name LIKE '1'`. +- **The `ESCAPE` clause is not supported**; `name LIKE 'x!%' ESCAPE '!'` is + refused. A pattern is written with the default escape character, the + backslash, which a string literal keeps as it is: `name LIKE '100\%'`. +- **What is in the pattern is not looked at**: see + "Patterns are not compiled", under [AST nodes](#ast-nodes). ### Numbers @@ -115,9 +144,10 @@ nothing else. this. **Bound the length of what you hand to `Parse`** as well: the parser reads its whole input before it answers. -**The parser does not know a column's type.** `name LIKE -1`, `id = 'x'` and a +**The parser does not know a column's type.** `name = -1`, `id = 'x'` and a bare `-1` are accepted, and PostgreSQL refuses each for its type, not for its -syntax. What this page promises is that an accepted expression is not a +syntax. (A pattern is the exception, because it has one type for every +column: see [Patterns](#patterns).) What this page promises is that an accepted expression is not a syntax error; a caller still has to answer for a value of the wrong type. ## Nested (dot-notation) field names @@ -154,12 +184,12 @@ flowchart LR ``` `IS` is the only way in. `id DISTINCT FROM 1` and `id NOT DISTINCT FROM 1`, -without `IS`, are not SQL and are refused; so is `active IS YES`. `YES` and -`NO` are this parser's spellings of a boolean **literal** and not truth values -of the `IS` test. They are not PostgreSQL's either: `active = YES` parses to a -`LiteralNode` whose `Value` is `true` and which renders as `true`, but the -text `YES` spliced into SQL is read as a column name. Write `TRUE`/`FALSE` -where the input itself is spliced. +without `IS`, are not SQL and are refused; so is `active IS YES`: the truth +values of the `IS` test are `TRUE`, `FALSE` and `UNKNOWN`. + +`IS` is the keyword in any case, as every keyword is: `id is null`, +`id Is Not Null` and `active is true` are the same tests as their upper-case +spellings. ## Worked examples diff --git a/docs/migration.md b/docs/migration.md index 3022268..b938e8a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,39 @@ # Migration Guide +## v1.0.3 → the next release + +Three places where the filter parser and PostgreSQL disagreed about an +input. Each row was run against PostgreSQL 18; what the pattern operators +take is from its manual, +[9.7. Pattern Matching](https://www.postgresql.org/docs/current/functions-matching.html). + +**Refused now, and accepted before:** + +| Input | v1.0.3 | Now | PostgreSQL 18 | +| --- | --- | --- | --- | +| a number or a boolean as a pattern: `name LIKE 1`, `name NOT ILIKE -1`, `name ~~ 1`, `name SIMILAR TO 1`, `name LIKE TRUE` | ✅ accepted | ❌ error: `expected string pattern for operator LIKE, got LITERAL` | `operator does not exist: text ~~ integer`, for every column type | +| `YES` and `NO` as boolean literals: `active = YES`, `active <> no`, `active IN (yes, no)` | ✅ accepted, as `true` and `false` | ❌ error | a name: the comparison is with a column called `yes`, or `column "yes" does not exist` | + +**Accepted now, and refused before:** + +| Input | v1.0.3 | Now | PostgreSQL 18 | +| --- | --- | --- | --- | +| `IS` in lower case: `id is null`, `id is not null`, `id is distinct from 1`, `active is true` | ❌ error (`Is`, `iS` and `IS` were accepted) | ✅ accepted | runs | +| a field called `yes` or `no`, when it is in the allow-list: `yes = 1` | ❌ error | ✅ accepted | runs | + +The POSIX operators (`~`, `~*`, `!~`, `!~*`) already took a string and +nothing else, and the `ESCAPE` clause is still not part of the grammar. + +**For callers that walk the tree.** A `LiteralNode` of `Kind` +`reflect.Bool` now comes from `TRUE` or `FALSE` only, and the right side of a +`LIKE`/`ILIKE` `BinaryOperatorNode` and a `SimilarToNode`'s `Pattern` are +always a string `LiteralNode`. `Lexer` users: `is` in lower case is the `IS` +token like every other spelling, and `YES`/`NO` are `IDENTIFIER` tokens. + +**Migration:** write `TRUE` and `FALSE`; quote a pattern (`name LIKE '1'`). +A field that was called `is` cannot be filtered on, as it cannot be named +unquoted in PostgreSQL. + ## v1.0.2 → v1.0.3 `v1.0.3` makes the filter parser refuse what PostgreSQL refuses as a syntax diff --git a/filter_lexer.go b/filter_lexer.go index 319f359..a743216 100644 --- a/filter_lexer.go +++ b/filter_lexer.go @@ -74,43 +74,43 @@ func (l *Lexer) Parse() { case scanner.EOF: tok = TokenEOF case scanner.Ident: - // Special handling for the "is" token in the test cases - // In the test cases, "is" is expected to be an identifier, not a keyword - if lit == "is" { + // A keyword is a keyword in any case, IS included: PostgreSQL + // folds an unquoted word, and "id is null" is the same test as + // "id IS NULL". The lower-case word used to be kept back as an + // identifier, for a test that tokenized English. + switch asciiUpper(lit) { + case "AND": + tok = TokenOperatorAnd + case "OR": + tok = TokenOperatorOr + case "LIKE": + tok = TokenOperatorLike + case "ILIKE": + tok = TokenOperatorILike + case "IN": + tok = TokenOperatorIn + case "BETWEEN": + tok = TokenOperatorBetween + case "DISTINCT": + tok = TokenOperatorDistinct + case "SIMILAR": + tok = TokenOperatorSimilarTo // Treat SIMILAR as its own token + case "TO": + tok = TokenIdentifier // Treat TO as a generic identifier for now, parser will handle context + case "IS": + tok = TokenOperatorIsNull // Treat IS as its own token + case "NOT": + tok = TokenOperatorNot // Treat NOT as its own token + case "TRUE", "FALSE": + // The two boolean literals SQL has. YES and NO were read as + // booleans too, and are names to PostgreSQL: "active = YES" + // is a comparison with a column called yes. + tok = TokenBoolean + case "NULL": + tok = TokenIdentifier // Treat NULL as an identifier + default: + // Check if it's a potential field name or other identifier tok = TokenIdentifier - } else { - upperLit := asciiUpper(lit) - switch upperLit { - case "AND": - tok = TokenOperatorAnd - case "OR": - tok = TokenOperatorOr - case "LIKE": - tok = TokenOperatorLike - case "ILIKE": - tok = TokenOperatorILike - case "IN": - tok = TokenOperatorIn - case "BETWEEN": - tok = TokenOperatorBetween - case "DISTINCT": - tok = TokenOperatorDistinct - case "SIMILAR": - tok = TokenOperatorSimilarTo // Treat SIMILAR as its own token - case "TO": - tok = TokenIdentifier // Treat TO as a generic identifier for now, parser will handle context - case "IS": - tok = TokenOperatorIsNull // Treat IS as its own token - case "NOT": - tok = TokenOperatorNot // Treat NOT as its own token - case "TRUE", "FALSE", "YES", "NO": - tok = TokenBoolean - case "NULL": - tok = TokenIdentifier // Treat NULL as an identifier - default: - // Check if it's a potential field name or other identifier - tok = TokenIdentifier - } } case scanner.Int, scanner.Float: tok, lit = l.numberToken(scanTok, lit) diff --git a/filter_lexer_test.go b/filter_lexer_test.go index 04f0e62..a9966e6 100644 --- a/filter_lexer_test.go +++ b/filter_lexer_test.go @@ -56,7 +56,7 @@ func TestLexer_Navigation(t *testing.T) { {Pos: scanner.Position{Line: 1, Column: 1}, Type: TokenIdentifier, Value: "comment"}, {Pos: scanner.Position{Line: 1, Column: 9}, Type: TokenOperatorEqual, Value: "="}, {Pos: scanner.Position{Line: 1, Column: 11}, Type: TokenIdentifier, Value: "This"}, - {Pos: scanner.Position{Line: 1, Column: 16}, Type: TokenIdentifier, Value: "is"}, + {Pos: scanner.Position{Line: 1, Column: 16}, Type: TokenOperatorIsNull, Value: "is"}, // IS, in any case {Pos: scanner.Position{Line: 1, Column: 19}, Type: TokenIdentifier, Value: "a"}, {Pos: scanner.Position{Line: 1, Column: 21}, Type: TokenIdentifier, Value: "bad"}, {Pos: scanner.Position{Line: 1, Column: 25}, Type: TokenIdentifier, Value: "string"}, @@ -71,7 +71,7 @@ func TestLexer_Navigation(t *testing.T) { {Pos: scanner.Position{Line: 1, Column: 1}, Type: TokenIdentifier, Value: "comment"}, {Pos: scanner.Position{Line: 1, Column: 9}, Type: TokenOperatorEqual, Value: "="}, {Pos: scanner.Position{Line: 1, Column: 11}, Type: TokenIdentifier, Value: "This"}, - {Pos: scanner.Position{Line: 1, Column: 16}, Type: TokenIdentifier, Value: "is"}, + {Pos: scanner.Position{Line: 1, Column: 16}, Type: TokenOperatorIsNull, Value: "is"}, // IS, in any case {Pos: scanner.Position{Line: 1, Column: 19}, Type: TokenIdentifier, Value: "a"}, {Pos: scanner.Position{Line: 1, Column: 21}, Type: TokenIdentifier, Value: "bad"}, {Pos: scanner.Position{Line: 1, Column: 25}, Type: TokenIdentifier, Value: "string"}, diff --git a/filter_nodes_string_test.go b/filter_nodes_string_test.go index 1ff73fc..75eeb35 100644 --- a/filter_nodes_string_test.go +++ b/filter_nodes_string_test.go @@ -147,7 +147,7 @@ func TestParsePrimaryLiteralKinds(t *testing.T) { "score = 3.14": reflect.Float64, "active = true": reflect.Bool, "active = false": reflect.Bool, - "active = YES": reflect.Bool, + "active = TRUE": reflect.Bool, } for in, wantKind := range cases { t.Run(in, func(t *testing.T) { diff --git a/filter_parser.go b/filter_parser.go index f7dc9fa..9b4daa5 100644 --- a/filter_parser.go +++ b/filter_parser.go @@ -428,7 +428,13 @@ func (p *filterParseState) parseSimilarToOperator(field Node, isNot bool) Node { return field // Return field on error } p.nextToken() // Consume TO - pattern := p.parsePrimary() + + operator := TokenOperatorSimilarTo + if isNot { + operator = TokenOperatorNotSimilarTo + } + + pattern := p.parsePattern(operator) return &SimilarToNode{ baseNode: baseNode{pos: pos}, Field: field, @@ -437,13 +443,34 @@ func (p *filterParseState) parseSimilarToOperator(field Node, isNot bool) Node { } } +// parsePattern reads the right side of LIKE, ILIKE and SIMILAR TO, negated or +// not, and of the ~~ operators that are their other spelling. +// +// PostgreSQL writes them "string LIKE pattern [ESCAPE escape-character]" and +// "string SIMILAR TO pattern", and a pattern is a string: with a number or a +// boolean there it answers "operator does not exist", whatever the column's +// type is, because no ~~ takes one. So a literal of another kind is refused +// here, as it already was for the POSIX operators (~, ~*, !~, !~*). +// +// The ESCAPE clause is not part of this grammar: a pattern is written with +// the default escape character, the backslash. +func (p *filterParseState) parsePattern(operator TokenType) Node { + pattern := p.parsePrimary() + + if literal, ok := pattern.(*LiteralNode); !ok || literal.Kind != reflect.String { + p.addError(&QFVFilterError{Message: fmt.Sprintf("expected string pattern for operator %s, got %s", operator, pattern.Type())}) + } + + return pattern +} + // parseLikeOperator parses a LIKE/ILIKE/NOT LIKE/NOT ILIKE operator. // Expects the current token to be the pattern after the operator was consumed. // operator is one of TokenOperatorLike, TokenOperatorILike, TokenOperatorNotLike, // or TokenOperatorNotILike. func (p *filterParseState) parseLikeOperator(field Node, operator TokenType) Node { pos := p.lexer.Current().Pos // Use position of the operator token (already consumed) - pattern := p.parsePrimary() + pattern := p.parsePattern(operator) return &BinaryOperatorNode{ baseNode: baseNode{pos: pos}, Left: field, @@ -572,8 +599,7 @@ func (p *filterParseState) parseIsPredicate(field Node) Node { return p.parseDistinctOperator(field, isNot) case p.currentToken.Type == TokenBoolean && (p.isKeyword("TRUE") || p.isKeyword("FALSE")): - // IS [NOT] TRUE | FALSE. YES and NO are lexed as booleans too, and are - // literals only: SQL has no "IS YES". + // IS [NOT] TRUE | FALSE. p.requireOp(OpBooleanTest) truth := BooleanTrue if p.isKeyword("FALSE") { @@ -665,7 +691,7 @@ func (p *filterParseState) parsePrimary() Node { return node case TokenBoolean: - val := asciiUpper(p.currentToken.Value) == "TRUE" || asciiUpper(p.currentToken.Value) == "YES" + val := asciiUpper(p.currentToken.Value) == "TRUE" node := &LiteralNode{ baseNode: baseNode{pos: p.currentToken.Pos}, Value: val, diff --git a/filter_pg_patterns_test.go b/filter_pg_patterns_test.go new file mode 100644 index 0000000..fedec68 --- /dev/null +++ b/filter_pg_patterns_test.go @@ -0,0 +1,234 @@ +package qfv + +import ( + "reflect" + "strings" + "testing" +) + +// What PostgreSQL's pattern operators take is in its manual, "9.7. Pattern +// Matching" (https://www.postgresql.org/docs/current/functions-matching.html): +// +// string LIKE pattern [ESCAPE escape-character] +// string SIMILAR TO pattern [ESCAPE escape-character] +// text ~ text → boolean +// +// and "the parser actually translates LIKE et al." to the operators ~~, ~~*, +// !~~ and !~~*. A pattern is a string. Each row of this file was run against +// PostgreSQL 18.6 as `SELECT … WHERE ()`, on a table with the columns +// id integer, name text, price numeric and active boolean; what it answered +// is in the comment. + +// TestFilterParser_APatternIsAString: a number or a boolean after a pattern +// operator is "operator does not exist" (42883) for every column type, since +// no ~~ takes one, so the parser refuses it. The POSIX operators already +// did; LIKE, ILIKE, SIMILAR TO and the ~~ family took any literal. +func TestFilterParser_APatternIsAString(t *testing.T) { + allowed := []string{"id", "name", "price", "active"} + + refused := []struct { + name string + input string + }{ + // operator does not exist: text ~~ integer + {name: "LIKE an integer", input: "name LIKE 1"}, + {name: "LIKE a negative integer", input: "name LIKE -1"}, + // operator does not exist: text !~~ numeric + {name: "NOT LIKE a float", input: "name NOT LIKE 1.5"}, + // operator does not exist: text ~~* integer + {name: "ILIKE an integer", input: "name ILIKE 1"}, + // operator does not exist: text !~~* integer + {name: "NOT ILIKE an integer", input: "name NOT ILIKE 1"}, + // operator does not exist: text ~~ integer, and so on + {name: "~~ an integer", input: "name ~~ 1"}, + {name: "~~* an integer", input: "name ~~* 1"}, + {name: "!~~ an integer", input: "name !~~ 1"}, + {name: "!~~* an integer", input: "name !~~* 1"}, + // function pg_catalog.similar_to_escape(integer) does not exist + {name: "SIMILAR TO an integer", input: "name SIMILAR TO 1"}, + {name: "NOT SIMILAR TO an integer", input: "name NOT SIMILAR TO 1"}, + // operator does not exist: text ~~ boolean + {name: "LIKE a boolean", input: "name LIKE TRUE"}, + // function pg_catalog.similar_to_escape(boolean) does not exist + {name: "SIMILAR TO a boolean", input: "name SIMILAR TO false"}, + // Not for the column's type: integer ~~ integer, numeric ~~ integer + // and boolean ~~ boolean do not exist either. + {name: "an integer column LIKE an integer", input: "id LIKE 1"}, + {name: "a numeric column LIKE an integer", input: "price LIKE 1"}, + {name: "a boolean column LIKE a boolean", input: "active LIKE true"}, + // In the middle of an expression, and negated. + {name: "inside a group", input: "id = 1 AND (name LIKE 2 OR id = 3)"}, + {name: "under NOT", input: "NOT name ILIKE 2"}, + // The ESCAPE clause is not part of this grammar. + {name: "ESCAPE", input: "name LIKE 'x!%' ESCAPE '!'"}, + {name: "SIMILAR TO with ESCAPE", input: "name SIMILAR TO 'x!%' ESCAPE '!'"}, + } + + for _, tt := range refused { + t.Run(tt.name, func(t *testing.T) { + node, err := NewFilterParser(allowed).Parse(tt.input) + if err == nil { + t.Fatalf("Parse(%q) = %v, want an error: PostgreSQL refuses it", tt.input, node) + } + }) + } + + // The refusal says what was expected, and names the operator as it is + // written in SQL. + for input, operator := range map[string]string{ + "name LIKE 1": "LIKE", + "name NOT LIKE 1": "NOT LIKE", + "name ILIKE 1": "ILIKE", + "name NOT ILIKE 1": "NOT ILIKE", + "name SIMILAR TO 1": "SIMILAR TO", + "name NOT SIMILAR TO 1": "NOT SIMILAR TO", + } { + t.Run("says so: "+input, func(t *testing.T) { + _, err := NewFilterParser(allowed).Parse(input) + if err == nil { + t.Fatalf("Parse(%q): no error", input) + } + + if want := "expected string pattern for operator " + operator + ","; !strings.Contains(err.Error(), want) { + t.Errorf("Parse(%q) = %q, want it to say %q", input, err.Error(), want) + } + }) + } + + // A string is a pattern, whatever is in it: PostgreSQL runs each of these. + for _, input := range []string{ + "name LIKE 'x%'", + "name NOT LIKE '_x'", + "name ILIKE 'X%'", + "name NOT ILIKE 'X%'", + "name ~~ 'x%'", + "name ~~* 'X%'", + "name !~~ 'x%'", + "name !~~* 'X%'", + "name SIMILAR TO '(x|y)%'", + "name NOT SIMILAR TO '(x|y)%'", + // A number written as a string is a pattern. + "name LIKE '1'", + "name LIKE '-1'", + // The default escape character, the backslash. + `name LIKE '100\%'`, + "name LIKE ''", + } { + t.Run("accepted: "+input, func(t *testing.T) { + node, err := NewFilterParser(allowed).Parse(input) + if err != nil { + t.Fatalf("Parse(%q): %v", input, err) + } + + var pattern Node + + switch n := node.(type) { + case *BinaryOperatorNode: + pattern = n.Right + case *SimilarToNode: + pattern = n.Pattern + default: + t.Fatalf("Parse(%q) = %T, want a pattern node", input, node) + } + + if literal, ok := pattern.(*LiteralNode); !ok || literal.Kind != reflect.String { + t.Errorf("Parse(%q): the pattern is %v, want a string literal", input, pattern) + } + }) + } +} + +// TestFilterParser_IsIsAKeywordInAnyCase: PostgreSQL folds an unquoted word, +// so `id is null` is `id IS NULL`. The lexer kept the lower-case word back as +// an identifier, so every other spelling of IS was the keyword and that one +// was refused. PostgreSQL answers each of these with rows. +func TestFilterParser_IsIsAKeywordInAnyCase(t *testing.T) { + allowed := []string{"id", "active"} + + for input, want := range map[string]string{ + "id is null": "id IS NULL", + "id IS NULL": "id IS NULL", + "id Is Not Null": "id IS NOT NULL", + "id is not null": "id IS NOT NULL", + "id is distinct from 1": "id IS DISTINCT FROM 1", + "id is not distinct from 1": "id IS NOT DISTINCT FROM 1", + "active is true": "active IS TRUE", + "active is not unknown": "active IS NOT UNKNOWN", + } { + t.Run(input, func(t *testing.T) { + node, err := NewFilterParser(allowed).Parse(input) + if err != nil { + t.Fatalf("Parse(%q): %v", input, err) + } + + if got := node.String(); got != want { + t.Errorf("Parse(%q) = %s, want %s", input, got, want) + } + }) + } + + // And so `is` is no name, as it is none in PostgreSQL: with a column of + // that name allowed, `is = 1` is a syntax error at or near "=". + if node, err := NewFilterParser([]string{"is", "id"}).Parse("is = 1"); err == nil { + t.Errorf(`Parse("is = 1") = %v, want an error: IS is reserved`, node) + } +} + +// TestFilterParser_YesAndNoAreNames: SQL has two boolean literals, TRUE and +// FALSE. YES and NO were read as booleans here, and PostgreSQL reads them as +// names: `active = YES` compares with a column called yes (and where there is +// none, "column yes does not exist"). The grammar compares a field with a +// literal, so it is refused; and a field called yes or no is one. +func TestFilterParser_YesAndNoAreNames(t *testing.T) { + allowed := []string{"id", "active"} + + for _, input := range []string{ + "active = YES", + "active = no", + "active <> NO", + "active IN (yes, no)", + "active IS DISTINCT FROM yes", + "id BETWEEN yes AND no", + // syntax error at or near "yes": IS takes TRUE, FALSE, UNKNOWN, NULL. + "active IS yes", + "active IS NOT NO", + } { + t.Run(input, func(t *testing.T) { + if node, err := NewFilterParser(allowed).Parse(input); err == nil { + t.Fatalf("Parse(%q) = %v, want an error: YES and NO are no literals", input, node) + } + }) + } + + // TRUE and FALSE, in any case, are the literals. + for input, want := range map[string]bool{"active = TRUE": true, "active = true": true, "active = False": false, "active <> FALSE": false} { + t.Run(input, func(t *testing.T) { + node, err := NewFilterParser(allowed).Parse(input) + if err != nil { + t.Fatalf("Parse(%q): %v", input, err) + } + + literal, ok := node.(*BinaryOperatorNode).Right.(*LiteralNode) + if !ok || literal.Kind != reflect.Bool || literal.Value != want { + t.Errorf("Parse(%q): the value is %v, want the boolean %v", input, node.(*BinaryOperatorNode).Right, want) + } + }) + } + + // A column called yes or no is a field like any other. PostgreSQL runs + // `yes = 1 AND no = 2` on a table that has them. + node, err := NewFilterParser([]string{"yes", "no"}).Parse("yes = 1 AND no = 2") + if err != nil { + t.Fatalf(`a field called yes: %v`, err) + } + + if got, want := node.String(), "((yes = 1) AND (no = 2))"; got != want { + t.Errorf("got %s, want %s", got, want) + } + + // And a string that says yes is a string: PostgreSQL reads 'yes' as a + // boolean's input, which is the caller's column to know. + if _, err := NewFilterParser(allowed).Parse("active = 'yes'"); err != nil { + t.Errorf(`active = 'yes': %v`, err) + } +} diff --git a/filter_postgres_literals_test.go b/filter_postgres_literals_test.go index ad65298..63a4846 100644 --- a/filter_postgres_literals_test.go +++ b/filter_postgres_literals_test.go @@ -383,12 +383,6 @@ func TestFilterParser_KeywordsAreASCII(t *testing.T) { // The same words in ASCII, in any case, are the keywords. for _, input := range []string{"name like 'x'", "id In (1)", "id Is nUlL", "active IS false", "name similar To 'x'", "id is distinct from 1"} { t.Run(input, func(t *testing.T) { - if input == "id is distinct from 1" || input == "id Is nUlL" { - // "is" in lower case is an older special case of the lexer and - // is refused; it is not this test's subject. - input = strings.Replace(input, "is ", "IS ", 1) - } - if _, err := NewFilterParser(allowed).Parse(input); err != nil { t.Fatalf("Parse(%q): %v", input, err) }