Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions internal/store/postgres/org_users_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like also uses iLIKE only.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — goqu's .ILike() emits SQL ILIKE, so like here is already case-insensitive. The only real difference between the like and ilike branches is who owns the %..% wildcards (server-side for like, caller-side for ilike). See my note on the ilike case below — Apsara only ever sends ilike with wildcards baked in, so I'll collapse these to one convention.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed. I aligned this file to the same convention org_pats_repository and org_billing_repository already use (the two other repos that own wildcards caller-side + cast to TEXT):

case "like":     return goqu.Cast(goqu.I(columnName), "TEXT").Like(filter.Value.(string)), nil     // case-sensitive
case "notlike":  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-insensitive
case "notilike": return goqu.Cast(goqu.I(columnName), "TEXT").NotILike(filter.Value.(string)), nil

So like now maps to real SQL LIKE (case-sensitive) instead of ILIKE, and ilike stays ILIKE. No more silent case-insensitivity on like.

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%")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not follow the same pattern? In one case, the frontend adds %..%, and in another, it does not. We should keep it consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both the ilike operator and the %..% wildcards are added by Apsara's DataTable filter transform before the request leaves the browser — not by our app code.

In @raystack/apsara (components/data-table/utils/), transformToDataTableQuery builds each filter via two helpers (filter-operations.tsx):

  • getFilterOperator maps the UI string operators to ilike:
    if (filterType === FilterType.string &&
        (operator === 'contains' || operator === 'starts_with' || operator === 'ends_with')) {
      return 'ilike';
    }
  • getFilterValuehandleStringBasedTypes bakes the wildcards into the value:
    if (operator === 'contains')     processedValue = `%${stringVal}%`;
    else if (operator === 'starts_with') processedValue = `${stringVal}%`;
    else if (operator === 'ends_with')   processedValue = `%${stringVal}`;
    else if (operator === 'ilike') { if (!stringVal.includes('%')) processedValue = `%${stringVal}%`; }

So for the org-users name filter the client always sends { operator: "ilike", value: "%foo%" } — never like. That's why the ilike branch here treats wildcards as caller-provided.

You're right that it's inconsistent, though: like/ilike both compile to SQL ILIKE (via goqu .ILike()), and since the real caller only ever emits ilike with wildcards pre-added, the like/notlike branches that wrap server-side are effectively dead for this list. I'll align them to one convention — keeping ilike/notilike (caller owns the wildcards) and dropping the server-side wrapping in like/notlike — so there's a single, consistent path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — wildcards are now caller-owned for all four operators (like/notlike/ilike/notilike), so there is a single consistent convention. The server no longer wraps %..% on like/notlike; the caller (Apsara DataTable, which only ever emits ilike with wildcards pre-baked) owns them in every case. This matches org_pats_repository/org_billing_repository.

Updated the like/notlike tests accordingly to pass pre-wrapped values.

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
}
Expand Down
39 changes: 39 additions & 0 deletions internal/store/postgres/org_users_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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{
Expand Down
Loading