Repository: ariga/atlas
Pull request #1323 (2022-12-01 16:13:35)

doc/md/guides: Add doc about AR101


Patch:
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index 9a325080..af7fa232 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -264,7 +264,13 @@ func reportApply(cmd *cobra.Command, flags migrateApplyFlags, r *cmdlog.MigrateA
 			return fmt.Errorf("parse log format: %w", err)
 		}
 	}
-	return f.Execute(cmd.OutOrStdout(), r)
+	if err = f.Execute(cmd.OutOrStdout(), r); err != nil {
+		return fmt.Errorf("execute log template: %w", err)
+	}
+	// In case a custom logging was configured, avoid reporting errors twice.
+	// For example, printing error lines may break parsing the JSON output.
+	cmd.SilenceErrors = flags.logFormat != ""
+	return nil
 }
 
 type migrateDiffFlags struct {
diff --git a/cmd/atlas/internal/cmdapi/project.go b/cmd/atlas/internal/cmdapi/project.go
index 5af6807c..c9b17130 100644
--- a/cmd/atlas/internal/cmdapi/project.go
+++ b/cmd/atlas/internal/cmdapi/project.go
@@ -223,15 +223,14 @@ func LoadEnv(name string, opts ...LoadOption) ([]*Env, error) {
 		return nil, fmt.Errorf("unsupported project file driver %q", u.Scheme)
 	}
 	path := filepath.Join(u.Host, u.Path)
-	b, err := os.ReadFile(path)
-	if err != nil {
+	if _, err := os.Stat(path); err != nil {
 		if os.IsNotExist(err) {
 			err = fmt.Errorf("project file %q was not found: %w", path, err)
 		}
 		return nil, err
 	}
 	project := &Project{Lint: &Lint{}}
-	if err := hclState.EvalBytes(b, project, cfg.inputVals); err != nil {
+	if err := hclState.EvalFiles([]string{path}, project, cfg.inputVals); err != nil {
 		return nil, err
 	}
 	envs := make(map[string][]*Env)
diff --git a/cmd/atlas/internal/cmdapi/schema.go b/cmd/atlas/internal/cmdapi/schema.go
index 49695918..91c32e5b 100644
--- a/cmd/atlas/internal/cmdapi/schema.go
+++ b/cmd/atlas/internal/cmdapi/schema.go
@@ -393,10 +393,11 @@ func schemaDiffRun(cmd *cobra.Command, _ []string, flags schemaDiffFlags) error
 }
 
 type schemaInspectFlags struct {
-	url     string   // URL of database to apply the changes on.
-	schemas []string // Schemas to take into account when diffing.
-	exclude []string // List of glob patterns used to filter resources from applying (see schema.InspectOptions).
-	dsn     string   // Deprecated: DSN is an alias for URL.
+	url       string   // URL of database to apply the changes on.
+	logFormat string   // Format of the log output.
+	schemas   []string // Schemas to take into account when diffing.
+	exclude   []string // List of glob patterns used to filter resources from applying (see schema.InspectOptions).
+	dsn       string   // Deprecated: DSN is an alias for URL.
 }
 
 // schemaInspectCmd represents the 'atlas schema inspect' subcommand.
@@ -438,6 +439,7 @@ flag.
 	addFlagURL(cmd.Flags(), &flags.url)
 	addFlagSchemas(cmd.Flags(), &flags.schemas)
 	addFlagExclude(cmd.Flags(), &flags.exclude)
+	addFlagLog(cmd.Flags(), &flags.logFormat)
 	addFlagDSN(cmd.Flags(), &flags.dsn)
 	cobra.CheckErr(cmd.MarkFlagRequired(flagURL))
 	return cmd
@@ -457,15 +459,17 @@ func schemaInspectRun(cmd *cobra.Command, _ []string, flags schemaInspectFlags)
 		Schemas: schemas,
 		Exclude: flags.exclude,
 	})
