diff --git a/internal/store/postgres/org_users_repository.go b/internal/store/postgres/org_users_repository.go index 86422e2d1..028ea4d2e 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/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,21 @@ 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 - default: // eq, neq + return goqu.Cast(goqu.I(columnName), "TEXT").NotLike(filter.Value.(string)), nil + case "ilike": + 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 + case "eq", "neq": return goqu.Ex{columnName: goqu.Op{filter.Operator: filter.Value.(string)}}, nil + default: + return nil, wrapBadOperatorError(filter.Operator, filter.Name) } } diff --git a/internal/store/postgres/org_users_repository_test.go b/internal/store/postgres/org_users_repository_test.go index 963f67c3f..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,29 @@ 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%"}, + }, + { + 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%"}, }, { @@ -164,6 +184,33 @@ 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: "empty operator not allowed on datetime column", + filter: rql.Filter{ + Name: "org_joined_at", + Operator: "empty", + }, + wantErr: true, + }, { name: "invalid operator", filter: rql.Filter{