diff --git a/internal/api/scim/resources/filter/filter_query_builder.go b/internal/api/scim/resources/filter/filter_query_builder.go index 17c5736b3..f0941fe13 100644 --- a/internal/api/scim/resources/filter/filter_query_builder.go +++ b/internal/api/scim/resources/filter/filter_query_builder.go @@ -33,6 +33,7 @@ type MappedQueryBuilderFunc func(ctx context.Context, compareValue *CompValue, o type QueryFieldInfo struct { Column query.Column FieldType FieldType + CaseInsensitive bool BuildMappedQuery MappedQueryBuilderFunc } @@ -290,19 +291,36 @@ func (b *queryBuilder) buildTextQuery(field *QueryFieldInfo, right CompValue, op } var comp query.TextComparison - switch { - case op.Equal: - comp = query.TextEquals - case op.NotEqual: - comp = query.TextNotEquals - case op.Contains: - comp = query.TextContains - case op.StartsWith: - comp = query.TextStartsWith - case op.EndsWith: - comp = query.TextEndsWith - default: - return nil, serrors.ThrowInvalidFilter(zerrors.ThrowInvalidArgument(nil, "SCIM-FF425", "Invalid filter expression: unsupported comparison operator for text fields")) + if field.CaseInsensitive { + switch { + case op.Equal: + comp = query.TextEqualsIgnoreCase + case op.NotEqual: + comp = query.TextNotEqualsIgnoreCase + case op.Contains: + comp = query.TextContainsIgnoreCase + case op.StartsWith: + comp = query.TextStartsWithIgnoreCase + case op.EndsWith: + comp = query.TextEndsWithIgnoreCase + default: + return nil, serrors.ThrowInvalidFilter(zerrors.ThrowInvalidArgument(nil, "SCIM-FF529", "Invalid filter expression: unsupported comparison operator for text fields")) + } + } else { + switch { + case op.Equal: + comp = query.TextEquals + case op.NotEqual: + comp = query.TextNotEquals + case op.Contains: + comp = query.TextContains + case op.StartsWith: + comp = query.TextStartsWith + case op.EndsWith: + comp = query.TextEndsWith + default: + return nil, serrors.ThrowInvalidFilter(zerrors.ThrowInvalidArgument(nil, "SCIM-FF425", "Invalid filter expression: unsupported comparison operator for text fields")) + } } return query.NewTextQuery(field.Column, *right.StringValue, comp) diff --git a/internal/api/scim/resources/filter/filter_query_builder_test.go b/internal/api/scim/resources/filter/filter_query_builder_test.go index 6213d0ad5..f7704d822 100644 --- a/internal/api/scim/resources/filter/filter_query_builder_test.go +++ b/internal/api/scim/resources/filter/filter_query_builder_test.go @@ -20,10 +20,11 @@ var fieldPathColumnMapping = FieldPathMapping{ Column: query.UserChangeDateCol, FieldType: FieldTypeTimestamp, }, - // a string field + // a case-insensitive string field "username": { - Column: query.UserUsernameCol, - FieldType: FieldTypeString, + Column: query.UserUsernameCol, + FieldType: FieldTypeString, + CaseInsensitive: true, }, // a nested string field "name.familyname": { @@ -76,7 +77,7 @@ func TestFilter_BuildQuery(t *testing.T) { { name: "simple binary operator", filter: `userName eq "bjensen"`, - want: test.Must(query.NewTextQuery(query.UserUsernameCol, "bjensen", query.TextEquals)), + want: test.Must(query.NewTextQuery(query.UserUsernameCol, "bjensen", query.TextEqualsIgnoreCase)), }, { name: "binary operator equals null", @@ -176,7 +177,7 @@ func TestFilter_BuildQuery(t *testing.T) { { name: "urn prefixed binary operator", filter: `urn:ietf:params:scim:schemas:core:2.0:User:userName sw "J"`, - want: test.Must(query.NewTextQuery(query.UserUsernameCol, "J", query.TextStartsWith)), + want: test.Must(query.NewTextQuery(query.UserUsernameCol, "J", query.TextStartsWithIgnoreCase)), }, { name: "urn prefixed nested binary operator", @@ -196,7 +197,7 @@ func TestFilter_BuildQuery(t *testing.T) { { name: "and logical expression", filter: `name.familyName pr and userName eq "bjensen"`, - want: test.Must(query.NewAndQuery(test.Must(query.NewNotNullQuery(query.HumanLastNameCol)), test.Must(query.NewTextQuery(query.UserUsernameCol, "bjensen", query.TextEquals)))), + want: test.Must(query.NewAndQuery(test.Must(query.NewNotNullQuery(query.HumanLastNameCol)), test.Must(query.NewTextQuery(query.UserUsernameCol, "bjensen", query.TextEqualsIgnoreCase)))), }, { name: "timestamp condition equal", @@ -243,7 +244,7 @@ func TestFilter_BuildQuery(t *testing.T) { filter: `userName eq "rudolpho" and emails co "example.com" or emails.value co "example2.org"`, want: test.Must(query.NewOrQuery( test.Must(query.NewAndQuery( - test.Must(query.NewTextQuery(query.UserUsernameCol, "rudolpho", query.TextEquals)), + test.Must(query.NewTextQuery(query.UserUsernameCol, "rudolpho", query.TextEqualsIgnoreCase)), test.Must(query.NewTextQuery(query.HumanEmailCol, "example.com", query.TextContains))), ), test.Must(query.NewTextQuery(query.HumanEmailCol, "example2.org", query.TextContains)))), @@ -252,7 +253,7 @@ func TestFilter_BuildQuery(t *testing.T) { name: "nested and / or with grouping", filter: `userName ne "rudolpho" and (emails co "example.com" or emails.value co "example.org")`, want: test.Must(query.NewAndQuery( - test.Must(query.NewTextQuery(query.UserUsernameCol, "rudolpho", query.TextNotEquals)), + test.Must(query.NewTextQuery(query.UserUsernameCol, "rudolpho", query.TextNotEqualsIgnoreCase)), test.Must(query.NewOrQuery( test.Must(query.NewTextQuery(query.HumanEmailCol, "example.com", query.TextContains)), test.Must(query.NewTextQuery(query.HumanEmailCol, "example.org", query.TextContains)), @@ -263,7 +264,7 @@ func TestFilter_BuildQuery(t *testing.T) { name: "nested value path path", filter: `userName eq "Hans" and emails[value ew "@example.org" or value ew "@example.com"]`, want: test.Must(query.NewAndQuery( - test.Must(query.NewTextQuery(query.UserUsernameCol, "Hans", query.TextEquals)), + test.Must(query.NewTextQuery(query.UserUsernameCol, "Hans", query.TextEqualsIgnoreCase)), test.Must(query.NewOrQuery( test.Must(query.NewTextQuery(query.HumanEmailCol, "@example.org", query.TextEndsWith)), test.Must(query.NewTextQuery(query.HumanEmailCol, "@example.com", query.TextEndsWith)), @@ -288,13 +289,13 @@ func TestFilter_BuildQuery(t *testing.T) { want: test.Must(query.NewAndQuery( test.Must(query.NewTextQuery(query.HumanEmailCol, "@example.com", query.TextEndsWith)), test.Must(query.NewTextQuery(query.HumanLastNameCol, "hans", query.TextContains)), - test.Must(query.NewTextQuery(query.UserUsernameCol, "peter", query.TextContains)), + test.Must(query.NewTextQuery(query.UserUsernameCol, "peter", query.TextContainsIgnoreCase)), )), }, { name: "negation", filter: `not(username eq "foo")`, - want: test.Must(query.NewNotQuery(test.Must(query.NewTextQuery(query.UserUsernameCol, "foo", query.TextEquals)))), + want: test.Must(query.NewNotQuery(test.Must(query.NewTextQuery(query.UserUsernameCol, "foo", query.TextEqualsIgnoreCase)))), }, { name: "negation with complex filter", diff --git a/internal/api/scim/resources/user_query_builder.go b/internal/api/scim/resources/user_query_builder.go index 3d87b7262..15252f8d5 100644 --- a/internal/api/scim/resources/user_query_builder.go +++ b/internal/api/scim/resources/user_query_builder.go @@ -29,8 +29,9 @@ var fieldPathColumnMapping = filter.FieldPathMapping{ FieldType: filter.FieldTypeString, }, "username": { - Column: query.UserUsernameCol, - FieldType: filter.FieldTypeString, + Column: query.UserUsernameCol, + FieldType: filter.FieldTypeString, + CaseInsensitive: true, }, "name.familyname": { Column: query.HumanLastNameCol, diff --git a/internal/query/search_query.go b/internal/query/search_query.go index 7f6991c1c..d5e09027c 100644 --- a/internal/query/search_query.go +++ b/internal/query/search_query.go @@ -288,6 +288,7 @@ func NewTextQuery(col Column, value string, compare TextComparison) (*textQuery, // handle the comparisons which use (i)like and therefore need to escape potential wildcards in the value switch compare { case TextEqualsIgnoreCase, + TextNotEqualsIgnoreCase, TextStartsWith, TextStartsWithIgnoreCase, TextEndsWith, @@ -334,6 +335,8 @@ func (q *textQuery) comp() sq.Sqlizer { return sq.NotEq{q.Column.identifier(): q.Text} case TextEqualsIgnoreCase: return sq.ILike{q.Column.identifier(): q.Text} + case TextNotEqualsIgnoreCase: + return sq.NotILike{q.Column.identifier(): q.Text} case TextStartsWith: return sq.Like{q.Column.identifier(): q.Text + "%"} case TextStartsWithIgnoreCase: @@ -368,6 +371,7 @@ const ( TextContainsIgnoreCase TextListContains TextNotEquals + TextNotEqualsIgnoreCase textCompareMax ) diff --git a/internal/query/search_query_test.go b/internal/query/search_query_test.go index f617836df..13142a015 100644 --- a/internal/query/search_query_test.go +++ b/internal/query/search_query_test.go @@ -905,6 +905,19 @@ func TestNewTextQuery(t *testing.T) { Compare: TextNotEquals, }, }, + { + name: "not equal ignore case", + args: args{ + column: testCol, + value: "h_urst%", + compare: TextNotEqualsIgnoreCase, + }, + want: &textQuery{ + Column: testCol, + Text: "h\\_urst\\%", + Compare: TextNotEqualsIgnoreCase, + }, + }, { name: "starts with", args: args{ @@ -1194,6 +1207,28 @@ func TestTextQuery_comp(t *testing.T) { query: sq.ILike{"test_table.test_col": "Hurst"}, }, }, + { + name: "not equals", + fields: fields{ + Column: testCol, + Text: "Hurst", + Compare: TextNotEquals, + }, + want: want{ + query: sq.NotEq{"test_table.test_col": "Hurst"}, + }, + }, + { + name: "not equals ignore case", + fields: fields{ + Column: testCol, + Text: "Hurst", + Compare: TextNotEqualsIgnoreCase, + }, + want: want{ + query: sq.NotILike{"test_table.test_col": "Hurst"}, + }, + }, { name: "equals ignore case wildcard", fields: fields{