Combine aggregate columns per function instead of summing every one
sumRows folded every numeric column by addition, which is only correct for count and sum, so min/max returned a sum, avg an average of averages, and a to_string extract collapsed into one empty-key row. - Combine count and sum by adding, min and max by the extreme, on text columns as well as numeric ones - Rewrite an avg extract into an upstream sum and count and divide the totals, answering under the avg key the client asked for - Refuse an aggregate pdbmux cannot merge with 400 naming the clash - Treat to_string and jsonb_typeof as row functions that group rather than fold, and key groups on every non-aggregate projected column - Give the e2e fixture per-node resource line numbers and titles whose extremes differ per backend
This commit is contained in:
+427
-53
@@ -2,8 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -29,29 +31,82 @@ func decodeRows(t *testing.T, raws []json.RawMessage) []map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
func TestParseAggregate_ExtractWithGroupBy(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
if spec == nil {
|
||||
t.Fatal("expected an aggregate spec")
|
||||
// mustAggregate parses a query that has to be a mergeable aggregate.
|
||||
func mustAggregate(t *testing.T, q string) *aggregateSpec {
|
||||
t.Helper()
|
||||
spec, err := parseAggregate(q)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAggregate(%s): %v", q, err)
|
||||
}
|
||||
if spec == nil {
|
||||
t.Fatalf("parseAggregate(%s) = nil, want an aggregate spec", q)
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
func aggNames(aggs []aggColumn) []string {
|
||||
out := make([]string, 0, len(aggs))
|
||||
for _, a := range aggs {
|
||||
out = append(out, a.name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestParseAggregate_ExtractWithGroupBy(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
if !slices.Equal(spec.keys, []string{"status"}) {
|
||||
t.Errorf("keys = %v, want [status]", spec.keys)
|
||||
}
|
||||
if !slices.Equal(spec.sums, []string{"count"}) {
|
||||
t.Errorf("sums = %v, want [count]", spec.sums)
|
||||
if !slices.Equal(aggNames(spec.aggs), []string{"count"}) {
|
||||
t.Errorf("aggs = %v, want [count]", aggNames(spec.aggs))
|
||||
}
|
||||
if spec.aggs[0].op != opAdd {
|
||||
t.Errorf("count op = %v, want opAdd", spec.aggs[0].op)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAggregate_GroupByAddsUnextractedField(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count","certname"]],["~","certname",".*"],["group_by","status"]]`)
|
||||
if spec == nil {
|
||||
t.Fatal("expected an aggregate spec")
|
||||
}
|
||||
spec := mustAggregate(t, `["extract",[["function","count","certname"]],["~","certname",".*"],["group_by","status"]]`)
|
||||
if !slices.Equal(spec.keys, []string{"status"}) {
|
||||
t.Errorf("keys = %v, want [status] from the group_by clause", spec.keys)
|
||||
}
|
||||
if !slices.Equal(spec.sums, []string{"count"}) {
|
||||
t.Errorf("sums = %v, want [count]", spec.sums)
|
||||
if !slices.Equal(aggNames(spec.aggs), []string{"count"}) {
|
||||
t.Errorf("aggs = %v, want [count]", aggNames(spec.aggs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAggregate_OpPerFunction(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","min","line"],["function","max","line"],["function","sum","line"],["function","count"]]]`)
|
||||
want := []aggColumn{
|
||||
{name: "min", op: opMin},
|
||||
{name: "max", op: opMax},
|
||||
{name: "sum", op: opAdd},
|
||||
{name: "count", op: opAdd},
|
||||
}
|
||||
if !reflect.DeepEqual(spec.aggs, want) {
|
||||
t.Errorf("aggs = %v, want %v", spec.aggs, want)
|
||||
}
|
||||
if len(spec.keys) != 0 {
|
||||
t.Errorf("keys = %v, want none", spec.keys)
|
||||
}
|
||||
}
|
||||
|
||||
// to_string and jsonb_typeof are row functions, so they identify a row rather
|
||||
// than being folded into it.
|
||||
func TestParseAggregate_RowFunctionsAreKeys(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","to_string","producer_timestamp","FMDD"],["function","jsonb_typeof","value"],["function","count"]]]`)
|
||||
if !slices.Equal(spec.keys, []string{"to_string", "jsonb_typeof"}) {
|
||||
t.Errorf("keys = %v, want [to_string jsonb_typeof]", spec.keys)
|
||||
}
|
||||
if !slices.Equal(aggNames(spec.aggs), []string{"count"}) {
|
||||
t.Errorf("aggs = %v, want [count]", aggNames(spec.aggs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAggregate_GroupByOnAFunctionColumn(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","to_string","producer_timestamp","FMDD"],["function","count"]],["group_by",["function","to_string","producer_timestamp","FMDD"]]]`)
|
||||
if !slices.Equal(spec.keys, []string{"to_string"}) {
|
||||
t.Errorf("keys = %v, want [to_string] once", spec.keys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,18 +119,33 @@ func TestParseAggregate_NoFunctionIsNotAggregate(t *testing.T) {
|
||||
`["extract"]`,
|
||||
`{"not":"an array"}`,
|
||||
} {
|
||||
if spec := parseAggregate(q); spec != nil {
|
||||
spec, err := parseAggregate(q)
|
||||
if err != nil {
|
||||
t.Errorf("parseAggregate(%q): unexpected error %v", q, err)
|
||||
}
|
||||
if spec != nil {
|
||||
t.Errorf("parseAggregate(%q) = %+v, want nil", q, spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_SharedKeysAreAdded(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := sumRows([]backendResult{
|
||||
// A function pdbmux has no combiner for is refused, never folded on a guess.
|
||||
func TestParseAggregate_UnknownFunctionIsRefused(t *testing.T) {
|
||||
spec, err := parseAggregate(`["extract",[["function","stddev","line"]]]`)
|
||||
if err == nil {
|
||||
t.Fatalf("parseAggregate = %+v, want an error", spec)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stddev") {
|
||||
t.Errorf("error %q does not name the function", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRows_SharedKeysAreAdded(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"count":3,"status":"changed"}`, `{"count":1,"status":"failed"}`)},
|
||||
{name: "b", records: rows(`{"count":4,"status":"changed"}`, `{"count":2,"status":"failed"}`)},
|
||||
}, spec.columns)
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{
|
||||
{"count": float64(7), "status": "changed"},
|
||||
@@ -86,12 +156,227 @@ func TestSumRows_SharedKeysAreAdded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_DisjointKeysAreKept(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := sumRows([]backendResult{
|
||||
func TestCombineRows_SumIsAdded(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","sum","line"]]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"sum":30}`)},
|
||||
{name: "b", records: rows(`{"sum":12}`)},
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{{"sum": float64(42)}}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The bug this replaces: max folded by addition returned the sum of the
|
||||
// backends' maxima, a number no backend ever held.
|
||||
func TestCombineRows_MaxIsNotASum(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","max","line"]]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"max":20}`)},
|
||||
{name: "b", records: rows(`{"max":50}`)},
|
||||
}, spec.shape)
|
||||
|
||||
got := decodeRows(t, merged)
|
||||
want := []map[string]any{{"max": float64(50)}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("merged = %v, want %v", got, want)
|
||||
}
|
||||
if got[0]["max"] == float64(70) {
|
||||
t.Error("max was summed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRows_MinIsTheSmallest(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","min","line"]]]`)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
a, b string
|
||||
want any
|
||||
}{
|
||||
{"later backend wins", `{"min":20}`, `{"min":5}`, float64(5)},
|
||||
{"earlier backend wins", `{"min":5}`, `{"min":20}`, float64(5)},
|
||||
{"negative values", `{"min":-1}`, `{"min":-9}`, float64(-9)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(tc.a)},
|
||||
{name: "b", records: rows(tc.b)},
|
||||
}, spec.shape)
|
||||
got := decodeRows(t, merged)
|
||||
if len(got) != 1 || got[0]["min"] != tc.want {
|
||||
t.Errorf("merged = %v, want min %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// openvoxdb allows min/max on text columns, where the wrong answer used to be
|
||||
// whichever backend answered first.
|
||||
func TestCombineRows_MinMaxOnTextColumns(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","min","name"],["function","max","name"]]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"min":"kernel","max":"role"}`)},
|
||||
{name: "b", records: rows(`{"min":"extra_b","max":"uptime"}`)},
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{{"min": "extra_b", "max": "uptime"}}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A backend that matched nothing answers null, which is not an extreme.
|
||||
func TestCombineRows_MinMaxIgnoreNulls(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","max","line"]]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"max":null}`)},
|
||||
{name: "b", records: rows(`{"max":7}`)},
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{{"max": float64(7)}}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRows_MinMaxWithGroupBy(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","min","line"],["function","max","line"],"type"],["group_by","type"]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"min":10,"max":20,"type":"File"}`, `{"min":1,"max":2,"type":"Stage"}`)},
|
||||
{name: "b", records: rows(`{"min":30,"max":50,"type":"File"}`)},
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{
|
||||
{"min": float64(10), "max": float64(50), "type": "File"},
|
||||
{"min": float64(1), "max": float64(2), "type": "Stage"},
|
||||
}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// avg is rewritten upstream into the sum and count of the same expression.
|
||||
func TestParseAggregate_AvgIsRewrittenToSumAndCount(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","avg","line"]],["=","type","File"]]`)
|
||||
if !spec.avg {
|
||||
t.Fatal("spec does not record the avg rewrite")
|
||||
}
|
||||
const want = `["extract",[["function","sum","line"],["function","count","line"]],["=","type","File"]]`
|
||||
if spec.query != want {
|
||||
t.Errorf("upstream query = %s, want %s", spec.query, want)
|
||||
}
|
||||
if !slices.Equal(aggNames(spec.aggs), []string{"sum", "count"}) {
|
||||
t.Errorf("aggs = %v, want the sum and count helpers", aggNames(spec.aggs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAggregate_AvgKeepsCompanionColumnsAndGroupBy(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","avg","line"],"type"],["group_by","type"]]`)
|
||||
const want = `["extract",[["function","sum","line"],"type",["function","count","line"]],["group_by","type"]]`
|
||||
if spec.query != want {
|
||||
t.Errorf("upstream query = %s, want %s", spec.query, want)
|
||||
}
|
||||
if !slices.Equal(spec.keys, []string{"type"}) {
|
||||
t.Errorf("keys = %v, want [type]", spec.keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRows_AvgIsWeightedByCount(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","avg","line"]]]`)
|
||||
// Backend a averages 10 over 1 row, b averages 20 over 3: the true average
|
||||
// is 70/4, not the 15 an average of averages gives.
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"sum":10,"count":1}`)},
|
||||
{name: "b", records: rows(`{"sum":60,"count":3}`)},
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{{"avg": float64(17.5)}}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A single backend still gets the avg key back, not the helper columns.
|
||||
func TestCombineRows_AvgFromOneBackend(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","avg","line"],"type"],["group_by","type"]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"sum":30,"count":4,"type":"File"}`)},
|
||||
{name: "b", records: nil},
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{{"avg": float64(7.5), "type": "File"}}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRows_AvgOverNoRowsIsNull(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","avg","line"]]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"sum":null,"count":0}`)},
|
||||
{name: "b", records: rows(`{"sum":null,"count":0}`)},
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{{"avg": nil}}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRows_AvgWithGroupBy(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","avg","line"],"type"],["group_by","type"]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"sum":10,"count":1,"type":"File"}`, `{"sum":8,"count":2,"type":"Stage"}`)},
|
||||
{name: "b", records: rows(`{"sum":60,"count":3,"type":"File"}`)},
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{
|
||||
{"avg": float64(17.5), "type": "File"},
|
||||
{"avg": float64(4), "type": "Stage"},
|
||||
}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The helper columns take the response keys openvoxdb would give the client's
|
||||
// own sum or count, so the pair cannot be served together.
|
||||
func TestParseAggregate_AvgWithSumOrCountIsRefused(t *testing.T) {
|
||||
for _, q := range []string{
|
||||
`["extract",[["function","avg","line"],["function","sum","line"]]]`,
|
||||
`["extract",[["function","avg","line"],["function","count"]]]`,
|
||||
} {
|
||||
spec, err := parseAggregate(q)
|
||||
if err == nil {
|
||||
t.Errorf("parseAggregate(%s) = %+v, want a refusal", q, spec)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "avg") {
|
||||
t.Errorf("error %q does not name avg", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAggregate_AvgWithoutAColumnIsRefused(t *testing.T) {
|
||||
if spec, err := parseAggregate(`["extract",[["function","avg"]]]`); err == nil {
|
||||
t.Errorf("parseAggregate = %+v, want a refusal", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAggregate_TwoAvgColumnsAreRefused(t *testing.T) {
|
||||
if spec, err := parseAggregate(`["extract",[["function","avg","line"],["function","avg","value"]]]`); err == nil {
|
||||
t.Errorf("parseAggregate = %+v, want a refusal", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRows_DisjointKeysAreKept(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"count":3,"status":"changed"}`)},
|
||||
{name: "b", records: rows(`{"count":2,"status":"skipped"}`)},
|
||||
}, spec.columns)
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{
|
||||
{"count": float64(3), "status": "changed"},
|
||||
@@ -102,25 +387,25 @@ func TestSumRows_DisjointKeysAreKept(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_SingleBackendRowKeepsUpstreamBytes(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
func TestCombineRows_SingleBackendRowKeepsUpstreamBytes(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
const raw = `{"count":3,"status":"changed","extra":{"kept":true}}`
|
||||
merged := sumRows([]backendResult{
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(raw)},
|
||||
{name: "b", records: nil},
|
||||
}, spec.columns)
|
||||
}, spec.shape)
|
||||
|
||||
if len(merged) != 1 || string(merged[0]) != raw {
|
||||
t.Errorf("merged = %s, want the row verbatim %s", merged, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_NonNumericAggregateColumnIsNotZeroed(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := sumRows([]backendResult{
|
||||
func TestCombineRows_NonNumericAggregateColumnIsNotZeroed(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"count":5,"status":"changed"}`)},
|
||||
{name: "b", records: rows(`{"count":null,"status":"changed"}`)},
|
||||
}, spec.columns)
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{{"count": float64(5), "status": "changed"}}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
@@ -128,12 +413,12 @@ func TestSumRows_NonNumericAggregateColumnIsNotZeroed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_MissingAggregateColumnStartsFromTheNumericRow(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := sumRows([]backendResult{
|
||||
func TestCombineRows_MissingAggregateColumnStartsFromTheNumericRow(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"status":"changed"}`)},
|
||||
{name: "b", records: rows(`{"count":6,"status":"changed"}`)},
|
||||
}, spec.columns)
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{{"count": float64(6), "status": "changed"}}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
@@ -141,29 +426,26 @@ func TestSumRows_MissingAggregateColumnStartsFromTheNumericRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_NonObjectRowsPassThrough(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := sumRows([]backendResult{
|
||||
func TestCombineRows_NonObjectRowsPassThrough(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`"surprise"`)},
|
||||
{name: "b", records: rows(`{"count":1,"status":"changed"}`)},
|
||||
}, spec.columns)
|
||||
}, spec.shape)
|
||||
|
||||
if len(merged) != 2 || string(merged[0]) != `"surprise"` {
|
||||
t.Fatalf("merged = %s, want the non-object row kept as-is", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_NoKeyColumnsCollapseToOneRow(t *testing.T) {
|
||||
func TestCombineRows_NoKeyColumnsCollapseToOneRow(t *testing.T) {
|
||||
// ["extract",[["function","count"]],...] is a whole-estate count: one row
|
||||
// per backend, and the merged answer is their sum.
|
||||
spec := parseAggregate(`["extract",[["function","count"]],["=","certname","h1"]]`)
|
||||
if spec == nil {
|
||||
t.Fatal("expected an aggregate spec")
|
||||
}
|
||||
merged := sumRows([]backendResult{
|
||||
spec := mustAggregate(t, `["extract",[["function","count"]],["=","certname","h1"]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"count":10}`)},
|
||||
{name: "b", records: rows(`{"count":32}`)},
|
||||
}, spec.columns)
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{{"count": float64(42)}}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
@@ -171,23 +453,96 @@ func TestSumRows_NoKeyColumnsCollapseToOneRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferredColumns_SplitsCountsFromIdentity(t *testing.T) {
|
||||
// to_string compiles to a scalar expression, so openvoxdb answers one row per
|
||||
// input row; with nothing to fold, every backend's rows are kept rather than
|
||||
// collapsing into a single empty-key bucket.
|
||||
func TestCombineRows_ToStringAloneDoesNotCollapse(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","to_string","producer_timestamp","FMDD"]]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"to_string":"01"}`, `{"to_string":"02"}`)},
|
||||
{name: "b", records: rows(`{"to_string":"02"}`, `{"to_string":"03"}`)},
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{
|
||||
{"to_string": "01"}, {"to_string": "02"},
|
||||
{"to_string": "02"}, {"to_string": "03"},
|
||||
}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want every row kept %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRows_ToStringGroupsItsCompanionCount(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","to_string","producer_timestamp","FMDD"],["function","count"]],["group_by",["function","to_string","producer_timestamp","FMDD"]]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"to_string":"01","count":2}`, `{"to_string":"02","count":3}`)},
|
||||
{name: "b", records: rows(`{"to_string":"02","count":4}`)},
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{
|
||||
{"to_string": "01", "count": float64(2)},
|
||||
{"to_string": "02", "count": float64(7)},
|
||||
}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRows_JsonbTypeofGroups(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",[["function","jsonb_typeof","value"],["function","count"]],["group_by",["function","jsonb_typeof","value"]]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"jsonb_typeof":"string","count":5}`)},
|
||||
{name: "b", records: rows(`{"jsonb_typeof":"string","count":6}`, `{"jsonb_typeof":"number","count":1}`)},
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{
|
||||
{"jsonb_typeof": "string", "count": float64(11)},
|
||||
{"jsonb_typeof": "number", "count": float64(1)},
|
||||
}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A plain projected column keeps rows apart even when the query has no group_by.
|
||||
func TestCombineRows_PlainColumnsAreGroupingKeys(t *testing.T) {
|
||||
spec := mustAggregate(t, `["extract",["type","title",["function","count"]]]`)
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(`{"type":"File","title":"/tmp/a","count":1}`)},
|
||||
{name: "b", records: rows(`{"type":"File","title":"/tmp/b","count":1}`, `{"type":"File","title":"/tmp/a","count":2}`)},
|
||||
}, spec.shape)
|
||||
|
||||
want := []map[string]any{
|
||||
{"type": "File", "title": "/tmp/a", "count": float64(3)},
|
||||
{"type": "File", "title": "/tmp/b", "count": float64(1)},
|
||||
}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferredShape_SplitsCountsFromIdentity(t *testing.T) {
|
||||
var row map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(`{"subject_type":"certname","subject":{"title":"h1"},"failures":1,"successes":2,"skips":null}`), &row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys, sums := inferredColumns(row)
|
||||
if !slices.Equal(keys, []string{"subject", "subject_type"}) {
|
||||
t.Errorf("keys = %v, want [subject subject_type]", keys)
|
||||
sh := inferredShape(row)
|
||||
if !slices.Equal(sh.keys, []string{"subject", "subject_type"}) {
|
||||
t.Errorf("keys = %v, want [subject subject_type]", sh.keys)
|
||||
}
|
||||
// A null count is an empty aggregate, not part of the row's identity.
|
||||
if !slices.Equal(sums, []string{"failures", "skips", "successes"}) {
|
||||
t.Errorf("sums = %v, want [failures skips successes]", sums)
|
||||
if !slices.Equal(aggNames(sh.aggs), []string{"failures", "skips", "successes"}) {
|
||||
t.Errorf("aggs = %v, want [failures skips successes]", aggNames(sh.aggs))
|
||||
}
|
||||
for _, a := range sh.aggs {
|
||||
if a.op != opAdd {
|
||||
t.Errorf("%s op = %v, want opAdd", a.name, a.op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_EventCountsPerSubject(t *testing.T) {
|
||||
merged := sumRows([]backendResult{
|
||||
func TestCombineRows_EventCountsPerSubject(t *testing.T) {
|
||||
merged := combineRows([]backendResult{
|
||||
{name: "a", records: rows(
|
||||
`{"subject_type":"certname","subject":{"title":"h1"},"failures":1,"successes":2,"noops":0,"skips":0}`,
|
||||
`{"subject_type":"certname","subject":{"title":"h2"},"failures":0,"successes":5,"noops":0,"skips":0}`,
|
||||
@@ -195,7 +550,7 @@ func TestSumRows_EventCountsPerSubject(t *testing.T) {
|
||||
{name: "b", records: rows(
|
||||
`{"subject_type":"certname","subject":{"title":"h1"},"failures":3,"successes":4,"noops":1,"skips":0}`,
|
||||
)},
|
||||
}, inferredColumns)
|
||||
}, inferredShape)
|
||||
|
||||
got := decodeRows(t, merged)
|
||||
want := []map[string]any{
|
||||
@@ -208,3 +563,22 @@ func TestSumRows_EventCountsPerSubject(t *testing.T) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropOrderBy(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, in, want string
|
||||
}{
|
||||
{"removes the named field", `[{"field":"avg","order":"desc"}]`, ``},
|
||||
{"keeps the others", `[{"field":"avg"},{"field":"type"}]`, `[{"field":"type"}]`},
|
||||
{"leaves an unrelated order_by alone", `[{"field":"type"}]`, `[{"field":"type"}]`},
|
||||
{"drops an unparseable order_by", `not json`, ``},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
params := url.Values{"order_by": {tc.in}}
|
||||
dropOrderBy(params, "avg")
|
||||
if got := params.Get("order_by"); got != tc.want {
|
||||
t.Errorf("order_by = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user