From 90a5531ba1f4562ba062c993d91ce342c44ee32e Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 1 Jul 2026 13:06:17 +0530 Subject: [PATCH 1/4] fix(orgusers): support case-insensitive name filtering --- .../store/postgres/org_users_repository.go | 31 ++++++++++++--- .../postgres/org_users_repository_test.go | 39 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/internal/store/postgres/org_users_repository.go b/internal/store/postgres/org_users_repository.go index 86422e2d1..56fe713eb 100644 --- a/internal/store/postgres/org_users_repository.go +++ b/internal/store/postgres/org_users_repository.go @@ -325,16 +325,29 @@ func (r OrgUsersRepository) buildHasAnyRoleSubquery(orgID string) *goqu.SelectDa } func (r OrgUsersRepository) buildNonRoleFilterCondition(filter rql.Filter) (goqu.Expression, error) { - //this list will always be a subset of all RQL supported operators - supportedStringOperators := []string{"eq", "neq", "like", "in", "notin", "notlike", "empty", "notempty"} - supportedDateTimeOperators := []string{"eq", "neq", "gt", "lt", "gte", "lte"} - - if !slices.Contains(supportedStringOperators, filter.Operator) && !slices.Contains(supportedDateTimeOperators, filter.Operator) { - return nil, wrapBadOperatorError(filter.Operator, filter.Name) + // Route by the column's declared datatype so that string-only operators + // (like/ilike) are rejected on the datetime column org_joined_at. + datatype, err := rql.GetDataTypeOfField(filter.Name, svc.AggregatedUser{}) + if err != nil { + return nil, err } columnName := r.getNonRoleColumnName(filter.Name) + if datatype == "datetime" { + supportedDateTimeOperators := []string{"eq", "neq", "gt", "lt", "gte", "lte"} + if !slices.Contains(supportedDateTimeOperators, filter.Operator) { + return nil, wrapBadOperatorError(filter.Operator, filter.Name) + } + return goqu.Ex{columnName: goqu.Op{filter.Operator: filter.Value.(string)}}, nil + } + + // string columns + supportedStringOperators := []string{"eq", "neq", "like", "ilike", "in", "notin", "notlike", "notilike", "empty", "notempty"} + if !slices.Contains(supportedStringOperators, filter.Operator) { + return nil, wrapBadOperatorError(filter.Operator, filter.Name) + } + switch filter.Operator { case "empty": return goqu.Or(goqu.I(columnName).IsNull(), goqu.I(columnName).Eq("")), nil @@ -343,11 +356,17 @@ func (r OrgUsersRepository) buildNonRoleFilterCondition(filter rql.Filter) (goqu case "in", "notin": return goqu.Ex{columnName: goqu.Op{filter.Operator: strings.Split(filter.Value.(string), ",")}}, nil case "like": + // case-insensitive match; wildcards are added here searchPattern := "%" + filter.Value.(string) + "%" return goqu.Cast(goqu.I(columnName), "TEXT").ILike(searchPattern), nil case "notlike": searchPattern := "%" + filter.Value.(string) + "%" return goqu.Cast(goqu.I(columnName), "TEXT").NotILike(searchPattern), nil + case "ilike": + // case-insensitive match; the frontend already includes the wildcards (e.g. "%foo%") + return goqu.Cast(goqu.I(columnName), "TEXT").ILike(filter.Value.(string)), nil + case "notilike": + return goqu.Cast(goqu.I(columnName), "TEXT").NotILike(filter.Value.(string)), nil default: // eq, neq return goqu.Ex{columnName: goqu.Op{filter.Operator: filter.Value.(string)}}, nil } diff --git a/internal/store/postgres/org_users_repository_test.go b/internal/store/postgres/org_users_repository_test.go index 963f67c3f..c929893c3 100644 --- a/internal/store/postgres/org_users_repository_test.go +++ b/internal/store/postgres/org_users_repository_test.go @@ -145,6 +145,26 @@ func TestOrgUsersRepository_BuildNonRoleFilterCondition(t *testing.T) { wantSQL: `(CAST("users"."name" AS TEXT) NOT ILIKE $1)`, wantArgs: []interface{}{"%john%"}, }, + { + name: "ilike operator", + filter: rql.Filter{ + Name: "title", + Operator: "ilike", + Value: "%john%", + }, + wantSQL: `(CAST("users"."title" AS TEXT) ILIKE $1)`, + wantArgs: []interface{}{"%john%"}, + }, + { + name: "notilike operator", + filter: rql.Filter{ + Name: "title", + Operator: "notilike", + Value: "%john%", + }, + wantSQL: `(CAST("users"."title" AS TEXT) NOT ILIKE $1)`, + wantArgs: []interface{}{"%john%"}, + }, { name: "in operator", filter: rql.Filter{ @@ -164,6 +184,25 @@ func TestOrgUsersRepository_BuildNonRoleFilterCondition(t *testing.T) { wantSQL: `(("users"."title" IS NULL) OR ("users"."title" = $1))`, wantArgs: []interface{}{""}, }, + { + name: "datetime gte operator", + filter: rql.Filter{ + Name: "org_joined_at", + Operator: "gte", + Value: "2024-01-01T00:00:00Z", + }, + wantSQL: `("policies"."created_at" >= $1)`, + wantArgs: []interface{}{"2024-01-01T00:00:00Z"}, + }, + { + name: "ilike operator not allowed on datetime column", + filter: rql.Filter{ + Name: "org_joined_at", + Operator: "ilike", + Value: "%2024%", + }, + wantErr: true, + }, { name: "invalid operator", filter: rql.Filter{ From 1b8676e0b2fb2664a5a2f70a529771a6412ed353 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 2 Jul 2026 12:06:13 +0530 Subject: [PATCH 2/4] simplify non-role filter operator validation --- .../store/postgres/org_users_repository.go | 28 +++++-------------- .../postgres/org_users_repository_test.go | 9 ------ 2 files changed, 7 insertions(+), 30 deletions(-) diff --git a/internal/store/postgres/org_users_repository.go b/internal/store/postgres/org_users_repository.go index 56fe713eb..9ed133310 100644 --- a/internal/store/postgres/org_users_repository.go +++ b/internal/store/postgres/org_users_repository.go @@ -325,29 +325,16 @@ func (r OrgUsersRepository) buildHasAnyRoleSubquery(orgID string) *goqu.SelectDa } func (r OrgUsersRepository) buildNonRoleFilterCondition(filter rql.Filter) (goqu.Expression, error) { - // Route by the column's declared datatype so that string-only operators - // (like/ilike) are rejected on the datetime column org_joined_at. - datatype, err := rql.GetDataTypeOfField(filter.Name, svc.AggregatedUser{}) - if err != nil { - return nil, err - } - - columnName := r.getNonRoleColumnName(filter.Name) - - if datatype == "datetime" { - supportedDateTimeOperators := []string{"eq", "neq", "gt", "lt", "gte", "lte"} - if !slices.Contains(supportedDateTimeOperators, filter.Operator) { - return nil, wrapBadOperatorError(filter.Operator, filter.Name) - } - return goqu.Ex{columnName: goqu.Op{filter.Operator: filter.Value.(string)}}, nil - } - - // string columns + //this list will always be a subset of all RQL supported operators supportedStringOperators := []string{"eq", "neq", "like", "ilike", "in", "notin", "notlike", "notilike", "empty", "notempty"} - if !slices.Contains(supportedStringOperators, filter.Operator) { + supportedDateTimeOperators := []string{"eq", "neq", "gt", "lt", "gte", "lte"} + + if !slices.Contains(supportedStringOperators, filter.Operator) && !slices.Contains(supportedDateTimeOperators, filter.Operator) { return nil, wrapBadOperatorError(filter.Operator, filter.Name) } + columnName := r.getNonRoleColumnName(filter.Name) + switch filter.Operator { case "empty": return goqu.Or(goqu.I(columnName).IsNull(), goqu.I(columnName).Eq("")), nil @@ -356,14 +343,13 @@ func (r OrgUsersRepository) buildNonRoleFilterCondition(filter rql.Filter) (goqu case "in", "notin": return goqu.Ex{columnName: goqu.Op{filter.Operator: strings.Split(filter.Value.(string), ",")}}, nil case "like": - // case-insensitive match; wildcards are added here searchPattern := "%" + filter.Value.(string) + "%" return goqu.Cast(goqu.I(columnName), "TEXT").ILike(searchPattern), nil case "notlike": searchPattern := "%" + filter.Value.(string) + "%" return goqu.Cast(goqu.I(columnName), "TEXT").NotILike(searchPattern), nil case "ilike": - // case-insensitive match; the frontend already includes the wildcards (e.g. "%foo%") + // caller supplies the wildcards (e.g. "%foo%"); the DataTable filter sends them pre-wrapped return goqu.Cast(goqu.I(columnName), "TEXT").ILike(filter.Value.(string)), nil case "notilike": return goqu.Cast(goqu.I(columnName), "TEXT").NotILike(filter.Value.(string)), nil diff --git a/internal/store/postgres/org_users_repository_test.go b/internal/store/postgres/org_users_repository_test.go index c929893c3..3d3c42f9d 100644 --- a/internal/store/postgres/org_users_repository_test.go +++ b/internal/store/postgres/org_users_repository_test.go @@ -194,15 +194,6 @@ func TestOrgUsersRepository_BuildNonRoleFilterCondition(t *testing.T) { wantSQL: `("policies"."created_at" >= $1)`, wantArgs: []interface{}{"2024-01-01T00:00:00Z"}, }, - { - name: "ilike operator not allowed on datetime column", - filter: rql.Filter{ - Name: "org_joined_at", - Operator: "ilike", - Value: "%2024%", - }, - wantErr: true, - }, { name: "invalid operator", filter: rql.Filter{ From 6cdbd0c2a55121e0bc5bd716b391afc4baf7fd76 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 2 Jul 2026 14:37:33 +0530 Subject: [PATCH 3/4] cleaning up for review feedback --- .../store/postgres/org_users_repository.go | 35 +++++++++++++------ .../postgres/org_users_repository_test.go | 25 ++++++++++--- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/internal/store/postgres/org_users_repository.go b/internal/store/postgres/org_users_repository.go index 9ed133310..1461d8f77 100644 --- a/internal/store/postgres/org_users_repository.go +++ b/internal/store/postgres/org_users_repository.go @@ -325,16 +325,29 @@ func (r OrgUsersRepository) buildHasAnyRoleSubquery(orgID string) *goqu.SelectDa } func (r OrgUsersRepository) buildNonRoleFilterCondition(filter rql.Filter) (goqu.Expression, error) { - //this list will always be a subset of all RQL supported operators - supportedStringOperators := []string{"eq", "neq", "like", "ilike", "in", "notin", "notlike", "notilike", "empty", "notempty"} - supportedDateTimeOperators := []string{"eq", "neq", "gt", "lt", "gte", "lte"} - - if !slices.Contains(supportedStringOperators, filter.Operator) && !slices.Contains(supportedDateTimeOperators, filter.Operator) { - return nil, wrapBadOperatorError(filter.Operator, filter.Name) + // Route by the column's declared datatype so that string-only operators + // (like/ilike/in/empty) are rejected on the datetime column org_joined_at. + datatype, err := rql.GetDataTypeOfField(filter.Name, svc.AggregatedUser{}) + if err != nil { + return nil, err } columnName := r.getNonRoleColumnName(filter.Name) + if datatype == "datetime" { + supportedDateTimeOperators := []string{"eq", "neq", "gt", "lt", "gte", "lte"} + if !slices.Contains(supportedDateTimeOperators, filter.Operator) { + return nil, wrapBadOperatorError(filter.Operator, filter.Name) + } + return goqu.Ex{columnName: goqu.Op{filter.Operator: filter.Value.(string)}}, nil + } + + // string columns + supportedStringOperators := []string{"eq", "neq", "like", "ilike", "in", "notin", "notlike", "notilike", "empty", "notempty"} + if !slices.Contains(supportedStringOperators, filter.Operator) { + return nil, wrapBadOperatorError(filter.Operator, filter.Name) + } + switch filter.Operator { case "empty": return goqu.Or(goqu.I(columnName).IsNull(), goqu.I(columnName).Eq("")), nil @@ -342,14 +355,14 @@ func (r OrgUsersRepository) buildNonRoleFilterCondition(filter rql.Filter) (goqu return goqu.And(goqu.I(columnName).IsNotNull(), goqu.I(columnName).Neq("")), nil case "in", "notin": return goqu.Ex{columnName: goqu.Op{filter.Operator: strings.Split(filter.Value.(string), ",")}}, nil + // like/notlike are case-sensitive (SQL LIKE); ilike/notilike are case-insensitive + // (SQL ILIKE). The caller owns the wildcards (e.g. "%foo%") in every case — the + // DataTable filter sends them pre-wrapped. case "like": - searchPattern := "%" + filter.Value.(string) + "%" - return goqu.Cast(goqu.I(columnName), "TEXT").ILike(searchPattern), nil + return goqu.Cast(goqu.I(columnName), "TEXT").Like(filter.Value.(string)), nil case "notlike": - searchPattern := "%" + filter.Value.(string) + "%" - return goqu.Cast(goqu.I(columnName), "TEXT").NotILike(searchPattern), nil + return goqu.Cast(goqu.I(columnName), "TEXT").NotLike(filter.Value.(string)), nil case "ilike": - // caller supplies the wildcards (e.g. "%foo%"); the DataTable filter sends them pre-wrapped return goqu.Cast(goqu.I(columnName), "TEXT").ILike(filter.Value.(string)), nil case "notilike": return goqu.Cast(goqu.I(columnName), "TEXT").NotILike(filter.Value.(string)), nil diff --git a/internal/store/postgres/org_users_repository_test.go b/internal/store/postgres/org_users_repository_test.go index 3d3c42f9d..b9434fbb8 100644 --- a/internal/store/postgres/org_users_repository_test.go +++ b/internal/store/postgres/org_users_repository_test.go @@ -130,9 +130,9 @@ func TestOrgUsersRepository_BuildNonRoleFilterCondition(t *testing.T) { filter: rql.Filter{ Name: "name", Operator: "like", - Value: "john", + Value: "%john%", }, - wantSQL: `(CAST("users"."name" AS TEXT) ILIKE $1)`, + wantSQL: `(CAST("users"."name" AS TEXT) LIKE $1)`, wantArgs: []interface{}{"%john%"}, }, { @@ -140,9 +140,9 @@ func TestOrgUsersRepository_BuildNonRoleFilterCondition(t *testing.T) { filter: rql.Filter{ Name: "name", Operator: "notlike", - Value: "john", + Value: "%john%", }, - wantSQL: `(CAST("users"."name" AS TEXT) NOT ILIKE $1)`, + wantSQL: `(CAST("users"."name" AS TEXT) NOT LIKE $1)`, wantArgs: []interface{}{"%john%"}, }, { @@ -194,6 +194,23 @@ func TestOrgUsersRepository_BuildNonRoleFilterCondition(t *testing.T) { wantSQL: `("policies"."created_at" >= $1)`, wantArgs: []interface{}{"2024-01-01T00:00:00Z"}, }, + { + name: "ilike operator not allowed on datetime column", + filter: rql.Filter{ + Name: "org_joined_at", + Operator: "ilike", + Value: "%2024%", + }, + wantErr: true, + }, + { + name: "empty operator not allowed on datetime column", + filter: rql.Filter{ + Name: "org_joined_at", + Operator: "empty", + }, + wantErr: true, + }, { name: "invalid operator", filter: rql.Filter{ From 291b8711eb14063b27bd53310212429885a3a4ba Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 2 Jul 2026 15:40:37 +0530 Subject: [PATCH 4/4] cleaning up for review feedback --- internal/store/postgres/org_users_repository.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/store/postgres/org_users_repository.go b/internal/store/postgres/org_users_repository.go index 1461d8f77..028ea4d2e 100644 --- a/internal/store/postgres/org_users_repository.go +++ b/internal/store/postgres/org_users_repository.go @@ -366,8 +366,10 @@ func (r OrgUsersRepository) buildNonRoleFilterCondition(filter rql.Filter) (goqu return goqu.Cast(goqu.I(columnName), "TEXT").ILike(filter.Value.(string)), nil case "notilike": return goqu.Cast(goqu.I(columnName), "TEXT").NotILike(filter.Value.(string)), nil - default: // eq, neq + case "eq", "neq": return goqu.Ex{columnName: goqu.Op{filter.Operator: filter.Value.(string)}}, nil + default: + return nil, wrapBadOperatorError(filter.Operator, filter.Name) } }