| package clickhouse |
|
|
| import ( |
| _ "embed" |
| "fmt" |
| "math" |
| "slices" |
| "sort" |
| "strings" |
| "time" |
|
|
| "github.com/ClickHouse/clickhouse-go/v2/lib/driver" |
| "github.com/huandu/go-sqlbuilder" |
| "github.com/samber/lo" |
| "github.com/shopspring/decimal" |
|
|
| meterpkg "github.com/openmeterio/openmeter/openmeter/meter" |
| "github.com/openmeterio/openmeter/openmeter/streaming" |
| "github.com/openmeterio/openmeter/pkg/filter" |
| "github.com/openmeterio/openmeter/pkg/models" |
| ) |
|
|
| type NullDecimal struct { |
| decimal.NullDecimal |
| } |
|
|
| func (v *NullDecimal) Scan(src any) error { |
| err := v.NullDecimal.Scan(src) |
| if err == nil { |
| return nil |
| } |
|
|
| if d, ok := src.(decimal.Decimal); ok { |
| v.Valid = true |
| v.Decimal = d |
| return nil |
| } |
|
|
| return err |
| } |
|
|
| type queryMeter struct { |
| Database string |
| EventsTableName string |
| Namespace string |
| Meter meterpkg.Meter |
| FilterCustomer []streaming.Customer |
| FilterSubject []string |
| FilterGroupBy map[string]filter.FilterString |
| FilterStoredAt *filter.FilterTimeUnix |
| From *time.Time |
| To *time.Time |
| GroupBy []string |
| WindowSize *meterpkg.WindowSize |
| WindowTimeZone *time.Location |
| QuerySettings map[string]string |
| EnablePrewhere bool |
| EnableDecimalPrecision bool |
| } |
|
|
| |
| func (d *queryMeter) from() *time.Time { |
| |
| from := d.From |
|
|
| |
| if from == nil && d.Meter.EventFrom == nil { |
| return nil |
| } |
|
|
| |
| if from == nil && d.Meter.EventFrom != nil { |
| return d.Meter.EventFrom |
| } |
|
|
| |
| if from != nil && d.Meter.EventFrom == nil { |
| return from |
| } |
|
|
| |
| |
| if from.After(*d.Meter.EventFrom) { |
| return from |
| } |
|
|
| return d.Meter.EventFrom |
| } |
|
|
| |
| |
| |
| func (d *queryMeter) toCountRowSQL() (string, []interface{}) { |
| tableName := getTableName(d.Database, d.EventsTableName) |
|
|
| query := sqlbuilder.ClickHouse.NewSelectBuilder() |
| query.Select("count() AS total") |
| query.From(tableName) |
|
|
| |
| query = d.whereByOrderedColumns(query) |
|
|
| sql, args := query.Build() |
| return sql, args |
| } |
|
|
| |
| func (d *queryMeter) toSQL() (string, []interface{}, error) { |
| tableName := getTableName(d.Database, d.EventsTableName) |
| getColumn := columnFactory(d.EventsTableName) |
| timeColumn := getColumn("time") |
|
|
| var selectColumns, groupByColumns []string |
|
|
| |
| groupByWindowSize := d.WindowSize != nil |
|
|
| tz := "UTC" |
| if d.WindowTimeZone != nil { |
| tz = d.WindowTimeZone.String() |
| } |
|
|
| if groupByWindowSize { |
| switch *d.WindowSize { |
| case meterpkg.WindowSizeMinute: |
| selectColumns = append( |
| selectColumns, |
| fmt.Sprintf("tumbleStart(%s, toIntervalMinute(1), '%s') AS windowstart", timeColumn, tz), |
| fmt.Sprintf("tumbleEnd(%s, toIntervalMinute(1), '%s') AS windowend", timeColumn, tz), |
| ) |
|
|
| case meterpkg.WindowSizeHour: |
| selectColumns = append( |
| selectColumns, |
| fmt.Sprintf("tumbleStart(%s, toIntervalHour(1), '%s') AS windowstart", timeColumn, tz), |
| fmt.Sprintf("tumbleEnd(%s, toIntervalHour(1), '%s') AS windowend", timeColumn, tz), |
| ) |
|
|
| case meterpkg.WindowSizeDay: |
| selectColumns = append( |
| selectColumns, |
| fmt.Sprintf("tumbleStart(%s, toIntervalDay(1), '%s') AS windowstart", timeColumn, tz), |
| "windowstart + toIntervalDay(1) AS windowend", |
| ) |
|
|
| case meterpkg.WindowSizeMonth: |
| selectColumns = append( |
| selectColumns, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| fmt.Sprintf("toDateTime(tumbleStart(%s, toIntervalMonth(1), '%s'), '%s') AS windowstart", timeColumn, tz, tz), |
| fmt.Sprintf("toDateTime(tumbleEnd(%s, toIntervalMonth(1), '%s'), '%s') AS windowend", timeColumn, tz, tz), |
| ) |
|
|
| default: |
| return "", nil, models.NewGenericValidationError( |
| fmt.Errorf("invalid window size type: %s", *d.WindowSize), |
| ) |
| } |
|
|
| groupByColumns = append(groupByColumns, "windowstart", "windowend") |
| } else { |
| |
| |
| |
| selectColumn := fmt.Sprintf("tumbleStart(min(%s), toIntervalMinute(1)) AS windowstart, tumbleEnd(max(%s), toIntervalMinute(1)) AS windowend", timeColumn, timeColumn) |
| selectColumns = append(selectColumns, selectColumn) |
| } |
|
|
| |
| sqlAggregation := "" |
| switch d.Meter.Aggregation { |
| case meterpkg.MeterAggregationSum: |
| sqlAggregation = "sum" |
| case meterpkg.MeterAggregationAvg: |
| sqlAggregation = "avg" |
| case meterpkg.MeterAggregationMin: |
| sqlAggregation = "min" |
| case meterpkg.MeterAggregationMax: |
| sqlAggregation = "max" |
| case meterpkg.MeterAggregationUniqueCount: |
| |
| |
| sqlAggregation = "uniqExact" |
| case meterpkg.MeterAggregationCount: |
| sqlAggregation = "count" |
| case meterpkg.MeterAggregationLatest: |
| sqlAggregation = "argMax" |
| default: |
| return "", []interface{}{}, models.NewGenericValidationError( |
| fmt.Errorf("invalid aggregation type: %s", d.Meter.Aggregation), |
| ) |
| } |
|
|
| switch d.Meter.Aggregation { |
| case meterpkg.MeterAggregationCount: |
| selectColumns = append(selectColumns, fmt.Sprintf("%s(*) AS value", sqlAggregation)) |
| case meterpkg.MeterAggregationUniqueCount: |
| selectColumns = append(selectColumns, fmt.Sprintf("%s(nullIf(JSON_VALUE(%s, '%s'), 'null')) AS value", sqlAggregation, getColumn("data"), escapeJSONPathLiteral(*d.Meter.ValueProperty))) |
| case meterpkg.MeterAggregationLatest: |
| if d.EnableDecimalPrecision { |
| selectColumns = append(selectColumns, fmt.Sprintf("%s(toDecimal128OrNull(nullIf(JSON_VALUE(%s, '%s'), 'null'), 19), %s) AS value", sqlAggregation, getColumn("data"), escapeJSONPathLiteral(*d.Meter.ValueProperty), timeColumn)) |
| } else { |
| selectColumns = append(selectColumns, fmt.Sprintf("%s(ifNotFinite(toFloat64OrNull(JSON_VALUE(%s, '%s')), null), %s) AS value", sqlAggregation, getColumn("data"), escapeJSONPathLiteral(*d.Meter.ValueProperty), timeColumn)) |
| } |
| default: |
| if d.EnableDecimalPrecision { |
| selectColumns = append(selectColumns, fmt.Sprintf("%s(toDecimal128OrNull(nullIf(JSON_VALUE(%s, '%s'), 'null'), 19)) AS value", sqlAggregation, getColumn("data"), escapeJSONPathLiteral(*d.Meter.ValueProperty))) |
| } else { |
| |
| selectColumns = append(selectColumns, fmt.Sprintf("%s(ifNotFinite(toFloat64OrNull(JSON_VALUE(%s, '%s')), null)) AS value", sqlAggregation, getColumn("data"), escapeJSONPathLiteral(*d.Meter.ValueProperty))) |
| } |
| } |
|
|
| for _, groupByKey := range d.GroupBy { |
| |
| if groupByKey == "subject" { |
| selectColumns = append(selectColumns, getColumn("subject")) |
| groupByColumns = append(groupByColumns, "subject") |
| continue |
| } |
|
|
| |
| if groupByKey == "customer_id" { |
| groupByColumns = append(groupByColumns, "customer_id") |
| continue |
| } |
|
|
| |
| groupByColumn := sqlbuilder.Escape(groupByKey) |
| groupByJSONPath := escapeJSONPathLiteral(d.Meter.GroupBy[groupByKey]) |
| selectColumn := fmt.Sprintf("JSON_VALUE(%s, '%s') as %s", getColumn("data"), groupByJSONPath, groupByColumn) |
|
|
| selectColumns = append(selectColumns, selectColumn) |
| groupByColumns = append(groupByColumns, groupByColumn) |
| } |
|
|
| query := sqlbuilder.ClickHouse.NewSelectBuilder() |
| query.Select(selectColumns...) |
| query.From(tableName) |
|
|
| |
| if slices.Contains(d.GroupBy, "customer_id") { |
| query = selectCustomerIdColumn(d.EventsTableName, d.FilterCustomer, query) |
| } |
|
|
| |
| query = d.whereByOrderedColumns(query) |
|
|
| var sqlBeforeApplyingDataWheres string |
|
|
| |
| if len(d.FilterGroupBy) > 0 { |
| |
| if d.EnablePrewhere { |
| sqlBeforeApplyingDataWheres, _ = query.Build() |
| } |
|
|
| |
| groupByKeys := make([]string, 0, len(d.FilterGroupBy)) |
| for k := range d.FilterGroupBy { |
| groupByKeys = append(groupByKeys, k) |
| } |
| sort.Strings(groupByKeys) |
|
|
| dataColumn := getColumn("data") |
|
|
| for _, groupByKey := range groupByKeys { |
| if _, ok := d.Meter.GroupBy[groupByKey]; !ok { |
| return "", nil, models.NewGenericValidationError( |
| fmt.Errorf("meter does not have group by: %s", groupByKey), |
| ) |
| } |
|
|
| groupByJSONPath := d.Meter.GroupBy[groupByKey] |
| filterString := d.FilterGroupBy[groupByKey] |
|
|
| |
| if filterString.IsEmpty() { |
| continue |
| } |
|
|
| |
| if err := filterString.Validate(); err != nil { |
| return "", nil, models.NewGenericValidationError( |
| fmt.Errorf("invalid filter for group by %s: %w", groupByKey, err), |
| ) |
| } |
|
|
| |
| column := fmt.Sprintf("JSON_VALUE(%s, '%s')", dataColumn, escapeJSONPathLiteral(groupByJSONPath)) |
|
|
| |
| if groupByKey == "subject" { |
| column = "subject" |
| } |
|
|
| |
| if groupByKey == "customer_id" { |
| column = "customer_id" |
| } |
|
|
| |
| whereExpr := filterString.SelectWhereExpr(column, query) |
| query = query.Where(whereExpr) |
| } |
| } |
|
|
| if d.FilterStoredAt != nil && !d.FilterStoredAt.IsEmpty() { |
| whereExpr := d.FilterStoredAt.SelectWhereExpr(getColumn("stored_at"), query) |
| query = query.Where(whereExpr) |
| } |
|
|
| |
| query = query.GroupBy(groupByColumns...) |
|
|
| |
| if groupByWindowSize { |
| query = query.OrderBy("windowstart") |
| } |
|
|
| settings := []string{} |
| sql, args := query.Build() |
|
|
| |
| if d.EnablePrewhere && sqlBeforeApplyingDataWheres != "" { |
| settings = append(settings, "optimize_move_to_prewhere = 1") |
| settings = append(settings, "allow_reorder_prewhere_conditions = 1") |
|
|
| sqlParts := strings.Split(sql, sqlBeforeApplyingDataWheres) |
| sqlAfter := sqlParts[1] |
|
|
| if strings.HasPrefix(sqlAfter, " AND") { |
| sqlAfter = strings.Replace(sqlAfter, "AND", "WHERE", 1) |
| } |
|
|
| sqlBeforeApplyingDataWheres = strings.Replace(sqlBeforeApplyingDataWheres, "WHERE", "PREWHERE", 1) |
| sql = fmt.Sprintf("%s%s", sqlBeforeApplyingDataWheres, sqlAfter) |
| } |
|
|
| |
| for key, value := range d.QuerySettings { |
| settings = append(settings, fmt.Sprintf("%s = %s", key, value)) |
| } |
|
|
| if len(settings) > 0 { |
| sql = sql + fmt.Sprintf(" SETTINGS %s", strings.Join(settings, ", ")) |
| } |
|
|
| return sql, args, nil |
| } |
|
|
| |
| |
| func (d *queryMeter) whereByOrderedColumns(query *sqlbuilder.SelectBuilder) *sqlbuilder.SelectBuilder { |
| getColumn := columnFactory(d.EventsTableName) |
|
|
| query.Where(query.Equal(getColumn("namespace"), d.Namespace)) |
| query.Where(query.Equal(getColumn("type"), d.Meter.EventType)) |
| query = customersWhere(d.EventsTableName, d.FilterCustomer, query) |
| query = subjectWhere(d.EventsTableName, d.FilterSubject, query) |
| query = d.timeWhere(query) |
|
|
| return query |
| } |
|
|
| |
| func (d *queryMeter) timeWhere(query *sqlbuilder.SelectBuilder) *sqlbuilder.SelectBuilder { |
| getColumn := columnFactory(d.EventsTableName) |
| timeColumn := getColumn("time") |
|
|
| from := d.from() |
|
|
| if from != nil { |
| query = query.Where(query.GreaterEqualThan(timeColumn, from.Unix())) |
| } |
|
|
| if d.To != nil { |
| query = query.Where(query.LessThan(timeColumn, d.To.Unix())) |
| } |
|
|
| return query |
| } |
|
|
| |
| func (queryMeter queryMeter) scanRows(rows driver.Rows) ([]meterpkg.MeterQueryRow, error) { |
| values := []meterpkg.MeterQueryRow{} |
|
|
| |
| columns := rows.Columns() |
|
|
| if columns[0] != "windowstart" { |
| return values, fmt.Errorf("first column is not windowstart") |
| } |
|
|
| if columns[1] != "windowend" { |
| return values, fmt.Errorf("second column is not windowend") |
| } |
|
|
| if columns[2] != "value" { |
| return values, fmt.Errorf("third column is not value") |
| } |
|
|
| |
| for rows.Next() { |
| row := meterpkg.MeterQueryRow{ |
| GroupBy: map[string]*string{}, |
| } |
|
|
| var value NullDecimal |
| args := []interface{}{&row.WindowStart, &row.WindowEnd, &value} |
| argCount := len(args) |
|
|
| if len(columns) > argCount { |
| for range columns[argCount:] { |
| args = append(args, lo.ToPtr("")) |
| } |
| } |
|
|
| if err := rows.Scan(args...); err != nil { |
| return values, fmt.Errorf("query meter view row scan: %w", err) |
| } |
|
|
| if !value.Valid { |
| continue |
| } |
|
|
| |
| row.Value = value.Decimal.InexactFloat64() |
|
|
| if math.IsNaN(row.Value) { |
| return values, fmt.Errorf("value is NaN") |
| } |
|
|
| if math.IsInf(row.Value, 0) { |
| return values, fmt.Errorf("value is infinite") |
| } |
|
|
| for i, column := range columns[argCount:] { |
| if s, ok := args[i+argCount].(*string); ok { |
| |
| if column == "subject" { |
| row.Subject = s |
| continue |
| } |
|
|
| |
| if column == "customer_id" { |
| row.CustomerID = s |
| continue |
| } |
|
|
| |
| if !slices.Contains(queryMeter.GroupBy, column) { |
| return values, fmt.Errorf("column %s is not a valid group by", column) |
| } |
|
|
| row.GroupBy[column] = s |
| } |
| } |
|
|
| values = append(values, row) |
| } |
|
|
| err := rows.Err() |
| if err != nil { |
| return values, fmt.Errorf("rows error: %w", err) |
| } |
|
|
| return values, nil |
| } |
|
|
| |
| |
| |
| |
| func escapeJSONPathLiteral(s string) string { |
| var sb strings.Builder |
| |
| sb.Grow(len(s) * 2) |
|
|
| for _, r := range s { |
| switch r { |
| case '\\': |
| sb.WriteString(`\\`) |
| case '\'': |
| |
| sb.WriteString(`\'`) |
| case '"': |
| |
| sb.WriteString(`\"`) |
| default: |
| |
| sb.WriteRune(r) |
| } |
| } |
| return sb.String() |
| } |
|
|