Compare commits

...

3 Commits

Author SHA1 Message Date
Pijus Kamandulis 05e8cd2842 Implement REGEXMATCH function (#15)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-30 21:31:45 +03:00
Pijus Kamandulis c3726a6633 Fix ARRAY_CONTAINS panic when optional partial-match argument is omitted (#14)
* Fix ARRAY_CONTAINS panic when partial match arg is omitted

The NoSQL parser always emits a third (nil) argument for the optional
partial-match flag of ARRAY_CONTAINS. The executor checked only
len(arguments) > 2 before type-asserting arguments[2] to
parsers.SelectItem, which panicked on the nil value whenever the query
omitted the partial-match argument (e.g. ARRAY_CONTAINS(c.arr, 2)).

Guard the type assertion with a nil check and add an API test covering
ARRAY_CONTAINS with and without the optional partial-match argument.

Co-authored-by: Pijus Kamandulis <pikami@users.noreply.github.com>

* Remove comments from ARRAY_CONTAINS API test

Co-authored-by: Pijus Kamandulis <pikami@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-30 17:43:05 +03:00
Pijus Kamandulis d76cc88175 Fix 'NOT (bool)' statements 2026-04-04 15:04:42 +03:00
11 changed files with 1764 additions and 1417 deletions
@@ -0,0 +1,47 @@
package tests_test
import (
"testing"
"github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos"
)
func Test_Documents_ArrayContains(t *testing.T) {
presets := []testPreset{PresetJsonStore, PresetBadgerStore}
runTestsWithPresets(t, "Test_Documents_ArrayContains", presets, func(t *testing.T, ts *TestServer, client *azcosmos.Client) {
collectionClient := documents_InitializeDb(t, ts)
t.Run("Should execute ARRAY_CONTAINS() without partial match argument", func(t *testing.T) {
testCosmosQuery(t, collectionClient,
`SELECT VALUE ARRAY_CONTAINS(["apple", "banana", "cherry"], "banana") FROM c ORDER BY c.id`,
nil,
[]interface{}{true, true},
)
})
t.Run("Should execute ARRAY_CONTAINS() returning false for missing item", func(t *testing.T) {
testCosmosQuery(t, collectionClient,
`SELECT VALUE ARRAY_CONTAINS(["apple", "banana", "cherry"], "grape") FROM c ORDER BY c.id`,
nil,
[]interface{}{false, false},
)
})
t.Run("Should execute ARRAY_CONTAINS() with object full match", func(t *testing.T) {
testCosmosQuery(t, collectionClient,
`SELECT VALUE ARRAY_CONTAINS([{"name": "apple", "color": "red"}], {"name": "apple"}) FROM c ORDER BY c.id`,
nil,
[]interface{}{false, false},
)
})
t.Run("Should execute ARRAY_CONTAINS() with object partial match", func(t *testing.T) {
testCosmosQuery(t, collectionClient,
`SELECT VALUE ARRAY_CONTAINS([{"name": "apple", "color": "red"}], {"name": "apple"}, true) FROM c ORDER BY c.id`,
nil,
[]interface{}{true, true},
)
})
})
}
@@ -0,0 +1,73 @@
package tests_test
import (
"fmt"
"testing"
"github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos"
"github.com/pikami/cosmium/api/config"
"github.com/pikami/cosmium/internal/datastore"
"github.com/stretchr/testify/assert"
)
func documents_InitializeSingleDocumentDb(t *testing.T, ts *TestServer) *azcosmos.ContainerClient {
ts.DataStore.CreateDatabase(datastore.Database{ID: testDatabaseName})
ts.DataStore.CreateCollection(testDatabaseName, datastore.Collection{
ID: testCollectionName,
PartitionKey: struct {
Paths []string "json:\"paths\""
Kind string "json:\"kind\""
Version int "json:\"Version\""
}{
Paths: []string{"/pk"},
},
})
ts.DataStore.CreateDocument(testDatabaseName, testCollectionName, map[string]interface{}{"id": "regexmatch-test", "pk": "regexmatch-test"})
client, err := azcosmos.NewClientFromConnectionString(
fmt.Sprintf("AccountEndpoint=%s;AccountKey=%s", ts.URL, config.DefaultAccountKey),
&azcosmos.ClientOptions{},
)
assert.Nil(t, err)
collectionClient, err := client.NewContainer(testDatabaseName, testCollectionName)
assert.Nil(t, err)
return collectionClient
}
func Test_Documents_RegexMatch(t *testing.T) {
presets := []testPreset{PresetJsonStore, PresetBadgerStore}
runTestsWithPresets(t, "Test_Documents_RegexMatch", presets, func(t *testing.T, ts *TestServer, client *azcosmos.Client) {
collectionClient := documents_InitializeSingleDocumentDb(t, ts)
t.Run("Should execute REGEXMATCH()", func(t *testing.T) {
testCosmosQuery(t, collectionClient,
`SELECT VALUE {
noModifiers: REGEXMATCH("abcd", "ABC"),
caseInsensitive: REGEXMATCH("abcd", "ABC", "i"),
wildcardCharacter: REGEXMATCH("abcd", "ab.", ""),
ignoreWhiteSpace: REGEXMATCH("abcd", "ab c", "x"),
caseInsensitiveAndIgnoreWhiteSpace: REGEXMATCH("abcd", "aB c", "ix"),
containNumberBetweenZeroAndNine: REGEXMATCH("03a", "[0-9]"),
containPrefix: REGEXMATCH("salt3824908", "salt{1}"),
containsFiveLetterWordStartingWithS: REGEXMATCH("shame", "s....", "i")
}`,
nil,
[]interface{}{
map[string]interface{}{
"noModifiers": false,
"caseInsensitive": true,
"wildcardCharacter": true,
"ignoreWhiteSpace": true,
"caseInsensitiveAndIgnoreWhiteSpace": true,
"containNumberBetweenZeroAndNine": true,
"containPrefix": true,
"containsFiveLetterWordStartingWithS": true,
},
},
)
})
})
}
+1
View File
@@ -107,6 +107,7 @@ const (
FunctionCallContains FunctionCallType = "Contains"
FunctionCallEndsWith FunctionCallType = "EndsWith"
FunctionCallStartsWith FunctionCallType = "StartsWith"
FunctionCallRegexMatch FunctionCallType = "RegexMatch"
FunctionCallIndexOf FunctionCallType = "IndexOf"
FunctionCallToString FunctionCallType = "ToString"
FunctionCallUpper FunctionCallType = "Upper"
+28
View File
@@ -143,6 +143,34 @@ func Test_Parse(t *testing.T) {
)
})
t.Run("Should parse NOT with parentheses", func(t *testing.T) {
testQueryParse(
t,
`SELECT c.id FROM c WHERE NOT (c.id IN ("123", "456"))`,
parsers.SelectStmt{
SelectItems: []parsers.SelectItem{
{Path: []string{"c", "id"}, Type: parsers.SelectItemTypeField},
},
Table: parsers.Table{SelectItem: testutils.SelectItem_Path("c")},
Filters: parsers.SelectItem{
Type: parsers.SelectItemTypeFunctionCall,
Invert: true,
Value: parsers.FunctionCall{
Type: parsers.FunctionCallIn,
Arguments: []interface{}{
parsers.SelectItem{
Path: []string{"c", "id"},
Type: parsers.SelectItemTypeField,
},
testutils.SelectItem_Constant_String("123"),
testutils.SelectItem_Constant_String("456"),
},
},
},
},
)
})
t.Run("Should parse IN function with function call", func(t *testing.T) {
testQueryParse(
t,
+1448 -1414
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -425,7 +425,15 @@ MulDivExpression <- left:SelectItemWithParentheses operations:(ws op:MultiplyOrD
return makeMathExpression(left, operations)
}
SelectItemWithParentheses <- "(" ws ex:OrExpression ws ")" { return ex, nil }
SelectItemWithParentheses <- inv:(Not ws)? "(" ws ex:OrExpression ws ")" {
if inv != nil {
if ex1, ok := ex.(parsers.SelectItem); ok {
ex1.Invert = true
return ex1, nil
}
}
return ex, nil
}
/ inv:(Not ws)? ex:SelectItem {
if inv != nil {
ex1 := ex.(parsers.SelectItem)
@@ -673,6 +681,8 @@ ThreeArgumentStringFunctionExpression <- function:ThreeArgumentStringFunction ws
functionType = parsers.FunctionCallEndsWith
case "STARTSWITH":
functionType = parsers.FunctionCallStartsWith
case "REGEXMATCH":
functionType = parsers.FunctionCallRegexMatch
case "INDEX_OF":
functionType = parsers.FunctionCallIndexOf
}
@@ -680,7 +690,7 @@ ThreeArgumentStringFunctionExpression <- function:ThreeArgumentStringFunction ws
return createFunctionCall(functionType, []interface{}{ex1, ex2, ignoreCase})
}
ThreeArgumentStringFunction <- ("CONTAINS"i / "ENDSWITH"i / "STARTSWITH"i / "INDEX_OF"i) {
ThreeArgumentStringFunction <- ("CONTAINS"i / "ENDSWITH"i / "STARTSWITH"i / "REGEXMATCH"i / "INDEX_OF"i) {
return string(c.text), nil
}
+26
View File
@@ -168,6 +168,32 @@ func Test_Execute_StringFunctions(t *testing.T) {
)
})
t.Run("Should parse function REGEXMATCH()", func(t *testing.T) {
testQueryParse(
t,
`SELECT REGEXMATCH(c.id, "aB c", "ix") FROM c`,
parsers.SelectStmt{
SelectItems: []parsers.SelectItem{
{
Type: parsers.SelectItemTypeFunctionCall,
Value: parsers.FunctionCall{
Type: parsers.FunctionCallRegexMatch,
Arguments: []interface{}{
parsers.SelectItem{
Path: []string{"c", "id"},
Type: parsers.SelectItemTypeField,
},
testutils.SelectItem_Constant_String("aB c"),
testutils.SelectItem_Constant_String("ix"),
},
},
},
},
Table: parsers.Table{SelectItem: testutils.SelectItem_Path("c")},
},
)
})
t.Run("Should parse function INDEX_OF()", func(t *testing.T) {
testQueryParse(
t,
@@ -25,7 +25,7 @@ func (r rowContext) array_Contains(arguments []interface{}) bool {
exprToSearch := r.resolveSelectItem(arguments[1].(parsers.SelectItem))
partialSearch := false
if len(arguments) > 2 {
if len(arguments) > 2 && arguments[2] != nil {
boolExpr := r.resolveSelectItem(arguments[2].(parsers.SelectItem))
if boolValue, ok := boolExpr.(bool); ok {
partialSearch = boolValue
@@ -162,6 +162,8 @@ func (r rowContext) selectItem_SelectItemTypeFunctionCall(functionCall parsers.F
return r.strings_EndsWith(functionCall.Arguments)
case parsers.FunctionCallStartsWith:
return r.strings_StartsWith(functionCall.Arguments)
case parsers.FunctionCallRegexMatch:
return r.strings_RegexMatch(functionCall.Arguments)
case parsers.FunctionCallConcat:
return r.strings_Concat(functionCall.Arguments)
case parsers.FunctionCallIndexOf:
@@ -2,6 +2,7 @@ package memoryexecutor
import (
"fmt"
"regexp"
"strings"
"github.com/pikami/cosmium/internal/logger"
@@ -75,6 +76,46 @@ func (r rowContext) strings_StartsWith(arguments []interface{}) bool {
return strings.HasPrefix(str1, str2)
}
func (r rowContext) strings_RegexMatch(arguments []interface{}) bool {
value, valueOk := r.parseString(arguments[0])
pattern, patternOk := r.parseString(arguments[1])
if !valueOk || !patternOk {
return false
}
modifiers, ok := r.getStringFlag(arguments)
if !ok {
return false
}
regexPattern := pattern
if strings.Contains(modifiers, "x") {
regexPattern = stripRegexIgnoredWhitespace(regexPattern)
}
var flags strings.Builder
if strings.Contains(modifiers, "i") {
flags.WriteByte('i')
}
if strings.Contains(modifiers, "m") {
flags.WriteByte('m')
}
if strings.Contains(modifiers, "s") {
flags.WriteByte('s')
}
if flags.Len() > 0 {
regexPattern = "(?" + flags.String() + ")" + regexPattern
}
matched, err := regexp.MatchString(regexPattern, value)
if err != nil {
logger.Errorf("strings_RegexMatch - invalid pattern %q: %v", pattern, err)
return false
}
return matched
}
func (r rowContext) strings_Concat(arguments []interface{}) string {
result := ""
@@ -318,6 +359,20 @@ func (r rowContext) getBoolFlag(arguments []interface{}) bool {
return ignoreCase
}
func (r rowContext) getStringFlag(arguments []interface{}) (string, bool) {
if len(arguments) <= 2 || arguments[2] == nil {
return "", true
}
flagItem := arguments[2].(parsers.SelectItem)
if value, ok := r.resolveSelectItem(flagItem).(string); ok {
return value, true
}
logger.ErrorLn("getStringFlag - got parameters of wrong type")
return "", false
}
func (r rowContext) parseString(argument interface{}) (value string, ok bool) {
exItem := argument.(parsers.SelectItem)
ex := r.resolveSelectItem(exItem)
@@ -329,6 +384,41 @@ func (r rowContext) parseString(argument interface{}) (value string, ok bool) {
return "", false
}
func stripRegexIgnoredWhitespace(pattern string) string {
var result strings.Builder
inCharClass := false
escaped := false
for _, r := range pattern {
if escaped {
result.WriteRune(r)
escaped = false
continue
}
if r == '\\' {
result.WriteRune(r)
escaped = true
continue
}
switch r {
case '[':
inCharClass = true
case ']':
inCharClass = false
}
if !inCharClass && (r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f') {
continue
}
result.WriteRune(r)
}
return result.String()
}
func convertToString(value interface{}) string {
switch v := value.(type) {
case string:
@@ -231,6 +231,42 @@ func Test_Execute_StringFunctions(t *testing.T) {
)
})
t.Run("Should execute function REGEXMATCH()", func(t *testing.T) {
testQueryExecute(
t,
parsers.SelectStmt{
SelectItems: []parsers.SelectItem{
{
Path: []string{"c", "id"},
Type: parsers.SelectItemTypeField,
},
{
Alias: "regexMatch",
Type: parsers.SelectItemTypeFunctionCall,
Value: parsers.FunctionCall{
Type: parsers.FunctionCallRegexMatch,
Arguments: []interface{}{
parsers.SelectItem{
Path: []string{"c", "str"},
Type: parsers.SelectItemTypeField,
},
testutils.SelectItem_Constant_String("COOL WORLD"),
testutils.SelectItem_Constant_String("i"),
},
},
},
},
Table: parsers.Table{SelectItem: testutils.SelectItem_Path("c")},
},
mockData,
[]memoryexecutor.RowType{
map[string]interface{}{"id": "123", "regexMatch": false},
map[string]interface{}{"id": "456", "regexMatch": false},
map[string]interface{}{"id": "789", "regexMatch": true},
},
)
})
t.Run("Should execute function INDEX_OF()", func(t *testing.T) {
testQueryExecute(
t,