Repository: ariga/atlas
Pull request #504 (2022-01-26 21:13:00)

sql/sqlspec: support marshaling index key parts
Also, added support for scanning and printing `string` and `bool` pointers. We can consider adding more types in the future if we'll see a need for this.  

testscript for PostgreSQL will be added in future PRs.


Patch:
diff --git a/cmd/action/apply.go b/cmd/action/apply.go
index 5521f602..749df5f1 100644
--- a/cmd/action/apply.go
+++ b/cmd/action/apply.go
@@ -19,6 +19,7 @@ var (
 		Web    bool
 		Addr   string
 		DryRun bool
+		Schema []string
 	}
 	// ApplyCmd represents the apply command.
 	ApplyCmd = &cobra.Command{
@@ -31,6 +32,7 @@ plan and prompt the user for approval.
 If run with the "--dry-run" flag, atlas will exit after printing out the planned migration.`,
 		Run: CmdApplyRun,
 		Example: `atlas schema apply -d "mysql://user:pass@tcp(localhost:3306)/dbname" -f atlas.hcl
+atlas schema apply -d "mysql://user:pass@tcp(localhost:3306)/" -f atlas.hcl --schema prod --schema staging
 atlas schema apply -d "mysql://user:pass@tcp(localhost:3306)/dbname" -f atlas.hcl --dry-run 
 atlas schema apply -d "mariadb://user:pass@tcp(localhost:3306)/dbname" -f atlas.hcl
 atlas schema apply --dsn "postgres://user:pass@host:port/dbname" -f atlas.hcl
@@ -50,6 +52,7 @@ func init() {
 	ApplyCmd.Flags().BoolVarP(&ApplyFlags.Web, "web", "w", false, "Open in a local Atlas UI")
 	ApplyCmd.Flags().BoolVarP(&ApplyFlags.DryRun, "dry-run", "", false, "Dry-run. Print SQL plan without prompting for execution")
 	ApplyCmd.Flags().StringVarP(&ApplyFlags.Addr, "addr", "", "127.0.0.1:5800", "used with -w, local address to bind the server to")
+	ApplyCmd.Flags().StringSliceVarP(&ApplyFlags.Schema, "schema", "s", nil, "Set schema name")
 	cobra.CheckErr(ApplyCmd.MarkFlagRequired("dsn"))
 	cobra.CheckErr(ApplyCmd.MarkFlagRequired("file"))
 }
@@ -67,16 +70,21 @@ func CmdApplyRun(cmd *cobra.Command, args []string) {
 
 func applyRun(d *Driver, dsn string, file string, dryRun bool) {
 	ctx := context.Background()
-	name, err := SchemaNameFromDSN(dsn)
-	cobra.CheckErr(err)
-	s, err := d.InspectSchema(ctx, name, nil)
+	schemas := ApplyFlags.Schema
+	if n, err := SchemaNameFromDSN(dsn); n != "" {
+		cobra.CheckErr(err)
+		schemas = append(schemas, n)
+	}
+	realm, err := d.InspectRealm(ctx, &schema.InspectRealmOption{
+		Schemas: schemas,
+	})
 	cobra.CheckErr(err)
 	f, err := ioutil.ReadFile(file)
 	cobra.CheckErr(err)
-	var desired schema.Schema
+	var desired schema.Realm
 	err = d.UnmarshalSpec(f, &desired)
 	cobra.CheckErr(err)
-	changes, err := d.SchemaDiff(s, &desired)
+	changes, err := d.RealmDiff(realm, &desired)
 	cobra.CheckErr(err)
 	if len(changes) == 0 {
 		schemaCmd.Println("Schema is synced, no changes to be made")
diff --git a/cmd/action/inspect.go b/cmd/action/inspect.go
index 2a133a3f..4dd48c55 100644
--- a/cmd/action/inspect.go
+++ b/cmd/action/inspect.go
@@ -26,7 +26,11 @@ saved to a file, commonly by redirecting the output to a file named with a ".hcl
 	atlas schema inspect -d "mysql://user:pass@tcp(localhost:3306)/dbname" > atlas.hcl
 
 This file can then be edited and used with the` + " `atlas schema apply` " + `command to plan
-and execute schema migrations against the given database. 
+and execute schema migrations against the given database. In cases where users wish to inspect
+all multiple schemas in a given database (for instance a MySQL server may contain multiple named
+databases), omit the relevant part from the dsn, e.g. "mysql://user:pass@tcp(localhost:3306)/".
+To select specific schemas from the databases, users may use the "--schema" (or "-s" shorthand)
+flag.
 	`,
 		Run: CmdInspectRun,
 		Example: `
diff --git a/doc/md/cli.md b/doc/md/cli.md
index b0a9a032..bf9fa22c 100644
--- a/doc/md/cli.md
+++ b/doc/md/cli.md
@@ -79,6 +79,7 @@ If run with the "--dry-run" flag, atlas will exit after printing out the planned
 
 ```
 atlas schema apply -d "mysql://user:pass@tcp(localhost:3306)/dbname" -f atlas.hcl
+atlas schema apply -d "mysql://user:pass@tcp(localhost:3306)/" -f atlas.hcl --schema prod --schema staging
 atlas schema apply -d "mysql://user:pass@tcp(localhost:3306)/dbname" -f atlas.hcl --dry-run 
 atlas schema apply -d "mariadb://user:pass@tcp(localhost:3306)/dbname" -f atlas.hcl
 atlas schema apply --dsn "postgres://user:pass@host:port/dbname" -f atlas.hcl
@@ -86,11 +87,12 @@ atlas schema apply -d "sqlite://file:ex1.db?_fk=1" -f atlas.hcl
 ```
 #### Flags
 ```
-      --addr string   used with -w, local address to bind the server to (default "127.0.0.1:5800")
-      --dry-run       Dry-run. Print SQL plan without prompting for execution
-  -d, --dsn string    [driver://username:password@protocol(address)/dbname?param=value] Select data source using the dsn format
-  -f, --file string   [/path/to/file] file containing schema
-  -w, --web           Open in a local Atlas UI
+      --addr string      used with -w, local address to bind the server to (default "127.0.0.1:5800")
+      --dry-run          Dry-run. Print SQL plan without prompting for execution
+  -d, --dsn string       [driver://username:password@protocol(address)/dbname?param=value] Select data source using the dsn format
+  -f, --file string      [/path/to/file] file containing schema
+  -s, --schema strings   Set schema name
+  -w, --web              Open in a local Atlas UI
 
 ```
 
@@ -153,7 +155,11 @@ saved to a file, commonly by redirecting the output to a file named with a ".hcl
 	atlas schema inspect -d "mysql://user:pass@tcp(localhost:3306)/dbname" > atlas.hcl
 
 This file can then be edited and used with the `atlas schema apply` command to plan
-and execute schema migrations against the given database. 
+and execute schema migrations against the given database. In cases where users wish to inspect
+all multiple schemas in a given database (for instance a MySQL server may contain multiple named
+databases), omit the relevant part from the dsn, e.g. "mysql://user:pass@tcp(localhost:3306)/".
+To select specific schemas from the databases, users may use the "--schema" (or "-s" shorthand)
+flag.
 	
 
 #### Example
diff --git a/schema/schemaspec/extension.go b/schema/schemaspec/extension.go
index f1f713f0..c247a434 100644
--- a/schema/schemaspec/extension.go
+++ b/schema/schemaspec/extension.go
@@ -291,7 +291,26 @@ func setPtr(field reflect.Value, val Value) error {
 			return nil
 		}
 	}
-	return fmt.Errorf("unhandled pointer type %T", val)
+	if field.IsNil() {
+		field.Set(reflect.New(field.Type().Elem()))
+	}
+	switch e := field.Elem(); e.Kind() {
+	case reflect.Bool:
+		b, err := BoolVal(val)
+		if err != nil {
+			return err
+		}
+		e.SetBool(b)
+	case reflect.String:
+		s, err := StrVal(val)
+		if err != nil {
+			return err
+		}
+		e.SetString(s)
+	default:
+		return fmt.Errorf("unhandled pointer type %T", val)
+	}
+	return nil
 }
 
 // setSliceAttr sets the value of attr to the slice field. This function expects both the target field
@@ -398,14 +417,17 @@ func (r *Resource) Scan(ext interface{}) error {
 
 func scanPtr(key string, r *Resource, field reflect.Value) error {
 	attr := &Attr{K: key}
-	switch e := field.Elem(); e.Type() {
-	case reflect.TypeOf(LiteralValue{}):
-		attr.V = &LiteralValue{V: e.FieldByName("V").String()}
-	case reflect.TypeOf(Ref{}):
-		attr.V = &Ref{V: e.FieldByName("V").String()}
-	case reflect.TypeOf(Type{}):
-		t := e.Interface().(Type)
-		attr.V = &t
+	switch e := field.Interface().(type) {
+	case *LiteralValue:
+		attr.V = e
+	case *Ref:
+		attr.V = e
+	case *Type:
+		attr.V = e
+	case *bool:
+		attr.V = &LiteralValue{V: strconv.FormatBool(*e)}
+	case *string:
+		attr.V = &LiteralValue{V: strconv.Quote(*e)}
 	default:
 		return fmt.Errorf("schemaspec: unsupported pointer to %s", e)
 	}
diff --git a/schema/schemaspec/schemahcl/hcl.go b/schema/schemaspec/schemahcl/hcl.go
index 21e6b6be..03f2044e 100644
--- a/schema/schemaspec/schemahcl/hcl.go
+++ b/schema/schemaspec/schemahcl/hcl.go
@@ -278,6 +278,11 @@ func (s *state) writeAttr(attr *schemaspec.Attr, body *hclwrite.Body) error {
 		fnc := fmt.Sprintf("sql(%q)", v.X)
 		body.SetAttributeRaw(attr.K, hclRawTokens(fnc))
 	case *schemaspec.ListValue:
+		// Skip scanning nil slices ([]T(nil)) by default. Users that
+		// want to print empty lists, should use make([]T, 0) instead.
+		if v.V == nil {
+			return nil
+		}
 		lst := make([]string, 0, len(v.V))
 		for _, item := range v.V {
 			switch v := item.(type) {
diff --git a/sql/internal/specutil/convert.go b/sql/internal/specutil/convert.go
index 67b14206..877aeedc 100644
--- a/sql/internal/specutil/convert.go
+++ b/sql/internal/specutil/convert.go
@@ -172,14 +172,17 @@ func Index(spec *sqlspec.Index, parent *schema.Table) (*schema.Index, error) {
 		}
 	case m > 0:
 		for i, p := range spec.Parts {
-			part := &schema.IndexPart{SeqNo: i, Desc: p.Desc}
+			part := &schema.IndexPart{SeqNo: i}
+			if p.Desc != nil {
+				part.Desc = *p.Desc
+			}
 			switch {
-			case p.Column == nil && p.Expr == "":
+			case p.Column == nil && p.Expr == nil:
 				return nil, fmt.Errorf(`"column" or "expr" are required for index %q at position %d`, spec.Name, i)
-			case p.Column != nil && p.Expr != "":
+			case p.Column != nil && p.Expr != nil:
 				return nil, fmt.Errorf(`cannot use both "column" and "expr" in index %q at position %d`, spec.Name, i)
-			case p.Expr != "":
-				part.X = &schema.RawExpr{X: p.Expr}
+			case p.Expr != nil:
+				part.X = &schema.RawExpr{X: *p.Expr}
 			case p.Column != nil:
 				c, err := column(parent, p.Column)
 				if err != nil {
@@ -431,20 +434,43 @@ func normalizeQuotes(s string) (string, error) {
 
 // FromIndex converts schema.Index to sqlspec.Index.
 func FromIndex(idx *schema.Index) (*sqlspec.Index, error) {
-	c := make([]*schemaspec.Ref, 0, len(idx.Parts))
-	for _, p := range idx.Parts {
-		if p.C == nil {
-			return nil, errors.New("index expression is not supported")
+	spec := &sqlspec.Index{Name: idx.Name, Unique: idx.Unique}
+	convertCommentFromSchema(idx.Attrs, &spec.Extra.Attrs)
+	if parts, ok := columnsOnly(idx); ok {
+		spec.Columns = parts
+		return spec, nil
+	}
+	spec.Parts = make([]*sqlspec.IndexPart, len(idx.Parts))
+	for i, p := range idx.Parts {
+		part := &sqlspec.IndexPart{Desc: &p.Desc}
+		switch {
+		case p.C == nil && p.X == nil:
+			return nil, fmt.Errorf("missing column or expression for key part of index %q", idx.Name)
+		case p.C != nil && p.X != nil:
+			return nil, fmt.Errorf("multiple key part definitions for index %q", idx.Name)
+		case p.C != nil:
+			part.Column = colRef(p.C.Name, idx.Table.Name)
+		case p.X != nil:
+			x, ok := p.X.(*schema.RawExpr)
+			if !ok {
+				return nil, fmt.Errorf("unexpected expression %T for index %q", p.X, idx.Name)
+			}
+			part.Expr = &x.X
 		}
-		c = append(c, colRef(p.C.Name, idx.Table.Name))
+		spec.Parts[i] = part
 	}
-	i := &sqlspec.Index{
-		Name:    idx.Name,
-		Unique:  idx.Unique,
-		Columns: c,
+	return spec, nil
+}
+
+func columnsOnly(idx *schema.Index) ([]*schemaspec.Ref, bool) {
+	parts := make([]*schemaspec.Ref, len(idx.Parts))
+	for i, p := range idx.Parts {
+		if p.C == nil || p.Desc || len(p.Attrs) > 0 {
+			return nil, false
+		}
+		parts[i] = colRef(p.C.Name, idx.Table.Name)
 	}
-	convertCommentFromSchema(idx.Attrs, &i.Extra.Attrs)
-	return i, nil
+	return parts, true
 }
 
 // FromForeignKey converts schema.ForeignKey to sqlspec.ForeignKey.
diff --git a/sql/sqlspec/sqlspec.go b/sql/sqlspec/sqlspec.go
index b004b00a..3298ef24 100644
--- a/sql/sqlspec/sqlspec.go
+++ b/sql/sqlspec/sqlspec.go
@@ -51,9 +51,9 @@ type (
 
 	// IndexPart holds a specification for the index key part.
 	IndexPart struct {
-		Desc   bool            `spec:"desc"`
+		Desc   *bool           `spec:"desc"`
 		Column *schemaspec.Ref `spec:"column"`
-		Expr   string          `spec:"expr"`
+		Expr   *string         `spec:"expr"`
 		schemaspec.DefaultExtension
 	}
 


Test patch:
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
index 9e1f0434..47a314e5 100644
--- a/internal/integration/integration_test.go
+++ b/internal/integration/integration_test.go
@@ -171,6 +171,40 @@ func testCLISchemaInspect(t T, h string, dsn string, unmarshaler schemaspec.Unma
 	require.Equal(t, expected, actual)
 }
 
+func testCLIMultiSchemaApply(t T, h string, dsn string, schemas []string, unmarshaler schemaspec.Unmarshaler) {
+	// Required to have a clean "stderr" while running first time.
+	err := exec.Command("go", "run", "-mod=mod", "ariga.io/atlas/cmd/atlas").Run()
+	f := "atlas.hcl"
+	err = ioutil.WriteFile(f, []byte(h), 0644)
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		os.Remove("atlas.hcl")
+	})
+	require.NoError(t, err)
+	var expected schema.Realm
+	err = unmarshaler.UnmarshalSpec([]byte(h), &expected)
+	require.NoError(t, err)
+	cmd := exec.Command("go", "run", "ariga.io/atlas/cmd/atlas",
+		"schema",
+		"apply",
+		"-f",
+		"atlas.hcl",
+		"-d",
+		dsn,
+		"-s",
+		strings.Join(schemas, ","),
+	)
+	stdout, stderr := bytes.NewBuffer(nil), bytes.NewBuffer(nil)
+	cmd.Stderr = stderr
+	cmd.Stdout = stdout
+	stdin, err := cmd.StdinPipe()
+	require.NoError(t, err)
+	defer stdin.Close()
+	_, err = io.WriteString(stdin, "\n")
+	require.NoError(t, cmd.Run(), stderr.String())
+	require.Contains(t, stdout.String(), `-- Add new schema named "test2"`)
+}
+
 func testCLIMultiSchemaInspect(t T, h string, dsn string, schemas []string, unmarshaler schemaspec.Unmarshaler) {
 	// Required to have a clean "stderr" while running first time.
 	err := exec.Command("go", "run", "-mod=mod", "ariga.io/atlas/cmd/atlas").Run()
diff --git a/internal/integration/mysql_test.go b/internal/integration/mysql_test.go
index 1e1420de..be7898c5 100644
--- a/internal/integration/mysql_test.go
+++ b/internal/integration/mysql_test.go
@@ -552,8 +552,7 @@ func TestMySQL_CLI(t *testing.T) {
 }
 
 func TestMySQL_CLI_MultiSchema(t *testing.T) {
-	t.Run("SchemaInspect", func(t *testing.T) {
-		h := `	
+	h := `	
 			schema "test" {
 				charset   = "%s"
 				collation = "%s"
@@ -580,6 +579,7 @@ func TestMySQL_CLI_MultiSchema(t *testing.T) {
 					columns = [table.users.column.id]
 				}
 			}`
+	t.Run("SchemaInspect", func(t *testing.T) {
 		myRun(t, func(t *myTest) {
 			t.dropDB("test2")
 			t.dropTables("users")
@@ -588,6 +588,15 @@ func TestMySQL_CLI_MultiSchema(t *testing.T) {
 			testCLIMultiSchemaInspect(t, fmt.Sprintf(h, charset.V, collate.V, charset.V, collate.V), t.dsn(""), []string{"test", "test2"}, mysql.UnmarshalHCL)
 		})
 	})
+	t.Run("SchemaApply", func(t *testing.T) {
+		myRun(t, func(t *myTest) {
+			t.dropDB("test2")
+			t.dropTables("users")
+			attrs := t.defaultAttrs()
+			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
+			testCLIMultiSchemaApply(t, fmt.Sprintf(h, charset.V, collate.V, charset.V, collate.V), t.dsn(""), []string{"test", "test2"}, mysql.UnmarshalHCL)
+		})
+	})
 }
 
 func TestMySQL_HCL_Realm(t *testing.T) {
@@ -1345,9 +1354,13 @@ func TestMySQL_Script(t *testing.T) {
 						t.Fatal(err)
 					}
 				})
+				// Store the testscript.T for later use.
+				// See "only" function below.
+				env.Values[keyT] = env.T()
 				return nil
 			},
 			Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){
+				"only":    t.cmdOnly,
 				"apply":   t.cmdApply,
 				"exist":   t.cmdExist,
 				"synced":  t.cmdSynced,
@@ -1357,7 +1370,27 @@ func TestMySQL_Script(t *testing.T) {
 	})
 }
 
-func (t *myTest) cmdCmpShow(ts *testscript.TestScript, neg bool, args []string) {
+var keyT struct{}
+
+// cmdOnly executes only tests that their driver version matches the given pattern.
+// For example, "only 8" or "only 8 maria*"
+func (t *myTest) cmdOnly(ts *testscript.TestScript, neg bool, args []string) {
+	if neg {
+		ts.Fatalf("unsupported: ! only")
+	}
+	for i := range args {
+		re, rerr := regexp.Compile(`(?mi)` + args[i])
+		ts.Check(rerr)
+		if re.MatchString(t.version) {
+			return
+		}
+	}
+	// This is not an elegant way to get the created testing.T for the script,
+	// but we need some workaround to get it in order to skip specific tests.
+	ts.Value(keyT).(testscript.T).Skip("skip version", t.version)
+}
+
+func (t *myTest) cmdCmpShow(ts *testscript.TestScript, _ bool, args []string) {
 	if len(args) < 2 {
 		ts.Fatalf("invalid number of args to 'cmpshow': %d", len(args))
 	}
diff --git a/internal/integration/postgres_test.go b/internal/integration/postgres_test.go
index 9227c9dc..7aa16d52 100644
--- a/internal/integration/postgres_test.go
+++ b/internal/integration/postgres_test.go
@@ -481,8 +481,7 @@ func TestPostgres_CLI(t *testing.T) {
 }
 
 func TestPostgres_CLI_MultiSchema(t *testing.T) {
-	t.Run("SchemaInspect", func(t *testing.T) {
-		h := `
+	h := `
 			schema "public" {	
 			}
 			table "users" {
@@ -505,12 +504,20 @@ func TestPostgres_CLI_MultiSchema(t *testing.T) {
 					columns = [table.users.column.id]
 				}
 			}`
+	t.Run("SchemaInspect", func(t *testing.T) {
 		pgRun(t, func(t *pgTest) {
 			t.dropSchemas("test2")
 			t.dropTables("users")
 			testCLIMultiSchemaInspect(t, h, t.dsn(), []string{"public", "test2"}, postgres.UnmarshalHCL)
 		})
 	})
+	t.Run("SchemaApply", func(t *testing.T) {
+		pgRun(t, func(t *pgTest) {
+			t.dropSchemas("test2")
+			t.dropTables("users")
+			testCLIMultiSchemaApply(t, h, t.dsn(), []string{"public", "test2"}, postgres.UnmarshalHCL)
+		})
+	})
 }
 
 func TestPostgres_DefaultsHCL(t *testing.T) {
diff --git a/internal/integration/testdata/mysql/index-desc.txt b/internal/integration/testdata/mysql/index-desc.txt
new file mode 100644
index 00000000..f57273a8
--- /dev/null
+++ b/internal/integration/testdata/mysql/index-desc.txt
@@ -0,0 +1,119 @@
+# Each test runs on a clean database.
+
+# Run this test only on MySQL 8 as it is not supported by other versions.
+only 8
+
+# Apply schema "1.hcl" on fresh database.
+apply 1.hcl
+cmpshow users 1.sql
+
+# Drop the "DESC" option from the key part.
+apply 2.hcl
+cmpshow users 2.sql
+# Use of "columns" instead of "on" should not trigger a change.
+synced 2-no-change.hcl
+
+apply 3.hcl
+cmpshow users 3.sql
+
+# Below files represent HCL and SQL. File names defined their index in
+# execution order. 1.hcl is executed first, 2.hcl executed second, etc.
+-- 1.hcl --
+schema "$db" {
+    charset = "$charset"
+    collation = "$collate"
+}
+
+table "users" {
+    schema = schema.$db
+    column "rank" {
+        type = bigint
+    }
+    index "rank_idx" {
+        on {
+            desc   = true
+            column = table.users.column.rank
+        }
+    }
+}
+
+-- 1.sql --
+CREATE TABLE `users` (
+  `rank` bigint NOT NULL,
+  KEY `rank_idx` (`rank` DESC)
+)
+
+-- 2.hcl --
+schema "$db" {
+    charset = "$charset"
+    collation = "$collate"
+}
+
+table "users" {
+    schema = schema.$db
+    column "rank" {
+        type = bigint
+    }
+    index "rank_idx" {
+        on {
+            column = table.users.column.rank
+        }
+    }
+}
+
+-- 2.sql --
+CREATE TABLE `users` (
+  `rank` bigint NOT NULL,
+  KEY `rank_idx` (`rank`)
+)
+
+
+-- 2-no-change.hcl --
+schema "$db" {
+    charset = "$charset"
+    collation = "$collate"
+}
+
+table "users" {
+    schema = schema.$db
+    column "rank" {
+        type = bigint
+    }
+    index "rank_idx" {
+        columns = [
+            table.users.column.rank,
+        ]
+    }
+}
+
+-- 3.hcl --
+schema "$db" {
+    charset = "$charset"
+    collation = "$collate"
+}
+
+table "users" {
+    schema = schema.$db
+    column "rank" {
+        type = bigint
+    }
+    column "score" {
+        type = int
+    }
+    index "rank_score_idx" {
+        on {
+            column = table.users.column.rank
+        }
+        on {
+            column = table.users.column.score
+            desc = true
+        }
+    }
+}
+
+-- 3.sql --
+CREATE TABLE `users` (
+  `rank` bigint NOT NULL,
+  `score` int NOT NULL,
+  KEY `rank_score_idx` (`rank`,`score` DESC)
+)
diff --git a/schema/schemaspec/extension_test.go b/schema/schemaspec/extension_test.go
index 7fdb2ab3..6d7664f5 100644
--- a/schema/schemaspec/extension_test.go
+++ b/schema/schemaspec/extension_test.go
@@ -15,6 +15,7 @@ type OwnerBlock struct {
 	FirstName string                   `spec:"first_name"`
 	Born      int                      `spec:"born"`
 	Active    bool                     `spec:"active"`
+	BoolPtr   *bool                    `spec:"bool_ptr"`
 	Lit       *schemaspec.LiteralValue `spec:"lit"`
 }
 
@@ -45,6 +46,7 @@ func TestExtension(t *testing.T) {
 			schemautil.StrLitAttr("first_name", "tzuri"),
 			schemautil.LitAttr("born", "2019"),
 			schemautil.LitAttr("active", "true"),
+			schemautil.LitAttr("bool_ptr", "true"),
 			schemautil.LitAttr("lit", "1000"),
 			schemautil.LitAttr("extra", "true"),
 		},
@@ -62,6 +64,8 @@ func TestExtension(t *testing.T) {
 	require.EqualValues(t, "name", owner.ID)
 	require.EqualValues(t, 2019, owner.Born)
 	require.EqualValues(t, true, owner.Active)
+	require.NotNil(t, owner.BoolPtr)
+	require.EqualValues(t, true, *owner.BoolPtr)
 	require.EqualValues(t, schemautil.LitAttr("lit", "1000").V, owner.Lit)
 	attr, ok := owner.Remain().Attr("extra")
 	require.True(t, ok)
diff --git a/sql/mysql/sqlspec_test.go b/sql/mysql/sqlspec_test.go
index 2d57265d..f72be8f0 100644
--- a/sql/mysql/sqlspec_test.go
+++ b/sql/mysql/sqlspec_test.go
@@ -512,6 +512,46 @@ table "users" {
 	require.EqualValues(t, exp, &s)
 }
 
+func TestMarshalSpec_IndexParts(t *testing.T) {
+	c := schema.NewStringColumn("name", "text")
+	s := schema.New("test").
+		AddTables(
+			schema.NewTable("users").
+				AddColumns(c).
+				AddIndexes(
+					schema.NewIndex("idx").
+						AddParts(
+							schema.NewColumnPart(c).SetDesc(true),
+							schema.NewExprPart(&schema.RawExpr{X: "lower(name)"}),
+						),
+				),
+		)
+	buf, err := MarshalHCL(s)
+	require.NoError(t, err)
+	exp := `table "users" {
+  schema = schema.test
+  column "name" {
+    null = false
+    type = text
+  }
+  index "idx" {
+    unique = false
+    on {
+      desc   = true
+      column = table.users.column.name
+    }
+    on {
+      desc = false
+      expr = "lower(name)"
+    }
+  }
+}
+schema "test" {
+}
+`
+	require.EqualValues(t, exp, buf)
+}
+
 func TestMarshalSpec_TimePrecision(t *testing.T) {
 	s := schema.New("test").
 		AddTables(