Files
pdbmux/aggregate_test.go
T
unkin-agent 3eb7d53fe8 Refuse a group_by naming an aggregate column
A group_by key that repeats a folded column made the column a grouping
key and an aggregate at once, and for avg it left the upstream query
grouping on a column the rewrite had removed, so the request failed as
an opaque 502.

- refuse a group_by field that names a folded aggregate or the avg column
- cover the function-then-field ordering of the existing clash check
2026-09-07 14:00:44 +10:00

666 lines
25 KiB
Go

package main
import (
"encoding/json"
"net/url"
"reflect"
"slices"
"strings"
"testing"
)
func rows(raws ...string) []record {
out := make([]record, 0, len(raws))
for _, r := range raws {
out = append(out, record{Raw: json.RawMessage(r)})
}
return out
}
// decodeRows turns a merged result set into comparable maps.
func decodeRows(t *testing.T, raws []json.RawMessage) []map[string]any {
t.Helper()
out := make([]map[string]any, 0, len(raws))
for _, raw := range raws {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
out = append(out, m)
}
return out
}
// 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(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 := 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(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)
}
}
func TestParseAggregate_NoFunctionIsNotAggregate(t *testing.T) {
for _, q := range []string{
``,
`["=","certname","h1"]`,
`["extract",["certname","hash"],["=","certname","h1"]]`, // projection, still real reports
`not json`,
`["extract"]`,
`{"not":"an array"}`,
} {
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)
}
}
}
// 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.shape)
want := []map[string]any{
{"count": float64(7), "status": "changed"},
{"count": float64(3), "status": "failed"},
}
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
t.Errorf("merged = %v, want %v", got, want)
}
}
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)
}
}
// Every extract function names its column after itself, so a second one of the
// same name clashes whatever the function is — not only for avg.
func TestParseAggregate_RepeatedFunctionNameIsRefused(t *testing.T) {
for _, fn := range []string{"count", "sum", "min", "max", "to_string", "jsonb_typeof", "avg"} {
q := `["extract",[["function","` + fn + `","line"],["function","` + fn + `","type"]],["group_by","line","type"]]`
spec, err := parseAggregate(q)
if err == nil {
t.Errorf("parseAggregate(%s) = %+v, want a refusal", q, spec)
continue
}
if !strings.Contains(err.Error(), fn) {
t.Errorf("error %q does not name the clashing column %q", err, fn)
}
}
}
// The same clash the other way round: a plain field takes the response key a
// later function column would name.
func TestParseAggregate_FieldClashingWithAFunctionIsRefused(t *testing.T) {
const q = `["extract",["count",["function","count","certname"]],["group_by","count"]]`
spec, err := parseAggregate(q)
if err == nil {
t.Fatalf("parseAggregate(%s) = %+v, want a refusal", q, spec)
}
if !strings.Contains(err.Error(), "count") {
t.Errorf("error %q does not name the clashing column", err)
}
}
// The ordering the sibling case does not cover: the function column comes first
// and a later plain field takes the response key it already named.
func TestParseAggregate_FunctionClashingWithALaterFieldIsRefused(t *testing.T) {
const q = `["extract",[["function","count","certname"],"count"],["=","certname","h1"]]`
spec, err := parseAggregate(q)
if err == nil {
t.Fatalf("parseAggregate(%s) = %+v, want a refusal", q, spec)
}
if !strings.Contains(err.Error(), "count") {
t.Errorf("error %q does not name the clashing column", err)
}
}
// A group_by naming a folded column would make it a grouping key and an
// aggregate at once, so it is refused rather than sent upstream to fail.
func TestParseAggregate_GroupByOnAnAggregateColumnIsRefused(t *testing.T) {
for _, fn := range []string{"count", "sum", "min", "max", "avg"} {
q := `["extract",[["function","` + fn + `","line"]],["group_by","` + fn + `"]]`
spec, err := parseAggregate(q)
if err == nil {
t.Errorf("parseAggregate(%s) = %+v, want a refusal", q, spec)
continue
}
if !strings.Contains(err.Error(), fn) {
t.Errorf("error %q does not name the clashing column %q", err, fn)
}
}
}
// The refusal is limited to the folded columns: grouping on a plain field or on
// a row function's own column stays legitimate.
func TestParseAggregate_GroupByOnANonAggregateColumnIsKept(t *testing.T) {
for _, q := range []string{
`["extract",[["function","count","certname"],"status"],["group_by","status"]]`,
`["extract",[["function","to_string","producer_timestamp","FMDD"],["function","count"]],["group_by",["function","to_string","producer_timestamp","FMDD"]]]`,
`["extract",[["function","avg","line"],"type"],["group_by","type"]]`,
} {
if spec, err := parseAggregate(q); err != nil || spec == nil {
t.Errorf("parseAggregate(%s) = %+v, %v, want an accepted spec", q, spec, err)
}
}
}
// A repeated plain field projects the same value twice, so it is no clash: the
// duplicate carries nothing the grouping key has not already got.
func TestParseAggregate_RepeatedPlainFieldIsKept(t *testing.T) {
spec := mustAggregate(t, `["extract",["type","type",["function","count","certname"]],["group_by","type"]]`)
if !slices.Equal(spec.keys, []string{"type"}) {
t.Errorf("keys = %v, want [type]", spec.keys)
}
}
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.shape)
want := []map[string]any{
{"count": float64(3), "status": "changed"},
{"count": float64(2), "status": "skipped"},
}
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
t.Errorf("merged = %v, want %v", got, want)
}
}
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 := combineRows([]backendResult{
{name: "a", records: rows(raw)},
{name: "b", records: nil},
}, spec.shape)
if len(merged) != 1 || string(merged[0]) != raw {
t.Errorf("merged = %s, want the row verbatim %s", merged, raw)
}
}
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.shape)
want := []map[string]any{{"count": float64(5), "status": "changed"}}
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
t.Errorf("merged = %v, want the numeric value preserved %v", got, want)
}
}
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.shape)
want := []map[string]any{{"count": float64(6), "status": "changed"}}
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
t.Errorf("merged = %v, want %v", got, want)
}
}
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.shape)
if len(merged) != 2 || string(merged[0]) != `"surprise"` {
t.Fatalf("merged = %s, want the non-object row kept as-is", merged)
}
}
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 := mustAggregate(t, `["extract",[["function","count"]],["=","certname","h1"]]`)
merged := combineRows([]backendResult{
{name: "a", records: rows(`{"count":10}`)},
{name: "b", records: rows(`{"count":32}`)},
}, spec.shape)
want := []map[string]any{{"count": float64(42)}}
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
t.Errorf("merged = %v, want %v", got, want)
}
}
// 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)
}
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(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 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}`,
)},
{name: "b", records: rows(
`{"subject_type":"certname","subject":{"title":"h1"},"failures":3,"successes":4,"noops":1,"skips":0}`,
)},
}, inferredShape)
got := decodeRows(t, merged)
want := []map[string]any{
{"subject_type": "certname", "subject": map[string]any{"title": "h1"},
"failures": float64(4), "successes": float64(6), "noops": float64(1), "skips": float64(0)},
{"subject_type": "certname", "subject": map[string]any{"title": "h2"},
"failures": float64(0), "successes": float64(5), "noops": float64(0), "skips": float64(0)},
}
if !reflect.DeepEqual(got, want) {
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)
}
})
}
}