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
23 changes: 18 additions & 5 deletions docs/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,15 @@ to.
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\%'`.
- **The `ESCAPE` clause** follows `LIKE`, `ILIKE`, their negations and
`SIMILAR TO`, written with the keyword: `name LIKE 'x!%' ESCAPE '!'`. The
escape character is a string of one character, or empty to turn escaping
off (`ESCAPE ''`). PostgreSQL refuses the rest, and so does the parser:
two characters (`invalid escape string`), a number, a boolean or a column
as the escape, and the clause after `~~`, `~~*`, `!~~` and `!~~*`, which
take none. Without the clause the escape character is the backslash, which
a string literal keeps as it is: `name LIKE '100\%'`. The node carries it
as `Escape`, nil when there is no clause.
- **What is in the pattern is not looked at**: see
"Patterns are not compiled", under [AST nodes](#ast-nodes).

Expand Down Expand Up @@ -305,8 +311,15 @@ classDiagram
class BinaryOperatorNode {
+Left Node
+Right Node
+Escape Node
+Operator TokenType
}
class SimilarToNode {
+Field Node
+Pattern Node
+Escape Node
+IsNot bool
}
class InNode {
+Field Node
+Values []Node
Expand All @@ -331,15 +344,15 @@ Common concrete types:

| Node | Produced by |
| --- | --- |
| `BinaryOperatorNode` | comparisons, `AND`/`OR`, `LIKE`/`ILIKE` |
| `BinaryOperatorNode` | comparisons, `AND`/`OR`, `LIKE`/`ILIKE` (`Escape`) |
| `UnaryOperatorNode` | a standalone leading `NOT` |
| `GroupNode` | parenthesized expressions |
| `InNode` | `IN` / `NOT IN` (`IsNot`) |
| `BetweenNode` | `BETWEEN` (`IsNot`, `IsSymmetric`) |
| `IsNullNode` | `IS [NOT] NULL`, `ISNULL`/`NOTNULL` (`IsNot`) |
| `BooleanTestNode` | `IS [NOT] TRUE/FALSE/UNKNOWN` (`Value`, `IsNot`) |
| `DistinctNode` | `IS [NOT] DISTINCT FROM` (`Value`, `IsNot`) |
| `SimilarToNode` | `SIMILAR TO` / `NOT SIMILAR TO` (`IsNot`) |
| `SimilarToNode` | `SIMILAR TO` / `NOT SIMILAR TO` (`IsNot`, `Escape`) |
| `RegexMatchNode` | `~ ~* !~ !~*` (`IsNot`, `IsCaseInsensitive`) |
| `IdentifierNode` / `LiteralNode` | field names / literal values |

Expand Down
32 changes: 32 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
# Migration Guide

## v1.0.5 → v1.1.0

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.

**Accepted now, and refused before:**

| Input | v1.0.5 | v1.1.0 | PostgreSQL 18 |
| --- | --- | --- | --- |
| `name LIKE 'x!%' ESCAPE '!'`, after `ILIKE`, `NOT LIKE`, `NOT ILIKE`, `SIMILAR TO` and `NOT SIMILAR TO` too, the keyword in any case | ❌ error | ✅ accepted | runs |
| an empty escape: `name LIKE 'x%' ESCAPE ''` | ❌ error | ✅ accepted | runs, with escaping off |

**Refused, as PostgreSQL refuses them:**

| Input | v1.1.0 | PostgreSQL 18 |
| --- | --- | --- |
| two characters: `ESCAPE '!!'` | ❌ error: `must be empty or one character` | `invalid escape string` |
| a number, a boolean or a column: `ESCAPE 1`, `ESCAPE TRUE`, `ESCAPE name` | ❌ error: `expected a string escape character after ESCAPE` | `function like_escape(unknown, integer) does not exist`; `invalid escape string` per row |
| after the operator spellings: `name ~~ 'x!%' ESCAPE '!'`, and after `~~*`, `!~~`, `!~~*` | ❌ error: `ESCAPE follows a pattern written with LIKE, ILIKE or SIMILAR TO` | `syntax error at or near "ESCAPE"` |

**For callers that walk the tree.** `BinaryOperatorNode` and `SimilarToNode`
have a new field, `Escape`: the escape character, a string `LiteralNode`, or
nil when there is no clause, which is every filter v1.0.5 accepted. Their
`String()` writes the clause when there is one. Nothing else changes, so the
version is a minor one.

**For callers that hand the filter to PostgreSQL** as written: the clause
goes with it and runs as the database runs it. A caller that prefixes field
names in the text (`name` to `t.name`) leaves `ESCAPE` alone, since it is no
field.

## v1.0.3 → v1.0.4

Three places where the filter parser and PostgreSQL disagreed about an
Expand Down
30 changes: 24 additions & 6 deletions filter_nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ type SimilarToNode struct {
baseNode
Field Node
Pattern Node
IsNot bool // true for NOT SIMILAR TO
// Escape is the ESCAPE clause's escape character, a string LiteralNode of
// zero or one character, or nil when there is no clause.
Escape Node
IsNot bool // true for NOT SIMILAR TO
}

func (n *SimilarToNode) Type() NodeType {
Expand All @@ -51,9 +54,9 @@ func (n *SimilarToNode) Type() NodeType {
}
func (n *SimilarToNode) String() string {
if n.IsNot {
return fmt.Sprintf("%s NOT SIMILAR TO %s", n.Field.String(), n.Pattern.String())
return fmt.Sprintf("%s NOT SIMILAR TO %s%s", n.Field.String(), n.Pattern.String(), escapeClause(n.Escape))
}
return fmt.Sprintf("%s SIMILAR TO %s", n.Field.String(), n.Pattern.String())
return fmt.Sprintf("%s SIMILAR TO %s%s", n.Field.String(), n.Pattern.String(), escapeClause(n.Escape))
}
func (n *SimilarToNode) Pos() scanner.Position { return n.pos }

Expand Down Expand Up @@ -148,14 +151,29 @@ func (n *UnaryOperatorNode) Pos() scanner.Position { return n.pos }
// BinaryOperatorNode represents a binary operation (AND, OR)
type BinaryOperatorNode struct {
baseNode
Left Node
Right Node
Left Node
Right Node
// Escape is the ESCAPE clause of LIKE, ILIKE and their negations, written
// with the keyword: a string LiteralNode of zero or one character, or nil
// when there is no clause. It is nil for every other operator, and for
// the ~~ operators, which take no clause.
Escape Node
Operator TokenType
}

func (n *BinaryOperatorNode) Type() NodeType { return NodeTypeBinaryOperator }
func (n *BinaryOperatorNode) String() string {
return fmt.Sprintf("(%s %s %s)", n.Left.String(), n.Operator, n.Right.String())
return fmt.Sprintf("(%s %s %s%s)", n.Left.String(), n.Operator, n.Right.String(), escapeClause(n.Escape))
}

// escapeClause is " ESCAPE <character>" for a pattern written with one, and
// nothing for a pattern without.
func escapeClause(escape Node) string {
if escape == nil {
return ""
}

return " ESCAPE " + escape.String()
}
func (n *BinaryOperatorNode) Pos() scanner.Position { return n.pos }

Expand Down
67 changes: 57 additions & 10 deletions filter_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strconv"
"strings"
"text/scanner"
"unicode/utf8"
)

type QFVFilterError struct {
Expand Down Expand Up @@ -302,20 +303,22 @@ func (p *filterParseState) parseComparison() Node {
return p.parseComparisonOperator(field)
case TokenOperatorLike:
p.requireOp(OpLike)
keyword := p.isKeyword("LIKE")
p.nextToken() // Consume LIKE (or ~~)
return p.parseLikeOperator(field, TokenOperatorLike)
return p.parseLikeOperator(field, TokenOperatorLike, keyword)
case TokenOperatorILike:
p.requireOp(OpILike)
keyword := p.isKeyword("ILIKE")
p.nextToken() // Consume ILIKE (or ~~*)
return p.parseLikeOperator(field, TokenOperatorILike)
return p.parseLikeOperator(field, TokenOperatorILike, keyword)
case TokenOperatorNotLike:
p.requireOp(OpLike)
p.nextToken() // Consume !~~ (operator form of NOT LIKE)
return p.parseLikeOperator(field, TokenOperatorNotLike)
return p.parseLikeOperator(field, TokenOperatorNotLike, false)
case TokenOperatorNotILike:
p.requireOp(OpILike)
p.nextToken() // Consume !~~* (operator form of NOT ILIKE)
return p.parseLikeOperator(field, TokenOperatorNotILike)
return p.parseLikeOperator(field, TokenOperatorNotILike, false)
case TokenOperatorIn:
p.requireOp(OpIn)
p.nextToken() // Consume IN
Expand Down Expand Up @@ -378,15 +381,15 @@ func (p *filterParseState) parseComparison() Node {

p.requireOp(OpLike)
p.nextToken() // Consume LIKE
return p.parseLikeOperator(field, TokenOperatorNotLike)
return p.parseLikeOperator(field, TokenOperatorNotLike, true)
case TokenOperatorILike:
if !p.isKeyword("ILIKE") {
return p.unexpectedAfterNot(field)
}

p.requireOp(OpILike)
p.nextToken() // Consume ILIKE
return p.parseLikeOperator(field, TokenOperatorNotILike)
return p.parseLikeOperator(field, TokenOperatorNotILike, true)
case TokenOperatorSimilarTo:
p.requireOp(OpSimilarTo)
p.nextToken() // Consume SIMILAR
Expand Down Expand Up @@ -439,6 +442,7 @@ func (p *filterParseState) parseSimilarToOperator(field Node, isNot bool) Node {
baseNode: baseNode{pos: pos},
Field: field,
Pattern: pattern,
Escape: p.parseEscape(operator, true),
IsNot: isNot,
}
}
Expand All @@ -452,8 +456,7 @@ func (p *filterParseState) parseSimilarToOperator(field Node, isNot bool) Node {
// 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.
// The ESCAPE clause that may follow it is read by parseEscape.
func (p *filterParseState) parsePattern(operator TokenType) Node {
pattern := p.parsePrimary()

Expand All @@ -467,18 +470,62 @@ func (p *filterParseState) parsePattern(operator TokenType) Node {
// 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 {
// or TokenOperatorNotILike; keyword is whether it was written as a word
// (LIKE, ILIKE) rather than as a ~~ operator, which takes no ESCAPE clause.
func (p *filterParseState) parseLikeOperator(field Node, operator TokenType, keyword bool) Node {
pos := p.lexer.Current().Pos // Use position of the operator token (already consumed)
pattern := p.parsePattern(operator)
return &BinaryOperatorNode{
baseNode: baseNode{pos: pos},
Left: field,
Right: pattern,
Escape: p.parseEscape(operator, keyword),
Operator: operator,
}
}

// parseEscape reads the ESCAPE clause after a pattern, and returns its
// escape character, or nil when the pattern has no clause.
//
// PostgreSQL writes it "string LIKE pattern [ESCAPE escape-character]", and
// the same after SIMILAR TO. What it refuses is refused here, so that a
// filter the parser accepts is one PostgreSQL runs (each answer is from
// PostgreSQL 18.6):
//
// - after the ~~ operators ("syntax error at or near ESCAPE"): the clause
// belongs to the keyword spelling;
// - an escape character that is not a string literal: a number or a
// boolean has no like_escape, and a column's value is decided per row;
// - a string of more than one character ("invalid escape string").
//
// An empty string is a clause: it turns escaping off.
func (p *filterParseState) parseEscape(operator TokenType, keyword bool) Node {
if p.currentToken.Type != TokenIdentifier || !p.isKeyword("ESCAPE") {
return nil
}

if !keyword {
p.addError(&QFVFilterError{Message: fmt.Sprintf("ESCAPE follows a pattern written with LIKE, ILIKE or SIMILAR TO, not with the operator %s", operator)})
}

p.nextToken() // Consume ESCAPE

escape := p.parsePrimary()

literal, ok := escape.(*LiteralNode)
if !ok || literal.Kind != reflect.String {
p.addError(&QFVFilterError{Message: fmt.Sprintf("expected a string escape character after ESCAPE, got %s", escape.Type())})

return escape
}

if text, _ := literal.Value.(string); utf8.RuneCountInString(text) > 1 {
p.addError(&QFVFilterError{Message: "the escape character after ESCAPE must be empty or one character"})
}

return escape
}

// parseInOperator parses IN operator
// Expects the current token to be LPAREN after IN was consumed.
func (p *filterParseState) parseInOperator(field Node, isNot bool) Node {
Expand Down
Loading
Loading