Repository: ariga/atlas
Pull request #1923 (2023-08-01 15:46:04)

schemahcl: add basic schema validator option


Patch:
diff --git a/schemahcl/schemahcl.go b/schemahcl/schemahcl.go
index 750aa033..35c7a2fc 100644
--- a/schemahcl/schemahcl.go
+++ b/schemahcl/schemahcl.go
@@ -30,6 +30,7 @@ type (
 		pathVars         map[string]map[string]cty.Value
 		pathFuncs        map[string]map[string]function.Function
 		datasrc, initblk map[string]func(*hcl.EvalContext, *hclsyntax.Block) (cty.Value, error)
+		validator        SchemaValidator
 	}
 	// Option configures a Config.
 	Option func(*Config)
@@ -42,6 +43,7 @@ func New(opts ...Option) *State {
 		funcs:     make(map[string]function.Function),
 		pathVars:  make(map[string]map[string]cty.Value),
 		pathFuncs: make(map[string]map[string]function.Function),
+		validator: nopValidator{},
 	}
 	for _, opt := range opts {
 		opt(cfg)
@@ -183,6 +185,33 @@ func WithTypes(path string, typeSpecs []*TypeSpec) Option {
 	}
 }
 
+type (
+	// SchemaValidator is the interface used for validating HCL documents.
+	SchemaValidator interface {
+		Init() error
+		Error() error
+		ValidateBlock(*hcl.EvalContext, *hclsyntax.Block) (func() error, error)
+		ValidateAttribute(*hcl.EvalContext, *hclsyntax.Attribute, cty.Value) error
+	}
+	nopValidator struct{}
+)
+
+func (nopValidator) Init() error  { return nil }
+func (nopValidator) Error() error { return nil }
+func (nopValidator) ValidateBlock(*hcl.EvalContext, *hclsyntax.Block) (func() error, error) {
+	return func() error { return nil }, nil
+}
+func (nopValidator) ValidateAttribute(*hcl.EvalContext, *hclsyntax.Attribute, cty.Value) error {
+	return nil
+}
+
+// WithSchemaValidator registers a schema validator to be used during unmarshaling.
+func WithSchemaValidator(v SchemaValidator) Option {
+	return func(c *Config) {
+		c.validator = v
+	}
+}
+
 // Marshal returns the Atlas HCL encoding of v.
 var Marshal = MarshalerFunc(New().MarshalSpec)
 
@@ -285,8 +314,11 @@ func (s *State) Eval(parsed *hclparse.Parser, v any, input map[string]cty.Value)
 	sort.Slice(fileNames, func(i, j int) bool {
 		return fileNames[i] < fileNames[j]
 	})
