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:
Kyle Brandt
2025-04-02 09:39:36 -04:00
committed by GitHub
parent 192d3783d5
commit c6e52c4766
19 changed files with 409 additions and 357 deletions

View File

@ -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=

View File

@ -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
}

View File

@ -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()
})
}

View File

@ -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"`
}
//-------------------------------

View File

@ -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"
}
]

View File

@ -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"

View File

@ -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"
}
]

View File

@ -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"

View File

@ -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": ""
}
}
]

View File

@ -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:

View File

@ -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
}

View 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())
})
}

View File

@ -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{}

View File

@ -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}</>;

View File

@ -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();

View File

@ -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));

View File

@ -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');
});
});

View File

@ -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,
});
};

View File

@ -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[];
}