From b45ad8894b6d15b052dd3203ae2287682581db7f Mon Sep 17 00:00:00 2001 From: Fred Carle Date: Tue, 5 Mar 2024 16:42:35 -0500 Subject: [PATCH] feat: Add case insensitive `like` operator (#2368) ## Relevant issue(s) Resolves #2367 ## Description This PR adds support for case insensitive like and not-like operators. This was requested by a partner. --- connor/connor.go | 4 + connor/ilike.go | 30 +++ connor/ilike_test.go | 41 +++ connor/like.go | 6 +- connor/nilike.go | 12 + connor/nilike_test.go | 41 +++ connor/nlike_test.go | 41 +++ db/fetcher/indexer_iterators.go | 56 ++-- request/graphql/schema/types/base.go | 16 ++ request/graphql/schema/types/descriptions.go | 10 + ...y_with_composite_index_field_order_test.go | 242 ++++++++++++++++++ .../query_with_index_combined_filter_test.go | 40 +++ ...with_unique_composite_index_filter_test.go | 39 +++ ...uery_with_unique_index_only_filter_test.go | 42 +++ .../query/one_to_many/with_filter_test.go | 95 +++++++ .../with_filter/with_like_string_test.go | 120 +++++++++ .../with_filter/with_nlike_string_test.go | 120 +++++++++ .../schema/aggregates/inline_array_test.go | 24 ++ 18 files changed, 955 insertions(+), 24 deletions(-) create mode 100644 connor/ilike.go create mode 100644 connor/ilike_test.go create mode 100644 connor/nilike.go create mode 100644 connor/nilike_test.go create mode 100644 connor/nlike_test.go diff --git a/connor/connor.go b/connor/connor.go index 4b174bc45c..927b1dfffd 100644 --- a/connor/connor.go +++ b/connor/connor.go @@ -40,6 +40,10 @@ func matchWith(op string, conditions, data any) (bool, error) { return like(conditions, data) case "_nlike": return nlike(conditions, data) + case "_ilike": + return ilike(conditions, data) + case "_nilike": + return nilike(conditions, data) case "_not": return not(conditions, data) default: diff --git a/connor/ilike.go b/connor/ilike.go new file mode 100644 index 0000000000..84181affb9 --- /dev/null +++ b/connor/ilike.go @@ -0,0 +1,30 @@ +package connor + +import ( + "strings" + + "github.com/sourcenetwork/immutable" + + "github.com/sourcenetwork/defradb/client" +) + +// ilike is an operator which performs case insensitive string equality tests. +func ilike(condition, data any) (bool, error) { + switch d := data.(type) { + case immutable.Option[string]: + if !d.HasValue() { + return condition == nil, nil + } + data = d.Value() + } + + switch cn := condition.(type) { + case string: + if d, ok := data.(string); ok { + return like(strings.ToLower(cn), strings.ToLower(d)) + } + return false, nil + default: + return false, client.NewErrUnhandledType("condition", cn) + } +} diff --git a/connor/ilike_test.go b/connor/ilike_test.go new file mode 100644 index 0000000000..cf9b38c40e --- /dev/null +++ b/connor/ilike_test.go @@ -0,0 +1,41 @@ +package connor + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestILike(t *testing.T) { + const testString = "Source Is The Glue of Web3" + + // case insensitive exact match + result, err := ilike("source is the glue of web3", testString) + require.NoError(t, err) + require.True(t, result) + + // case insensitive no match + result, err = ilike("source is the glue", testString) + require.NoError(t, err) + require.False(t, result) + + // case insensitive match prefix + result, err = ilike("source%", testString) + require.NoError(t, err) + require.True(t, result) + + // case insensitive match suffix + result, err = ilike("%web3", testString) + require.NoError(t, err) + require.True(t, result) + + // case insensitive match contains + result, err = ilike("%glue%", testString) + require.NoError(t, err) + require.True(t, result) + + // case insensitive match start and end with + result, err = ilike("source%web3", testString) + require.NoError(t, err) + require.True(t, result) +} diff --git a/connor/like.go b/connor/like.go index 0b1903eea0..63b8de5289 100644 --- a/connor/like.go +++ b/connor/like.go @@ -11,12 +11,12 @@ import ( // like is an operator which performs string equality // tests. func like(condition, data any) (bool, error) { - switch arr := data.(type) { + switch d := data.(type) { case immutable.Option[string]: - if !arr.HasValue() { + if !d.HasValue() { return condition == nil, nil } - data = arr.Value() + data = d.Value() } switch cn := condition.(type) { diff --git a/connor/nilike.go b/connor/nilike.go new file mode 100644 index 0000000000..be45d958ac --- /dev/null +++ b/connor/nilike.go @@ -0,0 +1,12 @@ +package connor + +// nilike performs case insensitive string inequality comparisons by inverting +// the result of the Like operator for non-error cases. +func nilike(conditions, data any) (bool, error) { + m, err := ilike(conditions, data) + if err != nil { + return false, err + } + + return !m, err +} diff --git a/connor/nilike_test.go b/connor/nilike_test.go new file mode 100644 index 0000000000..aa1a1350d0 --- /dev/null +++ b/connor/nilike_test.go @@ -0,0 +1,41 @@ +package connor + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNILike(t *testing.T) { + const testString = "Source Is The Glue of Web3" + + // case insensitive exact match + result, err := nilike("source is the glue of web3", testString) + require.NoError(t, err) + require.False(t, result) + + // case insensitive no match + result, err = nilike("source is the glue", testString) + require.NoError(t, err) + require.True(t, result) + + // case insensitive match prefix + result, err = nilike("source%", testString) + require.NoError(t, err) + require.False(t, result) + + // case insensitive match suffix + result, err = nilike("%web3", testString) + require.NoError(t, err) + require.False(t, result) + + // case insensitive match contains + result, err = nilike("%glue%", testString) + require.NoError(t, err) + require.False(t, result) + + // case insensitive match start and end with + result, err = nilike("source%web3", testString) + require.NoError(t, err) + require.False(t, result) +} diff --git a/connor/nlike_test.go b/connor/nlike_test.go new file mode 100644 index 0000000000..ae19bc5494 --- /dev/null +++ b/connor/nlike_test.go @@ -0,0 +1,41 @@ +package connor + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNLike(t *testing.T) { + const testString = "Source is the glue of web3" + + // exact match + result, err := nlike(testString, testString) + require.NoError(t, err) + require.False(t, result) + + // exact match error + result, err = nlike("Source is the glue", testString) + require.NoError(t, err) + require.True(t, result) + + // match prefix + result, err = nlike("Source%", testString) + require.NoError(t, err) + require.False(t, result) + + // match suffix + result, err = nlike("%web3", testString) + require.NoError(t, err) + require.False(t, result) + + // match contains + result, err = nlike("%glue%", testString) + require.NoError(t, err) + require.False(t, result) + + // match start and end with + result, err = nlike("Source%web3", testString) + require.NoError(t, err) + require.False(t, result) +} diff --git a/db/fetcher/indexer_iterators.go b/db/fetcher/indexer_iterators.go index b79e3bc9d7..482c15d31a 100644 --- a/db/fetcher/indexer_iterators.go +++ b/db/fetcher/indexer_iterators.go @@ -28,16 +28,18 @@ import ( ) const ( - opEq = "_eq" - opGt = "_gt" - opGe = "_ge" - opLt = "_lt" - opLe = "_le" - opNe = "_ne" - opIn = "_in" - opNin = "_nin" - opLike = "_like" - opNlike = "_nlike" + opEq = "_eq" + opGt = "_gt" + opGe = "_ge" + opLt = "_lt" + opLe = "_le" + opNe = "_ne" + opIn = "_in" + opNin = "_nin" + opLike = "_like" + opNlike = "_nlike" + opILike = "_ilike" + opNILike = "_nilike" // it's just there for composite indexes. We construct a slice of value matchers with // every matcher being responsible for a corresponding field in the index to match. // For some fields there might not be any criteria to match. For examples if you have @@ -382,16 +384,18 @@ func (m *indexInArrayMatcher) Match(value any) (bool, error) { // checks if the index value satisfies the LIKE condition type indexLikeMatcher struct { - hasPrefix bool - hasSuffix bool - startAndEnd []string - isLike bool - value string + hasPrefix bool + hasSuffix bool + startAndEnd []string + isLike bool + isCaseInsensitive bool + value string } -func newLikeIndexCmp(filterValue string, isLike bool) (*indexLikeMatcher, error) { +func newLikeIndexCmp(filterValue string, isLike bool, isCaseInsensitive bool) (*indexLikeMatcher, error) { matcher := &indexLikeMatcher{ - isLike: isLike, + isLike: isLike, + isCaseInsensitive: isCaseInsensitive, } if len(filterValue) >= 2 { if filterValue[0] == '%' { @@ -406,7 +410,11 @@ func newLikeIndexCmp(filterValue string, isLike bool) (*indexLikeMatcher, error) matcher.startAndEnd = strings.Split(filterValue, "%") } } - matcher.value = filterValue + if isCaseInsensitive { + matcher.value = strings.ToLower(filterValue) + } else { + matcher.value = filterValue + } return matcher, nil } @@ -417,6 +425,10 @@ func (m *indexLikeMatcher) Match(value any) (bool, error) { return false, NewErrUnexpectedTypeValue[string](currentVal) } + if m.isCaseInsensitive { + currentVal = strings.ToLower(currentVal) + } + return m.doesMatch(currentVal) == m.isLike, nil } @@ -571,7 +583,7 @@ func (f *IndexFetcher) createIndexIterator() (indexIterator, error) { } case opIn: return f.newInIndexIterator(fieldConditions, matchers) - case opGt, opGe, opLt, opLe, opNe, opNin, opLike, opNlike: + case opGt, opGe, opLt, opLe, opNe, opNin, opLike, opNlike, opILike, opNILike: return &scanningIndexIterator{ queryResultIterator: f.newQueryResultIterator(), indexKey: f.newIndexDataStoreKey(), @@ -627,12 +639,14 @@ func createValueMatcher(condition *fieldFilterCond) (valueMatcher, error) { return nil, ErrInvalidInOperatorValue } return newNinIndexCmp(inArr, condition.kind, condition.op == opIn) - case opLike, opNlike: + case opLike, opNlike, opILike, opNILike: strVal, ok := condition.val.(string) if !ok { return nil, NewErrUnexpectedTypeValue[string](condition.val) } - return newLikeIndexCmp(strVal, condition.op == opLike) + isLike := condition.op == opLike || condition.op == opILike + isCaseInsensitive := condition.op == opILike || condition.op == opNILike + return newLikeIndexCmp(strVal, isLike, isCaseInsensitive) case opAny: return &anyMatcher{}, nil } diff --git a/request/graphql/schema/types/base.go b/request/graphql/schema/types/base.go index b348a564f8..83aa11c55d 100644 --- a/request/graphql/schema/types/base.go +++ b/request/graphql/schema/types/base.go @@ -291,6 +291,14 @@ var StringOperatorBlock = gql.NewInputObject(gql.InputObjectConfig{ Description: nlikeStringOperatorDescription, Type: gql.String, }, + "_ilike": &gql.InputObjectFieldConfig{ + Description: ilikeStringOperatorDescription, + Type: gql.String, + }, + "_nilike": &gql.InputObjectFieldConfig{ + Description: nilikeStringOperatorDescription, + Type: gql.String, + }, }, }) @@ -323,6 +331,14 @@ var NotNullstringOperatorBlock = gql.NewInputObject(gql.InputObjectConfig{ Description: nlikeStringOperatorDescription, Type: gql.String, }, + "_ilike": &gql.InputObjectFieldConfig{ + Description: ilikeStringOperatorDescription, + Type: gql.String, + }, + "_nilike": &gql.InputObjectFieldConfig{ + Description: nilikeStringOperatorDescription, + Type: gql.String, + }, }, }) diff --git a/request/graphql/schema/types/descriptions.go b/request/graphql/schema/types/descriptions.go index ded07f81e6..27cd3a6f74 100644 --- a/request/graphql/schema/types/descriptions.go +++ b/request/graphql/schema/types/descriptions.go @@ -202,6 +202,16 @@ The like operator - if the target value contains the given sub-string the check The not-like operator - if the target value does not contain the given sub-string the check will pass. '%' characters may be used as wildcards, for example '_nlike: "%Ritchie"' would match on the string 'Quentin Tarantino'. +` + ilikeStringOperatorDescription string = ` +The case insensitive like operator - if the target value contains the given case insensitive sub-string the check + will pass. '%' characters may be used as wildcards, for example '_like: "%ritchie"' would match on strings + ending in 'Ritchie'. +` + nilikeStringOperatorDescription string = ` +The case insensitive not-like operator - if the target value does not contain the given case insensitive sub-string + the check will pass. '%' characters may be used as wildcards, for example '_nlike: "%ritchie"' would match on + the string 'Quentin Tarantino'. ` AndOperatorDescription string = ` The and operator - all checks within this clause must pass in order for this check to pass. diff --git a/tests/integration/index/query_with_composite_index_field_order_test.go b/tests/integration/index/query_with_composite_index_field_order_test.go index d3b1beee16..611bfed998 100644 --- a/tests/integration/index/query_with_composite_index_field_order_test.go +++ b/tests/integration/index/query_with_composite_index_field_order_test.go @@ -92,6 +92,82 @@ func TestQueryWithCompositeIndex_WithDefaultOrder_ShouldFetchInDefaultOrder(t *t testUtils.ExecuteTestCase(t, test) } +func TestQueryWithCompositeIndex_WithDefaultOrderCaseInsensitive_ShouldFetchInDefaultOrder(t *testing.T) { + test := testUtils.TestCase{ + Description: "Test composite index in default order and case insensitive operator", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type User @index(fields: ["name", "age"]) { + name: String + age: Int + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Alice", + "age": 22 + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Alan", + "age": 29 + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Alice", + "age": 38 + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Alice", + "age": 24 + }`, + }, + testUtils.Request{ + Request: ` + query { + User(filter: {name: {_ilike: "al%"}}) { + name + age + } + }`, + Results: []map[string]any{ + { + "name": "Alan", + "age": 29, + }, + { + "name": "Alice", + "age": 22, + }, + { + "name": "Alice", + "age": 24, + }, + { + "name": "Alice", + "age": 38, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + func TestQueryWithCompositeIndex_WithRevertedOrderOnFirstField_ShouldFetchInRevertedOrder(t *testing.T) { test := testUtils.TestCase{ Description: "Test composite index with reverted order on first field", @@ -180,6 +256,94 @@ func TestQueryWithCompositeIndex_WithRevertedOrderOnFirstField_ShouldFetchInReve testUtils.ExecuteTestCase(t, test) } +func TestQueryWithCompositeIndex_WithRevertedOrderOnFirstFieldCaseInsensitive_ShouldFetchInRevertedOrder(t *testing.T) { + test := testUtils.TestCase{ + Description: "Test composite index with reverted order on first field and case insensitive operator", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type User @index(fields: ["name", "age"], directions: [DESC, ASC]) { + name: String + age: Int + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Alice", + "age": 22 + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Alan", + "age": 29 + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Alice", + "age": 38 + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Andy", + "age": 24 + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Alice", + "age": 24 + }`, + }, + testUtils.Request{ + Request: ` + query { + User(filter: {name: {_ilike: "a%"}}) { + name + age + } + }`, + Results: []map[string]any{ + { + "name": "Andy", + "age": 24, + }, + { + "name": "Alice", + "age": 22, + }, + { + "name": "Alice", + "age": 24, + }, + { + "name": "Alice", + "age": 38, + }, + { + "name": "Alan", + "age": 29, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + func TestQueryWithCompositeIndex_WithRevertedOrderOnSecondField_ShouldFetchInRevertedOrder(t *testing.T) { test := testUtils.TestCase{ Description: "Test composite index with reverted order on second field", @@ -256,6 +420,84 @@ func TestQueryWithCompositeIndex_WithRevertedOrderOnSecondField_ShouldFetchInRev testUtils.ExecuteTestCase(t, test) } +func TestQueryWithCompositeIndex_WithRevertedOrderOnSecondFieldCaseInsensitive_ShouldFetchInRevertedOrder( + t *testing.T, +) { + test := testUtils.TestCase{ + Description: "Test composite index with reverted order on second field and case insensitive operator", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type User @index(fields: ["name", "age"], directions: [ASC, DESC]) { + name: String + age: Int + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Alice", + "age": 22 + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Alan", + "age": 29 + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Alice", + "age": 38 + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: ` + { + "name": "Alice", + "age": 24 + }`, + }, + testUtils.Request{ + Request: ` + query { + User(filter: {name: {_ilike: "al%"}}) { + name + age + } + }`, + Results: []map[string]any{ + { + "name": "Alan", + "age": 29, + }, + { + "name": "Alice", + "age": 38, + }, + { + "name": "Alice", + "age": 24, + }, + { + "name": "Alice", + "age": 22, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + func TestQueryWithCompositeIndex_IfExactMatchWithRevertedOrderOnFirstField_ShouldFetch(t *testing.T) { test := testUtils.TestCase{ Description: "Test composite index with reverted order on first field and filter with exact match", diff --git a/tests/integration/index/query_with_index_combined_filter_test.go b/tests/integration/index/query_with_index_combined_filter_test.go index c254f22589..595bf5fe44 100644 --- a/tests/integration/index/query_with_index_combined_filter_test.go +++ b/tests/integration/index/query_with_index_combined_filter_test.go @@ -94,6 +94,46 @@ func TestQueryWithIndex_IfMultipleIndexFiltersWithRegular_ShouldFilter(t *testin testUtils.ExecuteTestCase(t, test) } +func TestQueryWithIndex_IfMultipleIndexFiltersWithRegularCaseInsensitive_ShouldFilter(t *testing.T) { + req := `query { + User(filter: { + name: {_ilike: "a%"}, + age: {_gt: 30}, + }) { + name + } + }` + test := testUtils.TestCase{ + Description: "Combination of a filter on regular and of 2 indexed fields and case insensitive operator", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type User { + name: String @index + age: Int @index + email: String + }`, + }, + testUtils.CreatePredefinedDocs{ + Docs: getUserDocs(), + }, + testUtils.Request{ + Request: req, + Results: []map[string]any{ + {"name": "Andy"}, + {"name": "Addo"}, + }, + }, + testUtils.Request{ + Request: makeExplainQuery(req), + Asserter: testUtils.NewExplainAsserter().WithFieldFetches(6), + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + func TestQueryWithIndex_FilterOnNonIndexedField_ShouldIgnoreIndex(t *testing.T) { req := `query { User(filter: { diff --git a/tests/integration/index/query_with_unique_composite_index_filter_test.go b/tests/integration/index/query_with_unique_composite_index_filter_test.go index 8d37c141d4..52712bc181 100644 --- a/tests/integration/index/query_with_unique_composite_index_filter_test.go +++ b/tests/integration/index/query_with_unique_composite_index_filter_test.go @@ -749,6 +749,45 @@ func TestQueryWithUniqueCompositeIndex_WithNotLikeFilter_ShouldFetch(t *testing. testUtils.ExecuteTestCase(t, test) } +func TestQueryWithUniqueCompositeIndex_WithNotCaseInsensitiveLikeFilter_ShouldFetch(t *testing.T) { + req := `query { + User(filter: {name: {_nilike: "j%"}, email: {_nlike: "%d%"}}) { + name + } + }` + test := testUtils.TestCase{ + Description: "Test index filtering with _nilike and _nlike filter", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type User @index(unique: true, fields: ["name", "email"]) { + name: String + email: String + }`, + }, + testUtils.CreatePredefinedDocs{ + Docs: getUserDocs(), + }, + testUtils.Request{ + Request: req, + Results: []map[string]any{ + {"name": "Bruno"}, + {"name": "Chris"}, + {"name": "Islam"}, + {"name": "Keenan"}, + {"name": "Roy"}, + }, + }, + testUtils.Request{ + Request: makeExplainQuery(req), + Asserter: testUtils.NewExplainAsserter().WithFieldFetches(0).WithIndexFetches(10), + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + func TestQueryWithUniqueCompositeIndex_IfFirstFieldIsNotInFilter_ShouldNotUseIndex(t *testing.T) { test := testUtils.TestCase{ Description: "Test if index is not used when first field is not in filter", diff --git a/tests/integration/index/query_with_unique_index_only_filter_test.go b/tests/integration/index/query_with_unique_index_only_filter_test.go index c9bcd027a9..08f1b1b927 100644 --- a/tests/integration/index/query_with_unique_index_only_filter_test.go +++ b/tests/integration/index/query_with_unique_index_only_filter_test.go @@ -462,6 +462,48 @@ func TestQueryWithUniqueIndex_WithNotLikeFilter_ShouldFetch(t *testing.T) { testUtils.ExecuteTestCase(t, test) } +func TestQueryWithUniqueIndex_WithNotCaseInsensitiveLikeFilter_ShouldFetch(t *testing.T) { + req := `query { + User(filter: {name: {_nilike: "a%"}}) { + name + } + }` + test := testUtils.TestCase{ + Description: "Test index filtering with _nilike filter", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type User { + name: String @index(unique: true) + age: Int + }`, + }, + testUtils.CreatePredefinedDocs{ + Docs: getUserDocs(), + }, + testUtils.Request{ + Request: req, + Results: []map[string]any{ + {"name": "Bruno"}, + {"name": "Chris"}, + {"name": "Fred"}, + {"name": "Islam"}, + {"name": "John"}, + {"name": "Keenan"}, + {"name": "Roy"}, + {"name": "Shahzad"}, + }, + }, + testUtils.Request{ + Request: makeExplainQuery(req), + Asserter: testUtils.NewExplainAsserter().WithFieldFetches(0).WithIndexFetches(10), + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + func TestQueryWithUniqueIndex_IfNoMatch_ReturnEmptyResult(t *testing.T) { test := testUtils.TestCase{ Description: "If filter does not match any document, return empty result", diff --git a/tests/integration/query/one_to_many/with_filter_test.go b/tests/integration/query/one_to_many/with_filter_test.go index 405e345801..ce019f2afa 100644 --- a/tests/integration/query/one_to_many/with_filter_test.go +++ b/tests/integration/query/one_to_many/with_filter_test.go @@ -456,3 +456,98 @@ func TestQueryOneToManyWithCompoundOperatorInFilterAndRelation(t *testing.T) { } testUtils.ExecuteTestCase(t, test) } + +func TestQueryOneToMany_WithCompoundOperatorInFilterAndRelationAndCaseInsensitiveLike_NoError(t *testing.T) { + test := testUtils.TestCase{ + Description: "One-to-many relation query filter with compound operator and relation", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: bookAuthorGQLSchema, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: `{ + "name": "Painted House", + "rating": 4.9, + "author_id": "bae-41598f0c-19bc-5da6-813b-e80f14a10df3" + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: `{ + "name": "A Time for Mercy", + "rating": 4.5, + "author_id": "bae-41598f0c-19bc-5da6-813b-e80f14a10df3" + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: `{ + "name": "Theif Lord", + "rating": 4.8, + "author_id": "bae-b769708d-f552-5c3d-a402-ccfd7ac7fb04" + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: `{ + "name": "The Lord of the Rings", + "rating": 5.0, + "author_id": "bae-61d279c1-eab9-56ec-8654-dce0324ebfda" + }`, + }, + testUtils.CreateDoc{ + CollectionID: 1, + // bae-41598f0c-19bc-5da6-813b-e80f14a10df3 + Doc: `{ + "name": "John Grisham", + "age": 65, + "verified": true + }`, + }, + testUtils.CreateDoc{ + CollectionID: 1, + // bae-b769708d-f552-5c3d-a402-ccfd7ac7fb04 + Doc: `{ + "name": "Cornelia Funke", + "age": 62, + "verified": false + }`, + }, + testUtils.CreateDoc{ + CollectionID: 1, + // bae-61d279c1-eab9-56ec-8654-dce0324ebfda + Doc: `{ + "name": "John Tolkien", + "age": 70, + "verified": true + }`, + }, + testUtils.Request{ + Request: `query { + Author(filter: {_or: [ + {_and: [ + {published: {rating: {_lt: 5.0}}}, + {published: {rating: {_gt: 4.8}}} + ]}, + {_and: [ + {age: {_le: 65}}, + {published: {name: {_ilike: "%lord%"}}} + ]}, + ]}) { + name + } + }`, + Results: []map[string]any{ + { + "name": "John Grisham", + }, + { + "name": "Cornelia Funke", + }, + }, + }, + }, + } + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/query/simple/with_filter/with_like_string_test.go b/tests/integration/query/simple/with_filter/with_like_string_test.go index 00e53aed82..95ebabc5de 100644 --- a/tests/integration/query/simple/with_filter/with_like_string_test.go +++ b/tests/integration/query/simple/with_filter/with_like_string_test.go @@ -46,6 +46,36 @@ func TestQuerySimpleWithLikeStringContainsFilterBlockContainsString(t *testing.T executeTestCase(t, test) } +func TestQuerySimple_WithCaseInsensitiveLike_ShouldMatchString(t *testing.T) { + test := testUtils.RequestTestCase{ + Description: "Simple query with basic case insensitive like-string filter contains string", + Request: `query { + Users(filter: {Name: {_ilike: "%stormborn%"}}) { + Name + } + }`, + Docs: map[int][]string{ + 0: { + `{ + "Name": "Daenerys Stormborn of House Targaryen, the First of Her Name", + "HeightM": 1.65 + }`, + `{ + "Name": "Viserys I Targaryen, King of the Andals", + "HeightM": 1.82 + }`, + }, + }, + Results: []map[string]any{ + { + "Name": "Daenerys Stormborn of House Targaryen, the First of Her Name", + }, + }, + } + + executeTestCase(t, test) +} + func TestQuerySimpleWithLikeStringContainsFilterBlockAsPrefixString(t *testing.T) { test := testUtils.RequestTestCase{ Description: "Simple query with basic like-string filter with string as prefix", @@ -76,6 +106,36 @@ func TestQuerySimpleWithLikeStringContainsFilterBlockAsPrefixString(t *testing.T executeTestCase(t, test) } +func TestQuerySimple_WithCaseInsensitiveLikeString_ShouldMatchPrefixString(t *testing.T) { + test := testUtils.RequestTestCase{ + Description: "Simple query with basic case insensitive like-string filter with string as prefix", + Request: `query { + Users(filter: {Name: {_ilike: "viserys%"}}) { + Name + } + }`, + Docs: map[int][]string{ + 0: { + `{ + "Name": "Daenerys Stormborn of House Targaryen, the First of Her Name", + "HeightM": 1.65 + }`, + `{ + "Name": "Viserys I Targaryen, King of the Andals", + "HeightM": 1.82 + }`, + }, + }, + Results: []map[string]any{ + { + "Name": "Viserys I Targaryen, King of the Andals", + }, + }, + } + + executeTestCase(t, test) +} + func TestQuerySimpleWithLikeStringContainsFilterBlockAsSuffixString(t *testing.T) { test := testUtils.RequestTestCase{ Description: "Simple query with basic like-string filter with string as suffix", @@ -106,6 +166,36 @@ func TestQuerySimpleWithLikeStringContainsFilterBlockAsSuffixString(t *testing.T executeTestCase(t, test) } +func TestQuerySimple_WithCaseInsensitiveLikeString_ShouldMatchSuffixString(t *testing.T) { + test := testUtils.RequestTestCase{ + Description: "Simple query with basic case insensitive like-string filter with string as suffix", + Request: `query { + Users(filter: {Name: {_ilike: "%andals"}}) { + Name + } + }`, + Docs: map[int][]string{ + 0: { + `{ + "Name": "Daenerys Stormborn of House Targaryen, the First of Her Name", + "HeightM": 1.65 + }`, + `{ + "Name": "Viserys I Targaryen, King of the Andals", + "HeightM": 1.82 + }`, + }, + }, + Results: []map[string]any{ + { + "Name": "Viserys I Targaryen, King of the Andals", + }, + }, + } + + executeTestCase(t, test) +} + func TestQuerySimpleWithLikeStringContainsFilterBlockExactString(t *testing.T) { test := testUtils.RequestTestCase{ Description: "Simple query with basic like-string filter with string as suffix", @@ -136,6 +226,36 @@ func TestQuerySimpleWithLikeStringContainsFilterBlockExactString(t *testing.T) { executeTestCase(t, test) } +func TestQuerySimple_WithCaseInsensitiveLikeString_ShouldMatchExactString(t *testing.T) { + test := testUtils.RequestTestCase{ + Description: "Simple query with basic like-string filter with string as suffix", + Request: `query { + Users(filter: {Name: {_ilike: "daenerys stormborn of house targaryen, the first of her name"}}) { + Name + } + }`, + Docs: map[int][]string{ + 0: { + `{ + "Name": "Daenerys Stormborn of House Targaryen, the First of Her Name", + "HeightM": 1.65 + }`, + `{ + "Name": "Viserys I Targaryen, King of the Andals", + "HeightM": 1.82 + }`, + }, + }, + Results: []map[string]any{ + { + "Name": "Daenerys Stormborn of House Targaryen, the First of Her Name", + }, + }, + } + + executeTestCase(t, test) +} + func TestQuerySimpleWithLikeStringContainsFilterBlockContainsStringMuplitpleResults(t *testing.T) { test := testUtils.RequestTestCase{ Description: "Simple query with basic like-string filter with contains string multiple results", diff --git a/tests/integration/query/simple/with_filter/with_nlike_string_test.go b/tests/integration/query/simple/with_filter/with_nlike_string_test.go index e1e825abd2..a7ca84163d 100644 --- a/tests/integration/query/simple/with_filter/with_nlike_string_test.go +++ b/tests/integration/query/simple/with_filter/with_nlike_string_test.go @@ -46,6 +46,36 @@ func TestQuerySimpleWithNotLikeStringContainsFilterBlockContainsString(t *testin executeTestCase(t, test) } +func TestQuerySimple_WithNotCaseInsensitiveLikeString_ShouldMatchString(t *testing.T) { + test := testUtils.RequestTestCase{ + Description: "Simple query with basic not case insensitive like-string filter contains string", + Request: `query { + Users(filter: {Name: {_nilike: "%stormborn%"}}) { + Name + } + }`, + Docs: map[int][]string{ + 0: { + `{ + "Name": "Daenerys Stormborn of House Targaryen, the First of Her Name", + "HeightM": 1.65 + }`, + `{ + "Name": "Viserys I Targaryen, King of the Andals", + "HeightM": 1.82 + }`, + }, + }, + Results: []map[string]any{ + { + "Name": "Viserys I Targaryen, King of the Andals", + }, + }, + } + + executeTestCase(t, test) +} + func TestQuerySimpleWithNotLikeStringContainsFilterBlockAsPrefixString(t *testing.T) { test := testUtils.RequestTestCase{ Description: "Simple query with basic not like-string filter with string as prefix", @@ -76,6 +106,36 @@ func TestQuerySimpleWithNotLikeStringContainsFilterBlockAsPrefixString(t *testin executeTestCase(t, test) } +func TestQuerySimple_WithNotCaseInsensitiveLikeString_ShouldMatchPrefixString(t *testing.T) { + test := testUtils.RequestTestCase{ + Description: "Simple query with basic not case insensitive like-string filter with string as prefix", + Request: `query { + Users(filter: {Name: {_nilike: "viserys%"}}) { + Name + } + }`, + Docs: map[int][]string{ + 0: { + `{ + "Name": "Daenerys Stormborn of House Targaryen, the First of Her Name", + "HeightM": 1.65 + }`, + `{ + "Name": "Viserys I Targaryen, King of the Andals", + "HeightM": 1.82 + }`, + }, + }, + Results: []map[string]any{ + { + "Name": "Daenerys Stormborn of House Targaryen, the First of Her Name", + }, + }, + } + + executeTestCase(t, test) +} + func TestQuerySimpleWithNotLikeStringContainsFilterBlockAsSuffixString(t *testing.T) { test := testUtils.RequestTestCase{ Description: "Simple query with basic not like-string filter with string as suffix", @@ -106,6 +166,36 @@ func TestQuerySimpleWithNotLikeStringContainsFilterBlockAsSuffixString(t *testin executeTestCase(t, test) } +func TestQuerySimple_WithNotCaseInsensitiveLikeString_ShouldMatchSuffixString(t *testing.T) { + test := testUtils.RequestTestCase{ + Description: "Simple query with basic not like-string filter with string as suffix", + Request: `query { + Users(filter: {Name: {_nilike: "%andals"}}) { + Name + } + }`, + Docs: map[int][]string{ + 0: { + `{ + "Name": "Daenerys Stormborn of House Targaryen, the First of Her Name", + "HeightM": 1.65 + }`, + `{ + "Name": "Viserys I Targaryen, King of the Andals", + "HeightM": 1.82 + }`, + }, + }, + Results: []map[string]any{ + { + "Name": "Daenerys Stormborn of House Targaryen, the First of Her Name", + }, + }, + } + + executeTestCase(t, test) +} + func TestQuerySimpleWithNotLikeStringContainsFilterBlockExactString(t *testing.T) { test := testUtils.RequestTestCase{ Description: "Simple query with basic not like-string filter with string as suffix", @@ -136,6 +226,36 @@ func TestQuerySimpleWithNotLikeStringContainsFilterBlockExactString(t *testing.T executeTestCase(t, test) } +func TestQuerySimple_WithNotCaseInsensitiveLikeString_MatchExactString(t *testing.T) { + test := testUtils.RequestTestCase{ + Description: "Simple query with basic not case insensitive like-string filter with string as suffix", + Request: `query { + Users(filter: {Name: {_nilike: "daenerys stormborn of house targaryen, the first of her name"}}) { + Name + } + }`, + Docs: map[int][]string{ + 0: { + `{ + "Name": "Daenerys Stormborn of House Targaryen, the First of Her Name", + "HeightM": 1.65 + }`, + `{ + "Name": "Viserys I Targaryen, King of the Andals", + "HeightM": 1.82 + }`, + }, + }, + Results: []map[string]any{ + { + "Name": "Viserys I Targaryen, King of the Andals", + }, + }, + } + + executeTestCase(t, test) +} + func TestQuerySimpleWithNotLikeStringContainsFilterBlockContainsStringMuplitpleResults(t *testing.T) { test := testUtils.RequestTestCase{ Description: "Simple query with basic not like-string filter with contains string multiple results", diff --git a/tests/integration/schema/aggregates/inline_array_test.go b/tests/integration/schema/aggregates/inline_array_test.go index 75c9d76414..1dfaa4a858 100644 --- a/tests/integration/schema/aggregates/inline_array_test.go +++ b/tests/integration/schema/aggregates/inline_array_test.go @@ -1386,6 +1386,12 @@ func TestSchemaAggregateInlineArrayCreatesUsersNillableStringCountFilter(t *test "name": "String", }, }, + map[string]any{ + "name": "_ilike", + "type": map[string]any{ + "name": "String", + }, + }, map[string]any{ "name": "_in", "type": map[string]any{ @@ -1404,6 +1410,12 @@ func TestSchemaAggregateInlineArrayCreatesUsersNillableStringCountFilter(t *test "name": "String", }, }, + map[string]any{ + "name": "_nilike", + "type": map[string]any{ + "name": "String", + }, + }, map[string]any{ "name": "_nin", "type": map[string]any{ @@ -1524,6 +1536,12 @@ func TestSchemaAggregateInlineArrayCreatesUsersStringCountFilter(t *testing.T) { "name": "String", }, }, + map[string]any{ + "name": "_ilike", + "type": map[string]any{ + "name": "String", + }, + }, map[string]any{ "name": "_in", "type": map[string]any{ @@ -1542,6 +1560,12 @@ func TestSchemaAggregateInlineArrayCreatesUsersStringCountFilter(t *testing.T) { "name": "String", }, }, + map[string]any{ + "name": "_nilike", + "type": map[string]any{ + "name": "String", + }, + }, map[string]any{ "name": "_nin", "type": map[string]any{