feat(topdata): define canonical json contracts
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type globalCondition struct {
|
||||
Field string
|
||||
ID string
|
||||
}
|
||||
|
||||
type globalConditionGroups struct {
|
||||
RequirePresent []globalCondition
|
||||
AnyPresent []globalCondition
|
||||
UnlessPresent []globalCondition
|
||||
}
|
||||
|
||||
var (
|
||||
baseOrPlainRootKeys = []string{
|
||||
"output", "key", "columns", "rows", "compare_reference",
|
||||
}
|
||||
canonicalModuleRootKeys = []string{
|
||||
"output", "key", "columns", "entries", "overrides", "rows",
|
||||
}
|
||||
globalRootKeys = []string{
|
||||
"columns", "entries", "overrides", "defaults", "position", "injections",
|
||||
}
|
||||
globalInjectionKeys = []string{
|
||||
"row", "require_present", "any_present", "unless_present",
|
||||
}
|
||||
globalConditionKeys = []string{"field", "id"}
|
||||
globalDefaultRuleKeys = []string{"match", "values"}
|
||||
globalDefaultMatchKeys = []string{"source"}
|
||||
globalDefaultFormatKeys = []string{"format"}
|
||||
)
|
||||
|
||||
func unsupportedObjectKeys(obj map[string]any, supported ...string) []string {
|
||||
if len(obj) == 0 {
|
||||
return nil
|
||||
}
|
||||
supportedSet := make(map[string]struct{}, len(supported))
|
||||
for _, key := range supported {
|
||||
supportedSet[key] = struct{}{}
|
||||
}
|
||||
unsupported := make([]string, 0, len(obj))
|
||||
for key := range obj {
|
||||
if _, ok := supportedSet[key]; ok {
|
||||
continue
|
||||
}
|
||||
unsupported = append(unsupported, key)
|
||||
}
|
||||
slices.Sort(unsupported)
|
||||
return unsupported
|
||||
}
|
||||
|
||||
func unsupportedObjectKeysError(label string, obj map[string]any, supported ...string) error {
|
||||
unsupported := unsupportedObjectKeys(obj, supported...)
|
||||
if len(unsupported) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s contains unsupported key %q (supported keys: %s)", label, unsupported[0], strings.Join(supported, ", "))
|
||||
}
|
||||
|
||||
func parseGlobalConditionGroups(injection map[string]any) (globalConditionGroups, error) {
|
||||
if injection == nil {
|
||||
return globalConditionGroups{}, nil
|
||||
}
|
||||
if err := unsupportedObjectKeysError("global injection", injection, globalInjectionKeys...); err != nil {
|
||||
return globalConditionGroups{}, err
|
||||
}
|
||||
|
||||
var groups globalConditionGroups
|
||||
var err error
|
||||
|
||||
if raw, ok := injection["require_present"]; ok {
|
||||
groups.RequirePresent, err = parseGlobalConditionList("require_present", raw, false)
|
||||
if err != nil {
|
||||
return globalConditionGroups{}, err
|
||||
}
|
||||
}
|
||||
if raw, ok := injection["any_present"]; ok {
|
||||
groups.AnyPresent, err = parseGlobalConditionList("any_present", raw, true)
|
||||
if err != nil {
|
||||
return globalConditionGroups{}, err
|
||||
}
|
||||
}
|
||||
if raw, ok := injection["unless_present"]; ok {
|
||||
groups.UnlessPresent, err = parseGlobalConditionList("unless_present", raw, false)
|
||||
if err != nil {
|
||||
return globalConditionGroups{}, err
|
||||
}
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func parseGlobalConditionList(name string, raw any, requireNonEmpty bool) ([]globalCondition, error) {
|
||||
if raw == nil {
|
||||
if requireNonEmpty {
|
||||
return nil, fmt.Errorf("%s must be an array", name)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
conditions, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s must be an array", name)
|
||||
}
|
||||
if requireNonEmpty && len(conditions) == 0 {
|
||||
return nil, fmt.Errorf("%s must contain at least one condition", name)
|
||||
}
|
||||
|
||||
parsed := make([]globalCondition, 0, len(conditions))
|
||||
for index, rawCondition := range conditions {
|
||||
condition, ok := rawCondition.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] must be an object", name, index)
|
||||
}
|
||||
if err := unsupportedObjectKeysError(fmt.Sprintf("%s[%d]", name, index), condition, globalConditionKeys...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
field, _ := condition["field"].(string)
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
return nil, fmt.Errorf("%s[%d].field is required", name, index)
|
||||
}
|
||||
|
||||
id, _ := condition["id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("%s[%d].id is required", name, index)
|
||||
}
|
||||
|
||||
parsed = append(parsed, globalCondition{Field: field, ID: id})
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func globalConditionGroupsMatch(groups globalConditionGroups, rows []map[string]any) bool {
|
||||
for _, condition := range groups.RequirePresent {
|
||||
if !globalReferencePresent(rows, condition.Field, condition.ID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if groups.AnyPresent != nil {
|
||||
if len(groups.AnyPresent) == 0 {
|
||||
return false
|
||||
}
|
||||
matched := false
|
||||
for _, condition := range groups.AnyPresent {
|
||||
if globalReferencePresent(rows, condition.Field, condition.ID) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, condition := range groups.UnlessPresent {
|
||||
if globalReferencePresent(rows, condition.Field, condition.ID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnsupportedObjectKeysAreSorted(t *testing.T) {
|
||||
obj := map[string]any{
|
||||
"row": map[string]any{},
|
||||
"when_present": []any{},
|
||||
"aaa": true,
|
||||
}
|
||||
|
||||
got := unsupportedObjectKeys(
|
||||
obj,
|
||||
"row",
|
||||
"require_present",
|
||||
"any_present",
|
||||
"unless_present",
|
||||
)
|
||||
want := []string{"aaa", "when_present"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("unsupportedObjectKeys() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedObjectKeysErrorNamesObjectAndContract(t *testing.T) {
|
||||
err := unsupportedObjectKeysError(
|
||||
"global injection 10",
|
||||
map[string]any{"row": map[string]any{}, "when_present": []any{}},
|
||||
"row",
|
||||
"require_present",
|
||||
"any_present",
|
||||
"unless_present",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported-property error")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"global injection 10",
|
||||
`"when_present"`,
|
||||
"row",
|
||||
"require_present",
|
||||
"any_present",
|
||||
"unless_present",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error %q does not contain %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalConditionGroups(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
injection map[string]any
|
||||
want globalConditionGroups
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "all groups",
|
||||
injection: map[string]any{
|
||||
"require_present": []any{
|
||||
map[string]any{"field": "FeatIndex", "id": "feat:required"},
|
||||
},
|
||||
"any_present": []any{
|
||||
map[string]any{"field": "FeatIndex", "id": "feat:first"},
|
||||
map[string]any{"field": "FeatIndex", "id": "feat:second"},
|
||||
},
|
||||
"unless_present": []any{
|
||||
map[string]any{"field": "FeatIndex", "id": "feat:blocked"},
|
||||
},
|
||||
},
|
||||
want: globalConditionGroups{
|
||||
RequirePresent: []globalCondition{
|
||||
{Field: "FeatIndex", ID: "feat:required"},
|
||||
},
|
||||
AnyPresent: []globalCondition{
|
||||
{Field: "FeatIndex", ID: "feat:first"},
|
||||
{Field: "FeatIndex", ID: "feat:second"},
|
||||
},
|
||||
UnlessPresent: []globalCondition{
|
||||
{Field: "FeatIndex", ID: "feat:blocked"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty existing groups remain valid",
|
||||
injection: map[string]any{
|
||||
"require_present": []any{},
|
||||
"unless_present": []any{},
|
||||
},
|
||||
want: globalConditionGroups{
|
||||
RequirePresent: []globalCondition{},
|
||||
UnlessPresent: []globalCondition{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "any present must be array",
|
||||
injection: map[string]any{
|
||||
"any_present": "feat:first",
|
||||
},
|
||||
wantError: "any_present must be an array",
|
||||
},
|
||||
{
|
||||
name: "any present must not be empty",
|
||||
injection: map[string]any{
|
||||
"any_present": []any{},
|
||||
},
|
||||
wantError: "any_present must contain at least one condition",
|
||||
},
|
||||
{
|
||||
name: "condition is closed",
|
||||
injection: map[string]any{
|
||||
"require_present": []any{
|
||||
map[string]any{
|
||||
"field": "FeatIndex",
|
||||
"id": "feat:required",
|
||||
"typo": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantError: `require_present[0] contains unsupported key "typo"`,
|
||||
},
|
||||
{
|
||||
name: "field is required",
|
||||
injection: map[string]any{
|
||||
"require_present": []any{
|
||||
map[string]any{"id": "feat:required"},
|
||||
},
|
||||
},
|
||||
wantError: "require_present[0].field is required",
|
||||
},
|
||||
{
|
||||
name: "id is required",
|
||||
injection: map[string]any{
|
||||
"require_present": []any{
|
||||
map[string]any{"field": "FeatIndex"},
|
||||
},
|
||||
},
|
||||
wantError: "require_present[0].id is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseGlobalConditionGroups(tc.injection)
|
||||
if tc.wantError != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("parseGlobalConditionGroups() error = nil, want %q", tc.wantError)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Fatalf("parseGlobalConditionGroups() error = %q, want contains %q", err, tc.wantError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseGlobalConditionGroups() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("parseGlobalConditionGroups() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("absent groups remain valid", func(t *testing.T) {
|
||||
got, err := parseGlobalConditionGroups(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("parseGlobalConditionGroups() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, globalConditionGroups{}) {
|
||||
t.Fatalf("parseGlobalConditionGroups() = %#v, want zero value", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGlobalConditionGroupsMatch(t *testing.T) {
|
||||
rowsWithoutBlocked := []map[string]any{
|
||||
{"FeatIndex": map[string]any{"id": "feat:required"}},
|
||||
{"FeatIndex": map[string]any{"id": "feat:second"}},
|
||||
}
|
||||
rowsWithBlocked := append([]map[string]any(nil), rowsWithoutBlocked...)
|
||||
rowsWithBlocked = append(rowsWithBlocked, map[string]any{"FeatIndex": map[string]any{"id": "feat:blocked"}})
|
||||
|
||||
if !globalConditionGroupsMatch(globalConditionGroups{
|
||||
RequirePresent: []globalCondition{
|
||||
{Field: "FeatIndex", ID: "feat:required"},
|
||||
},
|
||||
AnyPresent: []globalCondition{
|
||||
{Field: "FeatIndex", ID: "feat:first"},
|
||||
{Field: "FeatIndex", ID: "feat:second"},
|
||||
},
|
||||
}, rowsWithoutBlocked) {
|
||||
t.Fatal("expected required + later any_present alternative to match")
|
||||
}
|
||||
|
||||
if globalConditionGroupsMatch(globalConditionGroups{
|
||||
AnyPresent: []globalCondition{
|
||||
{Field: "FeatIndex", ID: "feat:missing"},
|
||||
},
|
||||
}, rowsWithoutBlocked) {
|
||||
t.Fatal("expected missing any_present alternatives to reject")
|
||||
}
|
||||
|
||||
if globalConditionGroupsMatch(globalConditionGroups{
|
||||
UnlessPresent: []globalCondition{
|
||||
{Field: "FeatIndex", ID: "feat:blocked"},
|
||||
},
|
||||
}, rowsWithBlocked) {
|
||||
t.Fatal("expected unless_present match to reject")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user