From 18ffac631716903e557ddb40c08f16fdace4ac0d Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Thu, 25 Jun 2026 22:04:51 +0200 Subject: [PATCH] feat(topdata): define canonical json contracts --- internal/topdata/json_contract.go | 168 +++++++++++++++++++ internal/topdata/json_contract_test.go | 215 +++++++++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 internal/topdata/json_contract.go create mode 100644 internal/topdata/json_contract_test.go diff --git a/internal/topdata/json_contract.go b/internal/topdata/json_contract.go new file mode 100644 index 0000000..8a3edcf --- /dev/null +++ b/internal/topdata/json_contract.go @@ -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 +} diff --git a/internal/topdata/json_contract_test.go b/internal/topdata/json_contract_test.go new file mode 100644 index 0000000..82e043e --- /dev/null +++ b/internal/topdata/json_contract_test.go @@ -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") + } +}