Files
traces/pkg/traceql/ast_conditions_test.go
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

483 lines
14 KiB
Go

package traceql
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSpansetFilter_extractConditions(t *testing.T) {
tests := []struct {
query string
conditions []Condition
allConditions bool
}{
{
query: `{ .foo = "bar" && "bzz" = .fzz }`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("bar")),
newCondition(NewAttribute("fzz"), OpEqual, NewStaticString("bzz")),
},
allConditions: true,
},
{
query: `{ .foo = "bar" || "bzz" = .fzz }`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("bar")),
newCondition(NewAttribute("fzz"), OpEqual, NewStaticString("bzz")),
},
allConditions: false,
},
{
query: `{ "foo" = "bar" }`,
conditions: []Condition{},
allConditions: true,
},
{
query: `{ .foo = .bar }`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpNone),
newCondition(NewAttribute("bar"), OpNone),
},
allConditions: true,
},
{
query: `{ (.foo = "bar") = true }`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("bar")),
},
allConditions: true,
},
{
query: `{ true = (.foo = "bar") }`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("bar")),
},
allConditions: true,
},
{
query: `{ (.foo = "bar") = .bar }`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("bar")),
newCondition(NewAttribute("bar"), OpNone),
},
allConditions: true,
},
{
query: `{ .bar = (.foo = "bar") }`,
conditions: []Condition{
newCondition(NewAttribute("bar"), OpNone),
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("bar")),
},
allConditions: true,
},
// TODO we need a smarter engine to handle this - we can either negate OpEqual or just fetch .fzz
// {
// query: `{ (.foo = "bar") = !(.fzz = "bzz") }`,
// conditions: []Condition{
// newCondition(NewAttribute("foo"), OpEqual, NewStaticString("bar")),
// newCondition(NewAttribute("fzz"), OpNotEqual, NewStaticString("bzz")),
// },
// allConditions: true,
// },
{
query: `{ (.foo = "bar") = !.bar }`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("bar")),
newCondition(NewAttribute("bar"), OpNone),
},
allConditions: true,
},
{
query: `{ .foo = .bar + 1 }`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpNone),
newCondition(NewAttribute("bar"), OpNone),
},
allConditions: true,
},
{
query: `{ (.foo = 2) && (.bar / 1 > 3) }`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticInt(2)),
newCondition(NewAttribute("bar"), OpNone),
},
allConditions: true,
},
{
query: `{ .foo && true }`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpNone),
},
allConditions: true,
},
{
query: `{ true || .foo }`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpNone),
},
allConditions: true,
},
{
query: `{ span.foo = nil }`,
conditions: []Condition{
newCondition(NewScopedAttribute(AttributeScopeSpan, false, "foo"), OpNotExists),
},
allConditions: true,
},
}
for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) {
expr, err := Parse(tt.query)
require.NoError(t, err)
spansetFilter := expr.Pipeline.Elements[0].(*SpansetFilter)
req := &FetchSpansRequest{
Conditions: []Condition{},
AllConditions: true,
}
spansetFilter.extractConditions(req)
assert.Equal(t, tt.conditions, req.Conditions)
assert.Nil(t, req.SecondPassConditions)
assert.Equal(t, tt.allConditions, req.AllConditions, "FetchSpansRequest.AllConditions")
})
}
}
func TestScalarFilter_extractConditions(t *testing.T) {
tests := []struct {
query string
conditions []Condition
allConditions bool
}{
{
query: `{ .foo = "a" } | count() > 10`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("a")),
},
allConditions: false,
},
{
query: `{ .foo = "a" } | avg(duration) > 10ms`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("a")),
newCondition(NewIntrinsic(IntrinsicDuration), OpNone),
},
allConditions: false,
},
{
query: `({ span.http.status_code = 200 } | count()) > ({ span.http.status_code = 500 } | count())`,
conditions: []Condition{
newCondition(NewScopedAttribute(AttributeScopeSpan, false, "http.status_code"), OpEqual, NewStaticInt(200)),
newCondition(NewScopedAttribute(AttributeScopeSpan, false, "http.status_code"), OpEqual, NewStaticInt(500)),
},
allConditions: false,
},
{
query: `{ .foo = "a" } | 3 > 2`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("a")),
},
allConditions: true,
},
}
for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) {
expr, err := Parse(tt.query)
require.NoError(t, err)
req := &FetchSpansRequest{
Conditions: []Condition{},
AllConditions: true,
}
expr.Pipeline.extractConditions(req)
assert.Equal(t, tt.conditions, req.Conditions)
assert.Nil(t, req.SecondPassConditions)
assert.Equal(t, tt.allConditions, req.AllConditions, "FetchSpansRequest.AllConditions")
})
}
}
func TestStructuralNestedSet_extractConditions(t *testing.T) {
tests := []struct {
query string
conditions []Condition
allConditions bool
}{
{
query: `{} >> {}`,
conditions: []Condition{
newCondition(NewIntrinsic(IntrinsicStructuralDescendant), OpNone),
},
allConditions: false,
},
{
query: `{ nestedSetRight = 2 }`,
conditions: []Condition{
newCondition(NewIntrinsic(IntrinsicNestedSetRight), OpEqual, NewStaticInt(2)),
},
allConditions: true,
},
{
query: `{ nestedSetParent = 1 } > { nestedSetLeft < 3 }`,
conditions: []Condition{
newCondition(NewIntrinsic(IntrinsicStructuralChild), OpNone),
newCondition(NewIntrinsic(IntrinsicNestedSetParent), OpEqual, NewStaticInt(1)),
newCondition(NewIntrinsic(IntrinsicNestedSetLeft), OpLess, NewStaticInt(3)),
},
allConditions: false,
},
}
for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) {
expr, err := Parse(tt.query)
require.NoError(t, err)
req := &FetchSpansRequest{
Conditions: []Condition{},
AllConditions: true,
}
expr.Pipeline.extractConditions(req)
assert.Equal(t, tt.conditions, req.Conditions)
assert.Nil(t, req.SecondPassConditions)
assert.Equal(t, tt.allConditions, req.AllConditions, "FetchSpansRequest.AllConditions")
})
}
}
func TestSelect_extractConditions(t *testing.T) {
tests := []struct {
query string
conditions []Condition
secondPassConditions []Condition
allConditions bool
}{
{
query: `{ .foo = "a" } | select(resource.service.name)`,
conditions: []Condition{
newCondition(NewAttribute("foo"), OpEqual, NewStaticString("a")),
},
secondPassConditions: []Condition{
newCondition(NewScopedAttribute(AttributeScopeResource, false, "service.name"), OpNone),
},
allConditions: true,
},
{
query: `{ } | select(.name,name)`,
conditions: []Condition{
newCondition(NewIntrinsic(IntrinsicSpanStartTime), OpNone),
},
secondPassConditions: []Condition{
newCondition(NewAttribute("name"), OpNone),
newCondition(NewIntrinsic(IntrinsicName), OpNone),
},
allConditions: true,
},
{
// Pipleline elements after a select are always directed to the second pass
query: `{ } | select(span.foo) | { span.foo = "a" && span.bar = "b"}`,
conditions: []Condition{
newCondition(NewIntrinsic(IntrinsicSpanStartTime), OpNone),
},
secondPassConditions: []Condition{
// span.foo=a has no effect because it's already covered by the select statement
newCondition(NewScopedAttribute(AttributeScopeSpan, false, "foo"), OpNone),
newCondition(NewScopedAttribute(AttributeScopeSpan, false, "bar"), OpEqual, NewStaticString("b")),
},
allConditions: true,
},
}
for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) {
expr, err := Parse(tt.query)
require.NoError(t, err)
req := &FetchSpansRequest{
Conditions: []Condition{},
AllConditions: true,
}
expr.Pipeline.extractConditions(req)
assert.Equal(t, tt.conditions, req.Conditions)
assert.Equal(t, tt.secondPassConditions, req.SecondPassConditions)
assert.Equal(t, tt.allConditions, req.AllConditions, "FetchSpansRequest.AllConditions")
})
}
}
func TestMetricsAggregate_extractConditions(t *testing.T) {
tests := map[string]struct {
first []Condition
second []Condition
all bool
}{
`{} | rate() by (name)`: {
// Empty spanset implies start time
[]Condition{newCondition(IntrinsicSpanStartTimeAttribute, OpNone)},
[]Condition{newCondition(IntrinsicNameAttribute, OpNone)},
true,
},
`{name="foo"} | rate() by (name)`: {
// by() clause doesn't overwrite existing condition
[]Condition{newCondition(IntrinsicNameAttribute, OpEqual, NewStaticString("foo"))},
nil,
true,
},
}
for q, tt := range tests {
t.Run(q, func(t *testing.T) {
expr, err := Parse(q)
require.NoError(t, err)
req := &FetchSpansRequest{
AllConditions: true,
}
expr.extractConditions(req)
require.Equal(t, tt.first, req.Conditions)
require.Equal(t, tt.second, req.SecondPassConditions)
require.Equal(t, tt.all, req.AllConditions, "FetchSpansRequest.AllConditions")
})
}
}
func TestBinaryOperation_extractConditions(t *testing.T) {
tests := []struct {
name string
op *BinaryOperation
want []Condition
}{
{
name: "string array equal",
op: &BinaryOperation{Op: OpEqual, LHS: NewAttribute("attr"), RHS: NewStaticStringArray([]string{"a", "b"})},
want: []Condition{
newCondition(NewAttribute("attr"), OpEqual, NewStaticStringArray([]string{"a", "b"})),
},
},
{
name: "string IN array",
op: &BinaryOperation{Op: OpIn, LHS: NewAttribute("attr"), RHS: NewStaticStringArray([]string{"a", "b"})},
want: []Condition{
newCondition(NewAttribute("attr"), OpIn, NewStaticStringArray([]string{"a", "b"})),
},
},
{
name: "int array equal",
op: &BinaryOperation{Op: OpEqual, LHS: NewAttribute("attr"), RHS: NewStaticIntArray([]int{1, 2, 3})},
want: []Condition{
newCondition(NewAttribute("attr"), OpEqual, NewStaticIntArray([]int{1, 2, 3})),
},
},
{
name: "int IN array",
op: &BinaryOperation{Op: OpIn, LHS: NewAttribute("attr"), RHS: NewStaticIntArray([]int{1, 2, 3})},
want: []Condition{
newCondition(NewAttribute("attr"), OpIn, NewStaticIntArray([]int{1, 2, 3})),
},
},
{
name: "string array not regex",
op: &BinaryOperation{Op: OpNotRegex, LHS: NewAttribute("attr"), RHS: NewStaticStringArray([]string{"a.*", "b.*"})},
want: []Condition{
newCondition(NewAttribute("attr"), OpNotRegex, NewStaticStringArray([]string{"a.*", "b.*"})),
},
},
{
name: "string array MATCH ANY regex",
op: &BinaryOperation{Op: OpRegexMatchAny, LHS: NewAttribute("attr"), RHS: NewStaticStringArray([]string{"a.*", "b.*"})},
want: []Condition{
newCondition(NewAttribute("attr"), OpRegexMatchAny, NewStaticStringArray([]string{"a.*", "b.*"})),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &FetchSpansRequest{
AllConditions: true,
}
tt.op.extractConditions(req)
require.Equal(t, tt.want, req.Conditions)
require.Equal(t, true, req.AllConditions, "allConditions should be true")
})
t.Run(tt.name+" inverted", func(t *testing.T) {
req := &FetchSpansRequest{
AllConditions: true,
}
tt.op.RHS, tt.op.LHS = tt.op.LHS, tt.op.RHS
tt.op.extractConditions(req)
require.Equal(t, tt.want, req.Conditions)
require.Equal(t, true, req.AllConditions, "allConditions should be true")
})
}
}
func TestUnaryOperation_extractConditions(t *testing.T) {
tests := []struct {
query string
conditions []Condition
allConditions bool
}{
{
query: `{ span.foo != nil }`,
conditions: []Condition{
newCondition(NewScopedAttribute(AttributeScopeSpan, false, "foo"), OpExists),
},
allConditions: true,
},
{
query: `{ span.foo = nil }`,
conditions: []Condition{
newCondition(NewScopedAttribute(AttributeScopeSpan, false, "foo"), OpNotExists),
},
allConditions: true,
},
{
query: `{ span.foo }`,
conditions: []Condition{
newCondition(NewScopedAttribute(AttributeScopeSpan, false, "foo"), OpNone),
},
allConditions: true,
},
{
query: `{ !span.foo }`,
conditions: []Condition{
newCondition(NewScopedAttribute(AttributeScopeSpan, false, "foo"), OpNone),
},
allConditions: true,
},
{
query: `{ !(span.a = "a" || span.a = "c") }`,
conditions: []Condition{
newCondition(NewScopedAttribute(AttributeScopeSpan, false, "a"), OpEqual, NewStaticString("a")),
newCondition(NewScopedAttribute(AttributeScopeSpan, false, "a"), OpEqual, NewStaticString("c")),
},
allConditions: false,
},
}
for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) {
expr, err := Parse(tt.query)
require.NoError(t, err)
req := &FetchSpansRequest{
Conditions: []Condition{},
AllConditions: true,
}
expr.extractConditions(req)
assert.Equal(t, tt.conditions, req.Conditions)
assert.Nil(t, req.SecondPassConditions)
assert.Equal(t, tt.allConditions, req.AllConditions, "FetchSpansRequest.AllConditions")
})
}
}