-	if err != nil {
-		return err
-	}
-	ddl, err := client.MarshalSpec(s)
-	if err != nil {
-		return err
+	format := cmdlog.SchemaInspectTemplate
+	if v := flags.logFormat; v != "" {
+		if format, err = template.New("format").Funcs(cmdlog.ApplyTemplateFuncs).Parse(v); err != nil {
+			return fmt.Errorf("parse log format: %w", err)
+		}
 	}
-	cmd.Print(string(ddl))
-	return nil
+	return format.Execute(cmd.OutOrStdout(), &cmdlog.SchemaInspect{
+		Marshaler: client.Marshaler,
+		Realm:     s,
+		Error:     err,
+	})
 }
 
 // schemaFmtCmd represents the 'atlas schema fmt' subcommand.
diff --git a/cmd/atlas/internal/cmdlog/cmdlog.go b/cmd/atlas/internal/cmdlog/cmdlog.go
index d0d16d5f..5b13feb2 100644
--- a/cmd/atlas/internal/cmdlog/cmdlog.go
+++ b/cmd/atlas/internal/cmdlog/cmdlog.go
@@ -12,7 +12,9 @@ import (
 	"text/template"
 	"time"
 
+	"ariga.io/atlas/schemahcl"
 	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
 
 	"github.com/fatih/color"
@@ -316,6 +318,24 @@ func (a *MigrateApply) CountStmts() (n int) {
 	return
 }
 
+// MarshalJSON implements json.Marshaler.
+func (a *MigrateApply) MarshalJSON() ([]byte, error) {
+	type Alias MigrateApply
+	var v struct {
+		*Alias
+		Message string `json:"Message,omitempty"`
+	}
+	v.Alias = (*Alias)(a)
+	switch {
+	case a.Error != "":
+	case len(v.Applied) == 0:
+		v.Message = "No migration files to execute"
+	default:
+		v.Message = fmt.Sprintf("Migrated to version %s from %s (%d migrations in total)", v.Target, v.Current, len(v.Pending))
+	}
+	return json.Marshal(v)
+}
+
 // MarshalJSON implements json.Marshaler.
 func (f *AppliedFile) MarshalJSON() ([]byte, error) {
 	type local struct {
@@ -424,6 +444,130 @@ func (c Changes) MarshalJSON() ([]byte, error) {
 	return json.Marshal(v)
 }
 
+// SchemaInspect contains a summary of the 'schema inspect' command.
+type SchemaInspect struct {
+	schemahcl.Marshaler `json:"-"`
+	Realm               *schema.Realm `json:"Schema,omitempty"` // Inspected realm.
+	Error               error         `json:"Error,omitempty"`  // General error that occurred during inspection.
+}
+
+// SchemaInspectTemplate holds the default template of the 'schema inspect' command.
+var SchemaInspectTemplate = template.Must(template.
+	New("inspect").
+	Funcs(ApplyTemplateFuncs).
+	Parse(`{{ with .Error }}{{ .Error }}{{ else }}{{ $.MarshalHCL }}{{ end }}`))
+
+// MarshalHCL returns the default HCL representation of the schema.
+// Used by the template declared above.
+func (s *SchemaInspect) MarshalHCL() (string, error) {
+	spec, err := s.MarshalSpec(s.Realm)
+	if err != nil {
+		return "", err
+	}
+	return string(spec), nil
+}
+
+// MarshalJSON implements json.Marshaler.
+func (s *SchemaInspect) MarshalJSON() ([]byte, error) {
+	if s.Error != nil {
+		return json.Marshal(struct{ Error string }{s.Error.Error()})
+	}
+	type (
+		Column struct {
+			Name string `json:"name"`
+			Type string `json:"type,omitempty"`
+			Null bool   `json:"null,omitempty"`
+		}
+		IndexPart struct {
+			Desc   bool   `json:"desc,omitempty"`
+			Column string `json:"column,omitempty"`
+			Expr   string `json:"expr,omitempty"`
+		}
+		Index struct {
+			Name   string      `json:"name,omitempty"`
+			Unique bool        `json:"unique,omitempty"`
+			Parts  []IndexPart `json:"parts,omitempty"`
+		}
+		ForeignKey struct {
+			Name       string   `json:"name"`
+			Columns    []string `json:"columns,omitempty"`
+			References struct {
+				Table   string   `json:"table"`
+				Columns []string `json:"columns,omitempty"`
+			} `json:"references"`
+		}
+		Table struct {
+			Name        string       `json:"name"`
+			Columns     []Column     `json:"columns,omitempty"`
+			Indexes     []Index      `json:"indexes,omitempty"`
+			PrimaryKey  *Index       `json:"primary_key,omitempty"`
+			ForeignKeys []ForeignKey `json:"foreign_keys,omitempty"`
+		}
+		Schema struct {
+			Name   string  `json:"name"`
+			Tables []Table `json:"tables,omitempty"`
+		}
+	)
+	var realm struct {
+		Schemas []Schema `json:"schemas,omitempty"`
+	}
+	for _, s1 := range s.Realm.Schemas {
+		s2 := Schema{Name: s1.Name}
+		for _, t1 := range s1.Tables {
+			t2 := Table{Name: t1.Name}
+			for _, c1 := range t1.Columns {
+				t2.Columns = append(t2.Columns, Column{
+					Name: c1.Name,
+					Type: c1.Type.Raw,
+					Null: c1.Type.Null,
+				})
+			}
+			idxParts := func(idx *schema.Index) (parts []IndexPart) {
+				for _, p1 := range idx.Parts {
+					p2 := IndexPart{Desc: p1.Desc}
+					switch {
+					case p1.C != nil:
+						p2.Column = p1.C.Name
+					case p1.X != nil:
+						switch t := p1.X.(type) {
+						case *schema.Literal:
+							p2.Expr = t.V
+						case *schema.RawExpr:
+							p2.Expr = t.X
+						}
+					}
+					parts = append(parts, p2)
+				}
+				return parts
+			}
+			for _, idx1 := range t1.Indexes {
+				t2.Indexes = append(t2.Indexes, Index{
+					Name:   idx1.Name,
+					Unique: idx1.Unique,
+					Parts:  idxParts(idx1),
+				})
+			}
+			if t1.PrimaryKey != nil {
+				t2.PrimaryKey = &Index{Parts: idxParts(t1.PrimaryKey)}
+			}
+			for _, fk1 := range t1.ForeignKeys {
+				fk2 := ForeignKey{Name: fk1.Symbol}
+				for _, c1 := range fk1.Columns {
+					fk2.Columns = append(fk2.Columns, c1.Name)
+				}
+				fk2.References.Table = fk1.RefTable.Name
+				for _, c1 := range fk1.RefColumns {
+					fk2.References.Columns = append(fk2.References.Columns, c1.Name)
+				}
+				t2.ForeignKeys = append(t2.ForeignKeys, fk2)
+			}
+			s2.Tables = append(s2.Tables, t2)
+		}
+		realm.Schemas = append(realm.Schemas, s2)
+	}
+	return json.Marshal(realm)
+}
+
 func merge(maps ...template.FuncMap) template.FuncMap {
 	switch len(maps) {
 	case 0:
diff --git a/doc/md/guides/guides.md b/doc/md/guides/guides.md
index ac30358e..6799abc2 100644
--- a/doc/md/guides/guides.md
+++ b/doc/md/guides/guides.md
@@ -42,6 +42,8 @@ Welcome to the Atlas Guides.
 
 [Index Operator Classes](postgres/index-operator-classes.md)
 
+[Optimal data alignment (AR101)](postgres/ar-101.md)
+
 ### SQLite
 
 [Partial Indexes](sqlite/partial-indexes.md)
diff --git a/doc/md/guides/postgres/ar-101.md b/doc/md/guides/postgres/ar-101.md
new file mode 100644
index 00000000..54f046d6
--- /dev/null
+++ b/doc/md/guides/postgres/ar-101.md
@@ -0,0 +1,206 @@
+---
+id: ar-101
+title: Optimal data alignment in tables (AR101)
+slug: /guides/postgres/ar-101
+---
+
+### Alignment and Padding in PostgreSQL
+
+When creating a new table, columns of different data types take up different amounts of disk space. The order in which the columns are written determines the amount of space that is used by the rows in the table. Postgres may add padding to columns with smaller sizes to fulfill alignment requirements of the data type of the consecutive column with a larger size.
+
+For example, let’s consider a table with four `smallint` and one `bigint` type columns. When a column with a smaller size, such as a `smallint` (2 bytes), has a consecutive column with a larger size, such as `bigint` (8 bytes), Postgres adds 6 bytes of padding to the column of type `smallint` in order to fulfill an alignment requirement of 8 bytes for the consecutive `bigint` column. However, the space used in padding (6 bytes) can be utilized by rearranging the three other `smallint` type columns from the table before the `bigint` type column:
+
+![wrong](https://atlasgo.io/uploads/images/24bytes2.png)
+
+![correct](https://atlasgo.io/uploads/images/16bytes2.png)
+
+:::info
+The catalog pg_type stores information about alignment requirements for various data types. You can access the `typealign` column of this catalog table in order to determine alignment requirements for data types. To learn more about the catalog pg_type and interpretation of the  `typealign` column, visit the official documentation [here](https://www.postgresql.org/docs/current/catalog-pg-type.html).
+:::
+
+### Save up on precious space with optimal data alignment
+
+Creating a table with optimal data alignment can help minimize the amount of required disk space. This document describes how we can save space in a database just by changing the order of columns. Let’s see it in action.
+
+### Example
+
+Consider the following table on a 64-bit system.
+
+```sql
+CREATE TABLE users (
+    user_id bigint PRIMARY KEY,
+    active_status boolean,
+    data_balance integer,
+    plan_period smallint                  
+);
+```
+
+Here is what a portion of the table might look like after inserting values:
+
+```sql
+SELECT 
+    * 
+FROM
+    users
+```
+
+```console title="Output"
+ user_id | active_status | data_balance | plan_period 
+---------+---------------+--------------+-------------
+       0 | f             |       731147 |         267
+       1 | t             |       901448 |          26
+       2 | t             |       496823 |          24
+       3 | t             |       985877 |         280
+       4 | f             |       857124 |          83
+.
+.
+.
+ 1999995 | f             |       993259 |          83
+ 1999996 | f             |       926033 |         332
+ 1999997 | f             |       889133 |         280
+ 1999998 | t             |       943793 |           7
+ 1999999 | t             |       550192 |         146
+```
+
+Each tuple in the table takes up 24 bytes of total memory without the header. The `user_id` occupies the first 8 bytes. 
+
+Here, the `active_status` column is of type boolean and takes up only one byte. However, because the next consecutive column `data_balance` is an `integer` with size of 4 bytes, PostgreSQL automatically adds 3 bytes of padding to `active_status` so its size can match with the 4 bytes size of the consecutive `data_balance` column. 
+
+The second 8 bytes of the row are occupied by 1 byte of `active_status`, 3 bytes of padding, and then 4 bytes of  `data_balance`.
+
+Lastly, the `plan_period` column has a size of only 2 bytes. However, PostgreSQL adds 6 bytes of padding in order to complete the 8 bytes for the end of the row. The following table displays the data:
+
+| Column        | Type     | Size    | Padding |
+|---------------|----------|---------|---------|
+| user_id       | bigint   | 8 bytes | None    |
+| active_status | boolean  | 1 byte  | 3 bytes |
+| data_balance  | integer  | 4 bytes | None    |
+| plan_period   | smallint | 2 bytes | 6 bytes |
+
+![example1](https://atlasgo.io/uploads/images/24bytes.png)
+
+Now, let’s see how much space the table takes with the following command:
+
+```sql
+SELECT pg_size_pretty(pg_relation_size('users'));
+```
+
+```console title="Output"
+ pg_size_pretty 
+----------------
+ 100 MB
+(1 row)
+```
+
+Our table occupies 100 MB of space. Let’s see how we can optimize the occupied space by rearranging the columns.
+
+Let’s create the same table with the rearranged rows as follows:
+
+```sql
+CREATE TABLE users_optimized (
+    user_id bigint PRIMARY KEY,
+    data_balance integer,
+    plan_period smallint,
+    active_status boolean             
+);
+```
+
+Here is what a portion of the table might look like after inserting values:
+
+```sql
+SELECT 
+    * 
+FROM
+    users_optimized
+```
+```console title="Output"
+ user_id | data_balance | plan_period | active_status 
+---------+--------------+-------------+---------------
+       0 |       730878 |         267 | f
+       1 |       410081 |         329 | t
+       2 |       207715 |         181 | t
+       3 |       332717 |         360 | f
+       4 |       967756 |         313 | t
+.
+.
+.
+ 1999995 |       692890 |         363 | t
+ 1999996 |       878947 |         338 | f
+ 1999997 |       525196 |         325 | f
+ 1999998 |       170783 |         344 | t
+ 1999999 |        97850 |         201 | t
+```
+
+Now, let’s see how much space the table takes with the following command:
+
+```sql
+SELECT pg_size_pretty(pg_relation_size(users_optimized));
+```
+
+```console title="Output"
+ pg_size_pretty 
+----------------
+ 84 MB
+(1 row)
+```
+
+Awesome! We have saved almost 16 MB of space (which is also 16% in our example), just by changing the order of the columns.
+
+**Let’s understand how that happened:**
+
+The `user_id` occupies the first 8 bytes.
+Next, `data_balance` is an `integer` that takes up 4 bytes, followed by the `plan_period` column which has a size of only 2 bytes, followed by 1 byte of `active_status` and then 1 byte of padding for the end of the row.
+
+In this case, each tuple in the table takes 16 bytes of total memory without the header, and only 1 byte of padding is used, in contrast to 24 bytes of total memory with 9 bytes of total padding involved in our previous example. Here is the data:
+
+| Column        | Type     | Size    | Padding |
+|---------------|----------|---------|---------|
+| user_id       | bigint   | 8 bytes | None    |
+| data_balance  | integer  | 4 bytes | None    |
+| plan_period   | smallint | 2 bytes | None    |
+| active_status | boolean  | 1 byte  | 1 byte  |
+
+![example2](https://atlasgo.io/uploads/images/16bytes.png)
+
+## Determining optimal data alignment for a table is easy with Atlas CI
+
+As we have observed in the previous section, creating a table with non-optimal data alignment can cost more space, and thus more money. At the same time, calculating the disk space and determining the best arrangement of columns manually can be confusing and error-prone. 
+
+With the help of Atlas CI, you can now easily check whether your table alignment is optimal. If the alignment is not optimal, Atlas CI will suggest the best possible order of columns to make the alignment optimal and thus help you save maximum space (and money!).
+
+:::info
+Teams using GitHub that wish to ensure all changes to their database schema are safe can use the [Atlas GitHub Action](https://github.com/ariga/atlas-action). If you’re just getting started, you can visit the documentation to [set-up Atlas CI GitHub action](https://atlasgo.io/integrations/github-actions#usage) 
+:::
+
+Let’s create a migration file in Atlas format with the following SQL command:
+
+```sql
+CREATE TABLE users (
+    user_id bigint PRIMARY KEY,
+    active_status boolean,
+    data_balance integer,
+    plan_period smallint                  
+);
+```
+
+Once the Atlas CI check is completed, you should see the following message inside Atlas Lint Report:
+
+![atlasci](https://atlasgo.io/uploads/images/atlas-lint-report.png)
+
+Awesome! The Atlas CI check has analyzed the migration file and suggested the best possible arrangement of columns in order to optimize the space taken by padding per row.
+
+:::info
+Atlas CI provides different Analyzer implementations that are useful for determining the safety of migration scripts. To learn more about other checks currently supported by Atlas CI, visit the documentation about analyzers [here](https://atlasgo.io/lint/analyzers#analyzers).
+:::
+
+### Conclusion
+
+In this section, we learned about how we can reduce disk space costs without removing any data, just by arranging the table columns in an efficient order to reduce padding space. 
+
+Additionally, we have also learned how using Atlas CI can help us determine the best optimal order of columns in a table, removing the hassle of manually calculating the disk cost before and after rearranging columns.
+
+## Need More Help?​
+
+[Join the Ariga Discord Server](https://discord.com/invite/zZ6sWVg6NT) for early access to features and the ability to provide exclusive feedback that improves your Database Management Tooling.
+
+[Sign up](https://www.getrevue.co/profile/ariga) to our newsletter to stay up to date about [Atlas](https://atlasgo.io/), our OSS database schema management tool, and our cloud platform [Ariga Cloud](https://ariga.cloud/).
\ No newline at end of file
diff --git a/doc/md/reference.md b/doc/md/reference.md
index 4baf5e15..ac71b428 100644
--- a/doc/md/reference.md
+++ b/doc/md/reference.md
@@ -517,6 +517,7 @@ flag.
   -u, --url string        [driver://username:password@address/dbname?param=value] select a resource using the URL format
   -s, --schema strings    set schema names
       --exclude strings   list of glob patterns used to filter resources from applying
+      --log string        go template to use to format logs
 
 ```
 
diff --git a/doc/website/sidebars.js b/doc/website/sidebars.js
index 91a01c20..0c5b8512 100644
--- a/doc/website/sidebars.js
+++ b/doc/website/sidebars.js
@@ -179,6 +179,11 @@ module.exports = {
                     type: 'doc',
                     id: 'guides/postgres/index-operator-classes',
                     label: 'Index Operator Classes'
+                },
+                {
+                    type: 'doc',
+                    id: 'guides/postgres/ar-101',
+                    label: 'Optimal data alignment (AR101)'
                 }
             ],
         },
diff --git a/schemahcl/context.go b/schemahcl/context.go
index 663cec15..42fe187a 100644
--- a/schemahcl/context.go
+++ b/schemahcl/context.go
@@ -44,6 +44,11 @@ func (s *State) setInputVals(ctx *hcl.EvalContext, body hcl.Body, input map[stri
 	for _, v := range doc.Vars {
 		var vv cty.Value
 		switch iv, ok := input[v.Name]; {
+		case !v.Type.Type().IsCapsuleType():
+			return fmt.Errorf(
+				"invalid type %q for variable %q. Valid types are: string, number, bool, list, map, or set",
+				v.Type.AsString(), v.Name,
+			)
 		case ok:
 			vv = iv
 		case v.Default != cty.NilVal:
diff --git a/sql/mysql/sqlspec.go b/sql/mysql/sqlspec.go
index 23012c74..7435c739 100644
--- a/sql/mysql/sqlspec.go
+++ b/sql/mysql/sqlspec.go
@@ -27,12 +27,12 @@ type doc struct {
 
 // evalSpec evaluates an Atlas DDL document into v using the input.
 func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
-	var d doc
-	if err := hclState.Eval(p, &d, input); err != nil {
-		return err
-	}
 	switch v := v.(type) {
 	case *schema.Realm:
+		var d doc
+		if err := hclState.Eval(p, &d, input); err != nil {
+			return err
+		}
 		err := specutil.Scan(v, d.Schemas, d.Tables, convertTable)
 		if err != nil {
 			return fmt.Errorf("mysql: failed converting to *schema.Realm: %w", err)
@@ -47,6 +47,10 @@ func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
 			}
 		}
 	case *schema.Schema:
+		var d doc
+		if err := hclState.Eval(p, &d, input); err != nil {
+			return err
+		}
 		if len(d.Schemas) != 1 {
 			return fmt.Errorf("mysql: expecting document to contain a single schema, got %d", len(d.Schemas))
 		}
@@ -59,8 +63,10 @@ func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
 		}
 		r.Schemas[0].Realm = nil
 		*v = *r.Schemas[0]
+	case schema.Schema, schema.Realm:
+		return fmt.Errorf("mysql: Eval expects a pointer: received %[1]T, expected *%[1]T", v)
 	default:
-		return fmt.Errorf("mysql: failed unmarshaling spec. %T is not supported", v)
+		return hclState.Eval(p, v, input)
 	}
 	return nil
 }
diff --git a/sql/postgres/sqlspec.go b/sql/postgres/sqlspec.go
index 6aec4d70..01ac736b 100644
--- a/sql/postgres/sqlspec.go
+++ b/sql/postgres/sqlspec.go
@@ -42,12 +42,12 @@ func init() {
 
 // evalSpec evaluates an Atlas DDL document into v using the input.
 func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
-	var d doc
-	if err := hclState.Eval(p, &d, input); err != nil {
-		return err
-	}
 	switch v := v.(type) {
 	case *schema.Realm:
+		var d doc
+		if err := hclState.Eval(p, &d, input); err != nil {
+			return err
+		}
 		if err := specutil.Scan(v, d.Schemas, d.Tables, convertTable); err != nil {
 			return fmt.Errorf("specutil: failed converting to *schema.Realm: %w", err)
 		}
@@ -57,6 +57,10 @@ func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
 			}
 		}
 	case *schema.Schema:
+		var d doc
+		if err := hclState.Eval(p, &d, input); err != nil {
+			return err
+		}
 		if len(d.Schemas) != 1 {
 			return fmt.Errorf("specutil: expecting document to contain a single schema, got %d", len(d.Schemas))
 		}
@@ -68,8 +72,10 @@ func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
 			return err
 		}
 		*v = *r.Schemas[0]
+	case schema.Schema, schema.Realm:
+		return fmt.Errorf("postgres: Eval expects a pointer: received %[1]T, expected *%[1]T", v)
 	default:
-		return fmt.Errorf("specutil: failed unmarshaling spec. %T is not supported", v)
+		return hclState.Eval(p, v, input)
 	}
 	return nil
 }
diff --git a/sql/sqlite/sqlspec.go b/sql/sqlite/sqlspec.go
index fa3b8fa5..79ef3dab 100644
--- a/sql/sqlite/sqlspec.go
+++ b/sql/sqlite/sqlspec.go
@@ -22,17 +22,21 @@ import (
 
 // evalSpec evaluates an Atlas DDL document using an unmarshaler into v by using the input.
 func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
-	var d doc
-	if err := hclState.Eval(p, &d, input); err != nil {
-		return err
-	}
 	switch v := v.(type) {
 	case *schema.Realm:
+		var d doc
+		if err := hclState.Eval(p, &d, input); err != nil {
+			return err
+		}
 		err := specutil.Scan(v, d.Schemas, d.Tables, convertTable)
 		if err != nil {
 			return fmt.Errorf("specutil: failed converting to *schema.Realm: %w", err)
 		}
 	case *schema.Schema:
+		var d doc
+		if err := hclState.Eval(p, &d, input); err != nil {
+			return err
+		}
 		if len(d.Schemas) != 1 {
 			return fmt.Errorf("specutil: expecting document to contain a single schema, got %d", len(d.Schemas))
 		}
@@ -42,8 +46,10 @@ func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
 		}
 		r.Schemas[0].Realm = nil
 		*v = *r.Schemas[0]
+	case schema.Schema, schema.Realm:
+		return fmt.Errorf("sqlite: Eval expects a pointer: received %[1]T, expected *%[1]T", v)
 	default:
-		return fmt.Errorf("specutil: failed unmarshaling spec. %T is not supported", v)
+		return hclState.Eval(p, v, input)
 	}
 	return nil
 }


Test patch:
diff --git a/cmd/atlas/internal/cmdapi/schema_test.go b/cmd/atlas/internal/cmdapi/schema_test.go
index ad352029..522bfdab 100644
--- a/cmd/atlas/internal/cmdapi/schema_test.go
+++ b/cmd/atlas/internal/cmdapi/schema_test.go
@@ -464,6 +464,19 @@ env "local" {
 	}, lines)
 }
 
+func TestSchema_InspectLog(t *testing.T) {
+	db := openSQLite(t, "create table t1 (id integer primary key);create table t2 (name text);")
+	cmd := schemaCmd()
+	cmd.AddCommand(schemaInspectCmd())
+	s, err := runCmd(
+		cmd, "inspect",
+		"-u", db,
+		"--log", "{{ json . }}",
+	)
+	require.NoError(t, err)
+	require.Equal(t, `{"schemas":[{"name":"main","tables":[{"name":"t1","columns":[{"name":"id","type":"INTEGER","null":true}],"primary_key":{"parts":[{"column":"id"}]}},{"name":"t2","columns":[{"name":"name","type":"TEXT","null":true}]}]}]}`, s)
+}
+
 func TestFmt(t *testing.T) {
 	for _, tt := range []struct {
 		name          string
diff --git a/cmd/atlas/internal/cmdlog/cmdlog_test.go b/cmd/atlas/internal/cmdlog/cmdlog_test.go
new file mode 100644
index 00000000..86f02a25
--- /dev/null
+++ b/cmd/atlas/internal/cmdlog/cmdlog_test.go
@@ -0,0 +1,96 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package cmdlog_test
+
+import (
+	"encoding/json"
+	"io"
+	"testing"
+
+	"ariga.io/atlas/cmd/atlas/internal/cmdlog"
+	"ariga.io/atlas/sql/schema"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestSchemaInspect_MarshalJSON(t *testing.T) {
+	report := &cmdlog.SchemaInspect{
+		Realm: schema.NewRealm(
+			schema.New("test").
+				AddTables(
+					schema.NewTable("users").
+						AddColumns(
+							&schema.Column{
+								Name: "id",
+								Type: &schema.ColumnType{Raw: "bigint"},
+							},
+							&schema.Column{
+								Name: "name",
+								Type: &schema.ColumnType{Raw: "varchar(255)"},
+							},
+						),
+					schema.NewTable("posts").
+						AddColumns(
+							&schema.Column{
+								Name: "id",
+								Type: &schema.ColumnType{Raw: "bigint"},
+							},
+							&schema.Column{
+								Name: "text",
+								Type: &schema.ColumnType{Raw: "text"},
+							},
+						),
+				),
+			schema.New("temp"),
+		),
+	}
+	b, err := report.MarshalJSON()
+	require.NoError(t, err)
+	ident, err := json.MarshalIndent(json.RawMessage(b), "", "  ")
+	require.NoError(t, err)
+	require.Equal(t, `{
+  "schemas": [
+    {
+      "name": "test",
+      "tables": [
+        {
+          "name": "users",
+          "columns": [
+            {
+              "name": "id",
+              "type": "bigint"
+            },
+            {
+              "name": "name",
+              "type": "varchar(255)"
+            }
+          ]
+        },
+        {
+          "name": "posts",
+          "columns": [
+            {
+              "name": "id",
+              "type": "bigint"
+            },
+            {
+              "name": "text",
+              "type": "text"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "name": "temp"
+    }
+  ]
+}`, string(ident))
+
+	report = &cmdlog.SchemaInspect{Error: io.EOF}
+	b, err = report.MarshalJSON()
+	require.NoError(t, err)
+	require.Equal(t, `{"Error":"EOF"}`, string(b))
+}
diff --git a/schemahcl/context_test.go b/schemahcl/context_test.go
index decf66f7..7b84208b 100644
--- a/schemahcl/context_test.go
+++ b/schemahcl/context_test.go
@@ -502,6 +502,7 @@ variable "convert_bool" {
 variable "strings" {
   type = list(string)
   default = ["a", "b"]
+  description = "description is a valid attribute"
 }
 
 name = var.name
@@ -512,7 +513,6 @@ convert_int = var.convert_int
 convert_bool = var.convert_bool
 strings = var.strings
 `
-	state := New()
 	var test struct {
 		Name        string   `spec:"name"`
 		Default     string   `spec:"default"`
@@ -522,7 +522,7 @@ strings = var.strings
 		ConvertBool bool     `spec:"convert_bool"`
 		Strings     []string `spec:"strings"`
 	}
-	err := state.EvalBytes([]byte(h), &test, map[string]cty.Value{
+	err := New().EvalBytes([]byte(h), &test, map[string]cty.Value{
 		"name":         cty.StringVal("rotemtam"),
 		"int":          cty.NumberIntVal(42),
 		"bool":         cty.BoolVal(true),
@@ -539,3 +539,21 @@ strings = var.strings
 	require.EqualValues(t, true, test.ConvertBool)
 	require.EqualValues(t, []string{"a", "b"}, test.Strings)
 }
+
+func TestVariable_InvalidType(t *testing.T) {
+	h := `
+variable "name" {
+  type = "int"
+  default = "boring"
+}`
+	err := New().EvalBytes([]byte(h), &struct{}{}, nil)
+	require.EqualError(t, err, `invalid type "int" for variable "name". Valid types are: string, number, bool, list, map, or set`)
+
+	h = `
+variable "name" {
+  type = "boring"
+  default = "boring"
+}`
+	err = New().EvalBytes([]byte(h), &struct{}{}, nil)
+	require.EqualError(t, err, `invalid type "boring" for variable "name". Valid types are: string, number, bool, list, map, or set`)
+}