diff --git a/api/api.gen.go b/api/api.gen.go index fd43e3edf..32fda34e8 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -19,6 +19,7 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" "github.com/oapi-codegen/runtime" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/models" ) @@ -29,6 +30,24 @@ const ( // Event CloudEvents Specification JSON Schema type Event = event.Event +// Filter defines model for Filter. +type Filter = filter.Filter + +// FilterValue defines model for FilterValue. +type FilterValue = filter.FilterValue + +// FilterValueArrayNumber defines model for FilterValueArrayNumber. +type FilterValueArrayNumber = filter.FilterValueArrayNumber + +// FilterValueArrayString defines model for FilterValueArrayString. +type FilterValueArrayString = filter.FilterValueArrayString + +// FilterValueNumber defines model for FilterValueNumber. +type FilterValueNumber = filter.FilterValueNumber + +// FilterValueString defines model for FilterValueString. +type FilterValueString = filter.FilterValueString + // IdOrSlug defines model for IdOrSlug. type IdOrSlug = string @@ -92,6 +111,11 @@ type WindowSize = models.WindowSize // MeterIdOrSlug defines model for meterIdOrSlug. type MeterIdOrSlug = IdOrSlug +// QueryFilter Simple filter for subject and any group bys. +// Usage: ?filter[subject]={"$eq":"customer-1"}&filter[model]={"$eq":"gpt-4"} +// Complex example: ?filter[subject]={"$in":["customer-1","customer-2"]}&filter[model]={"$eq":"/gpt-4"}&filter[type]={"$nin":["input","system"]}` +type QueryFilter = map[string]string + // QueryFrom defines model for queryFrom. type QueryFrom = time.Time @@ -161,7 +185,11 @@ type QueryMeterParams struct { // WindowTimeZone The value is the name of the time zone as defined in the IANA Time Zone Database (http://www.iana.org/time-zones). // If not specified, the UTC timezone will be used. WindowTimeZone *QueryWindowTimeZone `form:"windowTimeZone,omitempty" json:"windowTimeZone,omitempty"` - Subject *QuerySubject `form:"subject,omitempty" json:"subject,omitempty"` + + // Subject Filtering and group by multiple subjects. + // Usage: ?subject=customer-1&subject=customer-2 + Subject *QuerySubject `form:"subject,omitempty" json:"subject,omitempty"` + Filter *QueryFilter `json:"filter,omitempty"` // GroupBy If not specified a single aggregate will be returned for each subject and time window. // `subject` is a reserved group by value. @@ -570,6 +598,14 @@ func (siw *ServerInterfaceWrapper) QueryMeter(w http.ResponseWriter, r *http.Req return } + // ------------- Optional query parameter "filter" ------------- + + err = runtime.BindQueryParameter("deepObject", true, false, "filter", r.URL.Query(), ¶ms.Filter) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter", Err: err}) + return + } + // ------------- Optional query parameter "groupBy" ------------- err = runtime.BindQueryParameter("form", true, false, "groupBy", r.URL.Query(), ¶ms.GroupBy) @@ -994,68 +1030,74 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "1w3TE/lIAAA=", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/client/go/client.gen.go b/api/client/go/client.gen.go index fd147fcbb..5683cf619 100644 --- a/api/client/go/client.gen.go +++ b/api/client/go/client.gen.go @@ -20,6 +20,7 @@ import ( "github.com/cloudevents/sdk-go/v2/event" "github.com/getkin/kin-openapi/openapi3" "github.com/oapi-codegen/runtime" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/models" ) @@ -30,6 +31,24 @@ const ( // Event CloudEvents Specification JSON Schema type Event = event.Event +// Filter defines model for Filter. +type Filter = filter.Filter + +// FilterValue defines model for FilterValue. +type FilterValue = filter.FilterValue + +// FilterValueArrayNumber defines model for FilterValueArrayNumber. +type FilterValueArrayNumber = filter.FilterValueArrayNumber + +// FilterValueArrayString defines model for FilterValueArrayString. +type FilterValueArrayString = filter.FilterValueArrayString + +// FilterValueNumber defines model for FilterValueNumber. +type FilterValueNumber = filter.FilterValueNumber + +// FilterValueString defines model for FilterValueString. +type FilterValueString = filter.FilterValueString + // IdOrSlug defines model for IdOrSlug. type IdOrSlug = string @@ -93,6 +112,11 @@ type WindowSize = models.WindowSize // MeterIdOrSlug defines model for meterIdOrSlug. type MeterIdOrSlug = IdOrSlug +// QueryFilter Simple filter for subject and any group bys. +// Usage: ?filter[subject]={"$eq":"customer-1"}&filter[model]={"$eq":"gpt-4"} +// Complex example: ?filter[subject]={"$in":["customer-1","customer-2"]}&filter[model]={"$eq":"/gpt-4"}&filter[type]={"$nin":["input","system"]}` +type QueryFilter = map[string]string + // QueryFrom defines model for queryFrom. type QueryFrom = time.Time @@ -162,7 +186,11 @@ type QueryMeterParams struct { // WindowTimeZone The value is the name of the time zone as defined in the IANA Time Zone Database (http://www.iana.org/time-zones). // If not specified, the UTC timezone will be used. WindowTimeZone *QueryWindowTimeZone `form:"windowTimeZone,omitempty" json:"windowTimeZone,omitempty"` - Subject *QuerySubject `form:"subject,omitempty" json:"subject,omitempty"` + + // Subject Filtering and group by multiple subjects. + // Usage: ?subject=customer-1&subject=customer-2 + Subject *QuerySubject `form:"subject,omitempty" json:"subject,omitempty"` + Filter *QueryFilter `json:"filter,omitempty"` // GroupBy If not specified a single aggregate will be returned for each subject and time window. // `subject` is a reserved group by value. @@ -1001,6 +1029,22 @@ func NewQueryMeterRequest(server string, meterIdOrSlug MeterIdOrSlug, params *Qu } + if params.Filter != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("deepObject", true, "filter", runtime.ParamLocationQuery, *params.Filter); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + if params.GroupBy != nil { if queryFrag, err := runtime.StyleParamWithLocation("form", true, "groupBy", runtime.ParamLocationQuery, *params.GroupBy); err != nil { @@ -2743,68 +2787,74 @@ func ParseGetSubjectResponse(rsp *http.Response) (*GetSubjectResponse, error) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "1w3TE/lIAAA=", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/openapi.yaml b/api/openapi.yaml index 2dd21be94..02571bcda 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -157,6 +157,7 @@ paths: - $ref: "#/components/parameters/queryWindowSize" - $ref: "#/components/parameters/queryWindowTimeZone" - $ref: "#/components/parameters/querySubject" + - $ref: "#/components/parameters/queryFilter" - $ref: "#/components/parameters/queryGroupBy" responses: "200": @@ -703,6 +704,81 @@ components: pattern: "^[a-z0-9]+(?:_[a-z0-9]+)*$" minLength: 1 maxLength: 63 + # Filter + Filter: + x-go-type: filter.Filter + x-go-type-import: + path: github.com/openmeterio/openmeter/pkg/filter + type: object + properties: + $and: + type: array + minItems: 2 + items: + $ref: "#/components/schemas/Filter" + $or: + type: array + minItems: 2 + items: + $ref: "#/components/schemas/Filter" + $in: + $ref: "#/components/schemas/FilterValue" + $nin: + $ref: "#/components/schemas/FilterValue" + $eq: + $ref: "#/components/schemas/FilterValue" + $ne: + $ref: "#/components/schemas/FilterValue" + $gt: + $ref: "#/components/schemas/FilterValue" + $gte: + $ref: "#/components/schemas/FilterValue" + $lt: + $ref: "#/components/schemas/FilterValue" + $lte: + $ref: "#/components/schemas/FilterValue" + FilterValue: + x-go-type: filter.FilterValue + x-go-type-import: + path: github.com/openmeterio/openmeter/pkg/filter + oneOf: + - $ref: "#/components/schemas/FilterValueString" + - $ref: "#/components/schemas/FilterValueNumber" + - $ref: "#/components/schemas/FilterValueArrayString" + - $ref: "#/components/schemas/FilterValueArrayNumber" + FilterValueArray: + type: array + minItems: 1 + items: + x-go-type-name: FilterValueArrayItem + oneOf: + - $ref: "#/components/schemas/FilterValueString" + - $ref: "#/components/schemas/FilterValueNumber" + FilterValueString: + x-go-type: filter.FilterValueString + x-go-type-import: + path: github.com/openmeterio/openmeter/pkg/filter + type: string + FilterValueNumber: + x-go-type: filter.FilterValueNumber + x-go-type-import: + path: github.com/openmeterio/openmeter/pkg/filter + type: number + FilterValueArrayString: + x-go-type: filter.FilterValueArrayString + x-go-type-import: + path: github.com/openmeterio/openmeter/pkg/filter + type: array + items: + type: string + FilterValueArrayNumber: + x-go-type: filter.FilterValueArrayNumber + x-go-type-import: + path: github.com/openmeterio/openmeter/pkg/filter + type: array + items: + type: number + x-go-type: float64 parameters: meterIdOrSlug: @@ -760,12 +836,35 @@ components: example: "America/New_York" querySubject: name: subject + description: | + Filtering and group by multiple subjects. + Usage: ?subject=customer-1&subject=customer-2 in: query required: false schema: type: array items: type: string + queryFilter: + name: filter + in: query + style: deepObject + explode: true + required: false + schema: + # TODO: ideally this would be: map[string]filter.Filter + # But the deepObject style parser doesn't support complex objects. + x-go-type: map[string]string + type: object + description: | + Simple filter for subject and any group bys. + Usage: ?filter[subject]={"$eq":"customer-1"}&filter[model]={"$eq":"gpt-4"} + Complex example: ?filter[subject]={"$in":["customer-1","customer-2"]}&filter[model]={"$eq":"/gpt-4"}&filter[type]={"$nin":["input","system"]}` + example: + subject: customer-1 + model: gpt-4 + additionalProperties: + $ref: "#/components/schemas/Filter" queryGroupBy: name: groupBy in: query diff --git a/internal/server/router/meter_query.go b/internal/server/router/meter_query.go index 986bec559..9b8cd28c8 100644 --- a/internal/server/router/meter_query.go +++ b/internal/server/router/meter_query.go @@ -7,6 +7,8 @@ import ( "log/slog" "mime" "net/http" + "slices" + "strings" "time" "github.com/go-chi/render" @@ -14,6 +16,7 @@ import ( "github.com/openmeterio/openmeter/api" "github.com/openmeterio/openmeter/internal/streaming" "github.com/openmeterio/openmeter/pkg/contextx" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/models" ) @@ -57,10 +60,6 @@ func (a *Router) QueryMeterWithMeter(ctx context.Context, w http.ResponseWriter, Aggregation: meter.Aggregation, } - if params.Subject != nil { - queryParams.Subject = *params.Subject - } - if params.GroupBy != nil { for _, groupBy := range *params.GroupBy { // Validate group by, `subject` is a special group by @@ -76,6 +75,22 @@ func (a *Router) QueryMeterWithMeter(ctx context.Context, w http.ResponseWriter, } } + // Subject is a special query parameter which both filters and groups by subject(s) + if params.Subject != nil { + subjects := []string{} + for _, subject := range *params.Subject { + subjects = append(subjects, fmt.Sprintf(`"%s"`, subject)) + } + + f, _ := filter.ToFilter(fmt.Sprintf(`{"$in": [%s]}`, strings.Join(subjects, ", "))) + queryParams.FilterSubject = &f + + // Add subject to group by if not already present + if !slices.Contains(queryParams.GroupBy, "subject") { + queryParams.GroupBy = append(queryParams.GroupBy, "subject") + } + } + if params.WindowTimeZone != nil { tz, err := time.LoadLocation(*params.WindowTimeZone) if err != nil { @@ -88,6 +103,47 @@ func (a *Router) QueryMeterWithMeter(ctx context.Context, w http.ResponseWriter, queryParams.WindowTimeZone = tz } + if params.Filter != nil { + for k, paramFilter := range *params.Filter { + // TODO: ideally `paramFilter` would be `filter.Filter` type but the OpenAPI parser + // doesn't support complext objects in query parameters so we have to parse it manually from string. + // With this we also loose the ability to validate the filter in the OpenAPI schema and we have to do it manually here. + f, err := filter.ToFilter(paramFilter) + if err != nil { + err := fmt.Errorf(`invalid "%s" filter (%s): %w`, k, paramFilter, err) + models.NewStatusProblem(ctx, err, http.StatusBadRequest).Respond(w, r) + return + } + + err = filter.Validate(f) + if err != nil { + err := fmt.Errorf("invalid %s filter (%s): %w", k, paramFilter, err) + models.NewStatusProblem(ctx, err, http.StatusBadRequest).Respond(w, r) + return + } + + // Subject filters + if k == "subject" { + queryParams.FilterSubject = &f + continue + } + + // GroupBy filters + if _, ok := meter.GroupBy[k]; ok { + if queryParams.FilterGroupBy == nil { + queryParams.FilterGroupBy = map[string]filter.Filter{} + } + + queryParams.FilterGroupBy[k] = f + continue + } else { + err := fmt.Errorf("invalid group by filter: %s", k) + models.NewStatusProblem(ctx, err, http.StatusBadRequest).Respond(w, r) + return + } + } + } + if err := queryParams.Validate(meter.WindowSize); err != nil { err := fmt.Errorf("invalid query parameters: %w", err) @@ -163,10 +219,19 @@ func (resp QueryMeterResponse) Render(_ http.ResponseWriter, _ *http.Request) er func (resp QueryMeterResponse) RenderCSV(w http.ResponseWriter, r *http.Request, groupByKeys []string, meterIDOrSlug string) { records := [][]string{} + // Filter out the subject from the group by keys + dataGroupByKeys := make([]string, 0, len(groupByKeys)) + for _, k := range groupByKeys { + if k == "subject" { + continue + } + dataGroupByKeys = append(dataGroupByKeys, k) + } + // CSV headers headers := []string{"window_start", "window_end", "subject"} - if len(groupByKeys) > 0 { - headers = append(headers, groupByKeys...) + if len(dataGroupByKeys) > 0 { + headers = append(headers, dataGroupByKeys...) } headers = append(headers, "value") records = append(records, headers) @@ -179,7 +244,7 @@ func (resp QueryMeterResponse) RenderCSV(w http.ResponseWriter, r *http.Request, } else { data = append(data, "") } - for _, k := range groupByKeys { + for _, k := range dataGroupByKeys { var groupByValue string if row.GroupBy[k] != nil { diff --git a/internal/server/server.go b/internal/server/server.go index 3c48d1191..88df9b5a4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,6 +3,7 @@ package server import ( "context" "errors" + "fmt" "log/slog" "net/http" @@ -104,8 +105,7 @@ func NewServer(config *Config) (*Server, error) { }, ErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { config.RouterConfig.ErrorHandler.HandleContext(r.Context(), err) - - models.NewStatusProblem(r.Context(), err, http.StatusInternalServerError).Respond(w, r) + errorHandlerReply(w, r, err) }, }) @@ -113,3 +113,31 @@ func NewServer(config *Config) (*Server, error) { Router: r, }, nil } + +// errorHandlerReply handles errors returned by the OpenAPI layer. +func errorHandlerReply(w http.ResponseWriter, r *http.Request, err error) { + switch e := err.(type) { + case *api.UnescapedCookieParamError: + err := fmt.Errorf("unescaped cookie param %s: %w", e.ParamName, err) + models.NewStatusProblem(r.Context(), err, http.StatusBadRequest).Respond(w, r) + case *api.UnmarshalingParamError: + err := fmt.Errorf("unmarshaling param %s: %w", e.ParamName, err) + models.NewStatusProblem(r.Context(), err, http.StatusBadRequest).Respond(w, r) + case *api.RequiredParamError: + err := fmt.Errorf("required param missing %s: %w", e.ParamName, err) + models.NewStatusProblem(r.Context(), err, http.StatusBadRequest).Respond(w, r) + case *api.RequiredHeaderError: + err := fmt.Errorf("required header missing %s: %w", e.ParamName, err) + models.NewStatusProblem(r.Context(), err, http.StatusBadRequest).Respond(w, r) + case *api.InvalidParamFormatError: + err := fmt.Errorf("invalid param format %s: %w", e.ParamName, err) + models.NewStatusProblem(r.Context(), err, http.StatusBadRequest).Respond(w, r) + case *api.TooManyValuesForParamError: + err := fmt.Errorf("too many values for param %s: %w", e.ParamName, err) + models.NewStatusProblem(r.Context(), err, http.StatusBadRequest).Respond(w, r) + + default: + err := fmt.Errorf("unhandled server error: %w", err) + models.NewStatusProblem(r.Context(), err, http.StatusInternalServerError).Respond(w, r) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index d375e2a17..76684ad36 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -80,7 +80,7 @@ func (c *MockConnector) DeleteMeter(ctx context.Context, namespace string, meter func (c *MockConnector) QueryMeter(ctx context.Context, namespace string, meterSlug string, params *streaming.QueryParams) ([]models.MeterQueryRow, error) { value := mockQueryValue - if params.Subject == nil { + if params.FilterSubject == nil { value.Subject = nil } @@ -301,6 +301,35 @@ func TestRoutes(t *testing.T) { }, }, }, + { + name: "query meter with filter", + req: testRequest{ + method: http.MethodGet, + contentType: "application/json", + path: "/api/v1/meters/" + mockMeters[0].ID + `/query?filter[subject]={"$eq":"s1"}`, + }, + res: testResponse{ + status: http.StatusOK, + body: struct { + Data []models.MeterQueryRow `json:"data"` + }{ + Data: []models.MeterQueryRow{ + {Subject: mockQueryValue.Subject, WindowStart: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), WindowEnd: time.Date(2021, 1, 1, 1, 0, 0, 0, time.UTC), Value: 300}, + }, + }, + }, + }, + { + name: "query meter with invalid group by filter", + req: testRequest{ + method: http.MethodGet, + contentType: "application/json", + path: "/api/v1/meters/" + mockMeters[0].ID + "/query?filter[invalid]=abcd", + }, + res: testResponse{ + status: http.StatusBadRequest, + }, + }, { name: "query meter as csv", req: testRequest{ diff --git a/internal/streaming/clickhouse_connector/connector.go b/internal/streaming/clickhouse_connector/connector.go index 9dd314cd4..c85dba51a 100644 --- a/internal/streaming/clickhouse_connector/connector.go +++ b/internal/streaming/clickhouse_connector/connector.go @@ -10,7 +10,6 @@ import ( "github.com/ClickHouse/clickhouse-go/v2" "github.com/cloudevents/sdk-go/v2/event" - "golang.org/x/exp/slices" "github.com/openmeterio/openmeter/api" "github.com/openmeterio/openmeter/internal/meter" @@ -314,7 +313,7 @@ func (c *ClickhouseConnector) queryMeterView(ctx context.Context, namespace stri Aggregation: params.Aggregation, From: params.From, To: params.To, - Subject: params.Subject, + FilterSubject: params.FilterSubject, FilterGroupBy: params.FilterGroupBy, GroupBy: params.GroupBy, WindowSize: params.WindowSize, @@ -348,12 +347,6 @@ func (c *ClickhouseConnector) queryMeterView(ctx context.Context, namespace stri args := []interface{}{&value.WindowStart, &value.WindowEnd, &value.Value} argCount := len(args) - // Grouping by subject is required when filtering for a subject - if len(queryMeter.Subject) > 0 && !slices.Contains(queryMeter.GroupBy, "subject") { - args = append(args, &value.Subject) - argCount++ - } - for range queryMeter.GroupBy { tmp := "" args = append(args, &tmp) diff --git a/internal/streaming/clickhouse_connector/query.go b/internal/streaming/clickhouse_connector/query.go index 59eab2328..db99da35d 100644 --- a/internal/streaming/clickhouse_connector/query.go +++ b/internal/streaming/clickhouse_connector/query.go @@ -8,10 +8,9 @@ import ( "time" "github.com/huandu/go-sqlbuilder" - "golang.org/x/exp/slices" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/models" - "github.com/openmeterio/openmeter/pkg/slicesx" ) type column struct { @@ -224,8 +223,8 @@ type queryMeterView struct { Namespace string MeterSlug string Aggregation models.MeterAggregation - Subject []string - FilterGroupBy map[string][]string + FilterSubject *filter.Filter + FilterGroupBy map[string]filter.Filter From *time.Time To *time.Time GroupBy []string @@ -292,11 +291,6 @@ func (d queryMeterView) toSQL() (string, []interface{}, error) { return "", nil, fmt.Errorf("invalid aggregation type: %s", d.Aggregation) } - // Grouping by subject is required when filtering for a subject - if len(d.Subject) > 0 && !slices.Contains(d.GroupBy, "subject") { - d.GroupBy = append([]string{"subject"}, d.GroupBy...) - } - for _, column := range d.GroupBy { c := sqlbuilder.Escape(column) selectColumns = append(selectColumns, c) @@ -307,12 +301,13 @@ func (d queryMeterView) toSQL() (string, []interface{}, error) { queryView.Select(selectColumns...) queryView.From(viewName) - if len(d.Subject) > 0 { - mapFunc := func(subject string) string { - return queryView.Equal("subject", subject) + if d.FilterSubject != nil { + w, err := filter.ToSQL("subject", *d.FilterSubject) + if err != nil { + return "", nil, err } - where = append(where, queryView.Or(slicesx.Map(d.Subject, mapFunc)...)) + where = append(where, w) } if len(d.FilterGroupBy) > 0 { @@ -324,15 +319,13 @@ func (d queryMeterView) toSQL() (string, []interface{}, error) { sort.Strings(columns) for _, column := range columns { - values := d.FilterGroupBy[column] - if len(values) == 0 { - return "", nil, fmt.Errorf("empty filter for group by: %s", column) - } - mapFunc := func(value string) string { - return queryView.Equal(sqlbuilder.Escape(column), value) + f := d.FilterGroupBy[column] + w, err := filter.ToSQL(column, f) + if err != nil { + return "", nil, err } - where = append(where, queryView.Or(slicesx.Map(values, mapFunc)...)) + where = append(where, w) } } diff --git a/internal/streaming/clickhouse_connector/query_test.go b/internal/streaming/clickhouse_connector/query_test.go index f27efd5a4..fff8ee574 100644 --- a/internal/streaming/clickhouse_connector/query_test.go +++ b/internal/streaming/clickhouse_connector/query_test.go @@ -1,11 +1,13 @@ package clickhouse_connector import ( + "fmt" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/models" ) @@ -172,6 +174,11 @@ func TestQueryMeterView(t *testing.T) { to, _ := time.Parse(time.RFC3339, "2023-01-02T00:00:00Z") tz, _ := time.LoadLocation("Asia/Shanghai") windowSize := models.WindowSizeHour + filterSubject1, _ := filter.ToFilter(fmt.Sprintf(`{ "$eq": "%s" }`, subject)) + filterSubject2, _ := filter.ToFilter(`{ "$or": [{ "$eq": "subject1" }, { "$eq": "subject2" }] }`) + filterGroupBy1, _ := filter.ToFilter(`{ "$eq": "g1v1" }`) + filterGroupBy2, _ := filter.ToFilter(`{ "$or": [{ "$eq": "g1v1" }, { "$eq": "g1v2" }] }`) + filterGroupBy3, _ := filter.ToFilter(`{ "$or": [{ "$eq": "g2v1" }, { "$eq": "g2v2" }] }`) tests := []struct { query queryMeterView @@ -180,18 +187,18 @@ func TestQueryMeterView(t *testing.T) { }{ { query: queryMeterView{ - Database: "openmeter", - Namespace: "my_namespace", - MeterSlug: "meter1", - Aggregation: models.MeterAggregationSum, - Subject: []string{subject}, - From: &from, - To: &to, - GroupBy: []string{"group1", "group2"}, - WindowSize: &windowSize, + Database: "openmeter", + Namespace: "my_namespace", + MeterSlug: "meter1", + Aggregation: models.MeterAggregationSum, + FilterSubject: &filterSubject1, + From: &from, + To: &to, + GroupBy: []string{"subject", "group1", "group2"}, + WindowSize: &windowSize, }, - wantSQL: "SELECT tumbleStart(windowstart, toIntervalHour(1), 'UTC') AS windowstart, tumbleEnd(windowstart, toIntervalHour(1), 'UTC') AS windowend, sumMerge(value) AS value, subject, group1, group2 FROM openmeter.om_my_namespace_meter1 WHERE (subject = ?) AND windowstart >= ? AND windowend <= ? GROUP BY windowstart, windowend, subject, group1, group2 ORDER BY windowstart", - wantArgs: []interface{}{"subject1", from.Unix(), to.Unix()}, + wantSQL: "SELECT tumbleStart(windowstart, toIntervalHour(1), 'UTC') AS windowstart, tumbleEnd(windowstart, toIntervalHour(1), 'UTC') AS windowend, sumMerge(value) AS value, subject, group1, group2 FROM openmeter.om_my_namespace_meter1 WHERE subject = 'subject1' AND windowstart >= ? AND windowend <= ? GROUP BY windowstart, windowend, subject, group1, group2 ORDER BY windowstart", + wantArgs: []interface{}{from.Unix(), to.Unix()}, }, { // Aggregate all available data query: queryMeterView{ @@ -265,70 +272,79 @@ func TestQueryMeterView(t *testing.T) { }, { // Aggregate data for a single subject query: queryMeterView{ - Database: "openmeter", - Namespace: "my_namespace", - MeterSlug: "meter1", - Aggregation: models.MeterAggregationSum, - Subject: []string{subject}, + Database: "openmeter", + Namespace: "my_namespace", + MeterSlug: "meter1", + Aggregation: models.MeterAggregationSum, + FilterSubject: &filterSubject1, + GroupBy: []string{"subject"}, }, - wantSQL: "SELECT min(windowstart), max(windowend), sumMerge(value) AS value, subject FROM openmeter.om_my_namespace_meter1 WHERE (subject = ?) GROUP BY subject", - wantArgs: []interface{}{"subject1"}, + wantSQL: "SELECT min(windowstart), max(windowend), sumMerge(value) AS value, subject FROM openmeter.om_my_namespace_meter1 WHERE subject = 'subject1' GROUP BY subject", + wantArgs: nil, }, { // Aggregate data for a single subject and group by additional fields query: queryMeterView{ - Database: "openmeter", - Namespace: "my_namespace", - MeterSlug: "meter1", - Aggregation: models.MeterAggregationSum, - Subject: []string{subject}, - GroupBy: []string{"group1", "group2"}, + Database: "openmeter", + Namespace: "my_namespace", + MeterSlug: "meter1", + Aggregation: models.MeterAggregationSum, + FilterSubject: &filterSubject1, + GroupBy: []string{"subject", "group1", "group2"}, }, - wantSQL: "SELECT min(windowstart), max(windowend), sumMerge(value) AS value, subject, group1, group2 FROM openmeter.om_my_namespace_meter1 WHERE (subject = ?) GROUP BY subject, group1, group2", - wantArgs: []interface{}{"subject1"}, + wantSQL: "SELECT min(windowstart), max(windowend), sumMerge(value) AS value, subject, group1, group2 FROM openmeter.om_my_namespace_meter1 WHERE subject = 'subject1' GROUP BY subject, group1, group2", + wantArgs: nil, }, { // Aggregate data for a multiple subjects - query: queryMeterView{ - Database: "openmeter", - Namespace: "my_namespace", - MeterSlug: "meter1", - Aggregation: models.MeterAggregationSum, - Subject: []string{subject, "subject2"}, - }, - wantSQL: "SELECT min(windowstart), max(windowend), sumMerge(value) AS value, subject FROM openmeter.om_my_namespace_meter1 WHERE (subject = ? OR subject = ?) GROUP BY subject", - wantArgs: []interface{}{"subject1", "subject2"}, - }, - { // Aggregate data with filtering for a single group and single value query: queryMeterView{ Database: "openmeter", Namespace: "my_namespace", MeterSlug: "meter1", Aggregation: models.MeterAggregationSum, - FilterGroupBy: map[string][]string{"g1": {"g1v1"}}, + FilterSubject: &filterSubject2, + GroupBy: []string{"subject"}, }, - wantSQL: "SELECT min(windowstart), max(windowend), sumMerge(value) AS value FROM openmeter.om_my_namespace_meter1 WHERE (g1 = ?)", - wantArgs: []interface{}{"g1v1"}, + wantSQL: "SELECT min(windowstart), max(windowend), sumMerge(value) AS value, subject FROM openmeter.om_my_namespace_meter1 WHERE (subject = 'subject1' OR subject = 'subject2') GROUP BY subject", + wantArgs: nil, + }, + { // Aggregate data with filtering for a single group and single value + query: queryMeterView{ + Database: "openmeter", + Namespace: "my_namespace", + MeterSlug: "meter1", + Aggregation: models.MeterAggregationSum, + FilterGroupBy: map[string]filter.Filter{ + "g1": filterGroupBy1, + }, + }, + wantSQL: "SELECT min(windowstart), max(windowend), sumMerge(value) AS value FROM openmeter.om_my_namespace_meter1 WHERE g1 = 'g1v1'", + wantArgs: nil, }, { // Aggregate data with filtering for a single group and multiple values query: queryMeterView{ - Database: "openmeter", - Namespace: "my_namespace", - MeterSlug: "meter1", - Aggregation: models.MeterAggregationSum, - FilterGroupBy: map[string][]string{"g1": {"g1v1", "g1v2"}}, + Database: "openmeter", + Namespace: "my_namespace", + MeterSlug: "meter1", + Aggregation: models.MeterAggregationSum, + FilterGroupBy: map[string]filter.Filter{ + "g1": filterGroupBy2, + }, }, - wantSQL: "SELECT min(windowstart), max(windowend), sumMerge(value) AS value FROM openmeter.om_my_namespace_meter1 WHERE (g1 = ? OR g1 = ?)", - wantArgs: []interface{}{"g1v1", "g1v2"}, + wantSQL: "SELECT min(windowstart), max(windowend), sumMerge(value) AS value FROM openmeter.om_my_namespace_meter1 WHERE (g1 = 'g1v1' OR g1 = 'g1v2')", + wantArgs: nil, }, { // Aggregate data with filtering for multiple groups and multiple values query: queryMeterView{ - Database: "openmeter", - Namespace: "my_namespace", - MeterSlug: "meter1", - Aggregation: models.MeterAggregationSum, - FilterGroupBy: map[string][]string{"g1": {"g1v1", "g1v2"}, "g2": {"g2v1", "g2v2"}}, + Database: "openmeter", + Namespace: "my_namespace", + MeterSlug: "meter1", + Aggregation: models.MeterAggregationSum, + FilterGroupBy: map[string]filter.Filter{ + "g1": filterGroupBy2, + "g2": filterGroupBy3, + }, }, - wantSQL: "SELECT min(windowstart), max(windowend), sumMerge(value) AS value FROM openmeter.om_my_namespace_meter1 WHERE (g1 = ? OR g1 = ?) AND (g2 = ? OR g2 = ?)", - wantArgs: []interface{}{"g1v1", "g1v2", "g2v1", "g2v2"}, + wantSQL: "SELECT min(windowstart), max(windowend), sumMerge(value) AS value FROM openmeter.om_my_namespace_meter1 WHERE (g1 = 'g1v1' OR g1 = 'g1v2') AND (g2 = 'g2v1' OR g2 = 'g2v2')", + wantArgs: nil, }, } diff --git a/internal/streaming/query_params.go b/internal/streaming/query_params.go index d9fa7c1c9..90d7005e9 100644 --- a/internal/streaming/query_params.go +++ b/internal/streaming/query_params.go @@ -5,14 +5,15 @@ import ( "fmt" "time" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/models" ) type QueryParams struct { From *time.Time To *time.Time - Subject []string - FilterGroupBy map[string][]string + FilterSubject *filter.Filter + FilterGroupBy map[string]filter.Filter GroupBy []string Aggregation models.MeterAggregation WindowSize *models.WindowSize diff --git a/pkg/filter/fillter.go b/pkg/filter/fillter.go new file mode 100644 index 000000000..2298c9189 --- /dev/null +++ b/pkg/filter/fillter.go @@ -0,0 +1,91 @@ +package filter + +import ( + "encoding/json" + "fmt" +) + +type Filter struct { + // Equality + Eq *FilterValue `json:"$eq,omitempty"` + Gt *FilterValue `json:"$gt,omitempty"` + Gte *FilterValue `json:"$gte,omitempty"` + Lt *FilterValue `json:"$lt,omitempty"` + Lte *FilterValue `json:"$lte,omitempty"` + + Ne *FilterValue `json:"$ne,omitempty"` + + // String + Like *FilterValue `json:"$like,omitempty"` + NotLike *FilterValue `json:"$notLike,omitempty"` + Match *FilterValue `json:"$match,omitempty"` + + // Array + In *FilterValue `json:"$in,omitempty"` + Nin *FilterValue `json:"$nin,omitempty"` + + // Controls + And *[]Filter `json:"$and,omitempty"` + Or *[]Filter `json:"$or,omitempty"` + Not *Filter `json:"$not,omitempty"` +} + +type FilterValue struct { + json.RawMessage +} + +// AsFilterValueNumber returns filter value as a FilterValueNumber +func (t FilterValue) AsFilterValueNumber() (FilterValueNumber, error) { + var body FilterValueNumber + err := json.Unmarshal(t.RawMessage, &body) + return body, err +} + +// AsFilterValueString returns filter value as a FilterValueString +func (t FilterValue) AsFilterValueString() (FilterValueString, error) { + var body FilterValueString + err := json.Unmarshal(t.RawMessage, &body) + return body, err +} + +// AsFilterValueArrayString returns filter value as a FilterValueArrayString +func (t FilterValue) AsFilterValueArrayString() (FilterValueArrayString, error) { + var body FilterValueArrayString + err := json.Unmarshal(t.RawMessage, &body) + return body, err +} + +// AsFilterValueArrayNumber returns filter value as a FilterValueArrayNumber +func (t FilterValue) AsFilterValueArrayNumber() (FilterValueArrayNumber, error) { + var body FilterValueArrayNumber + err := json.Unmarshal(t.RawMessage, &body) + return body, err +} + +// See: https://pkg.go.dev/encoding/json#Unmarshal +// float64, for JSON numbers +// string, for JSON strings +type JSONUnmarshald interface { + ~float64 | ~string +} + +// FilterValueArrayNumber defines model for FilterValueArrayNumber. +type FilterValueArrayNumber = []float64 + +// FilterValueArrayString defines model for FilterValueArrayString. +type FilterValueArrayString = []string + +// FilterValueNumber defines model for FilterValueNumber. +type FilterValueNumber = float64 + +// FilterValueString defines model for FilterValueString. +type FilterValueString = string + +func ToFilter(str string) (Filter, error) { + filter := Filter{} + err := json.Unmarshal([]byte(str), &filter) + if err != nil { + return filter, fmt.Errorf("invalid filter: %s", str) + } + return filter, nil +} diff --git a/pkg/filter/filter_sql.go b/pkg/filter/filter_sql.go new file mode 100644 index 000000000..8c7aec24d --- /dev/null +++ b/pkg/filter/filter_sql.go @@ -0,0 +1,259 @@ +package filter + +import ( + "fmt" + "regexp" + "strings" + + "github.com/huandu/go-sqlbuilder" +) + +type CompareType string + +var ( + // Equality + CompareTypeEq CompareType = "$eq" + CompareTypeGt CompareType = "$gt" + CompareTypeGte CompareType = "$gte" + CompareTypeLt CompareType = "$lt" + CompareTypeLte CompareType = "$lte" + CompareTypeNe CompareType = "$ne" + + // String + CompareTypeLike CompareType = "$like" + CompareTypeNotLike CompareType = "$notLike" + CompareTypeMatch CompareType = "$match" + + // Array + CompareTypeIn CompareType = "$in" + CompareTypeNin CompareType = "$nin" +) + +func ToSQL(field string, filter Filter) (string, error) { + return traverse(sqlbuilder.Escape(field), filter) +} + +// TODO: optimize this function +func Validate(filter Filter) error { + _, err := ToSQL("", filter) + return err +} + +func traverse(field string, filter Filter) (string, error) { + var results []string + if filter.And != nil || filter.Or != nil { + var childFilters []Filter + + if filter.And != nil { + childFilters = *filter.And + } else if filter.Or != nil { + childFilters = *filter.Or + } + + for _, childFilter := range childFilters { + result, err := traverse(field, childFilter) + if err != nil { + return "", err + } + results = append(results, result) + } + + if filter.And != nil { + return controlAnd(results), nil + } else if filter.Or != nil { + return controlOr(results), nil + } + } else if filter.Not != nil { + result, err := traverse(field, *filter.Not) + if err != nil { + return "", err + } + return controlNot(result) + } else if filter.Not != nil || filter.Eq != nil || filter.Ne != nil || filter.In != nil || filter.Nin != nil || filter.Gt != nil || filter.Gte != nil || filter.Lt != nil || filter.Lte != nil || filter.Like != nil || filter.NotLike != nil || filter.Match != nil { + var value FilterValue + var compareType CompareType + + if filter.Eq != nil { + value = *filter.Eq + compareType = CompareTypeEq + } else if filter.Ne != nil { + value = *filter.Ne + compareType = CompareTypeNe + } else if filter.In != nil { + value = *filter.In + compareType = CompareTypeIn + } else if filter.Nin != nil { + value = *filter.Nin + compareType = CompareTypeNin + } else if filter.Gt != nil { + value = *filter.Gt + compareType = CompareTypeGt + } else if filter.Gte != nil { + value = *filter.Gte + compareType = CompareTypeGte + } else if filter.Lt != nil { + value = *filter.Lt + compareType = CompareTypeLt + } else if filter.Lte != nil { + value = *filter.Lte + compareType = CompareTypeLte + } else if filter.Like != nil { + value = *filter.Like + compareType = CompareTypeLike + } else if filter.NotLike != nil { + value = *filter.NotLike + compareType = CompareTypeNotLike + } else if filter.Match != nil { + value = *filter.Match + compareType = CompareTypeMatch + } + + // Value is number + if v, err := value.AsFilterValueNumber(); err == nil { + return filterPrimitive(compareType, field, v) + } + + // Value is string + if v, err := value.AsFilterValueString(); err == nil { + return filterPrimitive(compareType, field, v) + } + + // Values is string array + if v, err := value.AsFilterValueArrayString(); err == nil { + return filterArray(compareType, field, v) + } + + // Values is number array + if v, err := value.AsFilterValueArrayNumber(); err == nil { + return filterArray(compareType, field, v) + } + + return "", fmt.Errorf("invalid value: %s", value) + } + + return "", fmt.Errorf("unsupported filter") +} + +func filterPrimitive[T JSONUnmarshald](compareType CompareType, field string, value T) (string, error) { + switch compareType { + case CompareTypeEq: + return filterArithmetic(compareType, field, "=", value) + case CompareTypeNe: + return filterArithmetic(compareType, field, "!=", value) + case CompareTypeGt: + return filterArithmetic(compareType, field, ">", value) + case CompareTypeGte: + return filterArithmetic(compareType, field, ">=", value) + case CompareTypeLt: + return filterArithmetic(compareType, field, "<", value) + case CompareTypeLte: + return filterArithmetic(compareType, field, "<=", value) + case CompareTypeLike: + return filterLike(field, value) + case CompareTypeNotLike: + return filterNotLike(field, value) + case CompareTypeMatch: + return filterMatch(field, value) + } + + return "", fmt.Errorf("invalid filter type: %s", compareType) +} + +func filterArray[T JSONUnmarshald](compareType CompareType, field string, value []T) (string, error) { + switch compareType { + case CompareTypeIn: + return filterIn(field, value) + case CompareTypeNin: + return filterNin(field, value) + } + + return "", fmt.Errorf("invalid filter type: %s", compareType) +} + +func controlAnd(results []string) string { + return fmt.Sprintf("(%s)", strings.Join(results, " AND ")) +} +func controlOr(results []string) string { + return fmt.Sprintf("(%s)", strings.Join(results, " OR ")) +} + +func controlNot(result string) (string, error) { + return fmt.Sprintf(`NOT (%v)`, result), nil +} + +func filterLike[T JSONUnmarshald](field string, value T) (string, error) { + switch v := any(value).(type) { + case string: + return fmt.Sprintf(`%s LIKE %s`, field, wrapString(v)), nil + } + return "", fmt.Errorf("unsupported $like value") +} + +func filterNotLike[T JSONUnmarshald](field string, value T) (string, error) { + switch v := any(value).(type) { + case string: + return fmt.Sprintf(`%s NOT LIKE %v`, field, wrapString(v)), nil + } + return "", fmt.Errorf("unsupported $notLike value") +} + +func filterMatch[T JSONUnmarshald](field string, value T) (string, error) { + switch v := any(value).(type) { + case string: + _, err := regexp.Compile(v) + if err != nil { + return "", fmt.Errorf("$match value has to be a valid regexp string") + } + + return fmt.Sprintf(`match(%s, /%v/)`, field, value), nil + } + return "", fmt.Errorf("unsupported $match value") +} + +func filterIn[T JSONUnmarshald](field string, values []T) (string, error) { + items := []string{} + + for _, value := range values { + switch v := any(value).(type) { + case float64: + items = append(items, fmt.Sprintf(`%v`, v)) + case string: + items = append(items, wrapString(v)) + default: + return "", fmt.Errorf("unsupported $in value") + } + } + + return fmt.Sprintf(`%s IN (%s)`, field, strings.Join(items, ", ")), nil +} + +func filterNin[T JSONUnmarshald](field string, values []T) (string, error) { + items := []string{} + + for _, value := range values { + switch v := any(value).(type) { + case float64: + items = append(items, fmt.Sprintf(`%v`, v)) + case string: + items = append(items, wrapString(v)) + default: + return "", fmt.Errorf("unsupported $nin value") + } + } + + return fmt.Sprintf(`%s NOT IN (%s)`, field, strings.Join(items, ", ")), nil +} + +func filterArithmetic[T JSONUnmarshald](compareType CompareType, field string, arithmetic string, value T) (string, error) { + switch v := any(value).(type) { + case float64: + return fmt.Sprintf(`%s %s %v`, field, arithmetic, v), nil + case string: + return fmt.Sprintf(`%s %s %s`, field, arithmetic, wrapString(v)), nil + } + return "", fmt.Errorf("unsupported value %v for %s", value, compareType) +} + +func wrapString(value string) string { + return fmt.Sprintf(`'%s'`, sqlbuilder.Escape(value)) +} diff --git a/pkg/filter/filter_sql_test.go b/pkg/filter/filter_sql_test.go new file mode 100644 index 000000000..7cec77fb1 --- /dev/null +++ b/pkg/filter/filter_sql_test.go @@ -0,0 +1,197 @@ +package filter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestToSQL(t *testing.T) { + tests := []struct { + name string + field string + filter Filter + want string + }{ + // Equality + { + name: "equal number", + field: "subject", + filter: toFilter(`{ + "$eq": 1 + }`), + want: `subject = 1`, + }, + { + name: "equal string", + field: "subject", + filter: toFilter(`{ + "$eq": "a" + }`), + want: `subject = 'a'`, + }, + { + name: "not equal number", + field: "subject", + filter: toFilter(`{ + "$ne": 1 + }`), + want: `subject != 1`, + }, + { + name: "gt", + field: "subject", + filter: toFilter(`{ + "$gt": 2 + }`), + want: `subject > 2`, + }, + { + name: "gte", + field: "subject", + filter: toFilter(`{ + "$gte": 2 + }`), + want: `subject >= 2`, + }, + { + name: "lt", + field: "subject", + filter: toFilter(`{ + "$lt": 2 + }`), + want: `subject < 2`, + }, + { + name: "lte", + field: "subject", + filter: toFilter(`{ + "$lte": 2 + }`), + want: `subject <= 2`, + }, + { + name: "match", + field: "subject", + filter: toFilter(`{ + "$match": "[0-9]+" + }`), + want: `match(subject, /[0-9]+/)`, + }, + { + name: "like", + field: "subject", + filter: toFilter(`{ + "$like": "%abc%" + }`), + want: `subject LIKE '%abc%'`, + }, + { + name: "notLike", + field: "subject", + filter: toFilter(`{ + "$notLike": "%abc%" + }`), + want: `subject NOT LIKE '%abc%'`, + }, + { + name: "in", + field: "subject", + filter: toFilter(`{ + "$in": [1, 2] + }`), + want: `subject IN (1, 2)`, + }, + { + name: "nin", + field: "subject", + filter: toFilter(`{ + "$nin": [1, 2] + }`), + want: `subject NOT IN (1, 2)`, + }, + // Controls + { + name: "not", + field: "subject", + filter: toFilter(`{ + "$not": { + "$eq": 1 + } + }`), + want: "NOT (subject = 1)", + }, + { + name: "and", + field: "subject", + filter: toFilter(`{ + "$and": [ + { + "$eq": 1 + }, + { + "$eq": 2 + } + ] + }`), + want: "(subject = 1 AND subject = 2)", + }, + { + name: "or", + field: "subject", + filter: toFilter(`{ + "$or": [ + { + "$eq": 1 + }, + { + "$eq": 2 + } + ] + }`), + want: "(subject = 1 OR subject = 2)", + }, + // Complex + { + name: "complex", + field: "subject", + filter: toFilter(`{ + "$and": [ + { + "$or": [ + { + "$in": [1, 2, 3] + }, + { + "$nin": [4, 5, 6] + } + ] + }, + { + "$eq": 2 + } + ] + }`), + want: "((subject IN (1, 2, 3) OR subject NOT IN (4, 5, 6)) AND subject = 2)", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + got, err := ToSQL(tt.field, tt.filter) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, tt.want, got) + }) + } +} + +func toFilter(s string) Filter { + filter, err := ToFilter(s) + if err != nil { + panic(err) + } + return filter +}