Files
cc86e98b04 Rewrite conditions on same attribute to equivalent array operation (#6353)
* Add operators IN, NOT IN, MATCH ANY, and MATCH NONE

* Implement Static.append()

* Print string arrays with '`' instead of '"'

Example ["aa", "bb"] -> [`aa`, `bb`]

* Rewrite binary operations to array equivalent

The rewriter handles the following cases:
- { .a =  "a" || .a =  "b" } => { .a IN  ["a", "b"] }
- { .a != "a" && .a != "b" } => { .a NOT IN ["a", "b"] }
- { .a =~ "a" || .a =~ "b" } => { .a MATCH ANY ["a", "b"] }
- { .a !~ "a" && .a !~ "b" } => { .a MATCH NONE ["a", "b"] }

* Add test cases with arrays to TestBinOp

* traceql.Parse() applies AST optimizations by default

* Rename predicate_test.go to predicates_test.go

* Add array predicates to parquetquery

* BREAKING CHANGE: more precise regex validation

BinaryOperation.validate() considered everythin a valid regex where
FieldExpression.EncodeToString(false) happened to return a valid regex.
With this change the RHS of a regex operation is required to be a string
or string array

* vp4: handle predicates with arrays correctly

* vp5: handle predicates with arrays correctly

* vp3: handle predicates with arrays correctly

* Add test cases for optimized queries

* Update query test examples with optimizable queries

* vp3: fix handling of array types

* CHANGELOG.md

* Pass array operators down to fetch layer

* Read to first virtual row after seeking on a low definition level

This helps avoid additional spans/op in queries with only
resource attributes

---------

Co-authored-by: Martin Disibio <[email protected]>
2026-02-22 00:55:23 -08:00

1221 lines
35 KiB
Go

package traceql
import (
"fmt"
"math"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRootExprIsNoop(t *testing.T) {
noops := []string{
"{false}",
"{1=0}",
"({false})",
"{false} && {false}",
"{false} >> {false}",
}
for _, q := range noops {
expr, err := Parse(q)
require.NoError(t, err)
require.True(t, expr.IsNoop(), "Query should be noop: %v", q)
}
ops := []string{
"{true}",
"{1=1}",
"{.foo = 1}",
"{false} || {true}",
// This is a noop but not yet detected
"{false} && {true}",
}
for _, q := range ops {
expr, err := Parse(q)
require.NoError(t, err)
require.False(t, expr.IsNoop(), "Query should not be noop: %v", q)
}
}
func TestNewStaticNil(t *testing.T) {
s := NewStaticNil()
assert.Equal(t, TypeNil, s.Type)
assert.Equal(t, Static{}, s)
}
func TestStatic_Int(t *testing.T) {
tests := []struct {
arg any
ok bool
}{
// supported values
{arg: -math.MaxInt, ok: true},
{arg: -1, ok: true},
{arg: 0, ok: true},
{arg: 1, ok: true},
{arg: math.MaxInt, ok: true},
// unsupported values
{arg: "test"},
{arg: 3.14},
{arg: true},
{arg: StatusOk},
{arg: KindClient},
{arg: []int{1}},
{arg: []float64{1.0}},
{arg: []bool{true}},
{arg: []string{"test"}},
}
for _, tt := range tests {
t.Run(testName(tt.arg), func(t *testing.T) {
static := newStatic(tt.arg)
i, ok := static.Int()
require.Equal(t, tt.ok, ok)
if tt.ok {
assert.Equal(t, tt.arg, i)
}
})
}
}
func TestStatic_Float(t *testing.T) {
tests := []struct {
arg any
want float64
}{
{arg: -math.MaxFloat64},
{arg: -1.0},
{arg: 0.0},
{arg: 10.0 + math.SmallestNonzeroFloat64},
{arg: 1.0},
{arg: math.MaxFloat64},
{arg: int(3117), want: 3117.0},
{arg: time.Duration(101), want: 101.0},
{arg: "test", want: math.NaN()},
{arg: true, want: math.NaN()},
{arg: StatusError, want: math.NaN()},
{arg: KindServer, want: math.NaN()},
{arg: []int{1}, want: math.NaN()},
{arg: []float64{1.0}, want: math.NaN()},
{arg: []bool{true}, want: math.NaN()},
{arg: []string{"test"}, want: math.NaN()},
}
for _, tt := range tests {
t.Run(testName(tt.arg), func(t *testing.T) {
static := newStatic(tt.arg)
f := static.Float()
if static.Type == TypeFloat {
assert.Equal(t, tt.arg, f)
} else if math.IsNaN(tt.want) {
assert.True(t, math.IsNaN(f))
} else {
assert.Equal(t, tt.want, f)
}
})
}
}
func TestStatic_String(t *testing.T) {
tests := []struct {
arg any
want string
}{
{arg: nil, want: "nil"},
{arg: 101, want: "101"},
{arg: -10, want: "-10"},
{arg: -1.0, want: "-1.0"},
{arg: 0.0, want: "0.0"},
{arg: 10.0, want: "10.0"},
{arg: 12.34, want: "12.34"},
{arg: 1.23e+23, want: "1.23e+23"},
{arg: 12.34e+2, want: "1234.0"},
{arg: "test", want: "`test`"},
{arg: true, want: "true"},
{arg: StatusOk, want: "ok"},
{arg: KindClient, want: "client"},
{arg: time.Duration(70) * time.Second, want: "1m10s"},
{arg: []int{1, 2, 3}, want: "[1, 2, 3]"},
{arg: []float64{1.1, 3.3}, want: "[1.1, 3.3]"},
{arg: []bool{true, true, false}, want: "[true, true, false]"},
{arg: []string{"aa", "bb"}, want: "[`aa`, `bb`]"},
}
for _, tt := range tests {
t.Run(testName(tt.arg), func(t *testing.T) {
static := newStatic(tt.arg)
assert.Equal(t, tt.want, static.String())
})
}
}
func TestStatic_Bool(t *testing.T) {
tests := []struct {
arg any
ok bool
}{
// supported values
{arg: false, ok: true},
{arg: true, ok: true},
// unsupported values
{arg: 3.14},
{arg: "test"},
{arg: time.Duration(1)},
{arg: StatusOk},
{arg: KindClient},
{arg: []int{1, 2, 3}},
{arg: []float64{1.0, 2.2}},
{arg: []bool{true, true}},
{arg: []string{"aa", "bb"}},
}
for _, tt := range tests {
t.Run(testName(tt.arg), func(t *testing.T) {
static := newStatic(tt.arg)
b, ok := static.Bool()
require.Equal(t, tt.ok, ok)
if tt.ok {
assert.Equal(t, tt.arg, b)
}
})
}
}
func TestStatic_Duration(t *testing.T) {
tests := []struct {
arg any
ok bool
}{
// supported values
{arg: time.Duration(0), ok: true},
{arg: time.Duration(1), ok: true},
{arg: time.Duration(100) * time.Second, ok: true},
// unsupported values
{arg: 1},
{arg: 3.14},
{arg: "test"},
{arg: true},
{arg: StatusOk},
{arg: KindClient},
{arg: []int{1, 2, 3}},
{arg: []float64{1.0, 2.2}},
{arg: []bool{true, true}},
{arg: []string{"aa", "bb"}},
}
for _, tt := range tests {
t.Run(testName(tt.arg), func(t *testing.T) {
static := newStatic(tt.arg)
d, ok := static.Duration()
require.Equal(t, tt.ok, ok)
if tt.ok {
assert.Equal(t, tt.arg, d)
}
})
}
}
func TestStatic_Status(t *testing.T) {
tests := []struct {
arg any
ok bool
}{
// supported values
{arg: StatusError, ok: true},
{arg: StatusOk, ok: true},
{arg: StatusUnset, ok: true},
// unsupported values
{arg: 1},
{arg: 3.14},
{arg: "test"},
{arg: true},
{arg: KindClient},
{arg: []int{1, 2, 3}},
{arg: []float64{1.0, 2.2}},
{arg: []bool{true, true}},
{arg: []string{"aa", "bb"}},
}
for _, tt := range tests {
t.Run(testName(tt.arg), func(t *testing.T) {
static := newStatic(tt.arg)
s, ok := static.Status()
require.Equal(t, tt.ok, ok)
if tt.ok {
assert.Equal(t, tt.arg, s)
}
})
}
}
func TestStatic_Kind(t *testing.T) {
tests := []struct {
arg any
ok bool
}{
// supported values
{arg: KindUnspecified, ok: true},
{arg: KindInternal, ok: true},
{arg: KindClient, ok: true},
{arg: KindServer, ok: true},
{arg: KindProducer, ok: true},
{arg: KindConsumer, ok: true},
// unsupported values
{arg: 1},
{arg: 3.14},
{arg: "test"},
{arg: true},
{arg: StatusOk},
{arg: []int{1, 2, 3}},
{arg: []float64{1.0, 2.2}},
{arg: []bool{true, true}},
{arg: []string{"aa", "bb"}},
}
for _, tt := range tests {
t.Run(testName(tt.arg), func(t *testing.T) {
static := newStatic(tt.arg)
k, ok := static.Kind()
require.Equal(t, tt.ok, ok)
if tt.ok {
assert.Equal(t, tt.arg, k)
}
})
}
}
func TestStatic_IntArray(t *testing.T) {
tests := []struct {
arg any
ok bool
}{
// supported values
{arg: []int(nil), ok: true},
{arg: []int{}, ok: true},
{arg: []int{1}, ok: true},
{arg: []int{1, 2, 3, 4, 5}, ok: true},
// unsupported values
{arg: 1},
{arg: 3.14},
{arg: "test"},
{arg: true},
{arg: StatusOk},
{arg: KindClient},
{arg: []float64{1.0, 2.2}},
{arg: []bool{true, true}},
{arg: []string{"aa", "bb"}},
}
for _, tt := range tests {
t.Run(testName(tt.arg), func(t *testing.T) {
static := newStatic(tt.arg)
a, ok := static.IntArray()
require.Equal(t, tt.ok, ok)
if tt.ok {
assert.Equal(t, tt.arg, a)
}
})
}
}
func TestStatic_FloatArray(t *testing.T) {
tests := []struct {
arg any
ok bool
}{
// supported values
{arg: []float64(nil), ok: true},
{arg: []float64{}, ok: true},
{arg: []float64{1.11}, ok: true},
{arg: []float64{1.11 + math.SmallestNonzeroFloat64}, ok: true},
{arg: []float64{1.23, 2.0, 3.14, 4.1, 5.15}, ok: true},
// unsupported values
{arg: 1},
{arg: 3.14},
{arg: "test"},
{arg: true},
{arg: StatusOk},
{arg: KindClient},
{arg: []int{1, 2}},
{arg: []bool{true, true}},
{arg: []string{"aa", "bb"}},
}
for _, tt := range tests {
t.Run(testName(tt.arg), func(t *testing.T) {
static := newStatic(tt.arg)
f, ok := static.FloatArray()
require.Equal(t, tt.ok, ok)
if tt.ok {
assert.Equal(t, tt.arg, f)
}
})
}
}
func TestStatic_StringArray(t *testing.T) {
tests := []struct {
arg any
ok bool
}{
// supported values
{arg: []string(nil), ok: true},
{arg: []string{}, ok: true},
{arg: []string{""}, ok: true},
{arg: []string{"aa"}, ok: true},
{arg: []string{"aa", "bb"}, ok: true},
// unsupported values
{arg: 1},
{arg: 3.14},
{arg: "test"},
{arg: true},
{arg: StatusOk},
{arg: KindClient},
{arg: []int{1, 2}},
{arg: []float64{1.0, 2.2}},
{arg: []bool{true, true}},
}
for _, tt := range tests {
t.Run(testName(tt.arg), func(t *testing.T) {
static := newStatic(tt.arg)
s, ok := static.StringArray()
require.Equal(t, tt.ok, ok)
if tt.ok {
assert.Equal(t, tt.arg, s)
}
})
}
}
func TestStatic_BooleanArray(t *testing.T) {
tests := []struct {
arg any
ok bool
}{
// supported values
{arg: []bool(nil), ok: true},
{arg: []bool{}, ok: true},
{arg: []bool{true}, ok: true},
{arg: []bool{false}, ok: true},
{arg: []bool{false, true}, ok: true},
// unsupported values
{arg: 1},
{arg: 3.14},
{arg: "test"},
{arg: true},
{arg: StatusOk},
{arg: KindClient},
{arg: []int{1, 2}},
{arg: []float64{1.0, 2.2}},
{arg: []string{"aa", "bb"}},
}
for _, tt := range tests {
t.Run(testName(tt.arg), func(t *testing.T) {
static := newStatic(tt.arg)
b, ok := static.BooleanArray()
require.Equal(t, tt.ok, ok)
if tt.ok {
assert.Equal(t, tt.arg, b)
}
})
}
}
func TestStatic_MapKey(t *testing.T) {
staticVals := []Static{
NewStaticNil(),
NewStaticInt(0), NewStaticInt(1), NewStaticInt(2), NewStaticInt(-2), NewStaticInt(math.MaxInt), NewStaticInt(math.MinInt),
NewStaticDuration(0), NewStaticDuration(2), NewStaticDuration(-2),
NewStaticFloat(0), NewStaticFloat(2), NewStaticFloat(-2), NewStaticFloat(math.SmallestNonzeroFloat64), NewStaticFloat(math.MaxFloat64), NewStaticFloat(-math.MaxFloat64),
NewStaticKind(KindUnspecified), NewStaticKind(KindClient), // corresponds to 0 and 2
NewStaticStatus(StatusError), NewStaticStatus(StatusUnset), // corresponds to 0 and 2
NewStaticBool(false), NewStaticBool(true),
NewStaticString(""), NewStaticString("foo"),
NewStaticIntArray([]int{}), NewStaticIntArray([]int{1, 2, 3}),
NewStaticFloatArray([]float64{}), NewStaticFloatArray([]float64{0.0}), NewStaticFloatArray([]float64{0.0, 2.0, 3.0}),
NewStaticStringArray([]string{}), NewStaticStringArray([]string{""}), NewStaticStringArray([]string{"foo"}), NewStaticStringArray([]string{"f", "o", "o"}),
NewStaticBooleanArray([]bool{}), NewStaticBooleanArray([]bool{false}), NewStaticBooleanArray([]bool{true}),
}
// All above values must have unique MapKey
occurredValues := make(map[StaticMapKey]Static, len(staticVals))
for _, s := range staticVals {
mk := s.MapKey()
prev, found := occurredValues[mk]
occurredValues[mk] = s
assert.False(t, found, "static values produce the same MapKey %s, %s", prev.String(), s.String())
}
}
func BenchmarkStatic_MapKey(b *testing.B) {
testCases := []Static{
NewStaticStringArray([]string{"foo", "bar"}),
}
for _, tc := range testCases {
b.Run(tc.String(), func(b *testing.B) {
for b.Loop() {
_ = tc.MapKey()
}
})
}
}
func TestStatic_Equals(t *testing.T) {
areEqual := []struct {
lhs, rhs Static
}{
{NewStaticInt(1), NewStaticInt(1)},
{NewStaticFloat(1.5), NewStaticFloat(1.5)},
{NewStaticInt(2), NewStaticFloat(2.0)},
{NewStaticFloat(2.0), NewStaticInt(2)},
{NewStaticString("foo"), NewStaticString("foo")},
{NewStaticBool(true), NewStaticBool(true)},
{NewStaticBool(false), NewStaticBool(false)},
{NewStaticDuration(1 * time.Second), NewStaticDuration(1000 * time.Millisecond)},
{NewStaticDuration(0), NewStaticInt(0)},
{NewStaticInt(0), NewStaticDuration(0)},
{NewStaticStatus(StatusOk), NewStaticStatus(StatusOk)},
{NewStaticKind(KindClient), NewStaticKind(KindClient)},
{NewStaticIntArray([]int{}), NewStaticIntArray(nil)},
{NewStaticIntArray([]int{11, 111}), NewStaticIntArray([]int{11, 111})},
{NewStaticFloatArray([]float64{}), NewStaticFloatArray(nil)},
{NewStaticFloatArray([]float64{1.1, 2.2}), NewStaticFloatArray([]float64{1.1, 2.2})},
{NewStaticStringArray([]string{}), NewStaticStringArray(nil)},
{NewStaticStringArray([]string{"foo", "bar"}), NewStaticStringArray([]string{"foo", "bar"})},
{NewStaticBooleanArray([]bool{}), NewStaticBooleanArray(nil)},
{NewStaticBooleanArray([]bool{true, false}), NewStaticBooleanArray([]bool{true, false})},
// Status and int comparison
{NewStaticStatus(StatusError), NewStaticInt(0)},
{NewStaticStatus(StatusOk), NewStaticInt(1)},
{NewStaticStatus(StatusUnset), NewStaticInt(2)},
{NewStaticInt(2), NewStaticStatus(StatusUnset)},
}
areNotEqual := []struct {
lhs, rhs Static
}{
{NewStaticInt(1), NewStaticInt(2)},
{NewStaticBool(true), NewStaticBool(false)},
{NewStaticBool(true), NewStaticInt(1)},
{NewStaticInt(1), NewStaticBool(true)},
{NewStaticString("foo"), NewStaticString("bar")},
{NewStaticKind(KindClient), NewStaticKind(KindConsumer)},
{NewStaticStatus(StatusError), NewStaticStatus(StatusOk)},
{NewStaticStatus(StatusOk), NewStaticStatus(StatusUnset)},
{NewStaticStatus(StatusOk), NewStaticKind(KindInternal)},
{NewStaticStatus(StatusError), NewStaticFloat(0)},
{NewStaticFloat(0), NewStaticStatus(StatusError)},
{NewStaticIntArray([]int{}), NewStaticIntArray([]int{0})},
{NewStaticIntArray([]int{111, 11}), NewStaticIntArray([]int{11, 111})},
{NewStaticFloatArray([]float64{}), NewStaticFloatArray([]float64{0.0})},
{NewStaticFloatArray([]float64{1.1, 2.2}), NewStaticFloatArray([]float64{2.2, 1.1})},
{NewStaticStringArray([]string{}), NewStaticStringArray([]string{""})},
{NewStaticStringArray([]string{"foo", "bar"}), NewStaticStringArray([]string{"bar", "foo"})},
{NewStaticBooleanArray([]bool{}), NewStaticBooleanArray([]bool{true})},
{NewStaticBooleanArray([]bool{true, false}), NewStaticBooleanArray([]bool{false, true})},
}
allNilComparisonsAreFalse := []struct {
lhs, rhs Static
}{
{NewStaticInt(1), StaticNil},
{NewStaticString(""), StaticNil},
{NewStaticStatus(StatusError), StaticNil},
}
for _, tt := range areEqual {
t.Run(fmt.Sprintf("%s==%s", testName(tt.rhs), testName(tt.rhs)), func(t *testing.T) {
assert.True(t, tt.lhs.Equals(&tt.rhs))
assert.True(t, tt.rhs.Equals(&tt.lhs))
})
}
for _, tt := range areNotEqual {
t.Run(fmt.Sprintf("%s!=%s", testName(tt.lhs), testName(tt.rhs)), func(t *testing.T) {
assert.True(t, tt.lhs.NotEquals(&tt.rhs))
assert.True(t, tt.rhs.NotEquals(&tt.lhs))
})
}
for _, tt := range allNilComparisonsAreFalse {
t.Run(fmt.Sprintf("%s==%s", testName(tt.lhs), testName(tt.rhs)), func(t *testing.T) {
assert.False(t, tt.lhs.Equals(&tt.rhs))
assert.False(t, tt.rhs.Equals(&tt.lhs))
assert.False(t, tt.lhs.NotEquals(&tt.rhs))
assert.False(t, tt.rhs.NotEquals(&tt.lhs))
})
}
}
func TestStatic_compare(t *testing.T) {
testCases := []struct {
s1, s2 Static
want int
}{
{s1: NewStaticInt(10), s2: NewStaticInt(5), want: 1},
{s1: NewStaticInt(5), s2: NewStaticInt(-10), want: 1},
{s1: NewStaticInt(20), s2: NewStaticInt(20), want: 0},
{s1: NewStaticFloat(10.5), s2: NewStaticFloat(5.5), want: 1},
{s1: NewStaticFloat(100.0), s2: NewStaticInt(100), want: 0},
{s1: NewStaticFloat(100.0), s2: NewStaticInt(50), want: 1},
{s1: NewStaticString("world"), s2: NewStaticString("hello"), want: 1},
{s1: NewStaticBool(true), s2: NewStaticBool(false), want: 1},
{s1: NewStaticDuration(10 * time.Second), s2: NewStaticDuration(5 * time.Second), want: 1},
{s1: NewStaticDuration(10), s2: NewStaticInt(10), want: 0},
{s1: NewStaticIntArray([]int{1, 3, 3}), s2: NewStaticIntArray([]int{1, -2, 3}), want: 1},
{s1: NewStaticIntArray([]int{1, 2, 3}), s2: NewStaticIntArray([]int{1, 2, 3}), want: 0},
{s1: NewStaticFloatArray([]float64{1.1, math.SmallestNonzeroFloat64}), s2: NewStaticFloatArray([]float64{1.1, 0}), want: 1},
{s1: NewStaticFloatArray([]float64{1.1, 2.2, 3.3}), s2: NewStaticFloatArray([]float64{1.1, -2.2, 3.3}), want: 1},
{s1: NewStaticFloatArray([]float64{1.1, 2.2, 3.3}), s2: NewStaticFloatArray([]float64{1.1, 2.2, 3.3}), want: 0},
{s1: NewStaticStringArray([]string{"a", "b", "c"}), s2: NewStaticStringArray([]string{"a", "b"}), want: 1},
{s1: NewStaticStringArray([]string{"a", "b"}), s2: NewStaticStringArray([]string{"a", "b"}), want: 0},
{s1: NewStaticBooleanArray([]bool{true, false, true}), s2: NewStaticBooleanArray([]bool{true, false}), want: 1},
{s1: NewStaticBooleanArray([]bool{true, false}), s2: NewStaticBooleanArray([]bool{true, false}), want: 0},
}
for _, tt := range testCases {
t.Run(fmt.Sprintf("%s<>%s", tt.s1.String(), tt.s2.String()), func(t *testing.T) {
res := tt.s1.compare(&tt.s2)
require.Equal(t, tt.want, res, "s1.compare(s2)")
res = tt.s2.compare(&tt.s1)
require.Equal(t, -tt.want, res, "s2.compare(s1)")
})
}
}
func TestStatic_sumInto(t *testing.T) {
tests := []struct {
s1, s2, want Static
}{
{NewStaticInt(1), NewStaticInt(2), NewStaticInt(3)},
{NewStaticInt(-3), NewStaticInt(2), NewStaticInt(-1)},
{NewStaticInt(-3), NewStaticDuration(3), NewStaticInt(-3)},
{NewStaticDuration(2 * time.Second), NewStaticDuration(1 * time.Second), NewStaticDuration(3 * time.Second)},
{NewStaticDuration(2 * time.Second), NewStaticInt(3000), NewStaticDuration(2 * time.Second)},
{NewStaticFloat(1.5), NewStaticFloat(2.5), NewStaticFloat(4.0)},
{NewStaticFloat(-4.5), NewStaticFloat(2.0), NewStaticFloat(-2.5)},
{NewStaticFloat(3.14), NewStaticInt(1), NewStaticFloat(3.14)},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s+%s", tt.s1.String(), tt.s2.String()), func(t *testing.T) {
tt.s1.sumInto(&tt.s2)
assert.Equal(t, tt.want, tt.s1, "s1.sumInto(s2)")
})
}
}
func TestStatic_divideBy(t *testing.T) {
tests := []struct {
s Static
f float64
want Static
}{
{NewStaticInt(10), 2, NewStaticFloat(5)},
{NewStaticDuration(12 * time.Second), 2.1, NewStaticDuration(6 * time.Second)},
{NewStaticFloat(12.2), 2.0, NewStaticFloat(6.1)},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s/%.2f", tt.s.String(), tt.f), func(t *testing.T) {
s := tt.s.divideBy(tt.f)
assert.Equal(t, tt.want, s, "s.divideBy(f)")
})
}
}
func TestStatic_append(t *testing.T) {
tests := []struct {
s1, s2, want Static
}{
// int
{s1: NewStaticInt(1), s2: NewStaticInt(2), want: NewStaticIntArray([]int{1, 2})},
{s1: NewStaticInt(1), s2: NewStaticIntArray([]int{2, 3}), want: NewStaticIntArray([]int{1, 2, 3})},
{s1: NewStaticIntArray([]int{1, 2}), s2: NewStaticInt(3), want: NewStaticIntArray([]int{1, 2, 3})},
{s1: NewStaticIntArray([]int{1, 2}), s2: NewStaticIntArray([]int{3, 4}), want: NewStaticIntArray([]int{1, 2, 3, 4})},
{s1: NewStaticIntArray([]int{1, 2}), s2: NewStaticDuration(1 * time.Second), want: NewStaticNil()},
{s1: NewStaticIntArray([]int{1, 2}), s2: NewStaticString("1"), want: NewStaticNil()},
// float
{s1: NewStaticFloat(1.1), s2: NewStaticFloat(2.2), want: NewStaticFloatArray([]float64{1.1, 2.2})},
{s1: NewStaticFloat(1.1), s2: NewStaticFloatArray([]float64{2.2, 3.3}), want: NewStaticFloatArray([]float64{1.1, 2.2, 3.3})},
{s1: NewStaticFloatArray([]float64{1.1, 2.2}), s2: NewStaticFloat(3.3), want: NewStaticFloatArray([]float64{1.1, 2.2, 3.3})},
{s1: NewStaticFloatArray([]float64{1.1, 2.2}), s2: NewStaticFloatArray([]float64{3.3, 4.4}), want: NewStaticFloatArray([]float64{1.1, 2.2, 3.3, 4.4})},
{s1: NewStaticFloatArray([]float64{1.1, 2.2}), s2: NewStaticDuration(1 * time.Second), want: NewStaticNil()},
{s1: NewStaticFloatArray([]float64{1.1, 2.2}), s2: NewStaticString("1.1"), want: NewStaticNil()},
// string
{s1: NewStaticString("1"), s2: NewStaticString("2"), want: NewStaticStringArray([]string{"1", "2"})},
{s1: NewStaticString("1"), s2: NewStaticStringArray([]string{"2", "3"}), want: NewStaticStringArray([]string{"1", "2", "3"})},
{s1: NewStaticStringArray([]string{"1", "2"}), s2: NewStaticString("3"), want: NewStaticStringArray([]string{"1", "2", "3"})},
{s1: NewStaticStringArray([]string{"1", "2"}), s2: NewStaticStringArray([]string{"3", "4"}), want: NewStaticStringArray([]string{"1", "2", "3", "4"})},
{s1: NewStaticStringArray([]string{"1", "2"}), s2: NewStaticInt(1), want: NewStaticNil()},
// bool
{s1: NewStaticBool(true), s2: NewStaticBool(false), want: NewStaticBooleanArray([]bool{true, false})},
{s1: NewStaticBool(true), s2: NewStaticBooleanArray([]bool{false, true}), want: NewStaticBooleanArray([]bool{true, false, true})},
{s1: NewStaticBooleanArray([]bool{true, false}), s2: NewStaticBool(true), want: NewStaticBooleanArray([]bool{true, false, true})},
{s1: NewStaticBooleanArray([]bool{true, false}), s2: NewStaticBooleanArray([]bool{true, false}), want: NewStaticBooleanArray([]bool{true, false, true, false})},
{s1: NewStaticBooleanArray([]bool{true, false}), s2: NewStaticInt(1), want: NewStaticNil()},
}
for _, tt := range tests {
res, ok := tt.s1.append(tt.s2)
assert.Equal(t, tt.want, res)
if res.IsNil() {
assert.False(t, ok)
}
}
}
func TestPipelineExtractConditions(t *testing.T) {
testCases := []struct {
query string
request FetchSpansRequest
}{
{
"{ .foo1 = `a` } | { .foo2 = `b` }",
FetchSpansRequest{
Conditions: []Condition{
newCondition(NewAttribute("foo1"), OpEqual, NewStaticString("a")),
newCondition(NewAttribute("foo2"), OpEqual, NewStaticString("b")),
},
AllConditions: true,
},
},
{
"{ .foo = `a` } | by(.namespace) | count() > 3",
FetchSpansRequest{
Conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("a")),
newCondition(NewAttribute("namespace"), OpNone),
},
AllConditions: false,
},
},
{
"{ .foo = `a` } | avg(duration) > 20ms",
FetchSpansRequest{
Conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("a")),
newCondition(NewIntrinsic(IntrinsicDuration), OpNone),
},
AllConditions: false,
},
},
}
for _, tc := range testCases {
t.Run(tc.query, func(t *testing.T) {
ast, err := Parse(tc.query)
require.NoError(t, err)
actualRequest := FetchSpansRequest{
AllConditions: true,
}
ast.Pipeline.extractConditions(&actualRequest)
require.Equal(t, tc.request, actualRequest)
})
}
}
func TestPipelineEvaluate(t *testing.T) {
testCases := []struct {
query string
input []*Spanset
output []*Spanset
}{
{
"{ true } | { true } | { true }",
[]*Spanset{
{Spans: []Span{&mockSpan{}}},
},
[]*Spanset{
{Spans: []Span{&mockSpan{}}},
},
},
{
"{ true } | { false } | { true }",
[]*Spanset{
{Spans: []Span{&mockSpan{}}},
},
[]*Spanset{},
},
{
"{ .foo1 = `a` } | { .foo2 = `b` }",
[]*Spanset{
{Spans: []Span{
// First span should be dropped here
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo1"): NewStaticString("a")}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo1"): NewStaticString("a"), NewAttribute("foo2"): NewStaticString("b")}},
}},
},
[]*Spanset{
{Spans: []Span{
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo1"): NewStaticString("a"), NewAttribute("foo2"): NewStaticString("b")}},
}},
},
},
}
for _, tc := range testCases {
t.Run(tc.query, func(t *testing.T) {
ast, err := Parse(tc.query)
require.NoError(t, err)
actual, err := ast.Pipeline.evaluate(tc.input)
require.NoError(t, err)
require.Equal(t, tc.output, actual)
})
}
}
func TestSpansetFilterEvaluate(t *testing.T) {
testCases := []struct {
query string
input []*Spanset
output []*Spanset
}{
{
"{ true }",
[]*Spanset{
// Empty spanset is dropped
{Spans: []Span{}},
{Spans: []Span{&mockSpan{}}},
},
[]*Spanset{
{Spans: []Span{&mockSpan{}}},
},
},
{
"{ .foo = `a` }",
[]*Spanset{
{Spans: []Span{
// Second span should be dropped here
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticString("a")}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticString("b")}},
}},
{Spans: []Span{
// This entire spanset will be dropped
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticString("b")}},
}},
},
[]*Spanset{
{Spans: []Span{
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticString("a")}},
}},
},
},
{
"{ .http.status > `200` }",
[]*Spanset{
{Spans: []Span{
// First span should be dropped here
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("200")}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("201")}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("300")}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("301")}},
}},
{Spans: []Span{
// This entire spanset will be dropped
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("100")}},
}},
},
[]*Spanset{
{Spans: []Span{
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("201")}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("300")}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("301")}},
}},
},
},
{
"{ .http.status <= `300` }",
[]*Spanset{
{Spans: []Span{
// Last span should be dropped here
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("200")}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("201")}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("300")}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("301")}},
}},
{Spans: []Span{
// This entire spanset is valid
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("100")}},
}},
},
[]*Spanset{
{Spans: []Span{
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("200")}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("201")}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("300")}},
}},
{Spans: []Span{
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticString("100")}},
}},
},
},
{
"{ .http.status > `200` }",
[]*Spanset{
{Spans: []Span{
// This entire spanset will be dropped because mismatch type
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticInt(200)}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticInt(201)}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticInt(300)}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticInt(301)}},
}},
{Spans: []Span{
// This entire spanset will be dropped because mismatch type
&mockSpan{attributes: map[Attribute]Static{NewAttribute("http.status"): NewStaticInt(100)}},
}},
},
nil,
},
{
"{ .foo = 1 || (.foo >= 4 && .foo < 6) }",
[]*Spanset{
{Spans: []Span{
// Second span should be dropped here
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticInt(1)}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticInt(2)}},
}},
{Spans: []Span{
// First span should be dropped here
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticInt(3)}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticInt(4)}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticInt(5)}},
}},
{Spans: []Span{
// Entire spanset should be dropped
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticInt(6)}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticInt(7)}},
}},
},
[]*Spanset{
{Spans: []Span{
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticInt(1)}},
}},
{Spans: []Span{
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticInt(4)}},
&mockSpan{attributes: map[Attribute]Static{NewAttribute("foo"): NewStaticInt(5)}},
}},
},
},
}
for _, tc := range testCases {
t.Run(tc.query, func(t *testing.T) {
ast, err := Parse(tc.query)
require.NoError(t, err)
filt := ast.Pipeline.Elements[0].(*SpansetFilter)
actual, err := filt.evaluate(tc.input)
require.NoError(t, err)
require.Equal(t, tc.output, actual)
})
}
}
var _ Span = (*mockSpan)(nil)
type mockSpan struct {
id []byte
startTimeUnixNanos uint64
durationNanos uint64
attributes map[Attribute]Static
parentID, left, right int
}
func newMockSpan(id []byte) *mockSpan {
return &mockSpan{
id: id,
attributes: map[Attribute]Static{},
}
}
func (m *mockSpan) WithStartTime(nanos uint64) *mockSpan {
m.startTimeUnixNanos = nanos
return m
}
func (m *mockSpan) WithDuration(nanos uint64) *mockSpan {
m.durationNanos = nanos
return m
}
func (m *mockSpan) WithNestedSetInfo(parentid, left, right int) *mockSpan {
m.parentID = parentid
m.left = left
m.right = right
return m
}
func (m *mockSpan) WithSpanString(key string, value string) *mockSpan {
m.attributes[NewScopedAttribute(AttributeScopeSpan, false, key)] = NewStaticString(value)
return m
}
func (m *mockSpan) WithSpanInt(key string, value int) *mockSpan {
m.attributes[NewScopedAttribute(AttributeScopeSpan, false, key)] = NewStaticInt(value)
return m
}
func (m *mockSpan) WithSpanFloat(key string, value float64) *mockSpan {
m.attributes[NewScopedAttribute(AttributeScopeSpan, false, key)] = NewStaticFloat(value)
return m
}
func (m *mockSpan) WithAttrBool(key string, value bool) *mockSpan {
m.attributes[NewAttribute(key)] = NewStaticBool(value)
return m
}
func (m *mockSpan) AttributeFor(a Attribute) (Static, bool) {
s, ok := m.attributes[a]
// if not found explicitly, check if it's a span attribute
if !ok && a.Scope == AttributeScopeNone {
aSpan := a
aSpan.Scope = AttributeScopeSpan
s, ok = m.attributes[aSpan]
}
// if not found explicitly, check if it's a resource attribute
if !ok && a.Scope == AttributeScopeNone {
aRes := a
aRes.Scope = AttributeScopeResource
s, ok = m.attributes[aRes]
}
return s, ok
}
func (m *mockSpan) AllAttributes() map[Attribute]Static {
return m.attributes
}
func (m *mockSpan) AllAttributesFunc(cb func(Attribute, Static)) {
for k, v := range m.attributes {
cb(k, v)
}
}
func (m *mockSpan) ID() []byte {
return m.id
}
func (m *mockSpan) StartTimeUnixNanos() uint64 {
return m.startTimeUnixNanos
}
func (m *mockSpan) DurationNanos() uint64 {
return m.durationNanos
}
func (m *mockSpan) DescendantOf(lhs []Span, rhs []Span, falseForAll bool, invert bool, _ bool, _ []Span) []Span {
return loop(lhs, rhs, falseForAll, invert, descendantOf)
}
func descendantOf(s1 Span, s2 Span) bool {
return s2.(*mockSpan).left > s1.(*mockSpan).left && s2.(*mockSpan).left < s1.(*mockSpan).right
}
func (m *mockSpan) SiblingOf(lhs []Span, rhs []Span, falseForAll bool, _ bool, _ []Span) []Span {
return loop(lhs, rhs, falseForAll, false, siblingOf)
}
func siblingOf(s1 Span, s2 Span) bool {
return s1.(*mockSpan).parentID == s2.(*mockSpan).parentID
}
func (m *mockSpan) ChildOf(lhs []Span, rhs []Span, falseForAll bool, invert bool, _ bool, _ []Span) []Span {
return loop(lhs, rhs, falseForAll, invert, childOf)
}
func childOf(s1 Span, s2 Span) bool {
return s1.(*mockSpan).left == s2.(*mockSpan).parentID
}
func loop(lhs []Span, rhs []Span, falseForAll bool, invert bool, eval func(s1 Span, s2 Span) bool) []Span {
out := []Span{}
for _, r := range rhs {
match := false
for _, l := range lhs {
if invert {
match = eval(r, l)
} else {
match = eval(l, r)
}
if match {
break
}
}
if (match && !falseForAll) ||
(!match && falseForAll) {
out = append(out, r)
}
}
return out
}
func newStatic(val any) Static {
if val == nil {
return NewStaticNil()
}
switch v := val.(type) {
case int:
return NewStaticInt(v)
case float64:
return NewStaticFloat(v)
case string:
return NewStaticString(v)
case bool:
return NewStaticBool(v)
case time.Duration:
return NewStaticDuration(v)
case Status:
return NewStaticStatus(v)
case Kind:
return NewStaticKind(v)
case []int:
return NewStaticIntArray(v)
case []float64:
return NewStaticFloatArray(v)
case []string:
return NewStaticStringArray(v)
case []bool:
return NewStaticBooleanArray(v)
default:
panic(fmt.Sprintf("unsupported type %T", val))
}
}
func testName(val any) string {
if val == nil {
return "nil"
}
switch v := val.(type) {
case float64:
return fmt.Sprintf("%e", v)
case []int:
return fmt.Sprintf("[%d]int", len(v))
case []float64:
return fmt.Sprintf("[%d]float", len(v))
case []string:
return fmt.Sprintf("[%d]str", len(v))
case []bool:
return fmt.Sprintf("[%d]bool", len(v))
case Static:
return v.EncodeToString(false)
default:
return fmt.Sprintf("%v", v)
}
}
func TestNeedsFullTrace(t *testing.T) {
dontNeedFullTrace := []string{
"{}",
"{} | rate()",
}
needFullTrace := []string{
"{} >> {}",
"{} && {}",
"{} | count() > 100",
"({} | count()) = ({} | count())",
}
for _, test := range dontNeedFullTrace {
t.Run(test, func(t *testing.T) {
expr, err := Parse(test)
require.NoError(t, err)
require.Equal(t, false, expr.NeedsFullTrace())
})
}
for _, test := range needFullTrace {
t.Run(test, func(t *testing.T) {
expr, err := Parse(test)
require.NoError(t, err)
require.Equal(t, true, expr.NeedsFullTrace())
})
}
}
func TestNewBinaryOperation_TraceIDNormalization(t *testing.T) {
traceIDAttr := NewIntrinsic(IntrinsicTraceID)
op := OpEqual
tests := []struct {
name string
expression FieldExpression
expected FieldExpression
}{
{
name: "with leading zeros",
expression: NewStaticString("0000123"),
expected: NewStaticString("123"),
},
{
name: "no leading zeros",
expression: NewStaticString("123456789abcdef123456789abcdef"),
expected: NewStaticString("123456789abcdef123456789abcdef"),
},
{
name: "int",
expression: NewStaticInt(123),
expected: NewStaticInt(123),
},
{
name: "float",
expression: NewStaticFloat(123.0),
expected: NewStaticFloat(123.0),
},
}
for _, tc := range []struct {
name string
reverse bool
}{
{name: "trace:id = value", reverse: false},
{name: "value = trace:id", reverse: true},
} {
for _, tt := range tests {
t.Run(tc.name+" "+tt.name, func(t *testing.T) {
var lhs FieldExpression = traceIDAttr
rhs := tt.expression
var expectedLHS FieldExpression = traceIDAttr
expectedRHS := tt.expected
if tc.reverse {
lhs = tt.expression
rhs = traceIDAttr
expectedLHS = tt.expected
expectedRHS = traceIDAttr
}
binop := newBinaryOperation(op, lhs, rhs)
// Verify the result is a BinaryOperation
require.IsType(t, &BinaryOperation{}, binop)
actualBinop := binop.(*BinaryOperation)
assert.Equal(t, expectedLHS, actualBinop.LHS)
assert.Equal(t, expectedRHS, actualBinop.RHS)
})
}
}
}