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
31 changes: 26 additions & 5 deletions cql/cql.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,34 @@ func (bc *BoolClause) String() string {
return sb.String()
}

const opsAndWhitespace = "()/<>= \t\r\n"

func quote(sb *strings.Builder, s string) {
if s == "" || strings.ContainsAny(s, " ()=<>\"/") {
sb.WriteString("\"")
sb.WriteString(s)
sb.WriteString("\"")
quote := s == ""
escaped := false
var term strings.Builder

for _, ch := range s {
if strings.ContainsRune(opsAndWhitespace, ch) {
quote = true
}
if ch == '"' && !escaped {
term.WriteByte('\\')
}
escaped = ch == '\\' && !escaped
term.WriteRune(ch)
}
if escaped {
// Trailing backslash: escape it.
term.WriteByte('\\')
}

if quote {
sb.WriteByte('"')
sb.WriteString(term.String())
sb.WriteByte('"')
} else {
sb.WriteString(s)
sb.WriteString(term.String())
}
}

Expand Down
53 changes: 53 additions & 0 deletions cql/quote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package cql

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestQuote(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "empty", in: "", want: "\"\""},
{name: "plain", in: "alpha", want: "alpha"},
{name: "whitespace", in: "two words", want: "\"two words\""},
{name: "operator", in: "a=b", want: "\"a=b\""},
{name: "quote_unquoted", in: "a\"b", want: "a\\\"b"},
{name: "quote_quoted", in: "a\" b", want: "\"a\\\" b\""},
{name: "trailing_backslash", in: "abc\\", want: "abc\\\\"},
{name: "escaped_quote", in: "a\\\"b", want: "a\\\"b"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q := &Query{
Clause: Clause{
BoolClause: &BoolClause{
Left: Clause{
SearchClause: &SearchClause{
Index: "idx",
Term: tt.in,
},
},
Operator: AND,
Right: Clause{
SearchClause: &SearchClause{
Index: "idx2",
Term: "val2",
},
},
},
},
}
expected := "idx = " + tt.want + " and idx2 = val2"
assert.Equal(t, expected, q.String(), "unexpected quoted term")
parsed, err := (&Parser{}).Parse(expected)
assert.NoError(t, err, "unexpected parse error for query %q", expected)
assert.Equal(t, expected, parsed.String(), "unexpected re-serialized query")
})
}
}
5 changes: 3 additions & 2 deletions cqlbuilder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ 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 masking characters (*, ?, ^) and disallows empty terms.
// It escapes input backslashes, quotes, and masking characters (*, ?, ^) and disallows empty terms.
func (sb *SearchBuilder) Term(term string) *ExprBuilder {
if strings.TrimSpace(term) == "" {
sb.err = fmt.Errorf("search term must be non-empty")
Expand All @@ -492,7 +492,8 @@ func (sb *SearchBuilder) Term(term string) *ExprBuilder {
}

// TermUnsafe finalizes the search clause and returns an expression builder.
// It does not escape or alter the term.
// It does not escape any input chars, but unescaped quotes and trailing backslash
// are always escaped when the query is stringified to ensure valid query syntax.
func (sb *SearchBuilder) TermUnsafe(term string) *ExprBuilder {
return sb.termWithEscaper(term, identityValue)
}
Expand Down
14 changes: 7 additions & 7 deletions cqlbuilder/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,19 @@ func TestBuilderPrefixSortAndEscaping(t *testing.T) {
func TestBuilderSafe(t *testing.T) {
query, err := NewQuery().
Search("title").
Term("a*b?c\\^d").
Term("a*b?c\\^d\"\\").
Build()
assert.NoError(t, err, "build failed")
assert.Equal(t, "title = a\\*b\\?c\\\\\\^d", query.String(), "unexpected query string")
assert.Equal(t, "title = a\\*b\\?c\\\\\\^d\\\"\\\\", query.String(), "unexpected query string")
}

func TestBuilderTermUnsafe(t *testing.T) {
query, err := NewQuery().
Search("title").
TermUnsafe("a*b?c\\^d").
TermUnsafe("a*b?c\\^d\"\\").
Build()
assert.NoError(t, err, "build failed")
assert.Equal(t, "title = a*b?c\\^d", query.String(), "unexpected query string")
assert.Equal(t, "title = a*b?c\\^d\\\"\\\\", query.String(), "unexpected query string")
}

func TestBuilderTermUnsafeEmpty(t *testing.T) {
Expand Down Expand Up @@ -181,7 +181,7 @@ func TestBuilderFromStringInjectionUnsafe(t *testing.T) {

query, err := qb.And().Search("author").TermUnsafe("\" OR injected=true").Build()
assert.NoError(t, err, "build failed")
assert.Equal(t, "title = base and author = \"\" OR injected=true\"", query.String(), "unexpected query string")
assert.Equal(t, "title = base and author = \"\\\" OR injected=true\"", query.String(), "unexpected query string")
}

func TestBuilderErrorsAndModifiers(t *testing.T) {
Expand Down Expand Up @@ -333,7 +333,7 @@ func TestBuilderSortByModifiersEscaping(t *testing.T) {
SortByModifiers("title", cql.Modifier{Name: "locale", Relation: cql.EQ, Value: "en\"US"}).
Build()
assert.NoError(t, err, "build failed")
assert.Equal(t, "title = hello sortBy title/locale=\"en\\\"US\"", query.String(), "unexpected query string")
assert.Equal(t, "title = hello sortBy title/locale=en\\\"US", query.String(), "unexpected query string")
}

func TestBuilderSortByModifiersDefaultRelation(t *testing.T) {
Expand Down Expand Up @@ -374,7 +374,7 @@ func TestBuilderSearchModifiers(t *testing.T) {
Build()

assert.NoError(t, err, "build failed")
assert.Equal(t, "title =/locale/locale=\"en\\\"US\" hello", query.String(), "unexpected query string")
assert.Equal(t, "title =/locale/locale=en\\\"US hello", query.String(), "unexpected query string")
}

func TestBuilderSearchModifiersValidation(t *testing.T) {
Expand Down