diff --git a/backend/internal/api/models.go b/backend/internal/api/models.go index c7777bc9..84b4ad0e 100644 --- a/backend/internal/api/models.go +++ b/backend/internal/api/models.go @@ -9,8 +9,8 @@ import ( type API struct { model.Base - Name string `sortable:"true"` - Audience string `sortable:"true"` + Name string `sortable:"case-insensitive"` + Audience string `sortable:"case-insensitive"` UpdatedAt *datatype.DateTime AllowCIMDClients bool `gorm:"column:allow_cimd_clients"` diff --git a/backend/internal/apikey/models.go b/backend/internal/apikey/models.go index e5a128e6..6ccd556a 100644 --- a/backend/internal/apikey/models.go +++ b/backend/internal/apikey/models.go @@ -9,7 +9,7 @@ import ( type ApiKey struct { model.Base - Name string `sortable:"true"` + Name string `sortable:"case-insensitive"` Key string Description *string ExpiresAt datatype.DateTime `sortable:"true"` diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index 0024c1ed..6eee20b0 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -46,7 +46,7 @@ const ( type OidcClient struct { Base - Name string `sortable:"true"` + Name string `sortable:"case-insensitive"` Description string CallbackURLs datatype.StringList LogoutCallbackURLs datatype.StringList diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index bfd90cc2..e0827a2e 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -14,12 +14,12 @@ import ( type User struct { Base - Username string `sortable:"true"` - Email *string `sortable:"true"` + Username string `sortable:"case-insensitive"` + Email *string `sortable:"case-insensitive"` EmailVerified bool `sortable:"true" filterable:"true"` - FirstName string `sortable:"true"` - LastName string `sortable:"true"` - DisplayName string `sortable:"true"` + FirstName string `sortable:"case-insensitive"` + LastName string `sortable:"case-insensitive"` + DisplayName string `sortable:"case-insensitive"` IsAdmin bool `sortable:"true" filterable:"true"` Locale *string LdapID *string diff --git a/backend/internal/model/user_group.go b/backend/internal/model/user_group.go index 85d9c150..1a257a0c 100644 --- a/backend/internal/model/user_group.go +++ b/backend/internal/model/user_group.go @@ -8,8 +8,8 @@ import ( type UserGroup struct { Base - FriendlyName string `sortable:"true"` - Name string `sortable:"true"` + FriendlyName string `sortable:"case-insensitive"` + Name string `sortable:"case-insensitive"` LdapID *string UpdatedAt *datatype.DateTime Users []User `gorm:"many2many:user_groups_users;"` diff --git a/backend/internal/utils/list_request_util.go b/backend/internal/utils/list_request_util.go index b0bb34c8..de16e530 100644 --- a/backend/internal/utils/list_request_util.go +++ b/backend/internal/utils/list_request_util.go @@ -29,9 +29,10 @@ type ListRequestOptions struct { } type FieldMeta struct { - ColumnName string - IsSortable bool - IsFilterable bool + ColumnName string + IsSortable bool + IsFilterable bool + IsCaseInsensitive bool } func ParseListRequestOptions(ctx *gin.Context) (listRequestOptions ListRequestOptions) { @@ -155,6 +156,18 @@ func applySorting(sortColumn string, sortDirection string, query *gorm.DB, meta } sortDirection = NormalizeSortDirection(sortDirection) + if fieldMeta.IsCaseInsensitive { + expression := "LOWER(?)" + if sortDirection == "desc" { + expression += " DESC" + } + return query.Clauses(clause.OrderBy{ + Expression: clause.Expr{ + SQL: expression, + Vars: []any{clause.Column{Name: fieldMeta.ColumnName}}, + }, + }) + } query = query.Clauses(clause.OrderBy{ Columns: []clause.OrderByColumn{ @@ -191,10 +204,12 @@ func extractModelMetadata(model any) map[string]FieldMeta { // Normal field: record metadata name := field.Name + sortMode := field.Tag.Get("sortable") meta[name] = FieldMeta{ - ColumnName: CamelCaseToSnakeCase(name), - IsSortable: field.Tag.Get("sortable") == "true", - IsFilterable: field.Tag.Get("filterable") == "true", + ColumnName: CamelCaseToSnakeCase(name), + IsSortable: sortMode == "true" || sortMode == "case-insensitive", + IsFilterable: field.Tag.Get("filterable") == "true", + IsCaseInsensitive: sortMode == "case-insensitive", } } } diff --git a/backend/internal/utils/list_request_util_test.go b/backend/internal/utils/list_request_util_test.go new file mode 100644 index 00000000..e5b39aa3 --- /dev/null +++ b/backend/internal/utils/list_request_util_test.go @@ -0,0 +1,52 @@ +package utils + +import ( + "testing" + + "github.com/libtnb/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestPaginateFilterAndSortSortsStringsCaseInsensitively(t *testing.T) { + type record struct { + ID uint + Name string `sortable:"case-insensitive"` + } + + db, err := gorm.Open(sqlite.Open("file:"+CreateSha256Hash(t.Name())+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.Exec("CREATE TABLE records (id INTEGER PRIMARY KEY, name TEXT NOT NULL)").Error) + require.NoError(t, db.Create(&[]record{{Name: "alpha"}, {Name: "Charlie"}, {Name: "bravo"}}).Error) + + for _, test := range []struct { + direction string + expected []string + }{ + {direction: "asc", expected: []string{"alpha", "bravo", "Charlie"}}, + {direction: "desc", expected: []string{"Charlie", "bravo", "alpha"}}, + } { + t.Run(test.direction, func(t *testing.T) { + options := ListRequestOptions{} + options.Sort.Column = "name" + options.Sort.Direction = test.direction + + var records []record + _, err := PaginateFilterAndSort(options, db.Model(&record{}), &records) + require.NoError(t, err) + require.Len(t, records, 3) + require.Equal(t, test.expected, []string{records[0].Name, records[1].Name, records[2].Name}) + }) + } +} + +func TestExtractModelMetadataRequiresCaseInsensitiveSortMode(t *testing.T) { + type record struct { + Name string `sortable:"case-insensitive"` + Event string `sortable:"true"` + } + + meta := extractModelMetadata(&[]record{}) + require.True(t, meta["Name"].IsCaseInsensitive) + require.False(t, meta["Event"].IsCaseInsensitive) +}