fix(topdata): close invariant JSON roots
build-binaries / build-binaries (pull_request) Successful in 2m15s
test-image / build-image (pull_request) Successful in 37s
test / test (pull_request) Successful in 1m27s

This commit is contained in:
2026-06-25 23:10:39 +02:00
parent 14fd1c2b41
commit 75bfb9f042
4 changed files with 180 additions and 85 deletions
+42 -12
View File
@@ -1,6 +1,7 @@
package topdata
import (
"errors"
"fmt"
"slices"
"strings"
@@ -63,36 +64,65 @@ func unsupportedObjectKeysError(label string, obj map[string]any, supported ...s
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) {
type globalConditionParseOptions struct {
Label string
Report func(error)
}
func parseGlobalConditionGroups(injection map[string]any, options ...globalConditionParseOptions) (globalConditionGroups, error) {
if injection == nil {
return globalConditionGroups{}, nil
}
if err := unsupportedObjectKeysError("global injection", injection, globalInjectionKeys...); err != nil {
return globalConditionGroups{}, err
label := "global injection"
var report func(error)
if len(options) > 0 {
if strings.TrimSpace(options[0].Label) != "" {
label = options[0].Label
}
report = options[0].Report
}
var errs []error
addError := func(err error) {
if err == nil {
return
}
errs = append(errs, err)
if report != nil {
report(err)
}
}
if err := unsupportedObjectKeysError(label, injection, globalInjectionKeys...); err != nil {
addError(err)
}
var groups globalConditionGroups
var err error
if raw, ok := injection["require_present"]; ok {
groups.RequirePresent, err = parseGlobalConditionList("require_present", raw, false)
parsed, err := parseGlobalConditionList(label+" require_present", raw, false)
if err != nil {
return globalConditionGroups{}, err
addError(err)
} else {
groups.RequirePresent = parsed
}
}
if raw, ok := injection["any_present"]; ok {
groups.AnyPresent, err = parseGlobalConditionList("any_present", raw, true)
parsed, err := parseGlobalConditionList(label+" any_present", raw, true)
if err != nil {
return globalConditionGroups{}, err
addError(err)
} else {
groups.AnyPresent = parsed
}
}
if raw, ok := injection["unless_present"]; ok {
groups.UnlessPresent, err = parseGlobalConditionList("unless_present", raw, false)
parsed, err := parseGlobalConditionList(label+" unless_present", raw, false)
if err != nil {
return globalConditionGroups{}, err
addError(err)
} else {
groups.UnlessPresent = parsed
}
}
return groups, nil
return groups, errors.Join(errs...)
}
func parseGlobalConditionList(name string, raw any, requireNonEmpty bool) ([]globalCondition, error) {
+130 -2
View File
@@ -1,6 +1,7 @@
package topdata
import (
"bytes"
"errors"
"os"
"path/filepath"
@@ -309,6 +310,59 @@ func TestValidateProjectRejectsUnsupportedCanonicalJSON(t *testing.T) {
}
}
func TestValidateProjectRejectsUnsupportedInvariantBaseRoots(t *testing.T) {
tests := []struct {
name string
dataset string
base string
lock string
}{
{
name: "feat",
dataset: "feat",
base: `{
"output": "feat.2da",
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
"rows": [
{"id": 0, "key": "feat:test", "LABEL": "FEAT_TEST", "FEAT": "100", "DESCRIPTION": "200"}
],
"typo": true
}` + "\n",
lock: `{"feat:test":0}` + "\n",
},
{
name: "masterfeats",
dataset: "masterfeats",
base: `{
"output": "masterfeats.2da",
"columns": ["LABEL", "STRREF", "DESCRIPTION"],
"rows": [
{"id": 0, "key": "masterfeats:test", "LABEL": "Test", "STRREF": "100", "DESCRIPTION": "200"}
],
"typo": true
}` + "\n",
lock: `{"masterfeats:test":0}` + "\n",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root := testProjectRoot(t)
writeCanonicalContractScaffold(t, root)
datasetDir := filepath.Join(root, "topdata", "data", tc.dataset)
mkdirAll(t, datasetDir)
writeFile(t, filepath.Join(datasetDir, "base.json"), tc.base)
writeFile(t, filepath.Join(datasetDir, "lock.json"), tc.lock)
report := ValidateProject(testProject(root))
if !report.HasErrors() {
t.Fatalf("expected unsupported invariant base-root key to fail validation, got:\n%s", diagnosticsText(report.Diagnostics))
}
assertDiagnosticTextContainsAll(t, report.Diagnostics, "base.json root", "typo", "unsupported")
})
}
}
func TestValidateProjectRejectsUnsupportedGlobalJSON(t *testing.T) {
tests := []struct {
name string
@@ -339,7 +393,7 @@ func TestValidateProjectRejectsUnsupportedGlobalJSON(t *testing.T) {
}
]
}` + "\n",
fragments: []string{"require_present[0]", "typo", "unsupported"},
fragments: []string{"global injection 0 require_present[0]", "typo", "unsupported"},
},
{
name: "default rule is closed",
@@ -483,7 +537,7 @@ func TestValidateProjectReportsUnsupportedGlobalInjectionKeysAndInvalidAnyPresen
}
assertDiagnosticTextContainsAll(t, report.Diagnostics,
`global injection 0 contains unsupported key "when_present"`,
"any_present must contain at least one condition",
"global injection 0 any_present must contain at least one condition",
)
}
@@ -1133,6 +1187,80 @@ func TestBuildNativeRejectsUnsupportedCanonicalRoot(t *testing.T) {
}
}
func TestBuildNativeRejectsUnsupportedInvariantBaseRootsWithoutChangingOutput(t *testing.T) {
tests := []struct {
name string
dataset string
outputName string
base string
lock string
}{
{
name: "feat",
dataset: "feat",
outputName: "feat.2da",
base: `{
"output": "feat.2da",
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
"rows": [
{"id": 0, "key": "feat:test", "LABEL": "FEAT_TEST", "FEAT": "100", "DESCRIPTION": "200"}
],
"typo": true
}` + "\n",
lock: `{"feat:test":0}` + "\n",
},
{
name: "masterfeats",
dataset: "masterfeats",
outputName: "masterfeats.2da",
base: `{
"output": "masterfeats.2da",
"columns": ["LABEL", "STRREF", "DESCRIPTION"],
"rows": [
{"id": 0, "key": "masterfeats:test", "LABEL": "Test", "STRREF": "100", "DESCRIPTION": "200"}
],
"typo": true
}` + "\n",
lock: `{"masterfeats:test":0}` + "\n",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root := testProjectRoot(t)
writeCanonicalContractScaffold(t, root)
datasetDir := filepath.Join(root, "topdata", "data", tc.dataset)
mkdirAll(t, datasetDir)
writeFile(t, filepath.Join(datasetDir, "base.json"), tc.base)
writeFile(t, filepath.Join(datasetDir, "lock.json"), tc.lock)
proj := testProject(root)
proj.Config.TopData.ReferenceBuilder = ""
outputPath := filepath.Join(compiled2DAOutputDir(proj), tc.outputName)
mkdirAll(t, filepath.Dir(outputPath))
sentinel := []byte("pre-existing output sentinel\n")
writeBytes(t, outputPath, sentinel)
_, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
if err == nil {
t.Fatal("expected BuildNativeWithOptions to fail")
}
for _, fragment := range []string{"base.json root", "typo", "unsupported"} {
if !strings.Contains(err.Error(), fragment) {
t.Fatalf("error %q does not contain %q", err, fragment)
}
}
got, readErr := os.ReadFile(outputPath)
if readErr != nil {
t.Fatalf("read preserved output: %v", readErr)
}
if !bytes.Equal(got, sentinel) {
t.Fatalf("output changed after invalid build error:\ngot: %q\nwant: %q", got, sentinel)
}
})
}
}
func tableContainsCellValue(table twoDATable, column, want string) bool {
for _, row := range table.rows {
if row[column] == want {
+7 -70
View File
@@ -532,6 +532,7 @@ func datasetInvariantForPath(dataPath topdataDataPath) (datasetValidationInvaria
}
func validateDatasetInvariantBaseFile(path string, obj map[string]any, invariant datasetValidationInvariant, report *ValidationReport) {
reportTopdataContractError(path, unsupportedObjectKeysError("base.json root", obj, baseOrPlainRootKeys...), report)
validateRowsFile(path, obj, report, true)
validateDerivedBaseOutput(path, obj, invariant.Name, report)
validateRequiredColumns(path, obj, invariant, report)
@@ -990,32 +991,6 @@ func validateOverridesFileWithColumns(path string, obj map[string]any, report *V
}
}
func parseGlobalConditionGroupsWithoutKeyCheck(injection map[string]any) (globalConditionGroups, error) {
var groups globalConditionGroups
if raw, ok := injection["require_present"]; ok {
parsed, err := parseGlobalConditionList("require_present", raw, false)
if err != nil {
return globalConditionGroups{}, err
}
groups.RequirePresent = parsed
}
if raw, ok := injection["any_present"]; ok {
parsed, err := parseGlobalConditionList("any_present", raw, true)
if err != nil {
return globalConditionGroups{}, err
}
groups.AnyPresent = parsed
}
if raw, ok := injection["unless_present"]; ok {
parsed, err := parseGlobalConditionList("unless_present", raw, false)
if err != nil {
return globalConditionGroups{}, err
}
groups.UnlessPresent = parsed
}
return groups, nil
}
func validateGlobalJSONFile(path string, obj map[string]any, report *ValidationReport) {
reportTopdataContractError(path, unsupportedObjectKeysError("global.json root", obj, globalRootKeys...), report)
if _, ok := obj["columns"]; ok {
@@ -1074,8 +1049,6 @@ func validateGlobalJSONFile(path string, obj map[string]any, report *ValidationR
})
continue
}
err := unsupportedObjectKeysError(fmt.Sprintf("global injection %d", index), injection, globalInjectionKeys...)
reportTopdataContractError(path, err, report)
row, ok := injection["row"].(map[string]any)
if !ok {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
@@ -1086,8 +1059,12 @@ func validateGlobalJSONFile(path string, obj map[string]any, report *ValidationR
} else {
rows = append(rows, row)
}
_, conditionErr := parseGlobalConditionGroupsWithoutKeyCheck(injection)
reportTopdataContractError(path, conditionErr, report)
_, _ = parseGlobalConditionGroups(injection, globalConditionParseOptions{
Label: fmt.Sprintf("global injection %d", index),
Report: func(err error) {
reportTopdataContractError(path, err, report)
},
})
}
validateRowCollection(path, obj, rows, report)
}
@@ -1269,46 +1246,6 @@ func validateGlobalDefaultValue(path string, index int, field string, value any,
}
}
func validateGlobalConditionList(path, label string, raw any, report *ValidationReport) {
if raw == nil {
return
}
conditions, ok := raw.([]any)
if !ok {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: fmt.Sprintf("%s must be a JSON array", label),
})
return
}
for index, rawCondition := range conditions {
condition, ok := rawCondition.(map[string]any)
if !ok {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: fmt.Sprintf("%s[%d] must be an object", label, index),
})
continue
}
if field, ok := condition["field"].(string); !ok || strings.TrimSpace(field) == "" {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: fmt.Sprintf("%s[%d].field is required", label, index),
})
}
if id, ok := condition["id"].(string); !ok || strings.TrimSpace(id) == "" {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: fmt.Sprintf("%s[%d].id is required", label, index),
})
}
}
}
func validateStateObject(path string, obj map[string]any, report *ValidationReport) {
entries, ok := obj["entries"].(map[string]any)
if !ok {