From 08e844bc8885737c0df89a0072703089e6500767 Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Mon, 11 May 2026 23:55:29 +0200 Subject: [PATCH 1/5] feat(api): add code filter to currency and sort option --- .../aip/src/currencies/operations.tsp | 18 +- api/v3/api.gen.go | 458 +++++++++--------- api/v3/handlers/currencies/convert.go | 41 ++ api/v3/handlers/currencies/list.go | 41 +- api/v3/openapi.yaml | 21 +- openmeter/currencies/adapter/currencies.go | 19 +- openmeter/currencies/models.go | 26 + openmeter/currencies/service/service.go | 22 +- openmeter/currencies/service/service_test.go | 207 ++++++++ 9 files changed, 620 insertions(+), 233 deletions(-) create mode 100644 openmeter/currencies/service/service_test.go diff --git a/api/spec/packages/aip/src/currencies/operations.tsp b/api/spec/packages/aip/src/currencies/operations.tsp index dfbcdfe3fd..fb3cd78212 100644 --- a/api/spec/packages/aip/src/currencies/operations.tsp +++ b/api/spec/packages/aip/src/currencies/operations.tsp @@ -17,10 +17,10 @@ namespace Currencies; */ @friendlyName("ListCurrenciesParamsFilter") model ListCurrenciesParamsFilter { - /** - * Filter currencies by type. - */ + #suppress "@openmeter/api-spec-aip/doc-decorator" "filter field" type?: CurrencyType; + #suppress "@openmeter/api-spec-aip/doc-decorator" "filter field" + code?: Common.StringFieldFilterExact; } interface CurrenciesOperations { @@ -35,6 +35,18 @@ interface CurrenciesOperations { list( ...Common.PagePaginationQuery, + /** + * Sort currencies returned in the response. Supported sort attributes are: + * + * - `code` (default) + * - `name` + * + * The `asc` suffix is optional as the default sort order is ascending. The `desc` + * suffix is used to specify a descending order. + */ + @query(#{ name: "sort" }) + sort?: Common.SortQuery, + /** * Filter currencies returned in the response. * diff --git a/api/v3/api.gen.go b/api/v3/api.gen.go index 81ad7a8682..195d5a77b7 100644 --- a/api/v3/api.gen.go +++ b/api/v3/api.gen.go @@ -4591,7 +4591,12 @@ type ListCreditTransactionsParamsFilter struct { // ListCurrenciesParamsFilter Filter options for listing currencies. type ListCurrenciesParamsFilter struct { - // Type Filter currencies by type. + // Code Filters on the given string field value by exact match. All properties are + // optional; provide exactly one to specify the comparison. + Code *StringFieldFilterExact `json:"code,omitempty"` + + // Type Currency type for custom currencies. It should be a unique code but not + // conflicting with any existing standard currency codes. Type *BillingCurrencyType `json:"type,omitempty"` } @@ -5539,6 +5544,15 @@ type ListCurrenciesParams struct { // Page Determines which page of the collection to retrieve. Page *PagePaginationQuery `json:"page,omitempty"` + // Sort Sort currencies returned in the response. Supported sort attributes are: + // + // - `code` (default) + // - `name` + // + // The `asc` suffix is optional as the default sort order is ascending. The `desc` + // suffix is used to specify a descending order. + Sort *SortQuery `form:"sort,omitempty" json:"sort,omitempty"` + // Filter Filter currencies returned in the response. // // To filter currencies by type add the following query param: filter[type]=custom @@ -7871,6 +7885,14 @@ func (siw *ServerInterfaceWrapper) ListCurrencies(w http.ResponseWriter, r *http return } + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameterWithOptions("form", false, false, "sort", r.URL.Query(), ¶ms.Sort, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sort", Err: err}) + return + } + // ------------- Optional query parameter "filter" ------------- err = filters.Parse(r.URL.Query(), ¶ms.Filter) @@ -10707,223 +10729,223 @@ var swaggerSpec = []string{ "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", - "qOJSV1dHd76dfft/AQAA//9a3d/1JgYEAA==", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/v3/handlers/currencies/convert.go b/api/v3/handlers/currencies/convert.go index f135be6e31..2301a0a571 100644 --- a/api/v3/handlers/currencies/convert.go +++ b/api/v3/handlers/currencies/convert.go @@ -1,12 +1,53 @@ package currencies import ( + "errors" "fmt" v3 "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/filters" "github.com/openmeterio/openmeter/openmeter/currencies" + "github.com/openmeterio/openmeter/pkg/models" ) +// FromAPICurrencyCodeFilter converts an API StringFieldFilterExact for the +// currency code into a flat []string of codes to match (positive list). +// Only eq and oeq operators are supported; neq returns an error. Each value +// is validated for length (3–24 chars), matching the custom_currencies ent +// schema constraints (and also accepting fiat ISO codes which are 3 chars). +func FromAPICurrencyCodeFilter(f *filters.FilterStringExact) ([]string, error) { + if f == nil { + return nil, nil + } + if f.Neq != nil { + return nil, errors.New("only eq and oeq operators are supported for currency code") + } + + var codes []string + if f.Eq != nil { + codes = append(codes, *f.Eq) + } + codes = append(codes, f.Oeq...) + + if len(codes) == 0 { + return nil, nil + } + + var errs []error + for _, code := range codes { + if len(code) < 3 { + errs = append(errs, fmt.Errorf("currency code must be at least 3 characters, got %q", code)) + } else if len(code) > 24 { + errs = append(errs, fmt.Errorf("currency code must be at most 24 characters, got %q", code)) + } + } + if len(errs) > 0 { + return nil, models.NewNillableGenericValidationError(errors.Join(errs...)) + } + + return codes, nil +} + func FromAPIBillingCurrencyType(t v3.BillingCurrencyType) currencies.CurrencyType { switch t { case v3.BillingCurrencyTypeCustom: diff --git a/api/v3/handlers/currencies/list.go b/api/v3/handlers/currencies/list.go index e605823ad2..c7fbdd30ad 100644 --- a/api/v3/handlers/currencies/list.go +++ b/api/v3/handlers/currencies/list.go @@ -9,11 +9,13 @@ import ( v3 "github.com/openmeterio/openmeter/api/v3" "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" "github.com/openmeterio/openmeter/api/v3/response" "github.com/openmeterio/openmeter/openmeter/currencies" "github.com/openmeterio/openmeter/pkg/framework/commonhttp" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" "github.com/openmeterio/openmeter/pkg/pagination" + "github.com/openmeterio/openmeter/pkg/sortx" ) type ( @@ -49,16 +51,43 @@ func (h *handler) ListCurrencies() ListCurrenciesHandler { }) } + var orderBy string + var order sortx.Order + if params.Sort != nil { + sort, err := request.ParseSortBy(*params.Sort) + if err != nil { + return ListCurrenciesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + orderBy = sort.Field + order = sort.Order.ToSortxOrder() + } + var filterType *currencies.CurrencyType - if params.Filter != nil && params.Filter.Type != nil { - ft := FromAPIBillingCurrencyType(*params.Filter.Type) - filterType = &ft + var filterCodes []string + if params.Filter != nil { + if params.Filter.Type != nil { + ft := FromAPIBillingCurrencyType(*params.Filter.Type) + filterType = &ft + } + + codes, err := FromAPICurrencyCodeFilter(params.Filter.Code) + if err != nil { + return ListCurrenciesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[code]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + filterCodes = codes } return ListCurrenciesRequest{ - Page: page, - Namespace: ns, - FilterType: filterType, + Page: page, + Namespace: ns, + FilterType: filterType, + FilterCodes: filterCodes, + OrderBy: currencies.OrderBy(orderBy), + Order: order, }, nil }, func(ctx context.Context, request ListCurrenciesRequest) (ListCurrenciesResponse, error) { diff --git a/api/v3/openapi.yaml b/api/v3/openapi.yaml index 2a469ac32c..f3e63fd97f 100644 --- a/api/v3/openapi.yaml +++ b/api/v3/openapi.yaml @@ -333,6 +333,21 @@ paths: description: List currencies supported by the billing system. parameters: - $ref: '#/components/parameters/PagePaginationQuery' + - name: sort + in: query + required: false + description: |- + Sort currencies returned in the response. Supported sort attributes are: + + - `code` (default) + - `name` + + The `asc` suffix is optional as the default sort order is ascending. The `desc` + suffix is used to specify a descending order. + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form - name: filter in: query required: false @@ -8232,9 +8247,9 @@ components: type: object properties: type: - allOf: - - $ref: '#/components/schemas/BillingCurrencyType' - description: Filter currencies by type. + $ref: '#/components/schemas/BillingCurrencyType' + code: + $ref: '#/components/schemas/StringFieldFilterExact' additionalProperties: false description: Filter options for listing currencies. ListCustomerEntitlementAccessResponseData: diff --git a/openmeter/currencies/adapter/currencies.go b/openmeter/currencies/adapter/currencies.go index 3300604112..81a8cba787 100644 --- a/openmeter/currencies/adapter/currencies.go +++ b/openmeter/currencies/adapter/currencies.go @@ -16,6 +16,7 @@ import ( "github.com/openmeterio/openmeter/pkg/framework/entutils" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination" + "github.com/openmeterio/openmeter/pkg/sortx" ) var _ currencies.Adapter = (*adapter)(nil) @@ -44,8 +45,22 @@ func mapCostBasisFromDB(c *entdb.CurrencyCostBasis) currencies.CostBasis { func (a *adapter) ListCustomCurrencies(ctx context.Context, params currencies.ListCurrenciesInput) (pagination.Result[currencies.Currency], error) { return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (pagination.Result[currencies.Currency], error) { q := a.db.CustomCurrency.Query(). - Where(customcurrency.Namespace(params.Namespace)). - Order(entdb.Asc(customcurrency.FieldCode)) + Where(customcurrency.Namespace(params.Namespace)) + + if len(params.FilterCodes) > 0 { + q = q.Where(customcurrency.CodeIn(params.FilterCodes...)) + } + + order := entutils.GetOrdering(sortx.OrderDefault) + if !params.Order.IsDefaultValue() { + order = entutils.GetOrdering(params.Order) + } + switch params.OrderBy { + case currencies.OrderByName: + q = q.Order(customcurrency.ByName(order...)) + default: + q = q.Order(customcurrency.ByCode(order...)) + } total, err := q.Count(ctx) if err != nil { diff --git a/openmeter/currencies/models.go b/openmeter/currencies/models.go index 0af86b289e..4474de5eb2 100644 --- a/openmeter/currencies/models.go +++ b/openmeter/currencies/models.go @@ -9,6 +9,7 @@ import ( "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination" + "github.com/openmeterio/openmeter/pkg/sortx" ) type Currency struct { @@ -19,6 +20,22 @@ type Currency struct { Symbol string `json:"symbol"` } +// OrderBy specifies the field to sort currencies by. +type OrderBy string + +const ( + OrderByCode OrderBy = "code" + OrderByName OrderBy = "name" +) + +func (o OrderBy) Validate() error { + switch o { + case OrderByCode, OrderByName, "": + return nil + } + return fmt.Errorf("invalid order by: %s", o) +} + var _ models.Validator = (*ListCurrenciesInput)(nil) type ListCurrenciesInput struct { @@ -28,6 +45,11 @@ type ListCurrenciesInput struct { // FilterType filters currencies by type: "custom" or "fiat". Nil means no filter. FilterType *CurrencyType `json:"filter_type,omitempty"` + // FilterCodes filters currencies by code. Empty means no filter; non-empty matches any of the listed codes. + FilterCodes []string `json:"filter_codes,omitempty"` + + OrderBy OrderBy + Order sortx.Order } func (i ListCurrenciesInput) Validate() error { @@ -43,6 +65,10 @@ func (i ListCurrenciesInput) Validate() error { } } + if err := i.OrderBy.Validate(); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) } diff --git a/openmeter/currencies/service/service.go b/openmeter/currencies/service/service.go index 55084cbd32..2f416f4b57 100644 --- a/openmeter/currencies/service/service.go +++ b/openmeter/currencies/service/service.go @@ -3,6 +3,8 @@ package service import ( "context" "fmt" + "slices" + "strings" "time" "github.com/invopop/gobl/currency" @@ -12,6 +14,7 @@ import ( "github.com/openmeterio/openmeter/pkg/framework/transaction" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination" + "github.com/openmeterio/openmeter/pkg/sortx" ) var _ currencies.CurrencyService = (*Service)(nil) @@ -59,14 +62,31 @@ func (s *Service) ListCurrencies(ctx context.Context, params currencies.ListCurr // NOTE: this filters out non-iso currencies such as crypto return def.ISONumeric != "" }) { + code := def.ISOCode.String() + if len(params.FilterCodes) > 0 && !slices.Contains(params.FilterCodes, code) { + continue + } items = append(items, currencies.Currency{ - Code: def.ISOCode.String(), + Code: code, Name: def.Name, Symbol: def.Symbol, }) } } + slices.SortFunc(items, func(a, b currencies.Currency) int { + result := 0 + if params.OrderBy == currencies.OrderByName { + result = strings.Compare(a.Name, b.Name) + } else { + result = strings.Compare(a.Code, b.Code) + } + if params.Order == sortx.OrderDesc { + return -result + } + return result + }) + total := len(items) pageSize := params.Page.PageSize diff --git a/openmeter/currencies/service/service_test.go b/openmeter/currencies/service/service_test.go new file mode 100644 index 0000000000..fd37135c1e --- /dev/null +++ b/openmeter/currencies/service/service_test.go @@ -0,0 +1,207 @@ +package service + +import ( + "context" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/currencies" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/pagination" + "github.com/openmeterio/openmeter/pkg/sortx" +) + +// noopDriver implements transaction.Driver as a no-op for unit tests. +type noopDriver struct{} + +func (noopDriver) Commit() error { return nil } +func (noopDriver) Rollback() error { return nil } +func (noopDriver) SavePoint() error { return nil } + +// fakeAdapter implements currencies.Adapter for unit testing the service layer. +// ListCustomCurrencies applies FilterCodes from params to simulate DB-level filtering. +type fakeAdapter struct { + custom []currencies.Currency +} + +func (f *fakeAdapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) { + return ctx, noopDriver{}, nil +} + +func (f *fakeAdapter) ListCustomCurrencies(_ context.Context, params currencies.ListCurrenciesInput) (pagination.Result[currencies.Currency], error) { + items := f.custom + if len(params.FilterCodes) > 0 { + items = slices.DeleteFunc(slices.Clone(items), func(c currencies.Currency) bool { + return !slices.Contains(params.FilterCodes, c.Code) + }) + } + return pagination.Result[currencies.Currency]{ + Items: items, + TotalCount: len(items), + Page: params.Page, + }, nil +} + +func (f *fakeAdapter) CreateCurrency(_ context.Context, _ currencies.CreateCurrencyInput) (currencies.Currency, error) { + panic("not implemented") +} + +func (f *fakeAdapter) CreateCostBasis(_ context.Context, _ currencies.CreateCostBasisInput) (currencies.CostBasis, error) { + panic("not implemented") +} + +func (f *fakeAdapter) ListCostBases(_ context.Context, _ currencies.ListCostBasesInput) (pagination.Result[currencies.CostBasis], error) { + panic("not implemented") +} + +// newTestService creates a Service backed by a fake adapter seeded with custom currencies. +func newTestService(custom []currencies.Currency) *Service { + return New(&fakeAdapter{custom: custom}) +} + +func TestListCurrencies_CombinedPath(t *testing.T) { + customCurrency := currencies.Currency{Code: "MYCUSTOM", Name: "My Custom Currency", Symbol: "MC"} + + svc := newTestService([]currencies.Currency{customCurrency}) + + tests := []struct { + name string + input currencies.ListCurrenciesInput + wantErr bool + assertResults func(t *testing.T, result pagination.Result[currencies.Currency]) + }{ + { + name: "no filter no sort returns combined list sorted by code asc", + input: currencies.ListCurrenciesInput{ + Namespace: "test", + Page: pagination.NewPage(1, 5), + }, + assertResults: func(t *testing.T, result pagination.Result[currencies.Currency]) { + t.Helper() + require.Equal(t, 5, len(result.Items)) + for i := 1; i < len(result.Items); i++ { + assert.LessOrEqual(t, result.Items[i-1].Code, result.Items[i].Code, "items should be sorted by code asc") + } + }, + }, + { + name: "filter by single fiat code returns only that currency", + input: currencies.ListCurrenciesInput{ + Namespace: "test", + FilterCodes: []string{"USD"}, + }, + assertResults: func(t *testing.T, result pagination.Result[currencies.Currency]) { + t.Helper() + require.Equal(t, 1, result.TotalCount) + assert.Equal(t, "USD", result.Items[0].Code) + }, + }, + { + name: "filter by multiple fiat codes returns only those currencies", + input: currencies.ListCurrenciesInput{ + Namespace: "test", + FilterCodes: []string{"USD", "EUR"}, + }, + assertResults: func(t *testing.T, result pagination.Result[currencies.Currency]) { + t.Helper() + require.Equal(t, 2, result.TotalCount) + codes := []string{result.Items[0].Code, result.Items[1].Code} + assert.ElementsMatch(t, []string{"USD", "EUR"}, codes) + }, + }, + { + name: "filter by custom currency code returns only that custom currency", + input: currencies.ListCurrenciesInput{ + Namespace: "test", + FilterCodes: []string{"MYCUSTOM"}, + }, + assertResults: func(t *testing.T, result pagination.Result[currencies.Currency]) { + t.Helper() + require.Equal(t, 1, result.TotalCount) + assert.Equal(t, "MYCUSTOM", result.Items[0].Code) + }, + }, + { + name: "sort by name returns items sorted by name asc", + input: currencies.ListCurrenciesInput{ + Namespace: "test", + FilterCodes: []string{"USD", "EUR", "GBP"}, + OrderBy: currencies.OrderByName, + }, + assertResults: func(t *testing.T, result pagination.Result[currencies.Currency]) { + t.Helper() + require.Equal(t, 3, result.TotalCount) + for i := 1; i < len(result.Items); i++ { + assert.LessOrEqual(t, result.Items[i-1].Name, result.Items[i].Name, "items should be sorted by name asc") + } + }, + }, + { + name: "sort by code desc returns items sorted by code descending", + input: currencies.ListCurrenciesInput{ + Namespace: "test", + FilterCodes: []string{"USD", "EUR", "GBP"}, + Order: sortx.OrderDesc, + }, + assertResults: func(t *testing.T, result pagination.Result[currencies.Currency]) { + t.Helper() + require.Equal(t, 3, result.TotalCount) + for i := 1; i < len(result.Items); i++ { + assert.GreaterOrEqual(t, result.Items[i-1].Code, result.Items[i].Code, "items should be sorted by code desc") + } + }, + }, + { + name: "invalid order by returns validation error", + input: currencies.ListCurrenciesInput{ + Namespace: "test", + OrderBy: currencies.OrderBy("invalid"), + }, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.input.Namespace = "test" + result, err := svc.ListCurrencies(t.Context(), tc.input) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + tc.assertResults(t, result) + }) + } +} + +func TestListCurrencies_CustomOnlyPath(t *testing.T) { + customCurrency := currencies.Currency{Code: "MYCUSTOM", Name: "My Custom Currency", Symbol: "MC"} + svc := newTestService([]currencies.Currency{customCurrency}) + + t.Run("filter by type custom with code filter uses custom-only fast path", func(t *testing.T) { + ft := currencies.CurrencyTypeCustom + result, err := svc.ListCurrencies(t.Context(), currencies.ListCurrenciesInput{ + Namespace: "test", + FilterType: &ft, + FilterCodes: []string{"MYCUSTOM"}, + }) + require.NoError(t, err) + require.Equal(t, 1, result.TotalCount) + assert.Equal(t, "MYCUSTOM", result.Items[0].Code) + }) + + t.Run("filter by type custom returns no fiat currencies", func(t *testing.T) { + ft := currencies.CurrencyTypeCustom + result, err := svc.ListCurrencies(t.Context(), currencies.ListCurrenciesInput{ + Namespace: "test", + FilterType: &ft, + }) + require.NoError(t, err) + require.Equal(t, 1, result.TotalCount) + assert.Equal(t, "MYCUSTOM", result.Items[0].Code) + }) +} From 8dcfb4c06252b9ef876c703cd1a9606a3d2e9da4 Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Tue, 12 May 2026 10:02:13 +0200 Subject: [PATCH 2/5] chore: use GenericValidationError at neq check --- api/v3/handlers/currencies/convert.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v3/handlers/currencies/convert.go b/api/v3/handlers/currencies/convert.go index 2301a0a571..44dc6d1cc8 100644 --- a/api/v3/handlers/currencies/convert.go +++ b/api/v3/handlers/currencies/convert.go @@ -20,7 +20,7 @@ func FromAPICurrencyCodeFilter(f *filters.FilterStringExact) ([]string, error) { return nil, nil } if f.Neq != nil { - return nil, errors.New("only eq and oeq operators are supported for currency code") + return nil, models.NewNillableGenericValidationError(errors.New("only eq and oeq operators are supported for currency code")) } var codes []string From 9218be72b12942f81737ed636a1ebc0c61e4dcab Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Tue, 12 May 2026 12:09:02 +0200 Subject: [PATCH 3/5] chore: Support fuzzy string filtering for currency code --- .../aip/src/currencies/operations.tsp | 2 +- api/v3/api.gen.go | 440 +++++++++--------- api/v3/handlers/currencies/convert.go | 41 -- api/v3/handlers/currencies/list.go | 25 +- api/v3/openapi.yaml | 2 +- openmeter/currencies/adapter/currencies.go | 5 +- openmeter/currencies/models.go | 11 +- openmeter/currencies/service/service.go | 12 +- openmeter/currencies/service/service_test.go | 62 ++- pkg/filter/filter.go | 101 ++++ pkg/filter/filter_test.go | 118 +++++ 11 files changed, 509 insertions(+), 310 deletions(-) diff --git a/api/spec/packages/aip/src/currencies/operations.tsp b/api/spec/packages/aip/src/currencies/operations.tsp index fb3cd78212..2a9637fc39 100644 --- a/api/spec/packages/aip/src/currencies/operations.tsp +++ b/api/spec/packages/aip/src/currencies/operations.tsp @@ -20,7 +20,7 @@ model ListCurrenciesParamsFilter { #suppress "@openmeter/api-spec-aip/doc-decorator" "filter field" type?: CurrencyType; #suppress "@openmeter/api-spec-aip/doc-decorator" "filter field" - code?: Common.StringFieldFilterExact; + code?: Common.StringFieldFilter; } interface CurrenciesOperations { diff --git a/api/v3/api.gen.go b/api/v3/api.gen.go index 195d5a77b7..a51baf9a83 100644 --- a/api/v3/api.gen.go +++ b/api/v3/api.gen.go @@ -4591,9 +4591,9 @@ type ListCreditTransactionsParamsFilter struct { // ListCurrenciesParamsFilter Filter options for listing currencies. type ListCurrenciesParamsFilter struct { - // Code Filters on the given string field value by exact match. All properties are - // optional; provide exactly one to specify the comparison. - Code *StringFieldFilterExact `json:"code,omitempty"` + // Code Filters on the given string field value by either exact or fuzzy match. All + // properties are optional; provide exactly one to specify the comparison. + Code *StringFieldFilter `json:"code,omitempty"` // Type Currency type for custom currencies. It should be a unique code but not // conflicting with any existing standard currency codes. @@ -10729,223 +10729,223 @@ var swaggerSpec = []string{ "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/v3/handlers/currencies/convert.go b/api/v3/handlers/currencies/convert.go index 44dc6d1cc8..f135be6e31 100644 --- a/api/v3/handlers/currencies/convert.go +++ b/api/v3/handlers/currencies/convert.go @@ -1,53 +1,12 @@ package currencies import ( - "errors" "fmt" v3 "github.com/openmeterio/openmeter/api/v3" - "github.com/openmeterio/openmeter/api/v3/filters" "github.com/openmeterio/openmeter/openmeter/currencies" - "github.com/openmeterio/openmeter/pkg/models" ) -// FromAPICurrencyCodeFilter converts an API StringFieldFilterExact for the -// currency code into a flat []string of codes to match (positive list). -// Only eq and oeq operators are supported; neq returns an error. Each value -// is validated for length (3–24 chars), matching the custom_currencies ent -// schema constraints (and also accepting fiat ISO codes which are 3 chars). -func FromAPICurrencyCodeFilter(f *filters.FilterStringExact) ([]string, error) { - if f == nil { - return nil, nil - } - if f.Neq != nil { - return nil, models.NewNillableGenericValidationError(errors.New("only eq and oeq operators are supported for currency code")) - } - - var codes []string - if f.Eq != nil { - codes = append(codes, *f.Eq) - } - codes = append(codes, f.Oeq...) - - if len(codes) == 0 { - return nil, nil - } - - var errs []error - for _, code := range codes { - if len(code) < 3 { - errs = append(errs, fmt.Errorf("currency code must be at least 3 characters, got %q", code)) - } else if len(code) > 24 { - errs = append(errs, fmt.Errorf("currency code must be at most 24 characters, got %q", code)) - } - } - if len(errs) > 0 { - return nil, models.NewNillableGenericValidationError(errors.Join(errs...)) - } - - return codes, nil -} - func FromAPIBillingCurrencyType(t v3.BillingCurrencyType) currencies.CurrencyType { switch t { case v3.BillingCurrencyTypeCustom: diff --git a/api/v3/handlers/currencies/list.go b/api/v3/handlers/currencies/list.go index c7fbdd30ad..dac824b8a4 100644 --- a/api/v3/handlers/currencies/list.go +++ b/api/v3/handlers/currencies/list.go @@ -9,6 +9,7 @@ import ( v3 "github.com/openmeterio/openmeter/api/v3" "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/filters" "github.com/openmeterio/openmeter/api/v3/request" "github.com/openmeterio/openmeter/api/v3/response" "github.com/openmeterio/openmeter/openmeter/currencies" @@ -64,31 +65,29 @@ func (h *handler) ListCurrencies() ListCurrenciesHandler { order = sort.Order.ToSortxOrder() } - var filterType *currencies.CurrencyType - var filterCodes []string + req := ListCurrenciesRequest{ + Page: page, + Namespace: ns, + OrderBy: currencies.OrderBy(orderBy), + Order: order, + } + if params.Filter != nil { if params.Filter.Type != nil { ft := FromAPIBillingCurrencyType(*params.Filter.Type) - filterType = &ft + req.FilterType = &ft } - codes, err := FromAPICurrencyCodeFilter(params.Filter.Code) + code, err := filters.FromAPIFilterString(params.Filter.Code) if err != nil { return ListCurrenciesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ {Field: "filter[code]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, }) } - filterCodes = codes + req.Code = code } - return ListCurrenciesRequest{ - Page: page, - Namespace: ns, - FilterType: filterType, - FilterCodes: filterCodes, - OrderBy: currencies.OrderBy(orderBy), - Order: order, - }, nil + return req, nil }, func(ctx context.Context, request ListCurrenciesRequest) (ListCurrenciesResponse, error) { result, err := h.currencyService.ListCurrencies(ctx, request) diff --git a/api/v3/openapi.yaml b/api/v3/openapi.yaml index f3e63fd97f..05868653db 100644 --- a/api/v3/openapi.yaml +++ b/api/v3/openapi.yaml @@ -8249,7 +8249,7 @@ components: type: $ref: '#/components/schemas/BillingCurrencyType' code: - $ref: '#/components/schemas/StringFieldFilterExact' + $ref: '#/components/schemas/StringFieldFilter' additionalProperties: false description: Filter options for listing currencies. ListCustomerEntitlementAccessResponseData: diff --git a/openmeter/currencies/adapter/currencies.go b/openmeter/currencies/adapter/currencies.go index 81a8cba787..7a1b9b1b8c 100644 --- a/openmeter/currencies/adapter/currencies.go +++ b/openmeter/currencies/adapter/currencies.go @@ -13,6 +13,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/ent/db/currencycostbasis" "github.com/openmeterio/openmeter/openmeter/ent/db/customcurrency" "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/framework/entutils" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination" @@ -47,9 +48,7 @@ func (a *adapter) ListCustomCurrencies(ctx context.Context, params currencies.Li q := a.db.CustomCurrency.Query(). Where(customcurrency.Namespace(params.Namespace)) - if len(params.FilterCodes) > 0 { - q = q.Where(customcurrency.CodeIn(params.FilterCodes...)) - } + q = filter.ApplyToQuery(q, params.Code, customcurrency.FieldCode) order := entutils.GetOrdering(sortx.OrderDefault) if !params.Order.IsDefaultValue() { diff --git a/openmeter/currencies/models.go b/openmeter/currencies/models.go index 4474de5eb2..c74281d10c 100644 --- a/openmeter/currencies/models.go +++ b/openmeter/currencies/models.go @@ -7,6 +7,7 @@ import ( "github.com/alpacahq/alpacadecimal" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination" "github.com/openmeterio/openmeter/pkg/sortx" @@ -45,8 +46,8 @@ type ListCurrenciesInput struct { // FilterType filters currencies by type: "custom" or "fiat". Nil means no filter. FilterType *CurrencyType `json:"filter_type,omitempty"` - // FilterCodes filters currencies by code. Empty means no filter; non-empty matches any of the listed codes. - FilterCodes []string `json:"filter_codes,omitempty"` + // Code filters currencies by code field. Nil means no filter. + Code *filter.FilterString `json:"code,omitempty"` OrderBy OrderBy Order sortx.Order @@ -65,6 +66,12 @@ func (i ListCurrenciesInput) Validate() error { } } + if i.Code != nil { + if err := i.Code.Validate(); err != nil { + errs = append(errs, fmt.Errorf("code: %w", err)) + } + } + if err := i.OrderBy.Validate(); err != nil { errs = append(errs, err) } diff --git a/openmeter/currencies/service/service.go b/openmeter/currencies/service/service.go index 2f416f4b57..2a7e0bbf2e 100644 --- a/openmeter/currencies/service/service.go +++ b/openmeter/currencies/service/service.go @@ -58,16 +58,16 @@ func (s *Service) ListCurrencies(ctx context.Context, params currencies.ListCurr } if includeFiat { + matchCode := params.Code.LoFilterPredicate() for _, def := range lo.Filter(currency.Definitions(), func(def *currency.Def, _ int) bool { // NOTE: this filters out non-iso currencies such as crypto - return def.ISONumeric != "" - }) { - code := def.ISOCode.String() - if len(params.FilterCodes) > 0 && !slices.Contains(params.FilterCodes, code) { - continue + if def.ISONumeric == "" { + return false } + return matchCode(def.ISOCode.String(), 0) + }) { items = append(items, currencies.Currency{ - Code: code, + Code: def.ISOCode.String(), Name: def.Name, Symbol: def.Symbol, }) diff --git a/openmeter/currencies/service/service_test.go b/openmeter/currencies/service/service_test.go index fd37135c1e..8005593f91 100644 --- a/openmeter/currencies/service/service_test.go +++ b/openmeter/currencies/service/service_test.go @@ -2,13 +2,14 @@ package service import ( "context" - "slices" "testing" + "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/openmeterio/openmeter/openmeter/currencies" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/framework/transaction" "github.com/openmeterio/openmeter/pkg/pagination" "github.com/openmeterio/openmeter/pkg/sortx" @@ -22,7 +23,7 @@ func (noopDriver) Rollback() error { return nil } func (noopDriver) SavePoint() error { return nil } // fakeAdapter implements currencies.Adapter for unit testing the service layer. -// ListCustomCurrencies applies FilterCodes from params to simulate DB-level filtering. +// ListCustomCurrencies applies the Code filter from params to simulate DB-level filtering. type fakeAdapter struct { custom []currencies.Currency } @@ -32,11 +33,11 @@ func (f *fakeAdapter) Tx(ctx context.Context) (context.Context, transaction.Driv } func (f *fakeAdapter) ListCustomCurrencies(_ context.Context, params currencies.ListCurrenciesInput) (pagination.Result[currencies.Currency], error) { - items := f.custom - if len(params.FilterCodes) > 0 { - items = slices.DeleteFunc(slices.Clone(items), func(c currencies.Currency) bool { - return !slices.Contains(params.FilterCodes, c.Code) - }) + items := make([]currencies.Currency, 0, len(f.custom)) + for _, c := range f.custom { + if params.Code.Match(c.Code) { + items = append(items, c) + } } return pagination.Result[currencies.Currency]{ Items: items, @@ -90,8 +91,8 @@ func TestListCurrencies_CombinedPath(t *testing.T) { { name: "filter by single fiat code returns only that currency", input: currencies.ListCurrenciesInput{ - Namespace: "test", - FilterCodes: []string{"USD"}, + Namespace: "test", + Code: &filter.FilterString{Eq: lo.ToPtr("USD")}, }, assertResults: func(t *testing.T, result pagination.Result[currencies.Currency]) { t.Helper() @@ -100,10 +101,10 @@ func TestListCurrencies_CombinedPath(t *testing.T) { }, }, { - name: "filter by multiple fiat codes returns only those currencies", + name: "filter by multiple fiat codes using In returns only those currencies", input: currencies.ListCurrenciesInput{ - Namespace: "test", - FilterCodes: []string{"USD", "EUR"}, + Namespace: "test", + Code: &filter.FilterString{In: lo.ToPtr([]string{"USD", "EUR"})}, }, assertResults: func(t *testing.T, result pagination.Result[currencies.Currency]) { t.Helper() @@ -115,8 +116,8 @@ func TestListCurrencies_CombinedPath(t *testing.T) { { name: "filter by custom currency code returns only that custom currency", input: currencies.ListCurrenciesInput{ - Namespace: "test", - FilterCodes: []string{"MYCUSTOM"}, + Namespace: "test", + Code: &filter.FilterString{Eq: lo.ToPtr("MYCUSTOM")}, }, assertResults: func(t *testing.T, result pagination.Result[currencies.Currency]) { t.Helper() @@ -127,9 +128,9 @@ func TestListCurrencies_CombinedPath(t *testing.T) { { name: "sort by name returns items sorted by name asc", input: currencies.ListCurrenciesInput{ - Namespace: "test", - FilterCodes: []string{"USD", "EUR", "GBP"}, - OrderBy: currencies.OrderByName, + Namespace: "test", + Code: &filter.FilterString{In: lo.ToPtr([]string{"USD", "EUR", "GBP"})}, + OrderBy: currencies.OrderByName, }, assertResults: func(t *testing.T, result pagination.Result[currencies.Currency]) { t.Helper() @@ -142,9 +143,9 @@ func TestListCurrencies_CombinedPath(t *testing.T) { { name: "sort by code desc returns items sorted by code descending", input: currencies.ListCurrenciesInput{ - Namespace: "test", - FilterCodes: []string{"USD", "EUR", "GBP"}, - Order: sortx.OrderDesc, + Namespace: "test", + Code: &filter.FilterString{In: lo.ToPtr([]string{"USD", "EUR", "GBP"})}, + Order: sortx.OrderDesc, }, assertResults: func(t *testing.T, result pagination.Result[currencies.Currency]) { t.Helper() @@ -154,6 +155,21 @@ func TestListCurrencies_CombinedPath(t *testing.T) { } }, }, + { + name: "filter by Ne excludes a single code from combined results", + input: currencies.ListCurrenciesInput{ + Namespace: "test", + Code: &filter.FilterString{Ne: lo.ToPtr("USD")}, + // Limit to known codes plus our custom one to make the assertion easy + Page: pagination.NewPage(1, 5), + }, + assertResults: func(t *testing.T, result pagination.Result[currencies.Currency]) { + t.Helper() + for _, item := range result.Items { + assert.NotEqual(t, "USD", item.Code, "USD should be excluded") + } + }, + }, { name: "invalid order by returns validation error", input: currencies.ListCurrenciesInput{ @@ -185,9 +201,9 @@ func TestListCurrencies_CustomOnlyPath(t *testing.T) { t.Run("filter by type custom with code filter uses custom-only fast path", func(t *testing.T) { ft := currencies.CurrencyTypeCustom result, err := svc.ListCurrencies(t.Context(), currencies.ListCurrenciesInput{ - Namespace: "test", - FilterType: &ft, - FilterCodes: []string{"MYCUSTOM"}, + Namespace: "test", + FilterType: &ft, + Code: &filter.FilterString{Eq: lo.ToPtr("MYCUSTOM")}, }) require.NoError(t, err) require.Equal(t, 1, result.TotalCount) diff --git a/pkg/filter/filter.go b/pkg/filter/filter.go index b57a007175..36871be92e 100644 --- a/pkg/filter/filter.go +++ b/pkg/filter/filter.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "reflect" + "regexp" "slices" "strings" "time" @@ -268,6 +269,106 @@ func (f FilterString) Select(field string) func(*sql.Selector) { } } +// Match reports whether the filter matches the given string value. A nil +// receiver and an empty filter match every value. Semantics mirror Select / +// SelectWhereExpr: Contains/Ncontains are case-insensitive; Like/Ilike treat +// the pattern as SQL LIKE (% and _ wildcards); Exists checks for a non-empty +// value. +func (f *FilterString) Match(value string) bool { + if f == nil || f.IsEmpty() { + return true + } + return f.matches(value) +} + +// LoFilterPredicate returns an lo.Filter-compatible predicate that evaluates +// the filter against a string field value. +func (f *FilterString) LoFilterPredicate() func(value string, _ int) bool { + return func(value string, _ int) bool { return f.Match(value) } +} + +func (f FilterString) matches(value string) bool { + switch { + case f.Eq != nil: + return value == *f.Eq + case f.Ne != nil: + return value != *f.Ne + case f.Exists != nil: + return *f.Exists == (value != "") + case f.In != nil: + return slices.Contains(*f.In, value) + case f.Nin != nil: + return !slices.Contains(*f.Nin, value) + case f.Contains != nil: + return strings.Contains(strings.ToLower(value), strings.ToLower(*f.Contains)) + case f.Ncontains != nil: + return !strings.Contains(strings.ToLower(value), strings.ToLower(*f.Ncontains)) + case f.Like != nil: + ok, _ := likeMatch(*f.Like, value, false) + return ok + case f.Nlike != nil: + ok, _ := likeMatch(*f.Nlike, value, false) + return !ok + case f.Ilike != nil: + ok, _ := likeMatch(*f.Ilike, value, true) + return ok + case f.Nilike != nil: + ok, _ := likeMatch(*f.Nilike, value, true) + return !ok + case f.Gt != nil: + return value > *f.Gt + case f.Gte != nil: + return value >= *f.Gte + case f.Lt != nil: + return value < *f.Lt + case f.Lte != nil: + return value <= *f.Lte + case f.And != nil: + for _, child := range *f.And { + if !child.matches(value) { + return false + } + } + return true + case f.Or != nil: + for _, child := range *f.Or { + if child.matches(value) { + return true + } + } + return false + default: + return true + } +} + +// likeMatch evaluates a SQL LIKE pattern (% = any sequence, _ = any single +// char) against value. If fold is true the comparison is case-insensitive. +func likeMatch(pattern, value string, fold bool) (bool, error) { + var b strings.Builder + if fold { + b.WriteString("(?i)") + } + b.WriteByte('^') + for i := 0; i < len(pattern); { + ch := pattern[i] + switch ch { + case '%': + b.WriteString(".*") + i++ + case '_': + b.WriteByte('.') + i++ + default: + // regex-escape the character + b.WriteString(regexp.QuoteMeta(string(ch))) + i++ + } + } + b.WriteByte('$') + return regexp.MatchString(b.String(), value) +} + // FilterInteger is a filter for an integer field. type FilterInteger struct { Eq *int `json:"$eq,omitempty"` diff --git a/pkg/filter/filter_test.go b/pkg/filter/filter_test.go index bc38180385..f3bb0098d0 100644 --- a/pkg/filter/filter_test.go +++ b/pkg/filter/filter_test.go @@ -2580,3 +2580,121 @@ func TestFilterULID_Validate(t *testing.T) { }) } } + +func TestFilterString_Match(t *testing.T) { + tests := []struct { + name string + filter *filter.FilterString + value string + want bool + }{ + // nil / empty filter matches everything + {name: "nil filter matches", filter: nil, value: "USD", want: true}, + {name: "empty filter matches", filter: &filter.FilterString{}, value: "USD", want: true}, + + // Eq / Ne + {name: "Eq match", filter: &filter.FilterString{Eq: lo.ToPtr("USD")}, value: "USD", want: true}, + {name: "Eq no match", filter: &filter.FilterString{Eq: lo.ToPtr("USD")}, value: "EUR", want: false}, + {name: "Ne match", filter: &filter.FilterString{Ne: lo.ToPtr("USD")}, value: "EUR", want: true}, + {name: "Ne no match", filter: &filter.FilterString{Ne: lo.ToPtr("USD")}, value: "USD", want: false}, + + // In / Nin + {name: "In match", filter: &filter.FilterString{In: lo.ToPtr([]string{"USD", "EUR"})}, value: "EUR", want: true}, + {name: "In no match", filter: &filter.FilterString{In: lo.ToPtr([]string{"USD", "EUR"})}, value: "GBP", want: false}, + {name: "Nin match", filter: &filter.FilterString{Nin: lo.ToPtr([]string{"USD", "EUR"})}, value: "GBP", want: true}, + {name: "Nin no match", filter: &filter.FilterString{Nin: lo.ToPtr([]string{"USD", "EUR"})}, value: "USD", want: false}, + + // Exists + {name: "Exists true matches non-empty", filter: &filter.FilterString{Exists: lo.ToPtr(true)}, value: "USD", want: true}, + {name: "Exists true no match empty", filter: &filter.FilterString{Exists: lo.ToPtr(true)}, value: "", want: false}, + {name: "Exists false matches empty", filter: &filter.FilterString{Exists: lo.ToPtr(false)}, value: "", want: true}, + {name: "Exists false no match non-empty", filter: &filter.FilterString{Exists: lo.ToPtr(false)}, value: "USD", want: false}, + + // Contains / Ncontains (case-insensitive) + {name: "Contains match case-insensitive", filter: &filter.FilterString{Contains: lo.ToPtr("us")}, value: "USD", want: true}, + {name: "Contains no match", filter: &filter.FilterString{Contains: lo.ToPtr("eur")}, value: "USD", want: false}, + {name: "Ncontains match", filter: &filter.FilterString{Ncontains: lo.ToPtr("eur")}, value: "USD", want: true}, + {name: "Ncontains no match", filter: &filter.FilterString{Ncontains: lo.ToPtr("us")}, value: "USD", want: false}, + + // Like / Nlike + {name: "Like percent wildcard", filter: &filter.FilterString{Like: lo.ToPtr("U%")}, value: "USD", want: true}, + {name: "Like underscore wildcard", filter: &filter.FilterString{Like: lo.ToPtr("U_D")}, value: "USD", want: true}, + {name: "Like no match", filter: &filter.FilterString{Like: lo.ToPtr("E%")}, value: "USD", want: false}, + {name: "Nlike match", filter: &filter.FilterString{Nlike: lo.ToPtr("E%")}, value: "USD", want: true}, + {name: "Nlike no match", filter: &filter.FilterString{Nlike: lo.ToPtr("U%")}, value: "USD", want: false}, + + // Ilike / Nilike (case-insensitive) + {name: "Ilike case-insensitive match", filter: &filter.FilterString{Ilike: lo.ToPtr("u%")}, value: "USD", want: true}, + {name: "Ilike no match", filter: &filter.FilterString{Ilike: lo.ToPtr("e%")}, value: "USD", want: false}, + {name: "Nilike match", filter: &filter.FilterString{Nilike: lo.ToPtr("e%")}, value: "USD", want: true}, + {name: "Nilike no match", filter: &filter.FilterString{Nilike: lo.ToPtr("u%")}, value: "USD", want: false}, + + // Gt / Gte / Lt / Lte (lexicographic) + {name: "Gt match", filter: &filter.FilterString{Gt: lo.ToPtr("GBP")}, value: "USD", want: true}, + {name: "Gt no match equal", filter: &filter.FilterString{Gt: lo.ToPtr("USD")}, value: "USD", want: false}, + {name: "Gte match equal", filter: &filter.FilterString{Gte: lo.ToPtr("USD")}, value: "USD", want: true}, + {name: "Gte match greater", filter: &filter.FilterString{Gte: lo.ToPtr("EUR")}, value: "USD", want: true}, + {name: "Lt match", filter: &filter.FilterString{Lt: lo.ToPtr("USD")}, value: "EUR", want: true}, + {name: "Lt no match equal", filter: &filter.FilterString{Lt: lo.ToPtr("EUR")}, value: "EUR", want: false}, + {name: "Lte match equal", filter: &filter.FilterString{Lte: lo.ToPtr("EUR")}, value: "EUR", want: true}, + {name: "Lte match less", filter: &filter.FilterString{Lte: lo.ToPtr("USD")}, value: "EUR", want: true}, + + // And / Or + { + name: "And all match", + filter: &filter.FilterString{And: lo.ToPtr([]filter.FilterString{ + {Gte: lo.ToPtr("EUR")}, + {Lte: lo.ToPtr("USD")}, + })}, + value: "GBP", + want: true, + }, + { + name: "And one no match", + filter: &filter.FilterString{And: lo.ToPtr([]filter.FilterString{ + {Gte: lo.ToPtr("EUR")}, + {Lt: lo.ToPtr("GBP")}, + })}, + value: "USD", + want: false, + }, + { + name: "Or first match", + filter: &filter.FilterString{Or: lo.ToPtr([]filter.FilterString{ + {Eq: lo.ToPtr("USD")}, + {Eq: lo.ToPtr("EUR")}, + })}, + value: "USD", + want: true, + }, + { + name: "Or none match", + filter: &filter.FilterString{Or: lo.ToPtr([]filter.FilterString{ + {Eq: lo.ToPtr("USD")}, + {Eq: lo.ToPtr("EUR")}, + })}, + value: "GBP", + want: false, + }, + // nested And/Or + { + name: "nested And inside Or", + filter: &filter.FilterString{Or: lo.ToPtr([]filter.FilterString{ + {Eq: lo.ToPtr("USD")}, + {And: lo.ToPtr([]filter.FilterString{ + {Gte: lo.ToPtr("GBP")}, + {Lte: lo.ToPtr("GBP")}, + })}, + })}, + value: "GBP", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.filter.Match(tt.value) + assert.Equal(t, tt.want, got) + }) + } +} From 54c804cafa7deb46c268877c23ee8d78edefd61a Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Wed, 13 May 2026 11:12:39 +0200 Subject: [PATCH 4/5] chore: remove like search option from LoFilterPredicate --- openmeter/currencies/service/service.go | 10 ++- openmeter/currencies/service/service_test.go | 2 +- pkg/filter/filter.go | 91 +++++++------------- pkg/filter/filter_test.go | 34 +++++--- 4 files changed, 60 insertions(+), 77 deletions(-) diff --git a/openmeter/currencies/service/service.go b/openmeter/currencies/service/service.go index 2a7e0bbf2e..c64cd500ab 100644 --- a/openmeter/currencies/service/service.go +++ b/openmeter/currencies/service/service.go @@ -59,13 +59,17 @@ func (s *Service) ListCurrencies(ctx context.Context, params currencies.ListCurr if includeFiat { matchCode := params.Code.LoFilterPredicate() - for _, def := range lo.Filter(currency.Definitions(), func(def *currency.Def, _ int) bool { + filteredMatchCode, err := lo.FilterErr(currency.Definitions(), func(def *currency.Def, _ int) (bool, error) { // NOTE: this filters out non-iso currencies such as crypto if def.ISONumeric == "" { - return false + return false, nil } return matchCode(def.ISOCode.String(), 0) - }) { + }) + if err != nil { + return pagination.Result[currencies.Currency]{}, fmt.Errorf("filtering fiat currencies by code: %w", err) + } + for _, def := range filteredMatchCode { items = append(items, currencies.Currency{ Code: def.ISOCode.String(), Name: def.Name, diff --git a/openmeter/currencies/service/service_test.go b/openmeter/currencies/service/service_test.go index 8005593f91..bac811cd62 100644 --- a/openmeter/currencies/service/service_test.go +++ b/openmeter/currencies/service/service_test.go @@ -35,7 +35,7 @@ func (f *fakeAdapter) Tx(ctx context.Context) (context.Context, transaction.Driv func (f *fakeAdapter) ListCustomCurrencies(_ context.Context, params currencies.ListCurrenciesInput) (pagination.Result[currencies.Currency], error) { items := make([]currencies.Currency, 0, len(f.custom)) for _, c := range f.custom { - if params.Code.Match(c.Code) { + if ok, _ := params.Code.Match(c.Code); ok { items = append(items, c) } } diff --git a/pkg/filter/filter.go b/pkg/filter/filter.go index 36871be92e..de0892484e 100644 --- a/pkg/filter/filter.go +++ b/pkg/filter/filter.go @@ -5,7 +5,6 @@ import ( "fmt" "math" "reflect" - "regexp" "slices" "strings" "time" @@ -36,6 +35,7 @@ var ( ErrFilterMultipleOperators = errors.New("filter is invalid: multiple operators are set") ErrFilterComplexityExceeded = errors.New("filter complexity exceeds maximum allowed depth") ErrFilterFormatMismatch = errors.New("filter is invalid: format mismatch") + ErrOperationNotSupported = errors.New("filter is invalid: operation not supported") ) var ( @@ -274,99 +274,72 @@ func (f FilterString) Select(field string) func(*sql.Selector) { // SelectWhereExpr: Contains/Ncontains are case-insensitive; Like/Ilike treat // the pattern as SQL LIKE (% and _ wildcards); Exists checks for a non-empty // value. -func (f *FilterString) Match(value string) bool { +func (f *FilterString) Match(value string) (bool, error) { if f == nil || f.IsEmpty() { - return true + return true, nil } return f.matches(value) } // LoFilterPredicate returns an lo.Filter-compatible predicate that evaluates // the filter against a string field value. -func (f *FilterString) LoFilterPredicate() func(value string, _ int) bool { - return func(value string, _ int) bool { return f.Match(value) } +func (f *FilterString) LoFilterPredicate() func(value string, _ int) (bool, error) { + return func(value string, _ int) (bool, error) { return f.Match(value) } } -func (f FilterString) matches(value string) bool { +func (f FilterString) matches(value string) (bool, error) { switch { case f.Eq != nil: - return value == *f.Eq + return value == *f.Eq, nil case f.Ne != nil: - return value != *f.Ne + return value != *f.Ne, nil case f.Exists != nil: - return *f.Exists == (value != "") + return *f.Exists == (value != ""), nil case f.In != nil: - return slices.Contains(*f.In, value) + return slices.Contains(*f.In, value), nil case f.Nin != nil: - return !slices.Contains(*f.Nin, value) + return !slices.Contains(*f.Nin, value), nil case f.Contains != nil: - return strings.Contains(strings.ToLower(value), strings.ToLower(*f.Contains)) + return strings.Contains(strings.ToLower(value), strings.ToLower(*f.Contains)), nil case f.Ncontains != nil: - return !strings.Contains(strings.ToLower(value), strings.ToLower(*f.Ncontains)) + return !strings.Contains(strings.ToLower(value), strings.ToLower(*f.Ncontains)), nil case f.Like != nil: - ok, _ := likeMatch(*f.Like, value, false) - return ok + return false, ErrOperationNotSupported case f.Nlike != nil: - ok, _ := likeMatch(*f.Nlike, value, false) - return !ok + return false, ErrOperationNotSupported case f.Ilike != nil: - ok, _ := likeMatch(*f.Ilike, value, true) - return ok + return false, ErrOperationNotSupported case f.Nilike != nil: - ok, _ := likeMatch(*f.Nilike, value, true) - return !ok + return false, ErrOperationNotSupported case f.Gt != nil: - return value > *f.Gt + return value > *f.Gt, nil case f.Gte != nil: - return value >= *f.Gte + return value >= *f.Gte, nil case f.Lt != nil: - return value < *f.Lt + return value < *f.Lt, nil case f.Lte != nil: - return value <= *f.Lte + return value <= *f.Lte, nil case f.And != nil: for _, child := range *f.And { - if !child.matches(value) { - return false + if match, err := child.matches(value); err != nil { + return false, err + } else if !match { + return false, nil } } - return true + return true, nil case f.Or != nil: for _, child := range *f.Or { - if child.matches(value) { - return true + if match, err := child.matches(value); err != nil { + return false, err + } else if match { + return true, nil } } - return false + return false, nil default: - return true - } -} - -// likeMatch evaluates a SQL LIKE pattern (% = any sequence, _ = any single -// char) against value. If fold is true the comparison is case-insensitive. -func likeMatch(pattern, value string, fold bool) (bool, error) { - var b strings.Builder - if fold { - b.WriteString("(?i)") - } - b.WriteByte('^') - for i := 0; i < len(pattern); { - ch := pattern[i] - switch ch { - case '%': - b.WriteString(".*") - i++ - case '_': - b.WriteByte('.') - i++ - default: - // regex-escape the character - b.WriteString(regexp.QuoteMeta(string(ch))) - i++ - } + return true, nil } - b.WriteByte('$') - return regexp.MatchString(b.String(), value) } // FilterInteger is a filter for an integer field. diff --git a/pkg/filter/filter_test.go b/pkg/filter/filter_test.go index f3bb0098d0..20c8ef6b27 100644 --- a/pkg/filter/filter_test.go +++ b/pkg/filter/filter_test.go @@ -2583,10 +2583,11 @@ func TestFilterULID_Validate(t *testing.T) { func TestFilterString_Match(t *testing.T) { tests := []struct { - name string - filter *filter.FilterString - value string - want bool + name string + filter *filter.FilterString + value string + want bool + wantErr bool }{ // nil / empty filter matches everything {name: "nil filter matches", filter: nil, value: "USD", want: true}, @@ -2617,17 +2618,17 @@ func TestFilterString_Match(t *testing.T) { {name: "Ncontains no match", filter: &filter.FilterString{Ncontains: lo.ToPtr("us")}, value: "USD", want: false}, // Like / Nlike - {name: "Like percent wildcard", filter: &filter.FilterString{Like: lo.ToPtr("U%")}, value: "USD", want: true}, - {name: "Like underscore wildcard", filter: &filter.FilterString{Like: lo.ToPtr("U_D")}, value: "USD", want: true}, - {name: "Like no match", filter: &filter.FilterString{Like: lo.ToPtr("E%")}, value: "USD", want: false}, - {name: "Nlike match", filter: &filter.FilterString{Nlike: lo.ToPtr("E%")}, value: "USD", want: true}, - {name: "Nlike no match", filter: &filter.FilterString{Nlike: lo.ToPtr("U%")}, value: "USD", want: false}, + {name: "Like percent wildcard", filter: &filter.FilterString{Like: lo.ToPtr("U%")}, value: "USD", wantErr: true}, + {name: "Like underscore wildcard", filter: &filter.FilterString{Like: lo.ToPtr("U_D")}, value: "USD", wantErr: true}, + {name: "Like no match", filter: &filter.FilterString{Like: lo.ToPtr("E%")}, value: "USD", wantErr: true}, + {name: "Nlike match", filter: &filter.FilterString{Nlike: lo.ToPtr("E%")}, value: "USD", wantErr: true}, + {name: "Nlike no match", filter: &filter.FilterString{Nlike: lo.ToPtr("U%")}, value: "USD", wantErr: true}, // Ilike / Nilike (case-insensitive) - {name: "Ilike case-insensitive match", filter: &filter.FilterString{Ilike: lo.ToPtr("u%")}, value: "USD", want: true}, - {name: "Ilike no match", filter: &filter.FilterString{Ilike: lo.ToPtr("e%")}, value: "USD", want: false}, - {name: "Nilike match", filter: &filter.FilterString{Nilike: lo.ToPtr("e%")}, value: "USD", want: true}, - {name: "Nilike no match", filter: &filter.FilterString{Nilike: lo.ToPtr("u%")}, value: "USD", want: false}, + {name: "Ilike case-insensitive match", filter: &filter.FilterString{Ilike: lo.ToPtr("u%")}, value: "USD", wantErr: true}, + {name: "Ilike no match", filter: &filter.FilterString{Ilike: lo.ToPtr("e%")}, value: "USD", wantErr: true}, + {name: "Nilike match", filter: &filter.FilterString{Nilike: lo.ToPtr("e%")}, value: "USD", wantErr: true}, + {name: "Nilike no match", filter: &filter.FilterString{Nilike: lo.ToPtr("u%")}, value: "USD", wantErr: true}, // Gt / Gte / Lt / Lte (lexicographic) {name: "Gt match", filter: &filter.FilterString{Gt: lo.ToPtr("GBP")}, value: "USD", want: true}, @@ -2693,7 +2694,12 @@ func TestFilterString_Match(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := tt.filter.Match(tt.value) + got, err := tt.filter.Match(tt.value) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } assert.Equal(t, tt.want, got) }) } From 76a9f2a96da221d596ca78d400b5c80d19221199 Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Wed, 13 May 2026 12:15:23 +0200 Subject: [PATCH 5/5] fix: resolve matches funtion's or statement to be more fault tolerant --- pkg/filter/filter.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/filter/filter.go b/pkg/filter/filter.go index de0892484e..511c055ddf 100644 --- a/pkg/filter/filter.go +++ b/pkg/filter/filter.go @@ -329,14 +329,15 @@ func (f FilterString) matches(value string) (bool, error) { } return true, nil case f.Or != nil: + var orErr error for _, child := range *f.Or { if match, err := child.matches(value); err != nil { - return false, err + orErr = err } else if match { return true, nil } } - return false, nil + return false, orErr default: return true, nil }