mirror of
https://github.com/grafana/grafana.git
synced 2025-07-31 15:32:27 +08:00
SQL Expressions: Make SQL Expressions work with Alerting (#101820)
Initial support for alerting with SQL expressions - When `format` is set to `alerting`, SQL expressions output in a format suitable for alerting evaluation. - Outstanding TODOs: - Deduplicate output rows - Add more tests - Fix broken alerting UI rendering (likely due to shape change to undocumented full-long format) - Basic usage: - SQL must return one numeric column and one or more string columns. - Each row may become an alert. - The alert fires if the numeric value is non-zero. - String columns are treated as labels. --------- Co-authored-by: Konrad Lalik <konradlalik@gmail.com> Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com> Co-authored-by: Sam Jewell <sam.jewell@grafana.com>
This commit is contained in:
@ -880,12 +880,15 @@ github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 h1:ZClxb8laGDf5arX
|
||||
github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96 h1:cenwrSVm+Z7QLSV/BsnenAOcDXdX4cMv4wP0B/5QbPg=
|
||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
|
||||
github.com/dolthub/go-icu-regex v0.0.0-20241215010122-db690dd53c90/go.mod h1:ylU4XjUpsMcvl/BKeRRMXSH7e7WBrPXdSLvnRJYrxEA=
|
||||
github.com/dolthub/go-icu-regex v0.0.0-20250319212010-451ea8d003fa/go.mod h1:ylU4XjUpsMcvl/BKeRRMXSH7e7WBrPXdSLvnRJYrxEA=
|
||||
github.com/dolthub/go-mysql-server v0.19.1-0.20250206012855-c216e59c21a7/go.mod h1:jYEJ8tNkA7K3k39X8iMqaX3MSMmViRgh222JSLHDgVc=
|
||||
github.com/dolthub/go-mysql-server v0.19.1-0.20250319232254-8c915e51131f/go.mod h1:9itIc5jYYDRxmchFmegPaLaqdf4XWYX6nua5HhrajgA=
|
||||
github.com/dolthub/sqllogictest/go v0.0.0-20201107003712-816f3ae12d81 h1:7/v8q9XGFa6q5Ap4Z/OhNkAMBaK5YeuEzwJt+NZdhiE=
|
||||
github.com/dolthub/sqllogictest/go v0.0.0-20201107003712-816f3ae12d81/go.mod h1:siLfyv2c92W1eN/R4QqG/+RjjX5W2+gCTRjZxBjI3TY=
|
||||
github.com/dolthub/swiss v0.2.1 h1:gs2osYs5SJkAaH5/ggVJqXQxRXtWshF6uE0lgR/Y3Gw=
|
||||
github.com/dolthub/swiss v0.2.1/go.mod h1:8AhKZZ1HK7g18j7v7k6c5cYIGEZJcPn0ARsai8cUrh0=
|
||||
github.com/dolthub/vitess v0.0.0-20250123002143-3b45b8cacbfa/go.mod h1:1gQZs/byeHLMSul3Lvl3MzioMtOW1je79QYGyi2fd70=
|
||||
github.com/dolthub/vitess v0.0.0-20250304211657-920ca9ec2b9a/go.mod h1:1gQZs/byeHLMSul3Lvl3MzioMtOW1je79QYGyi2fd70=
|
||||
github.com/drone/funcmap v0.0.0-20220929084810-72602997d16f h1:/jEs7lulqVO2u1+XI5rW4oFwIIusxuDOVKD9PAzlW2E=
|
||||
github.com/drone/funcmap v0.0.0-20220929084810-72602997d16f/go.mod h1:nDRkX7PHq+p39AD5/usv3KZMerxZTYU/9rfLS5IDspU=
|
||||
github.com/drone/signal v1.0.0 h1:NrnM2M/4yAuU/tXs6RP1a1ZfxnaHwYkd0kJurA1p6uI=
|
||||
|
@ -1,128 +0,0 @@
|
||||
package expr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
func ConvertFromFullLongToNumericMulti(frames data.Frames) (data.Frames, error) {
|
||||
if len(frames) != 1 {
|
||||
return nil, fmt.Errorf("expected exactly one frame, got %d", len(frames))
|
||||
}
|
||||
frame := frames[0]
|
||||
if frame.Meta == nil || frame.Meta.Type != numericFullLongType {
|
||||
return nil, fmt.Errorf("expected frame of type %q", numericFullLongType)
|
||||
}
|
||||
|
||||
var (
|
||||
metricField *data.Field
|
||||
valueField *data.Field
|
||||
displayField *data.Field
|
||||
labelFields []*data.Field
|
||||
)
|
||||
|
||||
// Identify key fields
|
||||
for _, f := range frame.Fields {
|
||||
switch f.Name {
|
||||
case SQLMetricFieldName:
|
||||
metricField = f
|
||||
case SQLValueFieldName:
|
||||
valueField = f
|
||||
case SQLDisplayFieldName:
|
||||
displayField = f
|
||||
default:
|
||||
if f.Type() == data.FieldTypeNullableString {
|
||||
labelFields = append(labelFields, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if metricField == nil || valueField == nil {
|
||||
return nil, fmt.Errorf("missing required fields: %q or %q", SQLMetricFieldName, SQLValueFieldName)
|
||||
}
|
||||
|
||||
type seriesKey struct {
|
||||
metric string
|
||||
labelFP data.Fingerprint
|
||||
displayName string
|
||||
}
|
||||
|
||||
type seriesEntry struct {
|
||||
indices []int
|
||||
labels data.Labels
|
||||
displayName *string
|
||||
}
|
||||
|
||||
grouped := make(map[seriesKey]*seriesEntry)
|
||||
|
||||
for i := 0; i < frame.Rows(); i++ {
|
||||
if valueField.NilAt(i) {
|
||||
continue // skip null values
|
||||
}
|
||||
|
||||
metric := metricField.At(i).(string)
|
||||
|
||||
// collect labels
|
||||
labels := data.Labels{}
|
||||
for _, f := range labelFields {
|
||||
if f.NilAt(i) {
|
||||
continue
|
||||
}
|
||||
val := f.At(i).(*string)
|
||||
if val != nil {
|
||||
labels[f.Name] = *val
|
||||
}
|
||||
}
|
||||
fp := labels.Fingerprint()
|
||||
|
||||
// handle optional display name
|
||||
var displayPtr *string
|
||||
displayKey := ""
|
||||
if displayField != nil && !displayField.NilAt(i) {
|
||||
if raw := displayField.At(i).(*string); raw != nil {
|
||||
displayPtr = raw
|
||||
displayKey = *raw
|
||||
}
|
||||
}
|
||||
|
||||
key := seriesKey{
|
||||
metric: metric,
|
||||
labelFP: fp,
|
||||
displayName: displayKey,
|
||||
}
|
||||
|
||||
entry, ok := grouped[key]
|
||||
if !ok {
|
||||
entry = &seriesEntry{
|
||||
labels: labels,
|
||||
displayName: displayPtr,
|
||||
}
|
||||
grouped[key] = entry
|
||||
}
|
||||
entry.indices = append(entry.indices, i)
|
||||
}
|
||||
|
||||
var result data.Frames
|
||||
for key, entry := range grouped {
|
||||
values := make([]*float64, 0, len(entry.indices))
|
||||
for _, i := range entry.indices {
|
||||
v, err := valueField.FloatAt(i)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert value at index %d to float: %w", i, err)
|
||||
}
|
||||
values = append(values, &v)
|
||||
}
|
||||
|
||||
field := data.NewField(key.metric, entry.labels, values)
|
||||
if entry.displayName != nil {
|
||||
field.Config = &data.FieldConfig{DisplayNameFromDS: *entry.displayName}
|
||||
}
|
||||
|
||||
frame := data.NewFrame("", field)
|
||||
frame.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
|
||||
result = append(result, frame)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
@ -1,192 +0,0 @@
|
||||
package expr
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertFromFullLongToNumericMulti(t *testing.T) {
|
||||
t.Run("SingleRowNoLabels", func(t *testing.T) {
|
||||
input := data.NewFrame("",
|
||||
data.NewField(SQLMetricFieldName, nil, []string{"cpu"}),
|
||||
data.NewField(SQLValueFieldName, nil, []*float64{fp(3.14)}),
|
||||
)
|
||||
input.Meta = &data.FrameMeta{Type: numericFullLongType}
|
||||
|
||||
out, err := ConvertFromFullLongToNumericMulti(data.Frames{input})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
|
||||
expected := data.NewFrame("",
|
||||
data.NewField("cpu", nil, []*float64{fp(3.14)}),
|
||||
)
|
||||
expected.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
|
||||
|
||||
if diff := cmp.Diff(expected, out[0], data.FrameTestCompareOptions()...); diff != "" {
|
||||
require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TwoRowsWithLabelsAndDisplay", func(t *testing.T) {
|
||||
input := data.NewFrame("",
|
||||
data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
|
||||
data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
|
||||
data.NewField(SQLDisplayFieldName, nil, []*string{sp("CPU A"), sp("CPU A")}),
|
||||
data.NewField("host", nil, []*string{sp("a"), sp("a")}),
|
||||
)
|
||||
input.Meta = &data.FrameMeta{Type: numericFullLongType}
|
||||
|
||||
out, err := ConvertFromFullLongToNumericMulti(data.Frames{input})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
|
||||
expected := data.NewFrame("",
|
||||
func() *data.Field {
|
||||
f := data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0), fp(2.0)})
|
||||
f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU A"}
|
||||
return f
|
||||
}(),
|
||||
)
|
||||
expected.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
|
||||
|
||||
if diff := cmp.Diff(expected, out[0], data.FrameTestCompareOptions()...); diff != "" {
|
||||
require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SkipsNullValues", func(t *testing.T) {
|
||||
input := data.NewFrame("",
|
||||
data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
|
||||
data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), nil}),
|
||||
)
|
||||
input.Meta = &data.FrameMeta{Type: numericFullLongType}
|
||||
|
||||
out, err := ConvertFromFullLongToNumericMulti(data.Frames{input})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
|
||||
expected := data.NewFrame("",
|
||||
data.NewField("cpu", nil, []*float64{fp(1.0)}),
|
||||
)
|
||||
expected.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
|
||||
|
||||
if diff := cmp.Diff(expected, out[0], data.FrameTestCompareOptions()...); diff != "" {
|
||||
require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertNumericMultiRoundTripToFullLongAndBack(t *testing.T) {
|
||||
t.Run("TwoFieldsWithSparseLabels", func(t *testing.T) {
|
||||
input := data.Frames{
|
||||
data.NewFrame("",
|
||||
data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0)}),
|
||||
),
|
||||
data.NewFrame("",
|
||||
data.NewField("cpu", data.Labels{"host": "b", "env": "prod"}, []*float64{fp(2.0)}),
|
||||
),
|
||||
}
|
||||
for _, f := range input {
|
||||
f.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
|
||||
}
|
||||
|
||||
fullLong, err := ConvertToFullLong(input)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fullLong, 1)
|
||||
|
||||
roundTrip, err := ConvertFromFullLongToNumericMulti(fullLong)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := data.Frames{
|
||||
data.NewFrame("",
|
||||
data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0)}),
|
||||
),
|
||||
data.NewFrame("",
|
||||
data.NewField("cpu", data.Labels{"host": "b", "env": "prod"}, []*float64{fp(2.0)}),
|
||||
),
|
||||
}
|
||||
for _, f := range expected {
|
||||
f.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
|
||||
}
|
||||
|
||||
sortFramesByMetricDisplayAndLabels(expected)
|
||||
sortFramesByMetricDisplayAndLabels(roundTrip)
|
||||
|
||||
require.Len(t, roundTrip, len(expected))
|
||||
for i := range expected {
|
||||
if diff := cmp.Diff(expected[i], roundTrip[i], data.FrameTestCompareOptions()...); diff != "" {
|
||||
t.Errorf("Mismatch on frame %d (-want +got):\n%s", i, diff)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PreservesDisplayName", func(t *testing.T) {
|
||||
input := data.Frames{
|
||||
data.NewFrame("",
|
||||
func() *data.Field {
|
||||
f := data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0)})
|
||||
f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU A"}
|
||||
return f
|
||||
}(),
|
||||
),
|
||||
}
|
||||
input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
|
||||
|
||||
fullLong, err := ConvertToFullLong(input)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fullLong, 1)
|
||||
|
||||
roundTrip, err := ConvertFromFullLongToNumericMulti(fullLong)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := data.Frames{
|
||||
data.NewFrame("",
|
||||
func() *data.Field {
|
||||
f := data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0)})
|
||||
f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU A"}
|
||||
return f
|
||||
}(),
|
||||
),
|
||||
}
|
||||
expected[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
|
||||
|
||||
sortFramesByMetricDisplayAndLabels(expected)
|
||||
sortFramesByMetricDisplayAndLabels(roundTrip)
|
||||
|
||||
require.Len(t, roundTrip, 1)
|
||||
if diff := cmp.Diff(expected[0], roundTrip[0], data.FrameTestCompareOptions()...); diff != "" {
|
||||
t.Errorf("Mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func sortFramesByMetricDisplayAndLabels(frames data.Frames) {
|
||||
sort.Slice(frames, func(i, j int) bool {
|
||||
fi := frames[i].Fields[0]
|
||||
fj := frames[j].Fields[0]
|
||||
|
||||
// 1. Metric name
|
||||
if fi.Name != fj.Name {
|
||||
return fi.Name < fj.Name
|
||||
}
|
||||
|
||||
// 2. Display name (if set)
|
||||
var di, dj string
|
||||
if fi.Config != nil {
|
||||
di = fi.Config.DisplayNameFromDS
|
||||
}
|
||||
if fj.Config != nil {
|
||||
dj = fj.Config.DisplayNameFromDS
|
||||
}
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
|
||||
// 3. Labels fingerprint
|
||||
return fi.Labels.Fingerprint() < fj.Labels.Fingerprint()
|
||||
})
|
||||
}
|
@ -77,6 +77,7 @@ type ClassicQuery struct {
|
||||
// SQLQuery requires the sqlExpression feature flag
|
||||
type SQLExpression struct {
|
||||
Expression string `json:"expression" jsonschema:"minLength=1,example=SELECT * FROM A LIMIT 1"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
//-------------------------------
|
||||
|
@ -16,8 +16,8 @@
|
||||
"type": "__expr__",
|
||||
"uid": "TheUID"
|
||||
},
|
||||
"type": "math",
|
||||
"expression": "$A - $B"
|
||||
"expression": "$A - $B",
|
||||
"type": "math"
|
||||
},
|
||||
{
|
||||
"refId": "C",
|
||||
@ -25,12 +25,12 @@
|
||||
"type": "__expr__",
|
||||
"uid": "TheUID"
|
||||
},
|
||||
"type": "reduce",
|
||||
"expression": "$A",
|
||||
"reducer": "max",
|
||||
"settings": {
|
||||
"mode": "dropNN"
|
||||
}
|
||||
},
|
||||
"type": "reduce"
|
||||
},
|
||||
{
|
||||
"refId": "D",
|
||||
@ -38,11 +38,11 @@
|
||||
"type": "__expr__",
|
||||
"uid": "TheUID"
|
||||
},
|
||||
"expression": "$A",
|
||||
"window": "1d",
|
||||
"downsampler": "last",
|
||||
"expression": "$A",
|
||||
"type": "resample",
|
||||
"upsampler": "pad",
|
||||
"type": "resample"
|
||||
"window": "1d"
|
||||
},
|
||||
{
|
||||
"refId": "E",
|
||||
@ -79,7 +79,6 @@
|
||||
"type": "__expr__",
|
||||
"uid": "TheUID"
|
||||
},
|
||||
"expression": "A",
|
||||
"conditions": [
|
||||
{
|
||||
"evaluator": {
|
||||
@ -90,6 +89,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"expression": "A",
|
||||
"type": "threshold"
|
||||
},
|
||||
{
|
||||
@ -98,7 +98,6 @@
|
||||
"type": "__expr__",
|
||||
"uid": "TheUID"
|
||||
},
|
||||
"expression": "B",
|
||||
"conditions": [
|
||||
{
|
||||
"evaluator": {
|
||||
@ -147,6 +146,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"expression": "B",
|
||||
"type": "threshold"
|
||||
},
|
||||
{
|
||||
@ -156,6 +156,7 @@
|
||||
"uid": "TheUID"
|
||||
},
|
||||
"expression": "SELECT * FROM A limit 1",
|
||||
"format": "",
|
||||
"type": "sql"
|
||||
}
|
||||
]
|
||||
|
@ -892,6 +892,7 @@
|
||||
"type": "object",
|
||||
"required": [
|
||||
"expression",
|
||||
"format",
|
||||
"type",
|
||||
"refId"
|
||||
],
|
||||
@ -926,6 +927,9 @@
|
||||
"SELECT * FROM A LIMIT 1"
|
||||
]
|
||||
},
|
||||
"format": {
|
||||
"type": "string"
|
||||
},
|
||||
"hide": {
|
||||
"description": "true if query is disabled (ie should not be returned to the dashboard)\nNOTE: this does not always imply that the query should not be executed since\nthe results from a hidden query may be used as the input to other queries (SSE etc)",
|
||||
"type": "boolean"
|
||||
|
@ -13,8 +13,8 @@
|
||||
"refId": "B",
|
||||
"maxDataPoints": 1000,
|
||||
"intervalMs": 5,
|
||||
"type": "math",
|
||||
"expression": "$A - $B"
|
||||
"expression": "$A - $B",
|
||||
"type": "math"
|
||||
},
|
||||
{
|
||||
"refId": "C",
|
||||
@ -31,11 +31,11 @@
|
||||
"refId": "D",
|
||||
"maxDataPoints": 1000,
|
||||
"intervalMs": 5,
|
||||
"upsampler": "pad",
|
||||
"type": "resample",
|
||||
"downsampler": "last",
|
||||
"expression": "$A",
|
||||
"window": "1d",
|
||||
"downsampler": "last"
|
||||
"type": "resample",
|
||||
"upsampler": "pad",
|
||||
"window": "1d"
|
||||
},
|
||||
{
|
||||
"refId": "E",
|
||||
@ -68,7 +68,6 @@
|
||||
"refId": "F",
|
||||
"maxDataPoints": 1000,
|
||||
"intervalMs": 5,
|
||||
"expression": "A",
|
||||
"conditions": [
|
||||
{
|
||||
"evaluator": {
|
||||
@ -79,13 +78,13 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"expression": "A",
|
||||
"type": "threshold"
|
||||
},
|
||||
{
|
||||
"refId": "G",
|
||||
"maxDataPoints": 1000,
|
||||
"intervalMs": 5,
|
||||
"expression": "B",
|
||||
"conditions": [
|
||||
{
|
||||
"evaluator": {
|
||||
@ -134,6 +133,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"expression": "B",
|
||||
"type": "threshold"
|
||||
},
|
||||
{
|
||||
@ -141,6 +141,7 @@
|
||||
"maxDataPoints": 1000,
|
||||
"intervalMs": 5,
|
||||
"expression": "SELECT * FROM A limit 1",
|
||||
"format": "",
|
||||
"type": "sql"
|
||||
}
|
||||
]
|
||||
|
@ -942,6 +942,7 @@
|
||||
"type": "object",
|
||||
"required": [
|
||||
"expression",
|
||||
"format",
|
||||
"type",
|
||||
"refId"
|
||||
],
|
||||
@ -976,6 +977,9 @@
|
||||
"SELECT * FROM A LIMIT 1"
|
||||
]
|
||||
},
|
||||
"format": {
|
||||
"type": "string"
|
||||
},
|
||||
"hide": {
|
||||
"description": "true if query is disabled (ie should not be returned to the dashboard)\nNOTE: this does not always imply that the query should not be executed since\nthe results from a hidden query may be used as the input to other queries (SSE etc)",
|
||||
"type": "boolean"
|
||||
|
@ -552,7 +552,7 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "sql",
|
||||
"resourceVersion": "1709915973363",
|
||||
"resourceVersion": "1743431268321",
|
||||
"creationTimestamp": "2024-02-29T00:58:00Z"
|
||||
},
|
||||
"spec": {
|
||||
@ -573,10 +573,14 @@
|
||||
],
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expression"
|
||||
"expression",
|
||||
"format"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@ -584,7 +588,8 @@
|
||||
{
|
||||
"name": "Select the first row from A",
|
||||
"saveModel": {
|
||||
"expression": "SELECT * FROM A limit 1"
|
||||
"expression": "SELECT * FROM A limit 1",
|
||||
"format": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -136,7 +136,7 @@ func (h *ExpressionQueryReader) ReadQuery(
|
||||
eq.Properties = q
|
||||
// TODO: Cascade limit from Grafana config in this (new Expression Parser) branch of the code
|
||||
cellLimit := 0 // zero means no limit
|
||||
eq.Command, err = NewSQLCommand(common.RefID, q.Expression, int64(cellLimit))
|
||||
eq.Command, err = NewSQLCommand(common.RefID, q.Format, q.Expression, int64(cellLimit))
|
||||
}
|
||||
|
||||
case QueryTypeThreshold:
|
||||
|
@ -30,10 +30,11 @@ type SQLCommand struct {
|
||||
varsToQuery []string
|
||||
refID string
|
||||
limit int64
|
||||
format string
|
||||
}
|
||||
|
||||
// NewSQLCommand creates a new SQLCommand.
|
||||
func NewSQLCommand(refID, rawSQL string, limit int64) (*SQLCommand, error) {
|
||||
func NewSQLCommand(refID, format, rawSQL string, limit int64) (*SQLCommand, error) {
|
||||
if rawSQL == "" {
|
||||
return nil, ErrMissingSQLQuery
|
||||
}
|
||||
@ -62,6 +63,7 @@ func NewSQLCommand(refID, rawSQL string, limit int64) (*SQLCommand, error) {
|
||||
varsToQuery: tables,
|
||||
refID: refID,
|
||||
limit: limit,
|
||||
format: format,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -83,7 +85,10 @@ func UnmarshalSQLCommand(rn *rawNode, limit int64) (*SQLCommand, error) {
|
||||
return nil, fmt.Errorf("expected sql expression to be type string, but got type %T", expressionRaw)
|
||||
}
|
||||
|
||||
return NewSQLCommand(rn.RefID, expression, limit)
|
||||
formatRaw := rn.Query["format"]
|
||||
format, _ := formatRaw.(string)
|
||||
|
||||
return NewSQLCommand(rn.RefID, format, expression, limit)
|
||||
}
|
||||
|
||||
// NeedsVars returns the variable names (refIds) that are dependencies
|
||||
@ -140,10 +145,24 @@ func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.V
|
||||
return rsp, nil
|
||||
}
|
||||
|
||||
switch gr.format {
|
||||
case "alerting":
|
||||
numberSet, err := extractNumberSetFromSQLForAlerting(frame)
|
||||
if err != nil {
|
||||
rsp.Error = err
|
||||
return rsp, nil
|
||||
}
|
||||
vals := make([]mathexp.Value, 0, len(numberSet))
|
||||
for i := range numberSet {
|
||||
vals = append(vals, numberSet[i])
|
||||
}
|
||||
rsp.Values = vals
|
||||
|
||||
default:
|
||||
rsp.Values = mathexp.Values{
|
||||
mathexp.TableData{Frame: frame},
|
||||
}
|
||||
|
||||
}
|
||||
return rsp, nil
|
||||
}
|
||||
|
||||
@ -162,3 +181,57 @@ func totalCells(frames []*data.Frame) (total int64) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func extractNumberSetFromSQLForAlerting(frame *data.Frame) ([]mathexp.Number, error) {
|
||||
var (
|
||||
numericField *data.Field
|
||||
numericFieldIx int
|
||||
)
|
||||
|
||||
// Find the only numeric field
|
||||
for i, f := range frame.Fields {
|
||||
if f.Type().Numeric() {
|
||||
if numericField != nil {
|
||||
return nil, fmt.Errorf("expected exactly one numeric field, but found multiple")
|
||||
}
|
||||
numericField = f
|
||||
numericFieldIx = i
|
||||
}
|
||||
}
|
||||
if numericField == nil {
|
||||
return nil, fmt.Errorf("no numeric field found in frame")
|
||||
}
|
||||
|
||||
numbers := make([]mathexp.Number, frame.Rows())
|
||||
|
||||
for i := 0; i < frame.Rows(); i++ {
|
||||
val, err := numericField.FloatAt(i)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read numeric value at row %d: %w", i, err)
|
||||
}
|
||||
|
||||
labels := data.Labels{}
|
||||
for j, f := range frame.Fields {
|
||||
if j == numericFieldIx || (f.Type() != data.FieldTypeString && f.Type() != data.FieldTypeNullableString) {
|
||||
continue
|
||||
}
|
||||
|
||||
val := f.At(i)
|
||||
switch v := val.(type) {
|
||||
case *string:
|
||||
if v != nil {
|
||||
labels[f.Name] = *v
|
||||
}
|
||||
case string:
|
||||
labels[f.Name] = v
|
||||
}
|
||||
}
|
||||
|
||||
n := mathexp.NewNumber(numericField.Name, labels)
|
||||
n.Frame.Fields[0].Config = numericField.Config
|
||||
n.SetValue(&val)
|
||||
numbers[i] = n
|
||||
}
|
||||
|
||||
return numbers, nil
|
||||
}
|
||||
|
80
pkg/expr/sql_command_alert_test.go
Normal file
80
pkg/expr/sql_command_alert_test.go
Normal file
@ -0,0 +1,80 @@
|
||||
package expr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExtractNumberSetFromSQLForAlerting(t *testing.T) {
|
||||
t.Run("SingleRowNoLabels", func(t *testing.T) {
|
||||
input := data.NewFrame("",
|
||||
data.NewField(SQLMetricFieldName, nil, []string{"cpu"}), // will be treated as a label
|
||||
data.NewField(SQLValueFieldName, nil, []*float64{fp(3.14)}),
|
||||
)
|
||||
|
||||
numbers, err := extractNumberSetFromSQLForAlerting(input)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, numbers, 1)
|
||||
|
||||
got := numbers[0]
|
||||
require.Equal(t, fp(3.14), got.GetFloat64Value())
|
||||
require.Equal(t, data.Labels{
|
||||
SQLMetricFieldName: "cpu",
|
||||
}, got.GetLabels())
|
||||
})
|
||||
|
||||
t.Run("TwoRowsWithLabelsAndDisplay", func(t *testing.T) {
|
||||
input := data.NewFrame("",
|
||||
data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
|
||||
data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
|
||||
data.NewField(SQLDisplayFieldName, nil, []*string{sp("CPU A"), sp("CPU A")}),
|
||||
data.NewField("host", nil, []*string{sp("a"), sp("a")}),
|
||||
)
|
||||
|
||||
numbers, err := extractNumberSetFromSQLForAlerting(input)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, numbers, 2)
|
||||
|
||||
require.Equal(t, fp(1.0), numbers[0].GetFloat64Value())
|
||||
require.Equal(t, data.Labels{
|
||||
SQLMetricFieldName: "cpu",
|
||||
SQLDisplayFieldName: "CPU A",
|
||||
"host": "a",
|
||||
}, numbers[0].GetLabels())
|
||||
|
||||
require.Equal(t, fp(2.0), numbers[1].GetFloat64Value())
|
||||
require.Equal(t, data.Labels{
|
||||
SQLMetricFieldName: "cpu",
|
||||
SQLDisplayFieldName: "CPU A",
|
||||
"host": "a",
|
||||
}, numbers[1].GetLabels())
|
||||
})
|
||||
|
||||
t.Run("TwoFieldsWithSparseLabels", func(t *testing.T) {
|
||||
input := data.NewFrame("",
|
||||
data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
|
||||
data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
|
||||
data.NewField("env", nil, []*string{nil, sp("prod")}),
|
||||
data.NewField("host", nil, []*string{sp("a"), sp("b")}),
|
||||
)
|
||||
|
||||
numbers, err := extractNumberSetFromSQLForAlerting(input)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, numbers, 2)
|
||||
|
||||
require.Equal(t, fp(1.0), numbers[0].GetFloat64Value())
|
||||
require.Equal(t, data.Labels{
|
||||
SQLMetricFieldName: "cpu",
|
||||
"host": "a",
|
||||
}, numbers[0].GetLabels())
|
||||
|
||||
require.Equal(t, fp(2.0), numbers[1].GetFloat64Value())
|
||||
require.Equal(t, data.Labels{
|
||||
SQLMetricFieldName: "cpu",
|
||||
"host": "b",
|
||||
"env": "prod",
|
||||
}, numbers[1].GetLabels())
|
||||
})
|
||||
}
|
@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewCommand(t *testing.T) {
|
||||
cmd, err := NewSQLCommand("a", "select a from foo, bar", 0)
|
||||
cmd, err := NewSQLCommand("a", "", "select a from foo, bar", 0)
|
||||
if err != nil && strings.Contains(err.Error(), "feature is not enabled") {
|
||||
return
|
||||
}
|
||||
@ -123,7 +123,7 @@ func TestSQLCommandCellLimits(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd, err := NewSQLCommand("a", "select a from foo, bar", tt.limit)
|
||||
cmd, err := NewSQLCommand("a", "", "select a from foo, bar", tt.limit)
|
||||
require.NoError(t, err, "Failed to create SQL command")
|
||||
|
||||
vars := mathexp.Vars{}
|
||||
|
@ -132,7 +132,7 @@ export const Expression: FC<ExpressionProps> = ({
|
||||
);
|
||||
|
||||
case ExpressionQueryType.sql:
|
||||
return <SqlExpr onChange={onChangeQuery} query={query} refIds={availableRefIds} />;
|
||||
return <SqlExpr onChange={(query) => onChangeQuery(query)} query={query} refIds={availableRefIds} alerting />;
|
||||
|
||||
default:
|
||||
return <>Expression not supported: {query.type}</>;
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
fingerprintGraph,
|
||||
getTargets,
|
||||
parseRefsFromMathExpression,
|
||||
parseRefsFromSqlExpression,
|
||||
} from './dag';
|
||||
|
||||
describe('working with dag', () => {
|
||||
@ -176,6 +177,124 @@ describe('parseRefsFromMathExpression', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRefsFromSqlExpression', () => {
|
||||
describe('basic FROM queries', () => {
|
||||
it('should extract table from simple SELECT', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM table1')).toEqual(['table1']);
|
||||
});
|
||||
|
||||
it('ignores comments that might contain "from"', () => {
|
||||
expect(
|
||||
parseRefsFromSqlExpression(`
|
||||
-- a from b
|
||||
/** foo from bar */
|
||||
/**
|
||||
joe from bloggs
|
||||
*/
|
||||
SELECT * FROM table1`)
|
||||
).toEqual(['table1']);
|
||||
});
|
||||
|
||||
it('should be case insensitive for SQL keywords', () => {
|
||||
expect(parseRefsFromSqlExpression('select * from table1')).toEqual(['table1']);
|
||||
});
|
||||
|
||||
it('should work with specific field selections', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT field1, field2 FROM table1')).toEqual(['table1']);
|
||||
});
|
||||
|
||||
it('should preserve the original case of table names', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM TableName')).toEqual(['TableName']);
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM tablename')).toEqual(['tablename']);
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM TABLENAME')).toEqual(['TABLENAME']);
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM Table_Name')).toEqual(['Table_Name']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple tables in FROM clause', () => {
|
||||
it('should extract multiple comma-separated tables with spaces', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM table1, table2')).toEqual(['table1', 'table2']);
|
||||
});
|
||||
|
||||
it('should extract multiple comma-separated tables without spaces', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM table1,table2')).toEqual(['table1', 'table2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JOIN queries', () => {
|
||||
it('should extract tables from basic JOIN', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM table1 JOIN table2 ON table1.id = table2.id')).toEqual([
|
||||
'table1',
|
||||
'table2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract tables from INNER JOIN', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id')).toEqual([
|
||||
'table1',
|
||||
'table2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract tables from LEFT JOIN', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM table1 LEFT JOIN table2 ON table1.id = table2.id')).toEqual([
|
||||
'table1',
|
||||
'table2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tables with aliases', () => {
|
||||
it('should extract table name when using AS keyword', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT t.* FROM table1 AS t')).toEqual(['table1']);
|
||||
});
|
||||
|
||||
it('should extract table name when using implicit alias', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT t.* FROM table1 t')).toEqual(['table1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema qualified tables', () => {
|
||||
it('should extract table name from schema.table format', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM schema.table1')).toEqual(['table1']);
|
||||
});
|
||||
|
||||
it('should extract table name from quoted identifiers', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM "schema"."table1"')).toEqual(['table1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex queries', () => {
|
||||
it('should extract all tables from a complex query with joins and aliases', () => {
|
||||
const complexQuery = `
|
||||
SELECT t1.field1, t2.field2
|
||||
FROM schema1.table1 AS t1
|
||||
JOIN schema2.table2 t2 ON t1.id = t2.id
|
||||
LEFT JOIN table3 ON t2.id = table3.id
|
||||
`;
|
||||
expect(parseRefsFromSqlExpression(complexQuery)).toEqual(['table1', 'table2', 'table3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty array for empty input', () => {
|
||||
expect(parseRefsFromSqlExpression('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for null input', () => {
|
||||
expect(parseRefsFromSqlExpression(null as unknown as string)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle unusual spacing', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM table1')).toEqual(['table1']);
|
||||
});
|
||||
|
||||
it('should handle line breaks', () => {
|
||||
expect(parseRefsFromSqlExpression('SELECT * FROM\ntable1')).toEqual(['table1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fingerprints', () => {
|
||||
test('DAG fingerprint', () => {
|
||||
const graph = new Graph();
|
||||
|
@ -90,6 +90,7 @@ export class DAGError extends Error {
|
||||
export function getTargets(model: ExpressionQuery) {
|
||||
const isMathExpression = model.type === ExpressionQueryType.math;
|
||||
const isClassicCondition = model.type === ExpressionQueryType.classic;
|
||||
const isSqlExpression = model.type === ExpressionQueryType.sql;
|
||||
|
||||
if (isMathExpression) {
|
||||
return parseRefsFromMathExpression(model.expression ?? '');
|
||||
@ -97,6 +98,9 @@ export function getTargets(model: ExpressionQuery) {
|
||||
if (isClassicCondition) {
|
||||
return model.conditions?.map((c) => c.query.params[0]) ?? [];
|
||||
}
|
||||
if (isSqlExpression) {
|
||||
return parseRefsFromSqlExpression(model.expression ?? '');
|
||||
}
|
||||
return [model.expression];
|
||||
}
|
||||
|
||||
@ -114,6 +118,64 @@ export function parseRefsFromMathExpression(input: string): string[] {
|
||||
return compact(uniq([...m1, ...m2]));
|
||||
}
|
||||
|
||||
export function parseRefsFromSqlExpression(input: string): string[] {
|
||||
if (!input) {
|
||||
return [];
|
||||
}
|
||||
// Remove any comment lines
|
||||
const lines = input.split('\n');
|
||||
const nonCommentLines = lines.filter((line) => !line.trim().startsWith('--'));
|
||||
const noComments = nonCommentLines.join(' ');
|
||||
|
||||
const query = noComments
|
||||
// Normalize whitespace
|
||||
.replace(/\s+/g, ' ')
|
||||
// Remove any potential multi line comments
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const tableMatches = [];
|
||||
|
||||
// Extract tables after FROM - case insensitive with /i flag
|
||||
const fromRegex = /from\s+([^;]*?)(?:\s+(?:join|where|group|having|order|limit)|\s*$)/gi;
|
||||
|
||||
for (const match of query.matchAll(fromRegex)) {
|
||||
const fromClause = match[1].trim();
|
||||
// Handle comma-separated tables
|
||||
const tables = fromClause.split(',').map((t) => t.trim());
|
||||
for (const table of tables) {
|
||||
tableMatches.push(cleanTableName(table));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tables after JOIN - case insensitive with /i flag
|
||||
const joinRegex = /join\s+([a-zA-Z0-9_."]+)/gi;
|
||||
|
||||
for (const match of query.matchAll(joinRegex)) {
|
||||
tableMatches.push(cleanTableName(match[1]));
|
||||
}
|
||||
|
||||
return compact(uniq(tableMatches));
|
||||
}
|
||||
|
||||
// Helper function to clean table names
|
||||
function cleanTableName(tableName: string): string {
|
||||
// Remove quotes
|
||||
let name = tableName.replace(/['"]/g, '');
|
||||
|
||||
// Remove alias if present (both "AS alias" and "alias" forms)
|
||||
if (name.includes(' as ')) {
|
||||
name = name.split(' as ')[0];
|
||||
} else if (name.includes(' ')) {
|
||||
name = name.split(' ')[0];
|
||||
}
|
||||
|
||||
// Extract table name from schema.table format
|
||||
if (name.includes('.')) {
|
||||
name = name.split('.').pop() || '';
|
||||
}
|
||||
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
export const getOriginOfRefId = memoize(_getOriginsOfRefId, (refId, graph) => refId + fingerprintGraph(graph));
|
||||
export const getDescendants = memoize(_getDescendants, (refId, graph) => refId + fingerprintGraph(graph));
|
||||
|
||||
|
@ -47,4 +47,15 @@ describe('SqlExpr', () => {
|
||||
// The SQLEditor should receive the existing expression
|
||||
expect(query.expression).toBe(existingExpression);
|
||||
});
|
||||
|
||||
it('adds alerting format when alerting prop is true', () => {
|
||||
const onChange = jest.fn();
|
||||
const refIds = [{ value: 'A' }];
|
||||
const query = { refId: 'expr1', type: 'sql' } as ExpressionQuery;
|
||||
|
||||
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} alerting />);
|
||||
|
||||
const updatedQuery = onChange.mock.calls[0][0];
|
||||
expect(updatedQuery.format).toBe('alerting');
|
||||
});
|
||||
});
|
||||
|
@ -5,7 +5,7 @@ import { SelectableValue } from '@grafana/data';
|
||||
import { SQLEditor, LanguageDefinition } from '@grafana/plugin-ui';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { ExpressionQuery } from '../types';
|
||||
import { SqlExpressionQuery } from '../types';
|
||||
|
||||
// Account for Monaco editor's border to prevent clipping
|
||||
const EDITOR_BORDER_ADJUSTMENT = 2; // 1px border on top and bottom
|
||||
@ -22,11 +22,13 @@ const EDITOR_LANGUAGE_DEFINITION: LanguageDefinition = {
|
||||
|
||||
interface Props {
|
||||
refIds: Array<SelectableValue<string>>;
|
||||
query: ExpressionQuery;
|
||||
onChange: (query: ExpressionQuery) => void;
|
||||
query: SqlExpressionQuery;
|
||||
onChange: (query: SqlExpressionQuery) => void;
|
||||
/** Should the `format` property be set to `alerting`? */
|
||||
alerting?: boolean;
|
||||
}
|
||||
|
||||
export const SqlExpr = ({ onChange, refIds, query }: Props) => {
|
||||
export const SqlExpr = ({ onChange, refIds, query, alerting = false }: Props) => {
|
||||
const vars = useMemo(() => refIds.map((v) => v.value!), [refIds]);
|
||||
const initialQuery = `-- Run MySQL-dialect SQL against the tables returned from your data sources.
|
||||
-- Data source queries (ie "${vars[0]}") are available as tables and referenced by query-name
|
||||
@ -42,6 +44,7 @@ LIMIT 10`;
|
||||
onChange({
|
||||
...query,
|
||||
expression,
|
||||
format: alerting ? 'alerting' : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -151,6 +151,11 @@ export interface ExpressionQuery extends DataQuery {
|
||||
settings?: ExpressionQuerySettings;
|
||||
}
|
||||
|
||||
export interface SqlExpressionQuery extends ExpressionQuery {
|
||||
/** Format `alerting` is expected when using SQL expressions in alert rules */
|
||||
format?: 'alerting';
|
||||
}
|
||||
|
||||
export interface ThresholdExpressionQuery extends ExpressionQuery {
|
||||
conditions: ClassicCondition[];
|
||||
}
|
||||
|
Reference in New Issue
Block a user