From 64d6044284d2b5dce7585b0763c67cb3a3beeea8 Mon Sep 17 00:00:00 2001 From: Adam Dickmeiss Date: Thu, 19 Feb 2026 11:11:41 +0100 Subject: [PATCH 1/4] fulltext: support CQL any operator; quote args --- pgcql/pg_field_string.go | 17 +++++++++++------ pgcql/pgcql_test.go | 8 ++++---- pgcql/pgx_test.go | 3 +++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/pgcql/pg_field_string.go b/pgcql/pg_field_string.go index f9412d7..0fd9cce 100644 --- a/pgcql/pg_field_string.go +++ b/pgcql/pg_field_string.go @@ -148,13 +148,16 @@ func (f *FieldString) handleEmptyTerm(sc cql.SearchClause) string { return "" } -func (f *FieldString) generateFullText(sc cql.SearchClause, queryArgumentIndex int, pgfunc string) (string, []any, error) { - pgTerm, err := maskedExact(sc.Term) +func (f *FieldString) generateTsQuery(sc cql.SearchClause, termOp string, queryArgumentIndex int) (string, []any, error) { + pgTerms, err := maskedSplit(sc.Term, " ") if err != nil { return "", nil, err } - sql := "to_tsvector('" + f.language + "', " + f.column + ") @@ " + pgfunc + "('" + f.language + "', " + fmt.Sprintf("$%d", queryArgumentIndex) + ")" - return sql, []any{pgTerm}, nil + for i, v := range pgTerms { + pgTerms[i] = "'" + strings.ReplaceAll(v, "'", "''") + "'" + } + sql := "to_tsvector('" + f.language + "', " + f.column + ") @@ to_tsquery('" + f.language + "', " + fmt.Sprintf("$%d", queryArgumentIndex) + ")" + return sql, []any{strings.Join(pgTerms, termOp)}, nil } func (f *FieldString) generateIn(sc cql.SearchClause, queryArgumentIndex int, not bool) (string, []any, error) { @@ -188,9 +191,11 @@ func (f *FieldString) Generate(sc cql.SearchClause, queryArgumentIndex int) (str if fulltext { switch sc.Relation { case cql.ADJ, cql.EQ: - return f.generateFullText(sc, queryArgumentIndex, "phraseto_tsquery") + return f.generateTsQuery(sc, "<->", queryArgumentIndex) case cql.ALL: - return f.generateFullText(sc, queryArgumentIndex, "plainto_tsquery") + return f.generateTsQuery(sc, "&", queryArgumentIndex) + case cql.ANY: + return f.generateTsQuery(sc, "|", queryArgumentIndex) } } if f.enableSplit { diff --git a/pgcql/pgcql_test.go b/pgcql/pgcql_test.go index 7577897..114ed91 100644 --- a/pgcql/pgcql_test.go +++ b/pgcql/pgcql_test.go @@ -88,11 +88,11 @@ func TestParsing(t *testing.T) { {"title=\"a\\^\"", "Title = $1", []any{"a^"}}, {"title=\"a\\", "error: a CQL string must not end with a masking backslash", nil}, {"title=\"a\\x\"", "error: a masking backslash in a CQL string must be followed by *, ?, ^, \" or \\", nil}, - {"full = \"abc\"", "to_tsvector('english', full) @@ phraseto_tsquery('english', $1)", []any{"abc"}}, - {"full adj \"abc\"", "to_tsvector('english', full) @@ phraseto_tsquery('english', $1)", []any{"abc"}}, - {"full all \"abc\"", "to_tsvector('english', full) @@ plainto_tsquery('english', $1)", []any{"abc"}}, + {"full = \"abc\"", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'abc'"}}, + {"full adj \"a b\"", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'a'<->'b'"}}, + {"full all \"a b\"", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'a'&'b'"}}, + {"full any \"a b\"", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'a'|'b'"}}, {"full=\"a*\"", "error: masking op * unsupported", nil}, - {"full any x", "error: unsupported relation any", nil}, {"full > x", "error: unsupported relation >", nil}, {"price = 10", "price = $1", []any{10.0}}, {"price == 10", "price = $1", []any{10.0}}, diff --git a/pgcql/pgx_test.go b/pgcql/pgx_test.go index f4c6263..87622d0 100644 --- a/pgcql/pgx_test.go +++ b/pgcql/pgx_test.go @@ -183,6 +183,8 @@ func TestPgx(t *testing.T) { {"author adj \"d e knuth\"", []int{2}}, {"author adj \"e knuth\"", []int{1, 2}}, {"author adj \"e d knuth\"", []int{}}, + {"author any \"e f\"", []int{1, 2}}, + {"author adj \"e | f\"", []int{}}, {"city = \"Reading\"", []int{1}}, {"city = \"reading\"", []int{1}}, {"address = USA", []int{1, 2}}, @@ -192,6 +194,7 @@ func TestPgx(t *testing.T) { {"address = \"unknown country\"", []int{3}}, {"address = \"country unknown\"", []int{}}, {"address adj \"unknown country\"", []int{3}}, + {"address any \"unknown reading\"", []int{1, 3}}, } { runQuery(t, parser, conn, ctx, def, testcase.query, testcase.expectedIds) } From 73f15ea0d2540ed3f3f93273589792cb43100002 Mon Sep 17 00:00:00 2001 From: Adam Dickmeiss Date: Thu, 19 Feb 2026 12:02:13 +0100 Subject: [PATCH 2/4] Check in maskedSplit for empty term --- pgcql/pg_field_string.go | 4 +++- pgcql/pgcql_test.go | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pgcql/pg_field_string.go b/pgcql/pg_field_string.go index 0fd9cce..fcaf490 100644 --- a/pgcql/pg_field_string.go +++ b/pgcql/pg_field_string.go @@ -96,7 +96,9 @@ func maskedSplit(cqlTerm string, splitChars string) ([]string, error) { if backslash { return terms, fmt.Errorf("a CQL string must not end with a masking backslash") } - terms = append(terms, string(pgTerm)) + if len(pgTerm) > 0 { + terms = append(terms, string(pgTerm)) + } return terms, nil } diff --git a/pgcql/pgcql_test.go b/pgcql/pgcql_test.go index 114ed91..fcd3b49 100644 --- a/pgcql/pgcql_test.go +++ b/pgcql/pgcql_test.go @@ -57,7 +57,7 @@ func TestParsing(t *testing.T) { {"title exact 2", "Title = $1", []any{"2"}}, {"title<>2", "Title <> $1", []any{"2"}}, {"tag any \"1 23 45\"", "Tag IN($1, $2, $3)", []any{"1", "23", "45"}}, - {"tag <> \"1 23 45\"", "Tag NOT IN($1, $2, $3)", []any{"1", "23", "45"}}, + {"tag <> \" 1 23 45 \"", "Tag NOT IN($1, $2, $3)", []any{"1", "23", "45"}}, {"tag any \"*\"", "error: masking op * unsupported", nil}, {"a or b and c", "(T = $1 OR T = $2) AND T = $3", []any{"a", "b", "c"}}, {"title = abc", "Title = $1", []any{"abc"}}, @@ -89,6 +89,7 @@ func TestParsing(t *testing.T) { {"title=\"a\\", "error: a CQL string must not end with a masking backslash", nil}, {"title=\"a\\x\"", "error: a masking backslash in a CQL string must be followed by *, ?, ^, \" or \\", nil}, {"full = \"abc\"", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'abc'"}}, + {"full = \"abc \"", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'abc'"}}, {"full adj \"a b\"", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'a'<->'b'"}}, {"full all \"a b\"", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'a'&'b'"}}, {"full any \"a b\"", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'a'|'b'"}}, From 7594c62b243d9eaf5a5235382f6b34c5416be4c5 Mon Sep 17 00:00:00 2001 From: Adam Dickmeiss Date: Thu, 19 Feb 2026 12:03:33 +0100 Subject: [PATCH 3/4] unquoted term in test --- pgcql/pgcql_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pgcql/pgcql_test.go b/pgcql/pgcql_test.go index fcd3b49..07e0c84 100644 --- a/pgcql/pgcql_test.go +++ b/pgcql/pgcql_test.go @@ -88,6 +88,7 @@ func TestParsing(t *testing.T) { {"title=\"a\\^\"", "Title = $1", []any{"a^"}}, {"title=\"a\\", "error: a CQL string must not end with a masking backslash", nil}, {"title=\"a\\x\"", "error: a masking backslash in a CQL string must be followed by *, ?, ^, \" or \\", nil}, + {"full = abc", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'abc'"}}, {"full = \"abc\"", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'abc'"}}, {"full = \"abc \"", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'abc'"}}, {"full adj \"a b\"", "to_tsvector('english', full) @@ to_tsquery('english', $1)", []any{"'a'<->'b'"}}, From 5ad8032fcc513ddce6c6837b7c61a574dd93d485 Mon Sep 17 00:00:00 2001 From: Adam Dickmeiss Date: Thu, 19 Feb 2026 12:14:17 +0100 Subject: [PATCH 4/4] At least one term returned --- pgcql/pg_field_string.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pgcql/pg_field_string.go b/pgcql/pg_field_string.go index fcaf490..acd43ce 100644 --- a/pgcql/pg_field_string.go +++ b/pgcql/pg_field_string.go @@ -96,7 +96,7 @@ func maskedSplit(cqlTerm string, splitChars string) ([]string, error) { if backslash { return terms, fmt.Errorf("a CQL string must not end with a masking backslash") } - if len(pgTerm) > 0 { + if len(pgTerm) > 0 || len(terms) == 0 { terms = append(terms, string(pgTerm)) } return terms, nil