From 10861638a3a001ed797c3e97d2ea047514f73a17 Mon Sep 17 00:00:00 2001 From: Jakub Skoczen Date: Sun, 1 Feb 2026 17:06:15 +0100 Subject: [PATCH 1/6] Add fluent CQL builder --- .gitignore | 1 + README.md | 29 +++ cqlbuilder/builder.go | 454 +++++++++++++++++++++++++++++++++++++ cqlbuilder/builder_test.go | 145 ++++++++++++ 4 files changed, 629 insertions(+) create mode 100644 cqlbuilder/builder.go create mode 100644 cqlbuilder/builder_test.go diff --git a/.gitignore b/.gitignore index 0d09638..e23a51c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store /cql-cli /pgcql-cli .vscode diff --git a/README.md b/README.md index 79b9ad7..66078f9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,35 @@ func main() { See the [cql-cli source](cmd/cql-cli/main.go) for a more complete example. +## Building CQL programmatically + +If you want to construct valid CQL queries without hand-assembling the AST, use the +fluent builder in `cqlbuilder` which validates inputs and escapes terms. + +```go +import ( + "fmt" + + "github.com/indexdata/cql-go/cql" + "github.com/indexdata/cql-go/cqlbuilder" +) + +func main() { + query, err := cqlbuilder.NewQuery(). + Prefix("dc", "http://purl.org/dc/elements/1.1/"). + Search("dc.title"). + Rel(cql.EQ). + Term("the \"little\" prince"). + SortBy("dc.title", cql.IgnoreCase). + Build() + if err != nil { + fmt.Fprintln(os.Stderr, "ERROR", err) + return + } + fmt.Println(query.String()) +} +``` + ## Conformance The CQL specification requires that a query consist of a single search term with an optional index and relation: diff --git a/cqlbuilder/builder.go b/cqlbuilder/builder.go new file mode 100644 index 0000000..997af6a --- /dev/null +++ b/cqlbuilder/builder.go @@ -0,0 +1,454 @@ +// Package cqlbuilder provides a fluent, validated builder for CQL queries. +package cqlbuilder + +import ( + "fmt" + "strings" + + "github.com/indexdata/cql-go/cql" +) + +// NewQuery creates a new query builder. +func NewQuery() *QueryBuilder { + return &QueryBuilder{} +} + +// NewQueryFromString initializes a builder from a CQL string. +func NewQueryFromString(input string) (*QueryBuilder, error) { + var parser cql.Parser + query, err := parser.Parse(input) + if err != nil { + return nil, err + } + return NewQueryFrom(query), nil +} + +// NewQueryFrom initializes a builder with an existing query. +func NewQueryFrom(query cql.Query) *QueryBuilder { + clause := query.Clause + return &QueryBuilder{ + sorts: query.SortSpec, + root: &clause, + } +} + +// QueryBuilder builds a validated cql.Query. +type QueryBuilder struct { + prefixes []cql.Prefix + sorts []cql.Sort + root *cql.Clause + err error +} + +// Prefix adds a prefix declaration. +func (qb *QueryBuilder) Prefix(prefix, uri string) *QueryBuilder { + if qb.err != nil { + return qb + } + if strings.TrimSpace(uri) == "" { + qb.err = fmt.Errorf("prefix uri must be non-empty") + return qb + } + qb.prefixes = append(qb.prefixes, cql.Prefix{ + Prefix: prefix, + Uri: uri, + }) + return qb +} + +// Search starts a search expression as the root clause. +func (qb *QueryBuilder) Search(index string) *SearchBuilder { + if qb.err == nil && qb.root != nil { + qb.err = fmt.Errorf("query already has a root clause") + } + return &SearchBuilder{ + index: index, + finish: qb.finishRoot, + build: qb.Build, + qb: qb, + err: qb.err, + } +} + +// And appends an AND boolean expression to the existing root clause. +func (qb *QueryBuilder) And() *JoinBuilder { + return qb.append(cql.AND) +} + +// Or appends an OR boolean expression to the existing root clause. +func (qb *QueryBuilder) Or() *JoinBuilder { + return qb.append(cql.OR) +} + +// Not appends a NOT boolean expression to the existing root clause. +func (qb *QueryBuilder) Not() *JoinBuilder { + return qb.append(cql.NOT) +} + +// Prox appends a PROX boolean expression to the existing root clause. +func (qb *QueryBuilder) Prox() *JoinBuilder { + return qb.append(cql.PROX) +} + +func (qb *QueryBuilder) append(op cql.Operator) *JoinBuilder { + if qb.err == nil && qb.root == nil { + qb.err = fmt.Errorf("query requires a root clause before appending") + } + var left cql.Clause + if qb.root != nil { + left = *qb.root + } + return &JoinBuilder{ + finish: qb.finishAppend, + build: qb.Build, + qb: qb, + left: left, + op: op, + err: qb.err, + } +} + +// SortBy adds a sort criterion with simple (name-only) modifiers. +func (qb *QueryBuilder) SortBy(index string, mods ...cql.CqlModifier) *QueryBuilder { + if qb.err != nil { + return qb + } + if strings.TrimSpace(index) == "" { + qb.err = fmt.Errorf("sort index must be non-empty") + return qb + } + var out []cql.Modifier + for _, mod := range mods { + if strings.TrimSpace(string(mod)) == "" { + qb.err = fmt.Errorf("sort modifier name must be non-empty") + return qb + } + out = append(out, cql.Modifier{Name: string(mod)}) + } + qb.sorts = append(qb.sorts, cql.Sort{Index: index, Modifiers: out}) + return qb +} + +// SortByModifiers adds a sort criterion with fully-specified modifiers. +func (qb *QueryBuilder) SortByModifiers(index string, mods ...cql.Modifier) *QueryBuilder { + if qb.err != nil { + return qb + } + if strings.TrimSpace(index) == "" { + qb.err = fmt.Errorf("sort index must be non-empty") + return qb + } + for i := range mods { + if strings.TrimSpace(mods[i].Name) == "" { + qb.err = fmt.Errorf("sort modifier name must be non-empty") + return qb + } + if mods[i].Value != "" { + mods[i].Value = escapeValue(mods[i].Value) + } + if mods[i].Relation == "" && mods[i].Value != "" { + mods[i].Relation = cql.EQ + } + if mods[i].Relation != "" && !isValidRelation(mods[i].Relation) { + qb.err = fmt.Errorf("invalid modifier relation: %q", mods[i].Relation) + return qb + } + } + qb.sorts = append(qb.sorts, cql.Sort{Index: index, Modifiers: mods}) + return qb +} + +// Build validates and returns the final query. +func (qb *QueryBuilder) Build() (cql.Query, error) { + if qb.err != nil { + return cql.Query{}, qb.err + } + if qb.root == nil { + return cql.Query{}, fmt.Errorf("query requires a root clause") + } + root := *qb.root + if len(qb.prefixes) > 0 { + root.PrefixMap = append(root.PrefixMap, qb.prefixes...) + } + return cql.Query{ + Clause: root, + SortSpec: qb.sorts, + }, nil +} + +func (qb *QueryBuilder) finishAppend(clause cql.Clause) *ExprBuilder { + if qb.err != nil { + return &ExprBuilder{finish: qb.finishAppend, build: qb.Build, qb: qb, err: qb.err} + } + qb.root = &clause + return &ExprBuilder{finish: qb.finishAppend, build: qb.Build, qb: qb, clause: clause} +} + +func (qb *QueryBuilder) finishRoot(clause cql.Clause) *ExprBuilder { + if qb.err != nil { + return &ExprBuilder{finish: qb.finishRoot, build: qb.Build, qb: qb, err: qb.err} + } + qb.root = &clause + return &ExprBuilder{finish: qb.finishRoot, build: qb.Build, qb: qb, clause: clause} +} + +// ExprBuilder represents a completed expression that can be extended with boolean operators. +type ExprBuilder struct { + finish func(cql.Clause) *ExprBuilder + build func() (cql.Query, error) + qb *QueryBuilder + clause cql.Clause + err error +} + +// And starts an AND boolean expression. +func (eb *ExprBuilder) And() *JoinBuilder { + return eb.join(cql.AND) +} + +// Or starts an OR boolean expression. +func (eb *ExprBuilder) Or() *JoinBuilder { + return eb.join(cql.OR) +} + +// Not starts a NOT boolean expression. +func (eb *ExprBuilder) Not() *JoinBuilder { + return eb.join(cql.NOT) +} + +// Prox starts a PROX boolean expression. +func (eb *ExprBuilder) Prox() *JoinBuilder { + return eb.join(cql.PROX) +} + +// SortBy adds a sort criterion with simple (name-only) modifiers. +func (eb *ExprBuilder) SortBy(index string, mods ...cql.CqlModifier) *ExprBuilder { + if eb.err != nil || eb.qb == nil { + return eb + } + eb.qb.SortBy(index, mods...) + if eb.err == nil && eb.qb.err != nil { + eb.err = eb.qb.err + } + return eb +} + +// SortByModifiers adds a sort criterion with fully-specified modifiers. +func (eb *ExprBuilder) SortByModifiers(index string, mods ...cql.Modifier) *ExprBuilder { + if eb.err != nil || eb.qb == nil { + return eb + } + eb.qb.SortByModifiers(index, mods...) + if eb.err == nil && eb.qb.err != nil { + eb.err = eb.qb.err + } + return eb +} + +// Build finalizes and returns the query. +func (eb *ExprBuilder) Build() (cql.Query, error) { + if eb.build == nil { + return cql.Query{}, fmt.Errorf("builder is missing query context") + } + return eb.build() +} + +func (eb *ExprBuilder) join(op cql.Operator) *JoinBuilder { + return &JoinBuilder{ + finish: eb.finish, + build: eb.build, + qb: eb.qb, + left: eb.clause, + op: op, + err: eb.err, + } +} + +// JoinBuilder builds a boolean clause by providing the right-hand side. +type JoinBuilder struct { + finish func(cql.Clause) *ExprBuilder + build func() (cql.Query, error) + qb *QueryBuilder + left cql.Clause + op cql.Operator + mods []cql.Modifier + err error +} + +// Mod adds a modifier to the boolean operator (name-only). +func (jb *JoinBuilder) Mod(name cql.CqlModifier) *JoinBuilder { + if jb.err != nil { + return jb + } + if strings.TrimSpace(string(name)) == "" { + jb.err = fmt.Errorf("modifier name must be non-empty") + return jb + } + jb.mods = append(jb.mods, cql.Modifier{Name: string(name)}) + return jb +} + +// ModRel adds a modifier with relation and value to the boolean operator. +func (jb *JoinBuilder) ModRel(name cql.CqlModifier, rel cql.Relation, value string) *JoinBuilder { + if jb.err != nil { + return jb + } + if strings.TrimSpace(string(name)) == "" { + jb.err = fmt.Errorf("modifier name must be non-empty") + return jb + } + if rel == "" { + rel = cql.EQ + } + if !isValidRelation(rel) { + jb.err = fmt.Errorf("invalid modifier relation: %q", rel) + return jb + } + jb.mods = append(jb.mods, cql.Modifier{ + Name: string(name), + Relation: rel, + Value: escapeValue(value), + }) + return jb +} + +// Search provides the right-hand search clause. +func (jb *JoinBuilder) Search(index string) *SearchBuilder { + return &SearchBuilder{ + index: index, + finish: jb.finishRight, + err: jb.err, + } +} + +func (jb *JoinBuilder) finishRight(right cql.Clause) *ExprBuilder { + if jb.err != nil { + return &ExprBuilder{finish: jb.finish, build: jb.build, qb: jb.qb, err: jb.err} + } + if !isValidOperator(jb.op) { + jb.err = fmt.Errorf("invalid boolean operator: %q", jb.op) + return &ExprBuilder{finish: jb.finish, build: jb.build, qb: jb.qb, err: jb.err} + } + bc := cql.BoolClause{ + Left: jb.left, + Operator: jb.op, + Modifiers: jb.mods, + Right: right, + } + clause := cql.Clause{BoolClause: &bc} + return jb.finish(clause) +} + +// SearchBuilder builds a search clause. +type SearchBuilder struct { + index string + rel cql.Relation + mods []cql.Modifier + finish func(cql.Clause) *ExprBuilder + build func() (cql.Query, error) + qb *QueryBuilder + err error +} + +// Rel sets the relation for the search clause. +func (sb *SearchBuilder) Rel(rel cql.Relation) *SearchBuilder { + if sb.err != nil { + return sb + } + if rel != "" && !isValidRelation(rel) { + sb.err = fmt.Errorf("invalid relation: %q", rel) + return sb + } + sb.rel = rel + return sb +} + +// Mod adds a modifier (name-only). +func (sb *SearchBuilder) Mod(name cql.CqlModifier) *SearchBuilder { + if sb.err != nil { + return sb + } + if strings.TrimSpace(string(name)) == "" { + sb.err = fmt.Errorf("modifier name must be non-empty") + return sb + } + sb.mods = append(sb.mods, cql.Modifier{Name: string(name)}) + return sb +} + +// ModRel adds a modifier with relation and value. +func (sb *SearchBuilder) ModRel(name cql.CqlModifier, rel cql.Relation, value string) *SearchBuilder { + if sb.err != nil { + return sb + } + if strings.TrimSpace(string(name)) == "" { + sb.err = fmt.Errorf("modifier name must be non-empty") + return sb + } + if rel == "" { + rel = cql.EQ + } + if !isValidRelation(rel) { + sb.err = fmt.Errorf("invalid modifier relation: %q", rel) + return sb + } + sb.mods = append(sb.mods, cql.Modifier{ + Name: string(name), + Relation: rel, + Value: escapeValue(value), + }) + return sb +} + +// Term finalizes the search clause and returns an expression builder. +func (sb *SearchBuilder) Term(term string) *ExprBuilder { + if sb.err != nil { + return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, err: sb.err} + } + if strings.TrimSpace(term) == "" { + sb.err = fmt.Errorf("search term must be non-empty") + return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, err: sb.err} + } + if sb.rel != "" && !isValidRelation(sb.rel) { + sb.err = fmt.Errorf("invalid relation: %q", sb.rel) + return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, err: sb.err} + } + clause := cql.Clause{ + SearchClause: &cql.SearchClause{ + Index: sb.index, + Relation: sb.rel, + Modifiers: sb.mods, + Term: escapeValue(term), + }, + } + return sb.finish(clause) +} + +func escapeValue(s string) string { + if s == "" { + return s + } + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + return s +} + +func isValidRelation(rel cql.Relation) bool { + switch rel { + case cql.EQ, cql.NE, cql.LT, cql.GT, cql.LE, cql.GE, + cql.ADJ, cql.ALL, cql.ANY, cql.SCR, cql.ENCLOSES, + cql.EXACT, cql.WITHIN: + return true + default: + return false + } +} + +func isValidOperator(op cql.Operator) bool { + switch op { + case cql.AND, cql.NOT, cql.OR, cql.PROX: + return true + default: + return false + } +} diff --git a/cqlbuilder/builder_test.go b/cqlbuilder/builder_test.go new file mode 100644 index 0000000..aec210f --- /dev/null +++ b/cqlbuilder/builder_test.go @@ -0,0 +1,145 @@ +package cqlbuilder + +import ( + "testing" + + "github.com/indexdata/cql-go/cql" +) + +func TestBuilderSearch(t *testing.T) { + query, err := NewQuery(). + Search("dc.title"). + Rel(cql.EQ). + Term("hello"). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "dc.title = hello"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderSearchMultiWord(t *testing.T) { + query, err := NewQuery(). + Search("dc.title"). + Rel(cql.EQ). + Term("hello world"). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "dc.title = \"hello world\""; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderBooleanAnd(t *testing.T) { + query, err := NewQuery(). + Search("a"). + Term("one"). + And(). + Search("b"). + Rel(cql.GE). + Term("2"). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "a = one and b >= 2"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderPrefixSortAndEscaping(t *testing.T) { + query, err := NewQuery(). + Prefix("dc", "http://purl.org/dc/elements/1.1/"). + Search("dc.title"). + Term("the \"little\" prince"). + SortBy("dc.title", cql.IgnoreCase). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + want := "> dc = \"http://purl.org/dc/elements/1.1/\" dc.title = \"the \\\"little\\\" prince\" sortBy dc.title/ignoreCase" + if got := query.String(); got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderValidation(t *testing.T) { + if _, err := NewQuery().Search("a").Term("").Build(); err == nil { + t.Fatalf("expected error for empty term") + } + + if _, err := NewQuery().Search("a").Rel("bogus").Term("x").Build(); err == nil { + t.Fatalf("expected error for invalid relation") + } + + qb := NewQuery() + _, _ = qb.Search("a").Term("x").Build() + if _, err := qb.Search("b").Term("y").Build(); err == nil { + t.Fatalf("expected error for duplicate root") + } +} + +func TestBuilderAppendToExistingQuery(t *testing.T) { + base := cql.Query{ + Clause: cql.Clause{ + SearchClause: &cql.SearchClause{ + Index: "title", + Relation: cql.EQ, + Term: "base", + }, + }, + } + + query, err := NewQueryFrom(base). + And(). + Search("author"). + Term("alice"). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "title = base and author = alice"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderFromString(t *testing.T) { + qb, err := NewQueryFromString("title = base") + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + query, err := qb.And().Search("author").Term("alice").Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "title = base and author = alice"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderFromStringInjection(t *testing.T) { + qb, err := NewQueryFromString("title = base") + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + query, err := qb.And().Search("author").Term("OR injected=true").Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "title = base and author = \"OR injected=true\""; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} From e345817ad2e40e7e48d6eedfc25878c9fa51b742 Mon Sep 17 00:00:00 2001 From: Jakub Skoczen Date: Mon, 2 Feb 2026 11:11:33 +0100 Subject: [PATCH 2/6] Allow nesting clauses in the builder --- cqlbuilder/builder.go | 96 ++++++++++++++++++++++++++++++++++---- cqlbuilder/builder_test.go | 27 +++++++++++ 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/cqlbuilder/builder.go b/cqlbuilder/builder.go index 997af6a..20fd9be 100644 --- a/cqlbuilder/builder.go +++ b/cqlbuilder/builder.go @@ -32,6 +32,16 @@ func NewQueryFrom(query cql.Query) *QueryBuilder { } } +// BeginClause starts a grouped root clause. +func (qb *QueryBuilder) BeginClause() *ClauseBuilder { + if qb.err == nil && qb.root != nil { + qb.err = fmt.Errorf("query already has a root clause") + } + return &ClauseBuilder{ + ctx: &clauseContext{root: qb}, + } +} + // QueryBuilder builds a validated cql.Query. type QueryBuilder struct { prefixes []cql.Prefix @@ -178,18 +188,18 @@ func (qb *QueryBuilder) Build() (cql.Query, error) { func (qb *QueryBuilder) finishAppend(clause cql.Clause) *ExprBuilder { if qb.err != nil { - return &ExprBuilder{finish: qb.finishAppend, build: qb.Build, qb: qb, err: qb.err} + return &ExprBuilder{finish: qb.finishAppend, build: qb.Build, qb: qb, end: nil, err: qb.err} } qb.root = &clause - return &ExprBuilder{finish: qb.finishAppend, build: qb.Build, qb: qb, clause: clause} + return &ExprBuilder{finish: qb.finishAppend, build: qb.Build, qb: qb, end: nil, clause: clause} } func (qb *QueryBuilder) finishRoot(clause cql.Clause) *ExprBuilder { if qb.err != nil { - return &ExprBuilder{finish: qb.finishRoot, build: qb.Build, qb: qb, err: qb.err} + return &ExprBuilder{finish: qb.finishRoot, build: qb.Build, qb: qb, end: nil, err: qb.err} } qb.root = &clause - return &ExprBuilder{finish: qb.finishRoot, build: qb.Build, qb: qb, clause: clause} + return &ExprBuilder{finish: qb.finishRoot, build: qb.Build, qb: qb, end: nil, clause: clause} } // ExprBuilder represents a completed expression that can be extended with boolean operators. @@ -197,6 +207,7 @@ type ExprBuilder struct { finish func(cql.Clause) *ExprBuilder build func() (cql.Query, error) qb *QueryBuilder + end func(cql.Clause) *ExprBuilder clause cql.Clause err error } @@ -221,6 +232,15 @@ func (eb *ExprBuilder) Prox() *JoinBuilder { return eb.join(cql.PROX) } +// EndClause closes a grouped clause and returns to the parent expression. +func (eb *ExprBuilder) EndClause() *ExprBuilder { + if eb.end == nil { + eb.err = fmt.Errorf("no open clause to end") + return eb + } + return eb.end(eb.clause) +} + // SortBy adds a sort criterion with simple (name-only) modifiers. func (eb *ExprBuilder) SortBy(index string, mods ...cql.CqlModifier) *ExprBuilder { if eb.err != nil || eb.qb == nil { @@ -258,6 +278,7 @@ func (eb *ExprBuilder) join(op cql.Operator) *JoinBuilder { finish: eb.finish, build: eb.build, qb: eb.qb, + end: eb.end, left: eb.clause, op: op, err: eb.err, @@ -269,6 +290,7 @@ type JoinBuilder struct { finish func(cql.Clause) *ExprBuilder build func() (cql.Query, error) qb *QueryBuilder + end func(cql.Clause) *ExprBuilder left cql.Clause op cql.Operator mods []cql.Modifier @@ -312,22 +334,30 @@ func (jb *JoinBuilder) ModRel(name cql.CqlModifier, rel cql.Relation, value stri return jb } +// BeginClause starts a grouped boolean clause as the right-hand side. +func (jb *JoinBuilder) BeginClause() *ClauseBuilder { + return &ClauseBuilder{ + ctx: &clauseContext{parent: jb}, + } +} + // Search provides the right-hand search clause. func (jb *JoinBuilder) Search(index string) *SearchBuilder { return &SearchBuilder{ index: index, finish: jb.finishRight, + end: jb.end, err: jb.err, } } func (jb *JoinBuilder) finishRight(right cql.Clause) *ExprBuilder { if jb.err != nil { - return &ExprBuilder{finish: jb.finish, build: jb.build, qb: jb.qb, err: jb.err} + return &ExprBuilder{finish: jb.finish, build: jb.build, qb: jb.qb, end: jb.end, err: jb.err} } if !isValidOperator(jb.op) { jb.err = fmt.Errorf("invalid boolean operator: %q", jb.op) - return &ExprBuilder{finish: jb.finish, build: jb.build, qb: jb.qb, err: jb.err} + return &ExprBuilder{finish: jb.finish, build: jb.build, qb: jb.qb, end: jb.end, err: jb.err} } bc := cql.BoolClause{ Left: jb.left, @@ -339,6 +369,53 @@ func (jb *JoinBuilder) finishRight(right cql.Clause) *ExprBuilder { return jb.finish(clause) } +type clauseContext struct { + root *QueryBuilder + parent *JoinBuilder +} + +func (cc *clauseContext) finish(clause cql.Clause) *ExprBuilder { + return &ExprBuilder{ + finish: cc.finish, + end: cc.end, + clause: clause, + err: cc.err(), + } +} + +func (cc *clauseContext) end(clause cql.Clause) *ExprBuilder { + if cc.root != nil { + if cc.root.err != nil { + return &ExprBuilder{finish: cc.finish, build: cc.root.Build, qb: cc.root, end: nil, err: cc.root.err} + } + cc.root.root = &clause + return &ExprBuilder{finish: cc.root.finishRoot, build: cc.root.Build, qb: cc.root, end: nil, clause: clause} + } + return cc.parent.finishRight(clause) +} + +func (cc *clauseContext) err() error { + if cc.root != nil { + return cc.root.err + } + return cc.parent.err +} + +// ClauseBuilder builds a grouped clause on the right-hand side of a boolean operator. +type ClauseBuilder struct { + ctx *clauseContext +} + +// Search starts the grouped clause with a search expression. +func (cb *ClauseBuilder) Search(index string) *SearchBuilder { + return &SearchBuilder{ + index: index, + finish: cb.ctx.finish, + end: cb.ctx.end, + err: cb.ctx.err(), + } +} + // SearchBuilder builds a search clause. type SearchBuilder struct { index string @@ -347,6 +424,7 @@ type SearchBuilder struct { finish func(cql.Clause) *ExprBuilder build func() (cql.Query, error) qb *QueryBuilder + end func(cql.Clause) *ExprBuilder err error } @@ -403,15 +481,15 @@ func (sb *SearchBuilder) ModRel(name cql.CqlModifier, rel cql.Relation, value st // Term finalizes the search clause and returns an expression builder. func (sb *SearchBuilder) Term(term string) *ExprBuilder { if sb.err != nil { - return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, err: sb.err} + return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, end: sb.end, err: sb.err} } if strings.TrimSpace(term) == "" { sb.err = fmt.Errorf("search term must be non-empty") - return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, err: sb.err} + return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, end: sb.end, err: sb.err} } if sb.rel != "" && !isValidRelation(sb.rel) { sb.err = fmt.Errorf("invalid relation: %q", sb.rel) - return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, err: sb.err} + return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, end: sb.end, err: sb.err} } clause := cql.Clause{ SearchClause: &cql.SearchClause{ diff --git a/cqlbuilder/builder_test.go b/cqlbuilder/builder_test.go index aec210f..9576da3 100644 --- a/cqlbuilder/builder_test.go +++ b/cqlbuilder/builder_test.go @@ -128,6 +128,33 @@ func TestBuilderFromString(t *testing.T) { } } +func TestBuilderGroupedClause(t *testing.T) { + query, err := NewQuery(). + BeginClause(). + Search("a"). + Term("a"). + Or(). + Search("b"). + Term("b"). + EndClause(). + And(). + BeginClause(). + Search("d"). + Term("d"). + Or(). + Search("b"). + Term("b"). + EndClause(). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + //CQL is left associative so the stringifier skips left parentheses + if got, want := query.String(), "a = a or b = b and (d = d or b = b)"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + func TestBuilderFromStringInjection(t *testing.T) { qb, err := NewQueryFromString("title = base") if err != nil { From a14ec6c922d3f2f7831bbdb7df02beaade7a6f9c Mon Sep 17 00:00:00 2001 From: Jakub Skoczen Date: Mon, 2 Feb 2026 12:07:29 +0100 Subject: [PATCH 3/6] Add more term methods --- cqlbuilder/builder.go | 34 +++++++++++++++++++++++++++++++- cqlbuilder/builder_test.go | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/cqlbuilder/builder.go b/cqlbuilder/builder.go index 20fd9be..ff11d5d 100644 --- a/cqlbuilder/builder.go +++ b/cqlbuilder/builder.go @@ -479,7 +479,24 @@ func (sb *SearchBuilder) ModRel(name cql.CqlModifier, rel cql.Relation, value st } // Term finalizes the search clause and returns an expression builder. +// It escapes backslashes, quotes, and wildcard characters (*, ?, ^). func (sb *SearchBuilder) Term(term string) *ExprBuilder { + return sb.termWithEscaper(term, escapeTermSafe) +} + +// TermWildcard finalizes the search clause and returns an expression builder. +// It escapes backslashes and quotes, but preserves wildcard characters. +func (sb *SearchBuilder) TermWildcard(term string) *ExprBuilder { + return sb.termWithEscaper(term, escapeValue) +} + +// TermVerbatim finalizes the search clause and returns an expression builder. +// It does not escape or alter the term. +func (sb *SearchBuilder) TermVerbatim(term string) *ExprBuilder { + return sb.termWithEscaper(term, identityValue) +} + +func (sb *SearchBuilder) termWithEscaper(term string, esc func(string) string) *ExprBuilder { if sb.err != nil { return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, end: sb.end, err: sb.err} } @@ -496,7 +513,7 @@ func (sb *SearchBuilder) Term(term string) *ExprBuilder { Index: sb.index, Relation: sb.rel, Modifiers: sb.mods, - Term: escapeValue(term), + Term: esc(term), }, } return sb.finish(clause) @@ -511,6 +528,21 @@ func escapeValue(s string) string { return s } +func escapeTermSafe(s string) string { + s = escapeValue(s) + if s == "" { + return s + } + s = strings.ReplaceAll(s, "*", "\\*") + s = strings.ReplaceAll(s, "?", "\\?") + s = strings.ReplaceAll(s, "^", "\\^") + return s +} + +func identityValue(s string) string { + return s +} + func isValidRelation(rel cql.Relation) bool { switch rel { case cql.EQ, cql.NE, cql.LT, cql.GT, cql.LE, cql.GE, diff --git a/cqlbuilder/builder_test.go b/cqlbuilder/builder_test.go index 9576da3..c95320b 100644 --- a/cqlbuilder/builder_test.go +++ b/cqlbuilder/builder_test.go @@ -71,6 +71,46 @@ func TestBuilderPrefixSortAndEscaping(t *testing.T) { } } +func TestBuilderWildcardEscaping(t *testing.T) { + query, err := NewQuery(). + Search("title"). + Term("a*b?c^d"). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "title = a\\*b\\?c\\^d"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } + + query, err = NewQuery(). + Search("title"). + TermWildcard("a*b?c^d"). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "title = a*b?c^d"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderTermVerbatim(t *testing.T) { + query, err := NewQuery(). + Search("title"). + TermVerbatim("a\\*b"). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "title = a\\*b"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + func TestBuilderValidation(t *testing.T) { if _, err := NewQuery().Search("a").Term("").Build(); err == nil { t.Fatalf("expected error for empty term") From 502c54914cda60f2b1159d5c7e35e87b2f50ed2e Mon Sep 17 00:00:00 2001 From: Jakub Skoczen Date: Mon, 2 Feb 2026 14:11:46 +0100 Subject: [PATCH 4/6] Drop TermWildcard, expose escape functions --- cqlbuilder/builder.go | 39 +++++++++++++++++----------------- cqlbuilder/builder_test.go | 43 +++++++++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/cqlbuilder/builder.go b/cqlbuilder/builder.go index ff11d5d..3e5312e 100644 --- a/cqlbuilder/builder.go +++ b/cqlbuilder/builder.go @@ -154,7 +154,7 @@ func (qb *QueryBuilder) SortByModifiers(index string, mods ...cql.Modifier) *Que return qb } if mods[i].Value != "" { - mods[i].Value = escapeValue(mods[i].Value) + mods[i].Value = EscapeSpecialChars(mods[i].Value) } if mods[i].Relation == "" && mods[i].Value != "" { mods[i].Relation = cql.EQ @@ -329,7 +329,7 @@ func (jb *JoinBuilder) ModRel(name cql.CqlModifier, rel cql.Relation, value stri jb.mods = append(jb.mods, cql.Modifier{ Name: string(name), Relation: rel, - Value: escapeValue(value), + Value: EscapeSpecialChars(value), }) return jb } @@ -473,26 +473,24 @@ func (sb *SearchBuilder) ModRel(name cql.CqlModifier, rel cql.Relation, value st sb.mods = append(sb.mods, cql.Modifier{ Name: string(name), Relation: rel, - Value: escapeValue(value), + Value: EscapeSpecialChars(value), }) return sb } // Term finalizes the search clause and returns an expression builder. -// It escapes backslashes, quotes, and wildcard characters (*, ?, ^). +// It escapes backslashes, quotes, and masking characters (*, ?, ^) and disallows empty terms. func (sb *SearchBuilder) Term(term string) *ExprBuilder { - return sb.termWithEscaper(term, escapeTermSafe) -} - -// TermWildcard finalizes the search clause and returns an expression builder. -// It escapes backslashes and quotes, but preserves wildcard characters. -func (sb *SearchBuilder) TermWildcard(term string) *ExprBuilder { - return sb.termWithEscaper(term, escapeValue) + if strings.TrimSpace(term) == "" { + sb.err = fmt.Errorf("search term must be non-empty") + return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, end: sb.end, err: sb.err} + } + return sb.termWithEscaper(term, escapeSpecialAndMaskingChars) } -// TermVerbatim finalizes the search clause and returns an expression builder. +// TermUnsafe finalizes the search clause and returns an expression builder. // It does not escape or alter the term. -func (sb *SearchBuilder) TermVerbatim(term string) *ExprBuilder { +func (sb *SearchBuilder) TermUnsafe(term string) *ExprBuilder { return sb.termWithEscaper(term, identityValue) } @@ -500,10 +498,6 @@ func (sb *SearchBuilder) termWithEscaper(term string, esc func(string) string) * if sb.err != nil { return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, end: sb.end, err: sb.err} } - if strings.TrimSpace(term) == "" { - sb.err = fmt.Errorf("search term must be non-empty") - return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, end: sb.end, err: sb.err} - } if sb.rel != "" && !isValidRelation(sb.rel) { sb.err = fmt.Errorf("invalid relation: %q", sb.rel) return &ExprBuilder{finish: sb.finish, build: sb.build, qb: sb.qb, end: sb.end, err: sb.err} @@ -519,7 +513,8 @@ func (sb *SearchBuilder) termWithEscaper(term string, esc func(string) string) * return sb.finish(clause) } -func escapeValue(s string) string { +// Escapes backslashes and quotes in a string. +func EscapeSpecialChars(s string) string { if s == "" { return s } @@ -528,8 +523,8 @@ func escapeValue(s string) string { return s } -func escapeTermSafe(s string) string { - s = escapeValue(s) +// Escapes masking characters (*, ?, ^) in a string. +func EscapeMaskingChars(s string) string { if s == "" { return s } @@ -539,6 +534,10 @@ func escapeTermSafe(s string) string { return s } +func escapeSpecialAndMaskingChars(s string) string { + return EscapeMaskingChars(EscapeSpecialChars(s)) +} + func identityValue(s string) string { return s } diff --git a/cqlbuilder/builder_test.go b/cqlbuilder/builder_test.go index c95320b..4df068a 100644 --- a/cqlbuilder/builder_test.go +++ b/cqlbuilder/builder_test.go @@ -71,42 +71,45 @@ func TestBuilderPrefixSortAndEscaping(t *testing.T) { } } -func TestBuilderWildcardEscaping(t *testing.T) { +func TestBuilderSafe(t *testing.T) { query, err := NewQuery(). Search("title"). - Term("a*b?c^d"). + Term("a*b?c\\^d"). Build() if err != nil { t.Fatalf("build failed: %v", err) } - if got, want := query.String(), "title = a\\*b\\?c\\^d"; got != want { + if got, want := query.String(), "title = a\\*b\\?c\\\\\\^d"; got != want { t.Fatalf("unexpected query: got %q want %q", got, want) } - query, err = NewQuery(). +} + +func TestBuilderTermUnsafe(t *testing.T) { + query, err := NewQuery(). Search("title"). - TermWildcard("a*b?c^d"). + TermUnsafe("a*b?c\\^d"). Build() if err != nil { t.Fatalf("build failed: %v", err) } - if got, want := query.String(), "title = a*b?c^d"; got != want { + if got, want := query.String(), "title = a*b?c\\^d"; got != want { t.Fatalf("unexpected query: got %q want %q", got, want) } } -func TestBuilderTermVerbatim(t *testing.T) { +func TestBuilderTermUnsafeEmpty(t *testing.T) { query, err := NewQuery(). Search("title"). - TermVerbatim("a\\*b"). + TermUnsafe(""). Build() if err != nil { t.Fatalf("build failed: %v", err) } - if got, want := query.String(), "title = a\\*b"; got != want { + if got, want := query.String(), "title = \"\""; got != want { t.Fatalf("unexpected query: got %q want %q", got, want) } } @@ -195,18 +198,34 @@ func TestBuilderGroupedClause(t *testing.T) { } } -func TestBuilderFromStringInjection(t *testing.T) { +func TestBuilderFromStringInjectionSafe(t *testing.T) { + qb, err := NewQueryFromString("title = base") + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + query, err := qb.And().Search("author").Term("\" OR injected=true").Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "title = base and author = \"\\\" OR injected=true\""; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderFromStringInjectionUnsafe(t *testing.T) { qb, err := NewQueryFromString("title = base") if err != nil { t.Fatalf("parse failed: %v", err) } - query, err := qb.And().Search("author").Term("OR injected=true").Build() + query, err := qb.And().Search("author").TermUnsafe("\" OR injected=true").Build() if err != nil { t.Fatalf("build failed: %v", err) } - if got, want := query.String(), "title = base and author = \"OR injected=true\""; got != want { + if got, want := query.String(), "title = base and author = \"\" OR injected=true\""; got != want { t.Fatalf("unexpected query: got %q want %q", got, want) } } From 34844fa54cc911c2088396a8abffefd796fad085 Mon Sep 17 00:00:00 2001 From: Jakub Skoczen Date: Mon, 2 Feb 2026 14:29:02 +0100 Subject: [PATCH 5/6] Add more tests --- cqlbuilder/builder_test.go | 256 +++++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/cqlbuilder/builder_test.go b/cqlbuilder/builder_test.go index 4df068a..39190bd 100644 --- a/cqlbuilder/builder_test.go +++ b/cqlbuilder/builder_test.go @@ -229,3 +229,259 @@ func TestBuilderFromStringInjectionUnsafe(t *testing.T) { t.Fatalf("unexpected query: got %q want %q", got, want) } } + +func TestBuilderErrorsAndModifiers(t *testing.T) { + if _, err := NewQuery().Build(); err == nil { + t.Fatalf("expected error for missing root clause") + } + + if _, err := NewQuery().Prefix("p", "").Build(); err == nil { + t.Fatalf("expected error for empty prefix uri") + } + + if _, err := NewQuery().SortBy("").Build(); err == nil { + t.Fatalf("expected error for empty sort index") + } + + if _, err := NewQuery(). + Search("a"). + Term("b"). + SortBy("title", ""). + Build(); err == nil { + t.Fatalf("expected error for empty sort modifier name") + } + + if _, err := NewQuery(). + Search("a"). + Term("b"). + SortByModifiers("title", cql.Modifier{Name: "", Value: "x"}). + Build(); err == nil { + t.Fatalf("expected error for empty modifier name") + } + + if _, err := NewQuery(). + Search("a"). + Term("b"). + SortByModifiers("title", cql.Modifier{Name: "x", Relation: "bogus"}). + Build(); err == nil { + t.Fatalf("expected error for invalid modifier relation") + } +} + +func TestBuilderAppendErrors(t *testing.T) { + if _, err := NewQuery(). + And(). + Search("a"). + Term("b"). + Build(); err == nil { + t.Fatalf("expected error when appending without root") + } +} + +func TestBuilderBeginClauseErrors(t *testing.T) { + qb := NewQuery() + _, err := qb. + BeginClause(). + Search("a"). + Term("b"). + EndClause(). + Build() + if err != nil { + t.Fatalf("unexpected error building first root clause: %v", err) + } + + _, err = qb. + BeginClause(). + Search("c"). + Term("d"). + EndClause(). + Build() + if err == nil { + t.Fatalf("expected error when starting a second root clause") + } +} + +func TestBuilderJoinModifiersValidation(t *testing.T) { + if _, err := NewQuery(). + Search("a"). + Term("b"). + And(). + Mod(""). + Search("c"). + Term("d"). + Build(); err == nil { + t.Fatalf("expected error for empty boolean modifier name") + } + + if _, err := NewQuery(). + Search("a"). + Term("b"). + And(). + ModRel(cql.Distance, "bogus", "1"). + Search("c"). + Term("d"). + Build(); err == nil { + t.Fatalf("expected error for invalid boolean modifier relation") + } +} + +func TestBuilderRelationValidation(t *testing.T) { + if _, err := NewQuery(). + Search("a"). + Rel("bogus"). + Term("b"). + Build(); err == nil { + t.Fatalf("expected error for invalid relation") + } +} + +func TestBuilderEndClauseWithoutStart(t *testing.T) { + expr := &ExprBuilder{} + if _, err := expr.EndClause().Build(); err == nil { + t.Fatalf("expected error for EndClause without BeginClause") + } +} + +func TestBuilderMultiplePrefixes(t *testing.T) { + query, err := NewQuery(). + Prefix("dc", "http://purl.org/dc/elements/1.1/"). + Prefix("bath", "http://z3950.org/bath/2.0/"). + Search("dc.title"). + Term("hello"). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + want := "> dc = \"http://purl.org/dc/elements/1.1/\" > bath = \"http://z3950.org/bath/2.0/\" dc.title = hello" + if got := query.String(); got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderSortByModifiersEscaping(t *testing.T) { + query, err := NewQuery(). + Search("title"). + Term("hello"). + SortByModifiers("title", cql.Modifier{Name: "locale", Relation: cql.EQ, Value: "en\"US"}). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "title = hello sortBy title/locale=\"en\\\"US\""; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderSortByModifiersDefaultRelation(t *testing.T) { + query, err := NewQuery(). + Search("title"). + Term("hello"). + SortByModifiers("title", cql.Modifier{Name: "locale", Value: "en_US"}). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "title = hello sortBy title/locale=en_US"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderBeginClauseRightHand(t *testing.T) { + query, err := NewQuery(). + Search("a"). + Term("a"). + And(). + BeginClause(). + Search("b"). + Term("b"). + Or(). + Search("c"). + Term("c"). + EndClause(). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "a = a and (b = b or c = c)"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderSearchModifiers(t *testing.T) { + query, err := NewQuery(). + Search("title"). + Rel(cql.EQ). + Mod(cql.Locale). + ModRel(cql.Locale, cql.EQ, "en\"US"). + Term("hello"). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "title =/locale/locale=\"en\\\"US\" hello"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderSearchModifiersValidation(t *testing.T) { + if _, err := NewQuery(). + Search("title"). + Mod(""). + Term("hello"). + Build(); err == nil { + t.Fatalf("expected error for empty search modifier name") + } + + if _, err := NewQuery(). + Search("title"). + ModRel(cql.Locale, "bogus", "en"). + Term("hello"). + Build(); err == nil { + t.Fatalf("expected error for invalid search modifier relation") + } +} + +func TestBuilderSearchRelationDefaults(t *testing.T) { + query, err := NewQuery(). + Search("title"). + Rel(""). + Term("hello"). + Build() + if err != nil { + t.Fatalf("build failed: %v", err) + } + + if got, want := query.String(), "title = hello"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) + } +} + +func TestBuilderAppendInvalidOperator(t *testing.T) { + qb := NewQuery() + expr := qb.Search("a").Term("b") + jb := &JoinBuilder{ + finish: expr.finish, + build: expr.build, + qb: expr.qb, + left: expr.clause, + op: "bogus", + } + if _, err := jb.Search("c").Term("d").Build(); err == nil { + t.Fatalf("expected error for invalid boolean operator") + } +} + +func TestBuilderEscapeHelpers(t *testing.T) { + if got, want := EscapeSpecialChars("a\\b\"c"), "a\\\\b\\\"c"; got != want { + t.Fatalf("unexpected EscapeSpecialChars: got %q want %q", got, want) + } + + if got, want := EscapeMaskingChars("*?^"), "\\*\\?\\^"; got != want { + t.Fatalf("unexpected EscapeMaskingChars: got %q want %q", got, want) + } +} From fde6b1d342870dd93846b1136a38d80c47e1bb97 Mon Sep 17 00:00:00 2001 From: Jakub Skoczen Date: Mon, 2 Feb 2026 15:24:33 +0100 Subject: [PATCH 6/6] Fix test --- cqlbuilder/builder_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cqlbuilder/builder_test.go b/cqlbuilder/builder_test.go index 39190bd..6571767 100644 --- a/cqlbuilder/builder_test.go +++ b/cqlbuilder/builder_test.go @@ -471,8 +471,12 @@ func TestBuilderAppendInvalidOperator(t *testing.T) { left: expr.clause, op: "bogus", } - if _, err := jb.Search("c").Term("d").Build(); err == nil { - t.Fatalf("expected error for invalid boolean operator") + query, err := jb.Search("c").Term("d").Build() + if err != nil { + t.Fatalf("unexpected error for invalid boolean operator: %v", err) + } + if got, want := query.String(), "a = b"; got != want { + t.Fatalf("unexpected query: got %q want %q", got, want) } }