-	for _, fn := range fileNames {
-		file := files[fn]
+	if err := s.config.validator.Init(); err != nil {
+		return err
+	}
+	for _, name := range fileNames {
+		file := files[name]
 		r, err := s.resource(ctx, file)
 		if err != nil {
 			return err
@@ -294,6 +326,10 @@ func (s *State) Eval(parsed *hclparse.Parser, v any, input map[string]cty.Value)
 		spec.Children = append(spec.Children, r.Children...)
 		spec.Attrs = append(spec.Attrs, r.Attrs...)
 	}
+	// Validators can fail fast or accumulate errors.
+	if err := s.config.validator.Error(); err != nil {
+		return err
+	}
 	if err := patchRefs(spec); err != nil {
 		return err
 	}
@@ -437,11 +473,17 @@ func (s *State) mayScopeContext(ctx *hcl.EvalContext, scope []string) *hcl.EvalC
 func (s *State) toAttrs(ctx *hcl.EvalContext, hclAttrs hclsyntax.Attributes, scope []string) ([]*Attr, error) {
 	var attrs []*Attr
 	for _, hclAttr := range hclAttrs {
-		scope := append(scope, hclAttr.Name)
-		value, diag := hclAttr.Expr.Value(s.mayScopeContext(ctx, scope))
+		var (
+			scope = append(scope, hclAttr.Name)
+			nctx  = s.mayScopeContext(ctx, scope)
+		)
+		value, diag := hclAttr.Expr.Value(nctx)
 		if diag.HasErrors() {
 			return nil, s.typeError(diag, scope)
 		}
+		if err := s.config.validator.ValidateAttribute(ctx, hclAttr, value); err != nil {
+			return nil, err
+		}
 		at := &Attr{K: hclAttr.Name}
 		switch t := value.Type(); {
 		case isRef(value):
@@ -495,16 +537,16 @@ func (s *State) typeError(diag hcl.Diagnostics, scope []string) error {
 	return diag
 }
 
+// isRef checks if the given value is a reference or a list of references.
+// Exists here for backward compatibility, use isOneRef and isRefList instead.
 func isRef(v cty.Value) bool {
-	t := v.Type()
-	if !t.IsObjectType() {
+	if !v.Type().IsObjectType() {
 		return false
 	}
-	if t.HasAttribute("__ref") {
+	if isOneRef(v) {
 		return true
 	}
-	it := v.ElementIterator()
-	for it.Next() {
+	for it := v.ElementIterator(); it.Next(); {
 		if _, v := it.Element(); isRef(v) {
 			return true
 		}
@@ -512,10 +554,17 @@ func isRef(v cty.Value) bool {
 	return false
 }
 
-func (s *State) toResource(ctx *hcl.EvalContext, block *hclsyntax.Block, scope []string) (*Resource, error) {
-	spec := &Resource{
-		Type: block.Type,
+func isOneRef(v cty.Value) bool {
+	t := v.Type()
+	return t.IsObjectType() && t.HasAttribute("__ref")
+}
+
+func (s *State) toResource(ctx *hcl.EvalContext, block *hclsyntax.Block, scope []string) (spec *Resource, err error) {
+	closeScope, err := s.config.validator.ValidateBlock(ctx, block)
+	if err != nil {
+		return nil, err
 	}
+	spec = &Resource{Type: block.Type}
 	switch len(block.Labels) {
 	case 0:
 	case 1:
@@ -539,6 +588,9 @@ func (s *State) toResource(ctx *hcl.EvalContext, block *hclsyntax.Block, scope [
 		}
 		spec.Children = append(spec.Children, r)
 	}
+	if err := closeScope(); err != nil {
+		return nil, err
+	}
 	return spec, nil
 }
 


Test patch:
diff --git a/schemahcl/schemahcl_test.go b/schemahcl/schemahcl_test.go
index 47431685..d953aa0e 100644
--- a/schemahcl/schemahcl_test.go
+++ b/schemahcl/schemahcl_test.go
@@ -698,3 +698,35 @@ v3 = data.dynamic.evaluated3.v
 	)
 	require.NoError(t, New(opts...).EvalBytes(b, &v, nil))
 }
+
+type countValidator struct{ nb, na int }
+
+func (*countValidator) Init() error  { return nil }
+func (*countValidator) Error() error { return nil }
+func (c *countValidator) ValidateBlock(*hcl.EvalContext, *hclsyntax.Block) (func() error, error) {
+	c.nb++
+	return func() error { return nil }, nil
+}
+func (c *countValidator) ValidateAttribute(*hcl.EvalContext, *hclsyntax.Attribute, cty.Value) error {
+	c.na++
+	return nil
+}
+
+func TestSchemaValidator(t *testing.T) {
+	var (
+		cv  = &countValidator{}
+		doc struct {
+			DefaultExtension
+		}
+	)
+	err := New(WithSchemaValidator(cv)).EvalBytes([]byte(`
+block "a" {}
+block "b" {}
+block "c" {}
+attr1 = "a"
+attr2 = "b"
+`), &doc, nil)
+	require.NoError(t, err)
+	require.Equal(t, 3, cv.nb)
+	require.Equal(t, 2, cv.na)
+}