Skip to content

Commit bf55820

Browse files
committed
test(pkgs/schema): add validation for aggregate tables
1 parent 5c48476 commit bf55820

1 file changed

Lines changed: 97 additions & 0 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package schema
2+
3+
import (
4+
"reflect"
5+
"slices"
6+
"sort"
7+
"strconv"
8+
"testing"
9+
)
10+
11+
// allowedAggTagKeys is the set of struct-tag keys the continuous-aggregate
12+
// generator understands (mt = column alias, fn = SQL expression, gb = GROUP BY
13+
// position). Anything else is a typo that would be silently ignored.
14+
var allowedAggTagKeys = map[string]bool{
15+
"mt": true,
16+
"fn": true,
17+
"gb": true,
18+
}
19+
20+
// TestAggregateTagsAreValid guards the reflection-driven continuous-aggregate
21+
// layer (mt/fn/gb tags), the sibling of TestTableTagsAreValid for plain tables.
22+
// A mistyped tag here would otherwise produce a silently wrong materialized view.
23+
func TestAggregateTagsAreValid(t *testing.T) {
24+
for _, agg := range AllAggregates() {
25+
rt := reflect.TypeOf(agg)
26+
t.Run(agg.TableName(), func(t *testing.T) {
27+
if rt.Kind() != reflect.Struct {
28+
t.Fatalf("expected struct, got %s", rt.Kind())
29+
}
30+
if agg.TableName() == "" {
31+
t.Error("TableName is empty")
32+
}
33+
if agg.FromTable() == "" {
34+
t.Error("FromTable is empty")
35+
}
36+
37+
fieldCount := 0
38+
var groupByIdx []int
39+
40+
for field := range rt.Fields() {
41+
if !field.IsExported() {
42+
continue
43+
}
44+
fieldCount++
45+
46+
// Every column must declare its materialized alias.
47+
if mt, ok := field.Tag.Lookup("mt"); !ok || mt == "" {
48+
t.Errorf("field %s: missing or empty `mt` tag", field.Name)
49+
}
50+
51+
// Reject unknown tag keys (catches typos like `m_t`/`gp`).
52+
for _, key := range structTagKeys(string(field.Tag)) {
53+
if !allowedAggTagKeys[key] {
54+
t.Errorf("field %s: unknown struct-tag key %q", field.Name, key)
55+
}
56+
}
57+
58+
// GROUP BY positions must be non-negative integers.
59+
if gb, ok := field.Tag.Lookup("gb"); ok {
60+
idx, err := strconv.Atoi(gb)
61+
if err != nil || idx < 0 {
62+
t.Errorf("field %s: gb tag %q is not a non-negative integer", field.Name, gb)
63+
continue
64+
}
65+
groupByIdx = append(groupByIdx, idx)
66+
}
67+
}
68+
69+
// GROUP BY positions must be a contiguous 0..n-1 with no gaps or dupes;
70+
// aggGroupBy fills a slice by index, so a gap leaves an empty column.
71+
sort.Ints(groupByIdx)
72+
for i, idx := range groupByIdx {
73+
if idx != i {
74+
t.Errorf("gb positions are not contiguous from 0: got %v", groupByIdx)
75+
break
76+
}
77+
}
78+
79+
// The generated column/function/group-by lists must be consistent.
80+
if got := len(agg.TableColumns()); got != fieldCount {
81+
t.Errorf("TableColumns returned %d entries, expected %d (one per field)", got, fieldCount)
82+
}
83+
if slices.Contains(agg.TableColumns(), "") {
84+
t.Errorf("TableColumns contains an empty alias: %v", agg.TableColumns())
85+
}
86+
if got := len(agg.TableFunctions()); got != fieldCount {
87+
t.Errorf("TableFunctions returned %d entries, expected %d (one per field)", got, fieldCount)
88+
}
89+
if got := len(agg.GroupBy()); got != len(groupByIdx) {
90+
t.Errorf("GroupBy returned %d entries, expected %d", got, len(groupByIdx))
91+
}
92+
if slices.Contains(agg.GroupBy(), "") {
93+
t.Errorf("GroupBy has an empty entry (gap in gb positions): %v", agg.GroupBy())
94+
}
95+
})
96+
}
97+
}

0 commit comments

Comments
 (0)