v0.1.0 fixes correctness bugs in the filter parser, adds the PostgreSQL 18
predicate set, and introduces configurable operators/directions and dotted
field names. If you only call Parse and check the returned error, most
changes are transparent — the parser now rejects inputs it previously accepted
by mistake. If you inspect the returned AST, review the notes below.
Previously the parser stopped at the first complete expression and silently ignored the rest, so invalid input validated successfully. It now requires the entire input to be consumed.
| Input | Before | v0.1.0 |
|---|---|---|
first_name = 'John' garbage |
✅ accepted | ❌ error |
age > 30 40 |
✅ accepted | ❌ error |
first_name = 'John') |
✅ accepted | ❌ error |
1 = 1 |
✅ accepted | ❌ error |
Migration: none required for valid queries. If you relied on the lenient behavior, fix the offending inputs — they were never valid.
NOT IN, NOT BETWEEN, NOT SIMILAR TO, and NOT DISTINCT FROM previously
produced a UnaryOperatorNode{Operator: NOT} wrapping the inner node (whose own
IsNot was always false). They now return the operator node directly with
IsNot = true, matching how RegexMatchNode already worked. NOT LIKE now
produces a BinaryOperatorNode with Operator = TokenOperatorNotLike.
For example, status NOT IN ('archived') changed shape:
flowchart LR
subgraph before["Before (v0.0.x)"]
direction TB
U["UnaryOperatorNode<br/>Operator: NOT"] --> I1["InNode<br/>IsNot: false"]
end
subgraph after["v0.1.0"]
direction TB
I2["InNode<br/>IsNot: true"]
end
before ==>|"flattened"| after
// Before: type-switch had to unwrap the NOT node
if u, ok := node.(*qfv.UnaryOperatorNode); ok && u.Operator == qfv.TokenOperatorNot {
if in, ok := u.X.(*qfv.InNode); ok { /* negated IN */ }
}
// After: the node carries its own negation
if in, ok := node.(*qfv.InNode); ok && in.IsNot { /* negated IN */ }The IsNot fields on InNode, BetweenNode, SimilarToNode, and
DistinctNode are now meaningful, Type() returns the corresponding
NodeTypeNotIn / NodeTypeNotBetween / NodeTypeNotSimilarTo /
NodeTypeNotDistinct, and String() renders the NOT/IS NOT form.
A standalone leading
NOT(e.g.NOT (age > 30)) is unchanged — it still produces aUnaryOperatorNode.
IS [NOT] DISTINCT FROM <value> previously discarded the compared value.
DistinctNode now holds it:
type DistinctNode struct {
Field Node
Value Node // NEW: the right-hand side value (nil when absent)
IsNot bool
}Its String() now renders the canonical field IS [NOT] DISTINCT FROM value.
FilterParser.Parse returns errors.Join(...) of *QFVFilterError values
rather than a single formatted string. Use errors.As / errors.Is to inspect
causes — see Error Handling. Code that only checks
err != nil is unaffected.
A single *FilterParser (like *SortParser and *FieldsParser) can now be
created once and shared across goroutines; per-request state is no longer stored
on the parser. No API change — this removes a data race.
The grammar gained the following; existing queries keep parsing:
| Predicate / operator | AST |
|---|---|
ILIKE, NOT ILIKE (and ~~, ~~*, !~~, !~~*) |
BinaryOperatorNode with ILIKE/NOT ILIKE (or LIKE/NOT LIKE) |
IS [NOT] TRUE/FALSE/UNKNOWN |
new BooleanTestNode{Field, Value, IsNot} |
IS [NOT] DISTINCT FROM <value> |
DistinctNode via the standard IS form |
BETWEEN [NOT] SYMMETRIC (explicit ASYMMETRIC) |
BetweenNode.IsSymmetric |
ISNULL, NOTNULL shorthands |
IsNullNode (IsNot=true for NOTNULL) |
NewFilterParser(fields) and NewSortParser(fields) gained variadic options:
func NewFilterParser(allowedFields []string, opts ...FilterOption) *FilterParser
func NewSortParser(allowedFields []string, opts ...SortOption) *SortParserExisting calls compile unchanged. See Configuration for
WithAllowedOperators, WithAllowedOperatorGroups, and WithAllowedDirections.
Field names may now contain dots (user.profile.age) in all three parsers.
Numeric literals such as 3.14 are unaffected. See
Filtering › Nested field names.