diff --git a/go.mod b/go.mod index 2768cfba38f..de33a103ab8 100644 --- a/go.mod +++ b/go.mod @@ -67,6 +67,7 @@ require ( github.com/prometheus/client_golang v1.9.0 github.com/prometheus/client_model v0.2.0 github.com/prometheus/common v0.18.0 + github.com/quasilyte/go-ruleguard/dsl/fluent v0.0.0-20201222093424-5d7e62a465d3 // indirect github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 github.com/robfig/cron/v3 v3.0.1 github.com/russellhaering/goxmldsig v1.1.0 diff --git a/go.sum b/go.sum index ad94b91c161..d552337a7fa 100644 --- a/go.sum +++ b/go.sum @@ -1226,6 +1226,10 @@ github.com/prometheus/prometheus v1.8.2-0.20200819132913-cb830b0a9c78/go.mod h1: github.com/prometheus/prometheus v1.8.2-0.20200923143134-7e2db3d092f3/go.mod h1:9VNWoDFHOMovlubld5uKKxfCDcPBj2GMOCjcUFXkYaM= github.com/prometheus/prometheus v1.8.2-0.20201014093524-73e2ce1bd643 h1:BDAexvKlOVjE5A8MlqRxzwkEpPl1/v6ydU1/J7kJtZc= github.com/prometheus/prometheus v1.8.2-0.20201014093524-73e2ce1bd643/go.mod h1:XYjkJiog7fyQu3puQNivZPI2pNq1C/775EIoHfDvuvY= +github.com/quasilyte/go-ruleguard v0.3.1 h1:2KTXnHBCR4BUl8UAL2bCUorOBGC8RsmYncuDA9NEFW4= +github.com/quasilyte/go-ruleguard/dsl v0.3.1 h1:CHGOKP2LDz35P49TjW4Bx4BCfFI6ZZU/8zcneECD0q4= +github.com/quasilyte/go-ruleguard/dsl/fluent v0.0.0-20201222093424-5d7e62a465d3 h1:eL7x4/zMnlquMxYe7V078BD7MGskZ0daGln+SJCVzuY= +github.com/quasilyte/go-ruleguard/dsl/fluent v0.0.0-20201222093424-5d7e62a465d3/go.mod h1:P7JlQWFT7jDcFZMtUPQbtGzzzxva3rBn6oIF+LPwFcM= github.com/rafaeljusto/redigomock v0.0.0-20190202135759-257e089e14a1/go.mod h1:JaY6n2sDr+z2WTsXkOmNRUfDy6FN0L6Nk7x06ndm4tY= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= diff --git a/pkg/expr/classic/classic.go b/pkg/expr/classic/classic.go new file mode 100644 index 00000000000..801c7da72a1 --- /dev/null +++ b/pkg/expr/classic/classic.go @@ -0,0 +1,160 @@ +package classic + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/grafana/grafana/pkg/expr/mathexp" +) + +// ConditionsCmd is command for the classic conditions +// expression operation. +type ConditionsCmd struct { + Conditions []condition + refID string +} + +// classicConditionJSON is the JSON model for a single condition. +// It is based on services/alerting/conditions/query.go's newQueryCondition(). +type classicConditionJSON struct { + Evaluator conditionEvalJSON `json:"evaluator"` + + Operator struct { + Type string `json:"type"` + } `json:"operator"` + + Query struct { + Params []string + } `json:"query"` + + Reducer struct { + // Params []interface{} `json:"params"` (Unused) + Type string `json:"type"` + } `json:"reducer"` +} + +type conditionEvalJSON struct { + Params []float64 `json:"params"` + Type string `json:"type"` // e.g. "gt" + +} + +// condition is a single condition within the ConditionsCmd. +type condition struct { + QueryRefID string + Reducer classicReducer + Evaluator evaluator + Operator string +} + +type classicReducer string + +// NeedsVars returns the variable names (refIds) that are dependencies +// to execute the command and allows the command to fulfill the Command interface. +func (ccc *ConditionsCmd) NeedsVars() []string { + vars := []string{} + for _, c := range ccc.Conditions { + vars = append(vars, c.QueryRefID) + } + return vars +} + +// Execute runs the command and returns the results or an error if the command +// failed to execute. +func (ccc *ConditionsCmd) Execute(ctx context.Context, vars mathexp.Vars) (mathexp.Results, error) { + firing := true + newRes := mathexp.Results{} + noDataFound := true + + for i, c := range ccc.Conditions { + querySeriesSet := vars[c.QueryRefID] + for _, val := range querySeriesSet.Values { + series, ok := val.(mathexp.Series) + if !ok { + return newRes, fmt.Errorf("can only reduce type series, got type %v", val.Type()) + } + + reducedNum := c.Reducer.Reduce(series) + // TODO handle error / no data signals + thisCondNoDataFound := reducedNum.GetFloat64Value() == nil + + evalRes := c.Evaluator.Eval(reducedNum) + + if i == 0 { + firing = evalRes + noDataFound = thisCondNoDataFound + } + + if c.Operator == "or" { + firing = firing || evalRes + noDataFound = noDataFound || thisCondNoDataFound + } else { + firing = firing && evalRes + noDataFound = noDataFound && thisCondNoDataFound + } + } + } + + num := mathexp.NewNumber("", nil) + + var v float64 + switch { + case noDataFound: + num.SetValue(nil) + case firing: + v = 1 + num.SetValue(&v) + case !firing: + num.SetValue(&v) + } + + newRes.Values = append(newRes.Values, num) + + return newRes, nil +} + +// UnmarshalConditionsCmd creates a new ConditionsCmd. +func UnmarshalConditionsCmd(rawQuery map[string]interface{}, refID string) (*ConditionsCmd, error) { + jsonFromM, err := json.Marshal(rawQuery["conditions"]) + if err != nil { + return nil, fmt.Errorf("failed to remarshal classic condition body: %w", err) + } + var ccj []classicConditionJSON + if err = json.Unmarshal(jsonFromM, &ccj); err != nil { + return nil, fmt.Errorf("failed to unmarshal remarshaled classic condition body: %w", err) + } + + c := &ConditionsCmd{ + refID: refID, + } + + for i, cj := range ccj { + cond := condition{} + + if cj.Operator.Type != "and" && cj.Operator.Type != "or" { + return nil, fmt.Errorf("classic condition %v operator must be `and` or `or`", i+1) + } + cond.Operator = cj.Operator.Type + + if len(cj.Query.Params) == 0 || cj.Query.Params[0] == "" { + return nil, fmt.Errorf("classic condition %v is missing the query refID argument", i+1) + } + + cond.QueryRefID = cj.Query.Params[0] + + cond.Reducer = classicReducer(cj.Reducer.Type) + if !cond.Reducer.ValidReduceFunc() { + return nil, fmt.Errorf("reducer '%v' in condition %v is not a valid reducer", cond.Reducer, i+1) + } + + cond.Evaluator, err = newAlertEvaluator(cj.Evaluator) + if err != nil { + return nil, err + } + + c.Conditions = append(c.Conditions, cond) + } + + return c, nil +} diff --git a/pkg/expr/classic/classic_test.go b/pkg/expr/classic/classic_test.go new file mode 100644 index 00000000000..e211dc8289d --- /dev/null +++ b/pkg/expr/classic/classic_test.go @@ -0,0 +1,199 @@ +package classic + +import ( + "context" + "encoding/json" + "testing" + + "github.com/grafana/grafana/pkg/expr/mathexp" + "github.com/stretchr/testify/require" + ptr "github.com/xorcare/pointer" +) + +func TestUnmarshalConditionCMD(t *testing.T) { + var tests = []struct { + name string + rawJSON string + expectedCommand *ConditionsCmd + needsVars []string + }{ + { + name: "basic threshold condition", + rawJSON: `{ + "conditions": [ + { + "evaluator": { + "params": [ + 2 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "A" + ] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ] + }`, + expectedCommand: &ConditionsCmd{ + Conditions: []condition{ + { + QueryRefID: "A", + Reducer: classicReducer("avg"), + Operator: "and", + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 2}, + }, + }, + }, + needsVars: []string{"A"}, + }, + { + name: "ranged condition", + rawJSON: `{ + "conditions": [ + { + "evaluator": { + "params": [ + 2, + 3 + ], + "type": "within_range" + }, + "operator": { + "type": "or" + }, + "query": { + "params": [ + "A" + ] + }, + "reducer": { + "params": [], + "type": "diff" + }, + "type": "query" + } + ] + }`, + expectedCommand: &ConditionsCmd{ + Conditions: []condition{ + { + QueryRefID: "A", + Reducer: classicReducer("diff"), + Operator: "or", + Evaluator: &rangedEvaluator{Type: "within_range", Lower: 2, Upper: 3}, + }, + }, + }, + needsVars: []string{"A"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var rq map[string]interface{} + + err := json.Unmarshal([]byte(tt.rawJSON), &rq) + require.NoError(t, err) + + cmd, err := UnmarshalConditionsCmd(rq, "") + require.NoError(t, err) + require.Equal(t, tt.expectedCommand, cmd) + + require.Equal(t, tt.needsVars, cmd.NeedsVars()) + }) + } +} + +func TestConditionsCmdExecute(t *testing.T) { + trueNumber := valBasedNumber(ptr.Float64(1)) + falseNumber := valBasedNumber(ptr.Float64(0)) + noDataNumber := valBasedNumber(nil) + + tests := []struct { + name string + vars mathexp.Vars + conditionsCmd *ConditionsCmd + resultNumber mathexp.Number + }{ + { + name: "single query and single condition", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + valBasedSeries(ptr.Float64(30), ptr.Float64(40)), + }, + }, + }, + conditionsCmd: &ConditionsCmd{ + Conditions: []condition{ + { + QueryRefID: "A", + Reducer: classicReducer("avg"), + Operator: "and", + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 34}, + }, + }}, + resultNumber: trueNumber, + }, + { + name: "single query and single ranged condition", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + valBasedSeries(ptr.Float64(30), ptr.Float64(40)), + }, + }, + }, + conditionsCmd: &ConditionsCmd{ + Conditions: []condition{ + { + QueryRefID: "A", + Reducer: classicReducer("diff"), + Operator: "and", + Evaluator: &rangedEvaluator{Type: "within_range", Lower: 2, Upper: 3}, + }, + }, + }, + resultNumber: falseNumber, + }, + { + name: "single query with no data", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{}, + }, + }, + conditionsCmd: &ConditionsCmd{ + Conditions: []condition{ + { + QueryRefID: "A", + Reducer: classicReducer("avg"), + Operator: "and", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + }, + }, + resultNumber: noDataNumber, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := tt.conditionsCmd.Execute(context.Background(), tt.vars) + require.NoError(t, err) + + require.Equal(t, 1, len(res.Values)) + + require.Equal(t, tt.resultNumber, res.Values[0]) + }) + } +} diff --git a/pkg/expr/classic/evaluator.go b/pkg/expr/classic/evaluator.go new file mode 100644 index 00000000000..cfaf5893e5a --- /dev/null +++ b/pkg/expr/classic/evaluator.go @@ -0,0 +1,98 @@ +package classic + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/expr/mathexp" +) + +type evaluator interface { + Eval(mathexp.Number) bool +} + +type noValueEvaluator struct{} + +type thresholdEvaluator struct { + Type string + Threshold float64 +} + +type rangedEvaluator struct { + Type string + Lower float64 + Upper float64 +} + +// newAlertEvaluator is a factory function for returning +// an AlertEvaluator depending on evaluation operator. +func newAlertEvaluator(model conditionEvalJSON) (evaluator, error) { + switch model.Type { + case "gt", "lt": + return newThresholdEvaluator(model) + case "within_range", "outside_range": + return newRangedEvaluator(model) + case "no_value": + return &noValueEvaluator{}, nil + } + + return nil, fmt.Errorf("evaluator invalid evaluator type: %s", model.Type) +} + +func (e *thresholdEvaluator) Eval(reducedValue mathexp.Number) bool { + fv := reducedValue.GetFloat64Value() + if fv == nil { + return false + } + + switch e.Type { + case "gt": + return *fv > e.Threshold + case "lt": + return *fv < e.Threshold + } + + return false +} + +func newThresholdEvaluator(model conditionEvalJSON) (*thresholdEvaluator, error) { + if len(model.Params) == 0 { + return nil, fmt.Errorf("evaluator '%v' is missing the threshold parameter", model.Type) + } + + return &thresholdEvaluator{ + Type: model.Type, + Threshold: model.Params[0], + }, nil +} + +func (e *noValueEvaluator) Eval(reducedValue mathexp.Number) bool { + return reducedValue.GetFloat64Value() == nil +} + +func newRangedEvaluator(model conditionEvalJSON) (*rangedEvaluator, error) { + if len(model.Params) != 2 { + return nil, fmt.Errorf("ranged evaluator requires 2 parameters") + } + + return &rangedEvaluator{ + Type: model.Type, + Lower: model.Params[0], + Upper: model.Params[1], + }, nil +} + +func (e *rangedEvaluator) Eval(reducedValue mathexp.Number) bool { + fv := reducedValue.GetFloat64Value() + if fv == nil { + return false + } + + switch e.Type { + case "within_range": + return (e.Lower < *fv && e.Upper > *fv) || (e.Upper < *fv && e.Lower > *fv) + case "outside_range": + return (e.Upper < *fv && e.Lower < *fv) || (e.Upper > *fv && e.Lower > *fv) + } + + return false +} diff --git a/pkg/expr/classic/evaluator_test.go b/pkg/expr/classic/evaluator_test.go new file mode 100644 index 00000000000..b4a61e98441 --- /dev/null +++ b/pkg/expr/classic/evaluator_test.go @@ -0,0 +1,143 @@ +package classic + +import ( + "testing" + + "github.com/grafana/grafana/pkg/expr/mathexp" + "github.com/stretchr/testify/require" + ptr "github.com/xorcare/pointer" +) + +func TestThresholdEvaluator(t *testing.T) { + var tests = []struct { + name string + evaluator evaluator + inputNumber mathexp.Number + expected bool + }{ + { + name: "value 3 is gt 1: true", + evaluator: &thresholdEvaluator{"gt", 1}, + inputNumber: valBasedNumber(ptr.Float64(3)), + expected: true, + }, + { + name: "value 1 is gt 3: false", + evaluator: &thresholdEvaluator{"gt", 3}, + inputNumber: valBasedNumber(ptr.Float64(1)), + expected: false, + }, + { + name: "value 3 is lt 1: true", + evaluator: &thresholdEvaluator{"lt", 1}, + inputNumber: valBasedNumber(ptr.Float64(3)), + expected: false, + }, + { + name: "value 1 is lt 3: false", + evaluator: &thresholdEvaluator{"lt", 3}, + inputNumber: valBasedNumber(ptr.Float64(1)), + expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := tt.evaluator.Eval(tt.inputNumber) + require.Equal(t, tt.expected, b) + }) + } +} + +func TestRangedEvaluator(t *testing.T) { + var tests = []struct { + name string + evaluator evaluator + inputNumber mathexp.Number + expected bool + }{ + // within + { + name: "value 3 is within range 1, 100: true", + evaluator: &rangedEvaluator{"within_range", 1, 100}, + inputNumber: valBasedNumber(ptr.Float64(3)), + expected: true, + }, + { + name: "value 300 is within range 1, 100: false", + evaluator: &rangedEvaluator{"within_range", 1, 100}, + inputNumber: valBasedNumber(ptr.Float64(300)), + expected: false, + }, + { + name: "value 3 is within range 100, 1: true", + evaluator: &rangedEvaluator{"within_range", 100, 1}, + inputNumber: valBasedNumber(ptr.Float64(3)), + expected: true, + }, + { + name: "value 300 is within range 100, 1: false", + evaluator: &rangedEvaluator{"within_range", 100, 1}, + inputNumber: valBasedNumber(ptr.Float64(300)), + expected: false, + }, + // outside + { + name: "value 1000 is outside range 1, 100: true", + evaluator: &rangedEvaluator{"outside_range", 1, 100}, + inputNumber: valBasedNumber(ptr.Float64(1000)), + expected: true, + }, + { + name: "value 50 is outside range 1, 100: false", + evaluator: &rangedEvaluator{"outside_range", 1, 100}, + inputNumber: valBasedNumber(ptr.Float64(50)), + expected: false, + }, + { + name: "value 1000 is outside range 100, 1: true", + evaluator: &rangedEvaluator{"outside_range", 100, 1}, + inputNumber: valBasedNumber(ptr.Float64(1000)), + expected: true, + }, + { + name: "value 50 is outside range 100, 1: false", + evaluator: &rangedEvaluator{"outside_range", 100, 1}, + inputNumber: valBasedNumber(ptr.Float64(50)), + expected: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := tt.evaluator.Eval(tt.inputNumber) + require.Equal(t, tt.expected, b) + }) + } +} + +func TestNoValueEvaluator(t *testing.T) { + var tests = []struct { + name string + evaluator evaluator + inputNumber mathexp.Number + expected bool + }{ + { + name: "value 50 is no_value: false", + evaluator: &noValueEvaluator{}, + inputNumber: valBasedNumber(ptr.Float64(50)), + expected: false, + }, + { + name: "value nil is no_value: true", + evaluator: &noValueEvaluator{}, + inputNumber: valBasedNumber(nil), + expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := tt.evaluator.Eval(tt.inputNumber) + require.Equal(t, tt.expected, b) + }) + } +} diff --git a/pkg/expr/classic/reduce.go b/pkg/expr/classic/reduce.go new file mode 100644 index 00000000000..be12bd98b7b --- /dev/null +++ b/pkg/expr/classic/reduce.go @@ -0,0 +1,205 @@ +package classic + +import ( + "math" + "sort" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/expr/mathexp" +) + +func nilOrNaN(f *float64) bool { + return f == nil || math.IsNaN(*f) +} + +func (cr classicReducer) ValidReduceFunc() bool { + switch cr { + case "avg", "sum", "min", "max", "count", "last", "median": + return true + case "diff", "diff_abs", "percent_diff", "percent_diff_abs", "count_not_null": + return true + } + return false +} + +//nolint: gocyclo +func (cr classicReducer) Reduce(series mathexp.Series) mathexp.Number { + num := mathexp.NewNumber("", nil) + num.SetValue(nil) + + if series.Len() == 0 { + return num + } + + value := float64(0) + allNull := true + + vF := series.Frame.Fields[series.ValueIdx] + + switch cr { + case "avg": + validPointsCount := 0 + for i := 0; i < vF.Len(); i++ { + if f, ok := vF.At(i).(*float64); ok { + if nilOrNaN(f) { + continue + } + value += *f + validPointsCount++ + allNull = false + } + } + if validPointsCount > 0 { + value /= float64(validPointsCount) + } + case "sum": + for i := 0; i < vF.Len(); i++ { + if f, ok := vF.At(i).(*float64); ok { + if nilOrNaN(f) { + continue + } + value += *f + allNull = false + } + } + case "min": + value = math.MaxFloat64 + for i := 0; i < vF.Len(); i++ { + if f, ok := vF.At(i).(*float64); ok { + if nilOrNaN(f) { + continue + } + allNull = false + if value > *f { + value = *f + } + } + } + if allNull { + value = 0 + } + case "max": + value = -math.MaxFloat64 + for i := 0; i < vF.Len(); i++ { + if f, ok := vF.At(i).(*float64); ok { + if nilOrNaN(f) { + continue + } + allNull = false + if value < *f { + value = *f + } + } + } + if allNull { + value = 0 + } + case "count": + value = float64(vF.Len()) + allNull = false + case "last": + for i := vF.Len() - 1; i >= 0; i-- { + if f, ok := vF.At(i).(*float64); ok { + if !nilOrNaN(f) { + value = *f + allNull = false + break + } + } + } + case "median": + var values []float64 + for i := 0; i < vF.Len(); i++ { + if f, ok := vF.At(i).(*float64); ok { + if nilOrNaN(f) { + continue + } + allNull = false + values = append(values, *f) + } + } + if len(values) >= 1 { + sort.Float64s(values) + length := len(values) + if length%2 == 1 { + value = values[(length-1)/2] + } else { + value = (values[(length/2)-1] + values[length/2]) / 2 + } + } + case "diff": + allNull, value = calculateDiff(vF, allNull, value, diff) + case "diff_abs": + allNull, value = calculateDiff(vF, allNull, value, diffAbs) + case "percent_diff": + allNull, value = calculateDiff(vF, allNull, value, percentDiff) + case "percent_diff_abs": + allNull, value = calculateDiff(vF, allNull, value, percentDiffAbs) + case "count_non_null": + for i := 0; i < vF.Len(); i++ { + if f, ok := vF.At(i).(*float64); ok { + if nilOrNaN(f) { + continue + } + value++ + } + } + + if value > 0 { + allNull = false + } + } + + if allNull { + return num + } + + num.SetValue(&value) + return num +} + +func calculateDiff(vF *data.Field, allNull bool, value float64, fn func(float64, float64) float64) (bool, float64) { + var ( + first float64 + i int + ) + // get the newest point + for i = vF.Len() - 1; i >= 0; i-- { + if f, ok := vF.At(i).(*float64); ok { + if !nilOrNaN(f) { + first = *f + allNull = false + break + } + } + } + if i >= 1 { + // get the oldest point + for i := 0; i < vF.Len(); i++ { + if f, ok := vF.At(i).(*float64); ok { + if !nilOrNaN(f) { + value = fn(first, *f) + allNull = false + break + } + } + } + } + return allNull, value +} + +var diff = func(newest, oldest float64) float64 { + return newest - oldest +} + +var diffAbs = func(newest, oldest float64) float64 { + return math.Abs(newest - oldest) +} + +var percentDiff = func(newest, oldest float64) float64 { + return (newest - oldest) / math.Abs(oldest) * 100 +} + +var percentDiffAbs = func(newest, oldest float64) float64 { + return math.Abs((newest - oldest) / oldest * 100) +} diff --git a/pkg/expr/classic/reduce_test.go b/pkg/expr/classic/reduce_test.go new file mode 100644 index 00000000000..bb82c5e41fd --- /dev/null +++ b/pkg/expr/classic/reduce_test.go @@ -0,0 +1,420 @@ +package classic + +import ( + "math" + "testing" + "time" + + "github.com/grafana/grafana/pkg/expr/mathexp" + "github.com/stretchr/testify/require" + ptr "github.com/xorcare/pointer" +) + +func TestReducer(t *testing.T) { + var tests = []struct { + name string + reducer classicReducer + inputSeries mathexp.Series + expectedNumber mathexp.Number + }{ + { + name: "sum", + reducer: classicReducer("sum"), + inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3)), + expectedNumber: valBasedNumber(ptr.Float64(6)), + }, + { + name: "min", + reducer: classicReducer("min"), + inputSeries: valBasedSeries(ptr.Float64(3), ptr.Float64(2), ptr.Float64(1)), + expectedNumber: valBasedNumber(ptr.Float64(1)), + }, + { + name: "min with NaNs only", + reducer: classicReducer("min"), + inputSeries: valBasedSeries(ptr.Float64(math.NaN()), ptr.Float64(math.NaN()), ptr.Float64(math.NaN())), + expectedNumber: valBasedNumber(nil), + }, + { + name: "max", + reducer: classicReducer("max"), + inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3)), + expectedNumber: valBasedNumber(ptr.Float64(3)), + }, + { + name: "count", + reducer: classicReducer("count"), + inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3000)), + expectedNumber: valBasedNumber(ptr.Float64(3)), + }, + { + name: "last", + reducer: classicReducer("last"), + inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3000)), + expectedNumber: valBasedNumber(ptr.Float64(3000)), + }, + { + name: "median with odd amount of numbers", + reducer: classicReducer("median"), + inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3000)), + expectedNumber: valBasedNumber(ptr.Float64(2)), + }, + { + name: "median with even amount of numbers", + reducer: classicReducer("median"), + inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(4), ptr.Float64(3000)), + expectedNumber: valBasedNumber(ptr.Float64(3)), + }, + { + name: "median with one value", + reducer: classicReducer("median"), + inputSeries: valBasedSeries(ptr.Float64(1)), + expectedNumber: valBasedNumber(ptr.Float64(1)), + }, + { + name: "median should ignore null values", + reducer: classicReducer("median"), + inputSeries: valBasedSeries(nil, nil, nil, ptr.Float64(1), ptr.Float64(2), ptr.Float64(3)), + expectedNumber: valBasedNumber(ptr.Float64(2)), + }, + { + name: "avg", + reducer: classicReducer("avg"), + inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3)), + expectedNumber: valBasedNumber(ptr.Float64(2)), + }, + { + name: "avg with only nulls", + reducer: classicReducer("avg"), + inputSeries: valBasedSeries(nil), + expectedNumber: valBasedNumber(nil), + }, + { + name: "avg of number values and null values should ignore nulls", + reducer: classicReducer("avg"), + inputSeries: valBasedSeries(ptr.Float64(3), nil, nil, ptr.Float64(3)), + expectedNumber: valBasedNumber(ptr.Float64(3)), + }, + { + name: "count_non_null with mixed null/real values", + reducer: classicReducer("count_non_null"), + inputSeries: valBasedSeries(nil, nil, ptr.Float64(3), ptr.Float64(4)), + expectedNumber: valBasedNumber(ptr.Float64(2)), + }, + { + name: "count_non_null with no values", + reducer: classicReducer("count_non_null"), + inputSeries: valBasedSeries(nil, nil), + expectedNumber: valBasedNumber(nil), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + num := tt.reducer.Reduce(tt.inputSeries) + require.Equal(t, tt.expectedNumber, num) + }) + } +} + +func TestDiffReducer(t *testing.T) { + var tests = []struct { + name string + inputSeries mathexp.Series + expectedNumber mathexp.Number + }{ + { + name: "diff of one positive point", + inputSeries: valBasedSeries(ptr.Float64(30)), + expectedNumber: valBasedNumber(ptr.Float64(0)), + }, + { + name: "diff of one negative point", + inputSeries: valBasedSeries(ptr.Float64(-30)), + expectedNumber: valBasedNumber(ptr.Float64(0)), + }, + { + name: "diff two positive points [1]", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40)), + expectedNumber: valBasedNumber(ptr.Float64(10)), + }, + { + name: "diff two positive points [2]", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(20)), + expectedNumber: valBasedNumber(ptr.Float64(-10)), + }, + { + name: "diff two negative points [1]", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40)), + expectedNumber: valBasedNumber(ptr.Float64(-10)), + }, + { + name: "diff two negative points [2]", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-10)), + expectedNumber: valBasedNumber(ptr.Float64(20)), + }, + { + name: "diff of one positive and one negative point", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(-40)), + expectedNumber: valBasedNumber(ptr.Float64(-70)), + }, + { + name: "diff of one negative and one positive point", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(40)), + expectedNumber: valBasedNumber(ptr.Float64(70)), + }, + { + name: "diff of three positive points", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40), ptr.Float64(50)), + expectedNumber: valBasedNumber(ptr.Float64(20)), + }, + { + name: "diff of three negative points", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40), ptr.Float64(-50)), + expectedNumber: valBasedNumber(ptr.Float64(-20)), + }, + { + name: "diff with only nulls", + inputSeries: valBasedSeries(nil, nil), + expectedNumber: valBasedNumber(nil), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + num := classicReducer("diff").Reduce(tt.inputSeries) + require.Equal(t, tt.expectedNumber, num) + }) + } +} + +func TestDiffAbsReducer(t *testing.T) { + var tests = []struct { + name string + inputSeries mathexp.Series + expectedNumber mathexp.Number + }{ + { + name: "diff_abs of one positive point", + inputSeries: valBasedSeries(ptr.Float64(30)), + expectedNumber: valBasedNumber(ptr.Float64(0)), + }, + { + name: "diff_abs of one negative point", + inputSeries: valBasedSeries(ptr.Float64(-30)), + expectedNumber: valBasedNumber(ptr.Float64(0)), + }, + { + name: "diff_abs two positive points [1]", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40)), + expectedNumber: valBasedNumber(ptr.Float64(10)), + }, + { + name: "diff_abs two positive points [2]", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(20)), + expectedNumber: valBasedNumber(ptr.Float64(10)), + }, + { + name: "diff_abs two negative points [1]", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40)), + expectedNumber: valBasedNumber(ptr.Float64(10)), + }, + { + name: "diff_abs two negative points [2]", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-10)), + expectedNumber: valBasedNumber(ptr.Float64(20)), + }, + { + name: "diff_abs of one positive and one negative point", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(-40)), + expectedNumber: valBasedNumber(ptr.Float64(70)), + }, + { + name: "diff_abs of one negative and one positive point", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(40)), + expectedNumber: valBasedNumber(ptr.Float64(70)), + }, + { + name: "diff_abs of three positive points", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40), ptr.Float64(50)), + expectedNumber: valBasedNumber(ptr.Float64(20)), + }, + { + name: "diff_abs of three negative points", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40), ptr.Float64(-50)), + expectedNumber: valBasedNumber(ptr.Float64(20)), + }, + { + name: "diff_abs with only nulls", + inputSeries: valBasedSeries(nil, nil), + expectedNumber: valBasedNumber(nil), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + num := classicReducer("diff_abs").Reduce(tt.inputSeries) + require.Equal(t, tt.expectedNumber, num) + }) + } +} + +func TestPercentDiffReducer(t *testing.T) { + var tests = []struct { + name string + inputSeries mathexp.Series + expectedNumber mathexp.Number + }{ + { + name: "percent_diff of one positive point", + inputSeries: valBasedSeries(ptr.Float64(30)), + expectedNumber: valBasedNumber(ptr.Float64(0)), + }, + { + name: "percent_diff of one negative point", + inputSeries: valBasedSeries(ptr.Float64(-30)), + expectedNumber: valBasedNumber(ptr.Float64(0)), + }, + { + name: "percent_diff two positive points [1]", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40)), + expectedNumber: valBasedNumber(ptr.Float64(33.33333333333333)), + }, + { + name: "percent_diff two positive points [2]", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(20)), + expectedNumber: valBasedNumber(ptr.Float64(-33.33333333333333)), + }, + { + name: "percent_diff two negative points [1]", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40)), + expectedNumber: valBasedNumber(ptr.Float64(-33.33333333333333)), + }, + { + name: "percent_diff two negative points [2]", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-10)), + expectedNumber: valBasedNumber(ptr.Float64(66.66666666666666)), + }, + { + name: "percent_diff of one positive and one negative point", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(-40)), + expectedNumber: valBasedNumber(ptr.Float64(-233.33333333333334)), + }, + { + name: "percent_diff of one negative and one positive point", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(40)), + expectedNumber: valBasedNumber(ptr.Float64(233.33333333333334)), + }, + { + name: "percent_diff of three positive points", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40), ptr.Float64(50)), + expectedNumber: valBasedNumber(ptr.Float64(66.66666666666666)), + }, + { + name: "percent_diff of three negative points", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40), ptr.Float64(-50)), + expectedNumber: valBasedNumber(ptr.Float64(-66.66666666666666)), + }, + { + name: "percent_diff with only nulls", + inputSeries: valBasedSeries(nil, nil), + expectedNumber: valBasedNumber(nil), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + num := classicReducer("percent_diff").Reduce(tt.inputSeries) + require.Equal(t, tt.expectedNumber, num) + }) + } +} + +func TestPercentDiffAbsReducer(t *testing.T) { + var tests = []struct { + name string + inputSeries mathexp.Series + expectedNumber mathexp.Number + }{ + { + name: "percent_diff_abs of one positive point", + inputSeries: valBasedSeries(ptr.Float64(30)), + expectedNumber: valBasedNumber(ptr.Float64(0)), + }, + { + name: "percent_diff_abs of one negative point", + inputSeries: valBasedSeries(ptr.Float64(-30)), + expectedNumber: valBasedNumber(ptr.Float64(0)), + }, + { + name: "percent_diff_abs two positive points [1]", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40)), + expectedNumber: valBasedNumber(ptr.Float64(33.33333333333333)), + }, + { + name: "percent_diff_abs two positive points [2]", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(20)), + expectedNumber: valBasedNumber(ptr.Float64(33.33333333333333)), + }, + { + name: "percent_diff_abs two negative points [1]", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40)), + expectedNumber: valBasedNumber(ptr.Float64(33.33333333333333)), + }, + { + name: "percent_diff_abs two negative points [2]", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-10)), + expectedNumber: valBasedNumber(ptr.Float64(66.66666666666666)), + }, + { + name: "percent_diff_abs of one positive and one negative point", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(-40)), + expectedNumber: valBasedNumber(ptr.Float64(233.33333333333334)), + }, + { + name: "percent_diff_abs of one negative and one positive point", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(40)), + expectedNumber: valBasedNumber(ptr.Float64(233.33333333333334)), + }, + { + name: "percent_diff_abs of three positive points", + inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40), ptr.Float64(50)), + expectedNumber: valBasedNumber(ptr.Float64(66.66666666666666)), + }, + { + name: "percent_diff_abs of three negative points", + inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40), ptr.Float64(-50)), + expectedNumber: valBasedNumber(ptr.Float64(66.66666666666666)), + }, + { + name: "percent_diff_abs with only nulls", + inputSeries: valBasedSeries(nil, nil), + expectedNumber: valBasedNumber(nil), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + num := classicReducer("percent_diff_abs").Reduce(tt.inputSeries) + require.Equal(t, tt.expectedNumber, num) + }) + } +} + +func valBasedSeries(vals ...*float64) mathexp.Series { + newSeries := mathexp.NewSeries("", nil, 0, false, 1, true, len(vals)) + for idx, f := range vals { + err := newSeries.SetPoint(idx, unixTimePointer(int64(idx)), f) + if err != nil { + panic(err) + } + } + return newSeries +} + +func unixTimePointer(sec int64) *time.Time { + t := time.Unix(sec, 0) + return &t +} + +func valBasedNumber(f *float64) mathexp.Number { + newNumber := mathexp.NewNumber("", nil) + newNumber.SetValue(f) + return newNumber +} diff --git a/pkg/expr/commands.go b/pkg/expr/commands.go index 8f410f04b01..15174ff44ef 100644 --- a/pkg/expr/commands.go +++ b/pkg/expr/commands.go @@ -239,6 +239,8 @@ const ( TypeReduce // TypeResample is the CMDType for a resampling expression. TypeResample + // TypeClassicConditions is the CMDType for the classic condition operation. + TypeClassicConditions ) func (gt CommandType) String() string { @@ -249,6 +251,8 @@ func (gt CommandType) String() string { return "reduce" case TypeResample: return "resample" + case TypeClassicConditions: + return "classic_conditions" default: return "unknown" } @@ -263,6 +267,8 @@ func ParseCommandType(s string) (CommandType, error) { return TypeReduce, nil case "resample": return TypeResample, nil + case "classic_conditions": + return TypeClassicConditions, nil default: return TypeUnknown, fmt.Errorf("'%v' is not a recognized expression type", s) } diff --git a/pkg/expr/nodes.go b/pkg/expr/nodes.go index c6a099da8b5..a8c618f5fc9 100644 --- a/pkg/expr/nodes.go +++ b/pkg/expr/nodes.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/expr/classic" "github.com/grafana/grafana/pkg/expr/mathexp" "gonum.org/v1/gonum/graph/simple" @@ -105,6 +106,8 @@ func buildCMDNode(dp *simple.DirectedGraph, rn *rawNode) (*CMDNode, error) { node.Command, err = UnmarshalReduceCommand(rn) case TypeResample: node.Command, err = UnmarshalResampleCommand(rn) + case TypeClassicConditions: + node.Command, err = classic.UnmarshalConditionsCmd(rn.Query, rn.RefID) default: return nil, fmt.Errorf("expression command type '%v' in '%v' not implemented", commandType, rn.RefID) }