Files
traces/pkg/parquetquery/predicates_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

471 lines
12 KiB
Go

package parquetquery
import (
"bytes"
"context"
"testing"
"github.com/google/uuid"
"github.com/parquet-go/parquet-go"
"github.com/stretchr/testify/require"
)
type testInt struct {
N int64 `parquet:",dict"`
}
type mockPredicate struct {
ret bool
valCalled bool
pageCalled bool
chunkCalled bool
}
type testDictString struct {
S string `parquet:",dict"`
}
var _ Predicate = (*mockPredicate)(nil)
func newAlwaysTruePredicate() *mockPredicate {
return &mockPredicate{ret: true}
}
func newAlwaysFalsePredicate() *mockPredicate {
return &mockPredicate{ret: false}
}
func (p *mockPredicate) String() string { return "mockPredicate{}" }
func (p *mockPredicate) KeepValue(parquet.Value) bool { p.valCalled = true; return p.ret }
func (p *mockPredicate) KeepPage(parquet.Page) bool { p.pageCalled = true; return p.ret }
func (p *mockPredicate) KeepColumnChunk(*ColumnChunkHelper) bool { p.chunkCalled = true; return p.ret }
type predicateTestCase struct {
testName string
writeData func(w *parquet.Writer) //nolint:all
keptChunks int
keptPages int
keptValues int
predicate Predicate
}
func TestSubstringPredicate(t *testing.T) {
testCases := []predicateTestCase{
{
testName: "all chunks/pages/values inspected",
predicate: NewSubstringPredicate("b"),
keptChunks: 1,
keptPages: 1,
keptValues: 2,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testDictString{"abc"})) // kept
require.NoError(t, w.Write(&testDictString{"bcd"})) // kept
require.NoError(t, w.Write(&testDictString{"cde"})) // skipped
},
},
{
testName: "dictionary in the page header allows for skipping a page",
predicate: NewSubstringPredicate("x"), // Not present in any values
keptChunks: 0,
keptPages: 0,
keptValues: 0,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testDictString{"abc"}))
require.NoError(t, w.Write(&testDictString{"abc"}))
require.NoError(t, w.Write(&testDictString{"abc"}))
require.NoError(t, w.Write(&testDictString{"abc"}))
require.NoError(t, w.Write(&testDictString{"abc"}))
},
},
}
for _, tC := range testCases {
t.Run(tC.testName, func(t *testing.T) {
testPredicate(t, tC)
})
}
}
func TestNewStringInPredicate(t *testing.T) {
testCases := []predicateTestCase{
{
testName: "all chunks/pages/values inspected",
predicate: func() Predicate {
return NewStringInPredicate([]string{"abc", "acd"})
}(),
keptChunks: 1,
keptPages: 1,
keptValues: 2,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testDictString{"abc"})) // kept
require.NoError(t, w.Write(&testDictString{"acd"})) // kept
require.NoError(t, w.Write(&testDictString{"cde"})) // skipped
},
},
{
testName: "dictionary in the page header allows for skipping a column chunk",
predicate: func() Predicate {
return NewStringInPredicate([]string{"x"})
}(), // Not present in any values
keptChunks: 0,
keptPages: 0,
keptValues: 0,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testDictString{"abc"}))
require.NoError(t, w.Write(&testDictString{"abc"}))
},
},
}
for _, tC := range testCases {
t.Run(tC.testName, func(t *testing.T) {
testPredicate(t, tC)
})
}
}
func TestNewRegexInPredicate(t *testing.T) {
testCases := []predicateTestCase{
{
testName: "all chunks/pages/values inspected",
predicate: func() Predicate {
pred, err := NewRegexInPredicate([]string{"a.*"})
require.NoError(t, err)
return pred
}(),
keptChunks: 1,
keptPages: 1,
keptValues: 2,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testDictString{"abc"})) // kept
require.NoError(t, w.Write(&testDictString{"acd"})) // kept
require.NoError(t, w.Write(&testDictString{"cde"})) // skipped
},
},
{
testName: "dictionary in the page header allows for skipping a column chunk",
predicate: func() Predicate {
pred, err := NewRegexInPredicate([]string{"x.*"})
require.NoError(t, err)
return pred
}(), // Not present in any values
keptChunks: 0,
keptPages: 0,
keptValues: 0,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testDictString{"abc"}))
require.NoError(t, w.Write(&testDictString{"abc"}))
},
},
}
for _, tC := range testCases {
t.Run(tC.testName, func(t *testing.T) {
testPredicate(t, tC)
})
}
}
func TestNewStringNotInPredicate(t *testing.T) {
testCases := []predicateTestCase{
{
testName: "all chunks/pages/values inspected",
predicate: func() Predicate {
return NewStringNotInPredicate([]string{"abc", "acd"})
}(),
keptChunks: 1,
keptPages: 1,
keptValues: 2,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testDictString{"abc"})) // skipped
require.NoError(t, w.Write(&testDictString{"acd"})) // skipped
require.NoError(t, w.Write(&testDictString{"cde"})) // kept
require.NoError(t, w.Write(&testDictString{"xde"})) // kept
},
},
{
testName: "dictionary in the page header allows for skipping a column chunk",
predicate: func() Predicate {
return NewStringNotInPredicate([]string{"xyz"})
}(), // All values excluded
keptChunks: 0,
keptPages: 0,
keptValues: 0,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testDictString{"xyz"}))
require.NoError(t, w.Write(&testDictString{"xyz"}))
},
},
}
for _, tC := range testCases {
t.Run(tC.testName, func(t *testing.T) {
testPredicate(t, tC)
})
}
}
func TestNewRegexNotInPredicate(t *testing.T) {
testCases := []predicateTestCase{
{
testName: "all chunks/pages/values inspected",
predicate: func() Predicate {
pred, err := NewRegexNotInPredicate([]string{"a.*"})
require.NoError(t, err)
return pred
}(),
keptChunks: 1,
keptPages: 1,
keptValues: 2,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testDictString{"abc"})) // skipped
require.NoError(t, w.Write(&testDictString{"acd"})) // skipped
require.NoError(t, w.Write(&testDictString{"cde"})) // kept
require.NoError(t, w.Write(&testDictString{"xde"})) // kept
},
},
{
testName: "dictionary in the page header allows for skipping a column chunk",
predicate: func() Predicate {
pred, err := NewRegexNotInPredicate([]string{"x.*"})
require.NoError(t, err)
return pred
}(), // Not present in any values
keptChunks: 0,
keptPages: 0,
keptValues: 0,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testDictString{"xyz"}))
require.NoError(t, w.Write(&testDictString{"xyz"}))
},
},
}
for _, tC := range testCases {
t.Run(tC.testName, func(t *testing.T) {
testPredicate(t, tC)
})
}
}
// TestOrPredicateCallsKeepColumnChunk ensures that the OrPredicate calls
// KeepColumnChunk on all of its children. This is important because the
// Dictionary predicates rely on KeepColumnChunk always being called at the
// beginning of a row group to reset their page.
func TestOrPredicateCallsKeepColumnChunk(t *testing.T) {
tcs := []struct {
preds []*mockPredicate
}{
{},
{
preds: []*mockPredicate{
newAlwaysTruePredicate(),
},
},
{
preds: []*mockPredicate{
newAlwaysFalsePredicate(),
},
},
{
preds: []*mockPredicate{
newAlwaysFalsePredicate(),
newAlwaysTruePredicate(),
},
},
{
preds: []*mockPredicate{
newAlwaysTruePredicate(),
newAlwaysFalsePredicate(),
},
},
}
for _, tc := range tcs {
preds := make([]Predicate, 0, len(tc.preds)+1)
for _, pred := range tc.preds {
preds = append(preds, pred)
}
recordPred := &mockPredicate{}
preds = append(preds, recordPred)
p := NewOrPredicate(preds...)
p.KeepColumnChunk(nil)
for _, pred := range preds {
require.True(t, pred.(*mockPredicate).chunkCalled)
}
}
}
// testPredicate by writing data and then iterating the column.
// The data model must contain a single column.
func testPredicate(t *testing.T, tc predicateTestCase) {
t.Helper()
buf := new(bytes.Buffer)
w := parquet.NewWriter(buf)
tc.writeData(w)
w.Flush()
w.Close()
file := bytes.NewReader(buf.Bytes())
r, err := parquet.OpenFile(file, int64(buf.Len()))
require.NoError(t, err)
p := InstrumentedPredicate{Pred: tc.predicate}
i := NewSyncIterator(context.TODO(), r.RowGroups(), 0, SyncIteratorOptPredicate(&p))
for {
res, err := i.Next()
require.NoError(t, err)
if res == nil {
break
}
}
require.Equal(t, tc.keptChunks, int(p.KeptColumnChunks), "keptChunks")
require.Equal(t, tc.keptPages, int(p.KeptPages), "keptPages")
require.Equal(t, tc.keptValues, int(p.KeptValues), "keptValues")
}
func BenchmarkSubstringPredicate(b *testing.B) {
p := NewSubstringPredicate("abc")
s := make([]parquet.Value, 1000)
for i := 0; i < 1000; i++ {
s[i] = parquet.ValueOf(uuid.New().String())
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, ss := range s {
p.KeepValue(ss)
}
}
}
func BenchmarkStringInPredicate(b *testing.B) {
p := NewStringInPredicate([]string{"abc"})
s := make([]parquet.Value, 1000)
for i := 0; i < 1000; i++ {
s[i] = parquet.ValueOf(uuid.New().String())
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, ss := range s {
p.KeepValue(ss)
}
}
}
func BenchmarkRegexInPredicate(b *testing.B) {
p, err := NewRegexInPredicate([]string{"abc"})
require.NoError(b, err)
s := make([]parquet.Value, 1000)
for i := 0; i < 1000; i++ {
s[i] = parquet.ValueOf(uuid.New().String())
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, ss := range s {
p.KeepValue(ss)
}
}
}
func TestIntInPredicate(t *testing.T) {
testCases := []predicateTestCase{
{
testName: "all chunks/pages/values inspected",
predicate: func() Predicate {
var p Predicate = NewIntInPredicate([]int64{1, 3})
return p
}(),
keptChunks: 1,
keptPages: 1,
keptValues: 2,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testInt{1})) // kept
require.NoError(t, w.Write(&testInt{2})) // skipped
require.NoError(t, w.Write(&testInt{3})) // kept
require.NoError(t, w.Write(&testInt{10})) // skipped
},
},
{
testName: "dictionary allows skipping a column chunk when no ints match",
predicate: func() Predicate {
var p Predicate = NewIntInPredicate([]int64{0, 4, 100})
return p
}(),
keptChunks: 0,
keptPages: 0,
keptValues: 0,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testInt{1}))
require.NoError(t, w.Write(&testInt{2}))
require.NoError(t, w.Write(&testInt{3}))
},
},
}
for _, tC := range testCases {
t.Run(tC.testName, func(t *testing.T) {
testPredicate(t, tC)
})
}
}
func TestIntNotInPredicate(t *testing.T) {
testCases := []predicateTestCase{
{
testName: "all chunks/pages/values inspected",
predicate: func() Predicate {
var p Predicate = NewIntNotInPredicate([]int64{1, 3})
return p
}(),
keptChunks: 1,
keptPages: 1,
keptValues: 2,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testInt{1})) // skipped
require.NoError(t, w.Write(&testInt{2})) // kept
require.NoError(t, w.Write(&testInt{3})) // skipped
require.NoError(t, w.Write(&testInt{4})) // kept
},
},
{
testName: "dictionary allows skipping a column chunk when all ints excluded",
predicate: func() Predicate {
var p Predicate = NewIntNotInPredicate([]int64{7, 8})
return p
}(),
keptChunks: 0,
keptPages: 0,
keptValues: 0,
writeData: func(w *parquet.Writer) { //nolint:all
require.NoError(t, w.Write(&testInt{7}))
require.NoError(t, w.Write(&testInt{8}))
},
},
}
for _, tC := range testCases {
t.Run(tC.testName, func(t *testing.T) {
testPredicate(t, tC)
})
}
}