Repository: ariga/atlas
Pull request #3008 (2024-07-30 10:26:24)

doc/md: migrate test guide


Patch:
diff --git a/cmd/atlas/internal/cloudapi/client.go b/cmd/atlas/internal/cloudapi/client.go
index ce744322..32f3f16b 100644
--- a/cmd/atlas/internal/cloudapi/client.go
+++ b/cmd/atlas/internal/cloudapi/client.go
@@ -48,7 +48,8 @@ func New(endpoint, token string) *Client {
 		endpoint: endpoint,
 		client: &http.Client{
 			Transport: &roundTripper{
-				token: token,
+				token:        token,
+				extraHeaders: make(map[string]string),
 			},
 			Timeout: time.Second * 30,
 		},
@@ -298,13 +299,23 @@ func (c *Client) post(ctx context.Context, query string, vars, data any) error {
 	return nil
 }
 
+// AddHeader adds a header to the client requests.
+func (c *Client) AddHeader(key, value string) {
+	rt, ok := c.client.Transport.(*roundTripper)
+	if !ok {
+		return
+	}
+	rt.extraHeaders[key] = value
+}
+
 type (
 	// errlist wraps the gqlerror.List to print errors without
 	// extra newlines and prefix info added.
 	errlist gqlerror.List
 	// roundTripper is a http.RoundTripper that adds the Authorization header.
 	roundTripper struct {
-		token string
+		token        string
+		extraHeaders map[string]string
 	}
 )
 
@@ -316,6 +327,9 @@ func (e errlist) Error() string {
 // RoundTrip implements http.RoundTripper.
 func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
 	SetHeader(req, r.token)
+	for k, v := range r.extraHeaders {
+		req.Header.Set(k, v)
+	}
 	return http.DefaultTransport.RoundTrip(req)
 }
 
diff --git a/cmd/atlas/internal/cmdapi/cmdapi_oss.go b/cmd/atlas/internal/cmdapi/cmdapi_oss.go
index dfef514a..11f434a8 100644
--- a/cmd/atlas/internal/cmdapi/cmdapi_oss.go
+++ b/cmd/atlas/internal/cmdapi/cmdapi_oss.go
@@ -40,9 +40,6 @@ func init() {
 		schemaInspectCmd(),
 		unsupportedCommand("schema", "test"),
 	)
-	schemaCmd.PersistentPreRun = func(cmd *cobra.Command, _ []string) {
-		maySuggestUpgrade(cmd)
-	}
 	Root.AddCommand(schemaCmd)
 	migrateCmd := migrateCmd()
 	migrateCmd.AddCommand(
@@ -63,9 +60,6 @@ func init() {
 		unsupportedCommand("migrate", "push"),
 		unsupportedCommand("migrate", "test"),
 	)
-	migrateCmd.PersistentPreRun = func(cmd *cobra.Command, _ []string) {
-		maySuggestUpgrade(cmd)
-	}
 	Root.AddCommand(migrateCmd)
 }
 
@@ -142,7 +136,7 @@ Or, visit the website to see all installation options:
 	https://atlasgo.io/docs#installation
 
 `
-	fmt.Fprintf(cmd.ErrOrStderr(), cmdlog.ColorCyan(s))
+	_ = cmdlog.WarnOnce(cmd.ErrOrStderr(), cmdlog.ColorCyan(s))
 	prev.UpgradeSuggested = time.Now()
 	_ = state.Write(prev)
 }
diff --git a/cmd/atlas/internal/cmdapi/project.go b/cmd/atlas/internal/cmdapi/project.go
index a32ae9c9..3cf0a836 100644
--- a/cmd/atlas/internal/cmdapi/project.go
+++ b/cmd/atlas/internal/cmdapi/project.go
@@ -605,19 +605,8 @@ func parseConfig(ctx context.Context, path, env string, vars map[string]cty.Valu
 				}),
 			}),
 			schemahcl.WithFunctions(map[string]function.Function{
-				"file": schemahcl.MakeFileFunc(base),
-				"getenv": function.New(&function.Spec{
-					Params: []function.Parameter{
-						{
-							Name: "key",
-							Type: cty.String,
-						},
-					},
-					Type: function.StaticReturnType(cty.String),
-					Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
-						return cty.StringVal(os.Getenv(args[0].AsString())), nil
-					},
-				}),
+				"file":   schemahcl.MakeFileFunc(base),
+				"getenv": getEnvFunc,
 			}),
 		)...,
 	)
@@ -686,3 +675,18 @@ const (
 )
 
 var ReviewModes = []string{ReviewAlways, ReviewWarning, ReviewError}
+
+// getEnvFunc is a custom HCL function that returns
+// the value of an environment variable.
+var getEnvFunc = function.New(&function.Spec{
+	Params: []function.Parameter{
+		{
+			Name: "key",
+			Type: cty.String,
+		},
+	},
+	Type: function.StaticReturnType(cty.String),
+	Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
+		return cty.StringVal(os.Getenv(args[0].AsString())), nil
+	},
+})
diff --git a/cmd/atlas/internal/cmdapi/schema.go b/cmd/atlas/internal/cmdapi/schema.go
index 51c1ba71..34485f78 100644
--- a/cmd/atlas/internal/cmdapi/schema.go
+++ b/cmd/atlas/internal/cmdapi/schema.go
@@ -186,6 +186,7 @@ func schemaApplyRun(cmd *cobra.Command, flags schemaApplyFlags, env *Env) error
 	if err != nil {
 		return err
 	}
+	maySuggestUpgrade(cmd)
 	// Returning at this stage should
 	// not trigger the help message.
 	cmd.SilenceUsage = true
@@ -438,6 +439,7 @@ func schemaDiffRun(cmd *cobra.Command, _ []string, flags schemaDiffFlags, env *E
 	if err != nil {
 		return err
 	}
+	maySuggestUpgrade(cmd)
 	return format.Execute(cmd.OutOrStdout(),
 		cmdlog.NewSchemaDiff(ctx, c, diff.from, diff.to, diff.changes),
 	)
@@ -544,6 +546,7 @@ func schemaInspectRun(cmd *cobra.Command, _ []string, flags schemaInspectFlags,
 	if err != nil {
 		return err
 	}
+	maySuggestUpgrade(cmd)
 	return format.Execute(cmd.OutOrStdout(), cmdlog.NewSchemaInspect(ctx, client, s))
 }
 
diff --git a/cmd/atlas/internal/cmdext/reader.go b/cmd/atlas/internal/cmdext/reader.go
index 0957559f..99ac81f1 100644
--- a/cmd/atlas/internal/cmdext/reader.go
+++ b/cmd/atlas/internal/cmdext/reader.go
@@ -44,10 +44,11 @@ type (
 )
 
 // Close redirects calls to Close to the enclosed io.Closer.
-func (r *StateReadCloser) Close() {
+func (r *StateReadCloser) Close() error {
 	if r.Closer != nil {
-		r.Closer.Close()
+		return r.Closer.Close()
 	}
+	return nil
 }
 
 // StateReaderSQL returns a migrate.StateReader from an SQL file or a directory of migrations.
diff --git a/cmd/atlas/internal/cmdlog/cmdlog.go b/cmd/atlas/internal/cmdlog/cmdlog.go
index 430dcb83..eb99cbc8 100644
--- a/cmd/atlas/internal/cmdlog/cmdlog.go
+++ b/cmd/atlas/internal/cmdlog/cmdlog.go
@@ -10,10 +10,13 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"net/url"
+	"reflect"
 	"slices"
 	"sort"
 	"strings"
+	"sync"
 	"text/template"
 	"time"
 
@@ -28,6 +31,7 @@ import (
 
 var (
 	ColorCyan         = color.CyanString
+	ColorGray         = color.New(color.Attribute(90))
 	ColorGreen        = color.HiGreenString
 	ColorRed          = color.HiRedString
 	ColorRedBgWhiteFg = color.New(color.FgHiWhite, color.BgHiRed).SprintFunc()
@@ -1119,3 +1123,20 @@ func indentLn(input string, indent int) string {
 	pad := strings.Repeat(" ", indent)
 	return strings.ReplaceAll(input, "\n", "\n"+pad)
 }
+
+// WarnOnce allow writing warning messages to the given writer,
+// but ensures only one message will be written in process run.
+func WarnOnce(w io.Writer, text string) error {
+	if !reflect.TypeOf(w).Comparable() {
+		_, err := w.Write([]byte(text))
+		return err
+	}
+	if _, loaded := oneWrites.LoadOrStore(w, true); !loaded {
+		_, err := w.Write([]byte(text))
+		return err
+	}
+	return nil
+}
+
+// oneWrites per writer.
+var oneWrites sync.Map
diff --git a/cmd/atlas/internal/migratelint/run.go b/cmd/atlas/internal/migratelint/run.go
index 74568249..e2139988 100644
--- a/cmd/atlas/internal/migratelint/run.go
+++ b/cmd/atlas/internal/migratelint/run.go
@@ -16,7 +16,6 @@ import (
 	"text/template"
 	"time"
 
-	"ariga.io/atlas/cmd/atlas/internal/cmdlog"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/sqlcheck"
 	"ariga.io/atlas/sql/sqlclient"
@@ -187,7 +186,7 @@ func (r *Runner) analyze(ctx context.Context, files []*sqlcheck.File) error {
 
 var (
 	// TemplateFuncs are global functions available in templates.
-	TemplateFuncs = cmdlog.WithColorFuncs(template.FuncMap{
+	TemplateFuncs = template.FuncMap{
 		"json": func(v any, args ...string) (string, error) {
 			var (
 				b   []byte
@@ -226,7 +225,12 @@ var (
 			}
 			return append(lines, strings.Join(words[j:], " "))
 		},
-	})
+		"cyan":         color.CyanString,
+		"green":        color.HiGreenString,
+		"red":          color.HiRedString,
+		"redBgWhiteFg": color.New(color.FgHiWhite, color.BgHiRed).SprintFunc(),
+		"yellow":       color.YellowString,
+	}
 	// DefaultTemplate is the default template used by the CI job.
 	DefaultTemplate = template.Must(template.New("report").
 			Funcs(TemplateFuncs).
diff --git a/cmd/atlas/main.go b/cmd/atlas/main.go
index 98f32ece..48f86b89 100644
--- a/cmd/atlas/main.go
+++ b/cmd/atlas/main.go
@@ -16,6 +16,7 @@ import (
 
 	"ariga.io/atlas/cmd/atlas/internal/cmdapi"
 	"ariga.io/atlas/cmd/atlas/internal/cmdapi/vercheck"
+	"ariga.io/atlas/cmd/atlas/internal/cmdlog"
 	_ "ariga.io/atlas/cmd/atlas/internal/docker"
 	_ "ariga.io/atlas/sql/mysql"
 	_ "ariga.io/atlas/sql/mysql/mysqlcheck"
@@ -58,7 +59,7 @@ func main() {
 	update := checkForUpdate(ctx)
 	err = cmdapi.Root.ExecuteContext(ctx)
 	if u := update(); u != "" {
-		fmt.Fprintln(os.Stderr, u)
+		_ = cmdlog.WarnOnce(os.Stderr, cmdlog.ColorCyan(u))
 	}
 	done(err)
 	if err != nil {
diff --git a/doc/md/cli-data-privacy.mdx b/doc/md/cli-data-privacy.mdx
index 6f4d970d..f9323cb0 100644
--- a/doc/md/cli-data-privacy.mdx
+++ b/doc/md/cli-data-privacy.mdx
@@ -3,22 +3,28 @@ title: Data Privacy and the CLI
 slug: /cli/data-privacy
 id: cli-data-privacy
 ---
-As mentioned on the [Feature Compatability](/features) page, users can unlock advanced features by logging in with the
-Atlas CLI. This can be done by running the following command:
 
-```bash
-atlas login
-```
+## What data does the Atlas CLI collect?
 
-After running this command, follow the instructions on the screen to log in. If needed, you will be prompted
-to create an account.
+This document explains what data the Atlas CLI collects and how we use it.
 
-## What data does the Atlas CLI collect?
+### Anonymous Telemetry
+
+When you run the Atlas CLI, we may collect anonymous telemetry data to help us understand how users are using the
+CLI and provide better support. This includes things like the commands you run, general statistics and information
+about the runtime environment. Under no circumstances do we collect any personally identifiable information
+or sensitive data.  This is further detailed in our [Privacy Policy](https://ariga.io/legal/privacy) and the
+[Atlas EULA](https://ariga.io/legal/atlas/eula).
+
+If you wish to opt-out of telemetry data collection, you can do so by setting the `ATLAS_NO_ANON_TELEMETRY` environment
+variable to `true`.  This will disable all anonymous telemetry collection.
+
+### Identified Users
 
 :::info Logged-in Users
 
-Notice that this document is only relevant to users who have logged in with the Atlas CLI. If you have not logged in,
-the CLI does not collect any data from you.
+The following section pertains only relevant to users who have created an [Atlas Pro](/pricing) account and are logged
+in to the CLI.
 
 :::
 
diff --git a/doc/website/package-lock.json b/doc/website/package-lock.json
index 5f85189b..2d1c2dc7 100644
--- a/doc/website/package-lock.json
+++ b/doc/website/package-lock.json
@@ -8,7 +8,7 @@
       "name": "website",
       "version": "0.0.0",
       "dependencies": {
-        "@ariga/atlas-website": "^0.0.54",
+        "@ariga/atlas-website": "^0.0.57",
         "@docusaurus/core": "^2.0.0-rc.1",
         "@docusaurus/plugin-client-redirects": "^2.4.1",
         "@docusaurus/plugin-ideal-image": "^2.4.1",
@@ -341,9 +341,9 @@
       }
     },
     "node_modules/@ariga/atlas-website": {
-      "version": "0.0.54",
-      "resolved": "file:ariga-atlas-website-0.0.54.tgz",
-      "integrity": "sha512-cJwFUDbDxIcLjQJTcZo9TEs8+PkTEva+bKPeapcqelqScZjW/B1+Ufhns/aJD4sqILuqpue0FZ98w29fgdKS2Q==",
+      "version": "0.0.57",
+      "resolved": "https://registry.npmjs.org/@ariga/atlas-website/-/atlas-website-0.0.57.tgz",
+      "integrity": "sha512-A0sFud7V2EPedemN06dOO0tX+m3Uj34N1IHQppa7XSEG7ERqOrywj56v+iv4CwNifiE+kbxNNx2NkvVUJ9GnLA==",
       "dependencies": {
         "@amplitude/analytics-browser": "^1.9.4",
         "@types/amplitude-js": "^8.16.2",
@@ -362,7 +362,6 @@
         "react": "^17.0.1",
         "react-dom": "^17.0.1",
         "react-ga4": "^2.1.0",
-        "react-responsive": "^10.0.0",
         "react-router-dom": "^6.9.0",
         "react-scroll": "^1.8.9",
         "react-scroll-parallax": "^3.3.2",
@@ -9047,11 +9046,6 @@
         }
       }
     },
-    "node_modules/css-mediaquery": {
-      "version": "0.1.2",
-      "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz",
-      "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q=="
-    },
     "node_modules/css-minimizer-webpack-plugin": {
       "version": "4.2.2",
       "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-4.2.2.tgz",
@@ -14742,11 +14736,6 @@
         "node": ">=10.17.0"
       }
     },
-    "node_modules/hyphenate-style-name": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
-      "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="
-    },
     "node_modules/iconv-lite": {
       "version": "0.4.24",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -16736,14 +16725,6 @@
         "node": ">= 0.10.0"
       }
     },
-    "node_modules/matchmediaquery": {
-      "version": "0.4.2",
-      "resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.4.2.tgz",
-      "integrity": "sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA==",
-      "dependencies": {
-        "css-mediaquery": "^0.1.2"
-      }
-    },
     "node_modules/math-random": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz",
@@ -21223,23 +21204,6 @@
         "webpack": ">=4.41.1 || 5.x"
       }
     },
-    "node_modules/react-responsive": {
-      "version": "10.0.0",
-      "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-10.0.0.tgz",
-      "integrity": "sha512-N6/UiRLGQyGUqrarhBZmrSmHi2FXSD++N5VbSKsBBvWfG0ZV7asvUBluSv5lSzdMyEVjzZ6Y8DL4OHABiztDOg==",
-      "dependencies": {
-        "hyphenate-style-name": "^1.0.0",
-        "matchmediaquery": "^0.4.2",
-        "prop-types": "^15.6.1",
-        "shallow-equal": "^3.1.0"
-      },
-      "engines": {
-        "node": ">=14"
-      },
-      "peerDependencies": {
-        "react": ">=16.8.0"
-      }
-    },
     "node_modules/react-router": {
       "version": "5.3.4",
       "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
@@ -23099,11 +23063,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/shallow-equal": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz",
-      "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg=="
-    },
     "node_modules/shallowequal": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
diff --git a/doc/website/package.json b/doc/website/package.json
index 1a1e6d5a..4fff84ee 100644
--- a/doc/website/package.json
+++ b/doc/website/package.json
@@ -16,7 +16,7 @@
     "invalidate-cdn": "aws cloudfront create-invalidation --distribution-id E3VOJYSV9YO33D --paths \"/*\""
   },
   "dependencies": {
-    "@ariga/atlas-website": "^0.0.54",
+    "@ariga/atlas-website": "^0.0.58",
     "@docusaurus/core": "^2.0.0-rc.1",
     "@docusaurus/plugin-client-redirects": "^2.4.1",
     "@docusaurus/plugin-ideal-image": "^2.4.1",
diff --git a/doc/website/sidebars.js b/doc/website/sidebars.js
index e3573023..c1d0997d 100644
--- a/doc/website/sidebars.js
+++ b/doc/website/sidebars.js
@@ -301,6 +301,11 @@ module.exports = {
                     id: 'guides/testing/testing-github-actions',
                     label: 'With GitHub Actions'
                 },
+                {
+                    type: 'doc',
+                    id: 'guides/testing/testing-data-migrations',
+                    label: 'Testing Data Migrations'
+                },
                 {
                     type: 'doc',
                     id: 'guides/testing/testing-views',
diff --git a/doc/website/src/constants/footer.js b/doc/website/src/constants/footer.js
index 7f7c22f9..c7a7c5e1 100644
--- a/doc/website/src/constants/footer.js
+++ b/doc/website/src/constants/footer.js
@@ -34,8 +34,8 @@ module.exports = {
       title: "Use Cases",
       items: [
         { label: "Database per Tenant", to: "/use-cases/database-per-tenant" },
+        { label: "Modernize Database Migrations", to: "/use-cases/modernize-database-migrations" },
         { label: "Atlas Cloud", to: "https://atlasgo.cloud/?utm_term=footer" },
-        { label: "Modern Database CI/CD", to: "https://atlasgo.io/guides/modern-database-ci-cd" },
       ],
     },
     {
diff --git a/doc/website/src/pages/use-cases/modernize-database-migrations.js b/doc/website/src/pages/use-cases/modernize-database-migrations.js
new file mode 100644
index 00000000..6f83af3b
--- /dev/null
+++ b/doc/website/src/pages/use-cases/modernize-database-migrations.js
@@ -0,0 +1,13 @@
+import React from "react";
+import Layout from "@theme/Layout";
+import { ModernizeSchemaMigrations } from "@ariga/atlas-website";
+
+import "@ariga/atlas-website/style.css";
+
+export default function () {
+  return (
+    <Layout>
+      <ModernizeSchemaMigrations />
+    </Layout>
+  );
+}
diff --git a/sql/internal/specutil/convert.go b/sql/internal/specutil/convert.go
index ada57f79..904ef49c 100644
--- a/sql/internal/specutil/convert.go
+++ b/sql/internal/specutil/convert.go
@@ -5,7 +5,6 @@
 package specutil
 
 import (
-	"bytes"
 	"errors"
 	"fmt"
 	"slices"
@@ -683,7 +682,7 @@ func FromView(v *schema.View, colFn ViewColumnSpecFunc, idxFn IndexSpecFunc) (*s
 	}
 	// In case the view definition is multi-line,
 	// format it as indented heredoc with two spaces.
-	as := MightHeredoc(v.Def)
+	as := sqlspec.MightHeredoc(v.Def)
 	embed := &schemahcl.Resource{
 		Attrs: []*schemahcl.Attr{
 			schemahcl.StringAttr("as", as),
@@ -706,39 +705,6 @@ func FromView(v *schema.View, colFn ViewColumnSpecFunc, idxFn IndexSpecFunc) (*s
 	return spec, nil
 }
 
-// normalizeCRLF for heredoc strings that inspected and printed in the HCL as-is to
-// avoid having mixed endings in the printed file - Unix-like (default) and Windows-like.
-func normalizeCRLF(s string) string {
-	if strings.Contains(s, "\r\n") {
-		return strings.ReplaceAll(s, "\r\n", "\n")
-	}
-	return s
-}
-
-// MightHeredoc returns the string as an indented heredoc if it has multiple lines.
-func MightHeredoc(s string) string {
-	s = normalizeCRLF(strings.TrimSpace(s))
-	// In case the given definition is multi-line,
-	// format it as indented heredoc with two spaces.
-	if lines := strings.Split(s, "\n"); len(lines) > 1 {
-		var b bytes.Buffer
-		b.Grow(len(s))
-		b.WriteString("<<-SQL\n")
-		for _, l := range lines {
-			// Skip spaces-only lines, as editors stripped these spaces off,
-			// and HCL parser results a different string for them.
-			if strings.TrimSpace(l) != "" {
-				b.WriteString("  ")
-				b.WriteString(l)
-			}
-			b.WriteByte('\n')
-		}
-		b.WriteString("  SQL")
-		s = b.String()
-	}
-	return s
-}
-
 // dependsOn returns the depends_on attribute for the given objects.
 func dependsOn(realm *schema.Realm, objects []schema.Object) (*schemahcl.Attr, bool) {
 	var (
diff --git a/sql/migrate/dir.go b/sql/migrate/dir.go
index 6f0f89dc..6c160f3c 100644
--- a/sql/migrate/dir.go
+++ b/sql/migrate/dir.go
@@ -561,6 +561,18 @@ func (t TemplateFormatter) Format(plan *Plan) ([]File, error) {
 	return files, nil
 }
 
+// FormatFile is like Format, but expects and returns a single file.
+func (t TemplateFormatter) FormatFile(p *Plan) (File, error) {
+	files, err := t.Format(p)
+	if err != nil {
+		return nil, err
+	}
+	if len(files) != 1 {
+		return nil, fmt.Errorf("expected a single file, got %d", len(files))
+	}
+	return files[0], nil
+}
+
 // FormatTo calls Format and writes the files' content to the given writer.
 func (t TemplateFormatter) FormatTo(plan *Plan, w io.Writer) error {
 	files, err := t.Format(plan)
diff --git a/sql/postgres/convert.go b/sql/postgres/convert.go
index 30bea838..abac3708 100644
--- a/sql/postgres/convert.go
+++ b/sql/postgres/convert.go
@@ -54,6 +54,7 @@ func FormatType(t schema.Type) (string, error) {
 		f = t.T
 	case *schema.IntegerType:
 		switch f = strings.ToLower(t.T); f {
+		case TypeXID, TypeXID8:
 		case TypeSmallInt, TypeInteger, TypeBigInt:
 		case TypeInt2:
 			f = TypeSmallInt
@@ -214,7 +215,7 @@ func ParseType(typ string) (schema.Type, error) {
 func columnType(c *columnDesc) (schema.Type, error) {
 	var typ schema.Type
 	switch t := c.typ; strings.ToLower(t) {
-	case TypeBigInt, TypeInt8, TypeInt, TypeInteger, TypeInt4, TypeSmallInt, TypeInt2, TypeInt64:
+	case TypeBigInt, TypeInt8, TypeInt, TypeInteger, TypeInt4, TypeSmallInt, TypeInt2, TypeInt64, TypeXID, TypeXID8:
 		typ = &schema.IntegerType{T: t}
 	case TypeBit, TypeBitVar:
 		typ = &BitType{T: t, Len: c.size}
diff --git a/sql/postgres/driver.go b/sql/postgres/driver.go
index 91dec5b0..551a9c65 100644
--- a/sql/postgres/driver.go
+++ b/sql/postgres/driver.go
@@ -438,6 +438,9 @@ const (
 	TypeInt4     = "int4" // integer.
 	TypeInt8     = "int8" // bigint.
 
+	TypeXID  = "xid"  // transaction identifier.
+	TypeXID8 = "xid8" // 64-bit transaction identifier.
+
 	TypeCIDR     = "cidr"
 	TypeInet     = "inet"
 	TypeMACAddr  = "macaddr"
diff --git a/sql/postgres/migrate.go b/sql/postgres/migrate.go
index ee994085..4d302f8f 100644
--- a/sql/postgres/migrate.go
+++ b/sql/postgres/migrate.go
@@ -427,8 +427,8 @@ func (s *state) modifyTable(modify *schema.ModifyTable) error {
 			changes = append(changes, &migrate.Change{
 				Source:  change,
 				Comment: fmt.Sprintf("rename an index from %q to %q", change.From.Name, change.To.Name),
-				Cmd:     s.Build("ALTER INDEX").Ident(change.From.Name).P("RENAME TO").Ident(change.To.Name).String(),
-				Reverse: s.Build("ALTER INDEX").Ident(change.To.Name).P("RENAME TO").Ident(change.From.Name).String(),
+				Cmd:     s.Build("ALTER INDEX").SchemaResource(modify.T.Schema, change.From.Name).P("RENAME TO").Ident(change.To.Name).String(),
+				Reverse: s.Build("ALTER INDEX").SchemaResource(modify.T.Schema, change.To.Name).P("RENAME TO").Ident(change.From.Name).String(),
 			})
 		case *schema.ModifyForeignKey:
 			// Foreign-key modification is translated into 2 steps.
@@ -1151,9 +1151,9 @@ func (s *state) index(b *sqlx.Builder, idx *schema.Index) error {
 			})
 		})
 	}
-	// Avoid appending the default behavior, which NULL values are distinct.
-	if n := (IndexNullsDistinct{}); sqlx.Has(idx.Attrs, &n) && !n.V {
-		b.P("NULLS NOT DISTINCT")
+	// Handled separately by the UNIQUE builder.
+	if _, ok := uniqueConst(idx.Attrs); !ok {
+		nullsNotDistinct(b, idx)
 	}
 	if p, ok := indexStorageParams(idx.Attrs); ok {
 		b.P("WITH")
@@ -1228,6 +1228,9 @@ func (s *state) unique(b *sqlx.Builder, idx *schema.Index) error {
 		name = idx.Name
 	}
 	b.P("CONSTRAINT").Ident(name).P("UNIQUE")
+	// In UNIQUE constraints, the NULLS [NOT] DISTINCT
+	// clause is written before the index parts.
+	nullsNotDistinct(b, idx)
 	return s.index(b, idx)
 }
 
@@ -1426,3 +1429,11 @@ func dropConst(c schema.Change) bool {
 		return false
 	}
 }
+
+// nullsNotDistinct handles the NULLS [NOT] DISTINCT clause for indexes.
+func nullsNotDistinct(b *sqlx.Builder, idx *schema.Index) {
+	// Avoid appending the default behavior, which NULL values are distinct.
+	if n := (IndexNullsDistinct{}); sqlx.Has(idx.Attrs, &n) && !n.V {
+		b.P("NULLS NOT DISTINCT")
+	}
+}
diff --git a/sql/postgres/sqlspec.go b/sql/postgres/sqlspec.go
index 32430c7d..b537272b 100644
--- a/sql/postgres/sqlspec.go
+++ b/sql/postgres/sqlspec.go
@@ -1032,6 +1032,8 @@ var TypeRegistry = schemahcl.NewRegistry(
 		schemahcl.NewTypeSpec(TypeDateRange),
 		schemahcl.NewTypeSpec(TypeDateMultiRange),
 		schemahcl.NewTypeSpec("hstore"),
+		schemahcl.NewTypeSpec(TypeXID),
+		schemahcl.NewTypeSpec(TypeXID8),
 	),
 	// PostgreSQL internal, pseudo, and special types.
 	schemahcl.WithSpecs(func() (specs []*schemahcl.TypeSpec) {
diff --git a/sql/sqlspec/sqlspec.go b/sql/sqlspec/sqlspec.go
index 4060da35..12bca2f3 100644
--- a/sql/sqlspec/sqlspec.go
+++ b/sql/sqlspec/sqlspec.go
@@ -5,6 +5,9 @@
 package sqlspec
 
 import (
+	"bytes"
+	"strings"
+
 	"ariga.io/atlas/schemahcl"
 
 	"github.com/zclconf/go-cty/cty"
@@ -192,3 +195,36 @@ func init() {
 	schemahcl.Register("sequence", &Sequence{})
 	schemahcl.Register("schema", &Schema{})
 }
+
+// normalizeCRLF for heredoc strings that inspected and printed in the HCL as-is to
+// avoid having mixed endings in the printed file - Unix-like (default) and Windows-like.
+func normalizeCRLF(s string) string {
+	if strings.Contains(s, "\r\n") {
+		return strings.ReplaceAll(s, "\r\n", "\n")
+	}
+	return s
+}
+
+// MightHeredoc returns the string as an indented heredoc if it has multiple lines.
+func MightHeredoc(s string) string {
+	s = normalizeCRLF(strings.TrimSpace(s))
+	// In case the given definition is multi-line,
+	// format it as indented heredoc with two spaces.
+	if lines := strings.Split(s, "\n"); len(lines) > 1 {
+		var b bytes.Buffer
+		b.Grow(len(s))
+		b.WriteString("<<-SQL\n")
+		for _, l := range lines {
+			// Skip spaces-only lines, as editors stripped these spaces off,
+			// and HCL parser results a different string for them.
+			if strings.TrimSpace(l) != "" {
+				b.WriteString("  ")
+				b.WriteString(l)
+			}
+			b.WriteByte('\n')
+		}
+		b.WriteString("  SQL")
+		s = b.String()
+	}
+	return s
+}


Test patch:
diff --git a/cmd/atlas/internal/cloudapi/client_test.go b/cmd/atlas/internal/cloudapi/client_test.go
index fd3dc9be..c50da3f8 100644
--- a/cmd/atlas/internal/cloudapi/client_test.go
+++ b/cmd/atlas/internal/cloudapi/client_test.go
@@ -155,3 +155,17 @@ func TestUserAgent(t *testing.T) {
 	require.Equal(t, fmt.Sprintf("Atlas/%s (%s; foo/bar; bar/baz)", version, platform), UserAgent("foo/bar", "bar/baz"))
 	require.Equal(t, fmt.Sprintf("Atlas/%s (%s; bar/baz)", version, platform), UserAgent("  ", "", "bar/baz"))
 }
+
+func TestClient_AddHeader(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, "val", r.Header.Get("key"))
+	}))
+	client := New(srv.URL, "atlas")
+	defer srv.Close()
+	client.AddHeader("key", "val")
+	_, err := client.ReportMigration(context.Background(), ReportMigrationInput{
+		EnvName:     "foo",
+		ProjectName: "bar",
+	})
+	require.NoError(t, err)
+}
diff --git a/cmd/atlas/internal/cmdlog/cmdlog_test.go b/cmd/atlas/internal/cmdlog/cmdlog_test.go
index ccc6bb6f..14493aac 100644
--- a/cmd/atlas/internal/cmdlog/cmdlog_test.go
+++ b/cmd/atlas/internal/cmdlog/cmdlog_test.go
@@ -9,8 +9,10 @@ import (
 	"bytes"
 	"context"
 	"encoding/json"
+	"io"
 	"path/filepath"
 	"strings"
+	"sync"
 	"testing"
 	"text/template"
 	"time"
@@ -909,3 +911,38 @@ func TestReporter_OutOfOrder(t *testing.T) {
   ERROR: migration files 1.5.sql, 1.6.sql were added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error
 `, buf.String())
 }
+
+func TestWarnOnce(t *testing.T) {
+	b := &strings.Builder{}
+	require.NoError(t, cmdlog.WarnOnce(b, "one"))
+	require.NoError(t, cmdlog.WarnOnce(b, "two"))
+	require.Equal(t, "one", b.String())
+
+	var wg sync.WaitGroup
+	b = &strings.Builder{}
+	wg.Add(5)
+	for i := 0; i < 5; i++ {
+		go func() {
+			defer wg.Done()
+			require.NoError(t, cmdlog.WarnOnce(b, "one"))
+		}()
+	}
+	wg.Wait()
+	require.Equal(t, "one", b.String())
+
+	// Ensure the type is not assignable.
+	require.Panics(t, func() {
+		var m sync.Map
+		m.LoadOrStore(unassignable{}, "text")
+	})
+	b.Reset()
+	require.NoError(t, cmdlog.WarnOnce(unassignable{Writer: b}, "done"))
+	require.Equal(t, "done", b.String())
+}
+
+type unassignable struct {
+	_ func()
+	io.Writer
+}
+
+func (a unassignable) Write(p []byte) (n int, err error) { return a.Writer.Write(p) }
diff --git a/doc/md/guides/testing/testing-migrations.mdx b/doc/md/guides/testing/testing-migrations.mdx
new file mode 100644
index 00000000..deac1dcc
--- /dev/null
+++ b/doc/md/guides/testing/testing-migrations.mdx
@@ -0,0 +1,433 @@
+---
+id: testing-data-migrations
+title: Testing Data Migrations
+slug: /guides/testing/data-migrations
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+Most commonly, migrations deal with schema changes, such as adding or removing columns, creating tables, or altering constraints.
+However, as your application evolves, you may need to add or refactor data within the database, which is where data migrations come in.
+For instance, you may need to seed data in a table, backfill data for existing records in new columns, or somehow
+transform existing data to accommodate changes in your application.
+
+Data migrations can be especially tricky to get right, and mistakes can be problematic and irreversible. For this reason
+testing data migrations is crucial. Testing data migrations typically involves the following steps:
+1. Setting up an empty database.
+2. Applying migrations up to the one before the test.
+3. Seeding test data.
+4. Running the migration under test.
+5. Making assertions to verify the results.
+
+This process can be cumbersome to set up and error-prone as it often involves writing an ad-hoc program to automate
+the steps mentioned above or manually testing the migration.
+
+Atlas's [`migrate test`](/testing/migrate) command simplifies this by allowing you to define test cases in a concise
+syntax and acts as a harness to run these tests during local development and in CI.
+
+In this guide we will learn how to use the `migrate test` command to test migration files.
+
+:::info [Atlas Pro Feature](/features#pro-plan)
+Testing is currently available only to [Atlas Pro users](/features#pro-plan). To use this feature, run:
+```
+atlas login
+```
+:::
+
+## Project Setup
+
+Before we begin writing tests, we will start by setting up a project with a config file, schema file,
+and a migration directory containing a few migration files.
+
+### Config File
+Create a [config file](/atlas-schema/projects#project-files) named `atlas.hcl`.
+
+In this file we will define an environment, specify the source of our schema,
+and a URL for our [dev database](/concepts/dev-database).
+
+We will also create a file named `migrate.test.hcl` to write our tests, and
+add it to the `atlas.hcl` file in the test block.
+
+```hcl title="atlas.hcl"
+env "dev" {
+  src = "file://schema.hcl"
+  dev = "docker://postgres/15/dev"
+
+  # Test configuration for local development.
+  test {
+    migrate {
+      src = ["migrate.test.hcl"]
+    }
+  }
+}
+```
+
+### Schema File
+
+Next, we will create a schema containing two tables:
+* **`users`**: stores user-specific data, such as `id` and `email`.
+* **`posts`**: holds the post content, when it was created, and links each post to its author with a `user_id`.
+
+<Tabs>
+<TabItem value="hcl" label="schema.hcl">
+
+```hcl title="schema.hcl"
+schema "public" {}
+
+table "users" {
+  schema = schema.public
+  column "id" {
+    type    = int
+    null    = false
+  }
+  column "email" {
+    type    = varchar(255)
+    null    = false
+  }
+  primary_key {
+    columns = [column.id]
+  }
+}
+
+table "posts" {
+  schema = schema.public
+  column "id" {
+    type    = int
+    null    = false
+  }
+  column "title" {
+    type    = varchar(100)
+    null    = false
+  }
+  column "created_at" {
+    null = false
+    type = timestamp
+  }
+  column "user_id" {
+    type    = int
+    null    = false
+  }
+  primary_key {
+    columns = [column.id]
+  }
+  foreign_key "authors_fk" {
+    columns = [column.user_id]
+    ref_columns = [table.users.column.id]
+  }
+}
+```
+
+</TabItem>
+<TabItem value="sql" label="schema.sql">
+
+```sql title="schema.sql
+-- Add new schema named "public"
+CREATE SCHEMA IF NOT EXISTS "public";
+-- Create "users" table
+CREATE TABLE "public"."users" ("id" integer NOT NULL, "email" character varying(255) NOT NULL, PRIMARY KEY ("id"));
+-- Create "posts" table
+CREATE TABLE "public"."posts" ("id" integer NOT NULL, "title" character varying(100) NOT NULL, "created_at" TIMESTAMP NOT NULL, "user_id" integer NOT NULL, PRIMARY KEY ("id"), CONSTRAINT "authors_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION);
+```
+
+</TabItem>
+</Tabs>
+
+
+### Generating a Migration
+
+To generate our initial migration, we will run the following command:
+
+```bash
+atlas migrate diff --env dev
+```
+
+We should see a new `migrations` directory created with the following files:
+
+<Tabs>
+<TabItem value="20240807192632.sql" label="20240807192632.sql" default>
+
+```sql title="20240807192632.sql"
+-- Create "users" table
+CREATE TABLE "users" ("id" integer NOT NULL, "email" character varying(255) NOT NULL, PRIMARY KEY ("id"));
+-- Create "posts" table
+CREATE TABLE "posts" ("id" integer NOT NULL, "title" character varying(100) NOT NULL, "created_at" timestamp NOT NULL, "user_id" integer NOT NULL, PRIMARY KEY ("id"), CONSTRAINT "authors_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION);
+```
+
+</TabItem>
+<TabItem value="atlas.sum" label="atlas.sum">
+
+```sum title="atlas.sum"
+h1:fzAvfmbDGG5M4KAjGXqAylAE3PawD5+6GqkVqvizMys=
+20240807192632.sql h1:oOoSEXM3VyBkPGeWdyoYfAVF1dFn79c3yGhsy3Ys8PI=
+```
+
+</TabItem>
+</Tabs>
+
+### Expanding Business Logic
+
+Great! Now that we have a basic schema and migration directory, let's add some business logic before
+we begin testing.
+
+We will add a column `latest_post_ts` to the `users` table, which will hold the timestamp
+of the user's most recent post. To automatically populate this column we will create a trigger
+`update_latest_post_trigger`.
+
+<Tabs>
+<TabItem value="hcl" label="schema.hcl">
+
+```hcl title="schema.hcl"
+schema "public" {}
+table "users" {
+  schema = schema.public
+  column "id" {
+    null = false
+    type = integer
+  }
+  column "email" {
+    null = false
+    type = character_varying(255)
+  }
+// highlight-start
+  column "latest_post_ts" {
+    null = true
+    type = timestamp
+  }
+// highlight-end
+  primary_key {
+    columns = [column.id]
+  }
+}
+
+table "posts" {
+  schema = schema.public
+  column "id" {
+    null = false
+    type = integer
+  }
+  column "title" {
+    null = false
+    type = character_varying(100)
+  }
+  column "created_at" {
+    null = false
+    type = timestamp
+  }
+  column "user_id" {
+    null = false
+    type = integer
+  }
+  primary_key {
+    columns = [column.id]
+  }
+  foreign_key "authors_fk" {
+    columns     = [column.user_id]
+    ref_columns = [table.users.column.id]
+  }
+}
+
+// highlight-start
+function "update_latest_post" {
+  schema = schema.public
+  lang   = PLpgSQL
+  return = trigger
+  as     = <<-SQL
+    BEGIN
+    UPDATE "public"."users"
+    SET "latest_post_ts" = (
+      SELECT MAX("created_at")
+      FROM "public"."posts"
+      WHERE "user_id" = NEW."user_id"
+    )
+    WHERE "id" = NEW."user_id";
+    RETURN NEW;
+    END;
+  SQL
+}
+
+trigger "update_latest_post_trigger" {
+  on = table.posts
+  after {
+    insert = true
+  }
+  for = ROW
+  execute {
+    function = function.update_latest_post
+  }
+}
+// highlight-end
+```
+
+</TabItem>
+<TabItem value="sql" label="schema.sql">
+
+```sql title="schema.sql"
+-- Add new schema named "public"
+CREATE SCHEMA IF NOT EXISTS "public";
+// highlight-start
+-- Create "users" table
+CREATE TABLE "public"."users" ("id" integer NOT NULL, "email" character varying(255) NOT NULL, "latest_post_ts" TIMESTAMP, PRIMARY KEY ("id"));
+// highlight-end
+-- Create "posts" table
+CREATE TABLE "public"."posts" ("id" integer NOT NULL, "title" character varying(100) NOT NULL, "created_at" TIMESTAMP NOT NULL, "user_id" integer NOT NULL, PRIMARY KEY ("id"), CONSTRAINT "authors_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION);
+
+// highlight-start
+-- Create the trigger function
+CREATE OR REPLACE FUNCTION update_latest_post()
+RETURNS TRIGGER AS $$
+BEGIN
+UPDATE "public"."users"
+SET "latest_post_ts" = (
+    SELECT MAX("created_at")
+    FROM "public"."posts"
+    WHERE "user_id" = NEW."user_id"
+)
+WHERE "id" = NEW."user_id";
+RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Create the trigger
+CREATE TRIGGER update_latest_post_trigger
+AFTER INSERT ON "public"."posts"
+FOR EACH ROW
+EXECUTE FUNCTION update_latest_post();
+// highlight-end
+```
+
+</TabItem>
+</Tabs>
+
+Run the `migrate diff` command once more to generate another migration:
+
+```bash
+atlas migrate diff --env dev
+```
+
+The following migration should be created:
+
+```sql title="20240807192934.sql"
+-- Create "update_latest_post" function
+CREATE FUNCTION "update_latest_post" () RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+UPDATE "public"."users"
+SET "latest_post_ts" = (
+  SELECT MAX("created_at")
+  FROM "public"."posts"
+  WHERE "user_id" = NEW."user_id"
+)
+WHERE "id" = NEW."user_id";
+RETURN NEW;
+END;
+$$;
+-- Create trigger "update_latest_post_trigger"
+CREATE TRIGGER "update_latest_post_trigger" AFTER INSERT ON "posts" FOR EACH ROW EXECUTE FUNCTION "update_latest_post"();
+-- Modify "users" table
+ALTER TABLE "users" ADD COLUMN "latest_post_ts" timestamp NULL;
+```
+
+## Back-filling Data
+
+The trigger we created will automatically update the `latest_post_ts` column in the `users` table
+whenever a new post is added. However, we need to back-fill the existing data in the `posts` table
+to ensure that the `latest_post_ts` column is accurate.
+
+To do this, let's edit the `20240730073842.sql` migration file to include a query that will update
+the `latest_post_ts` column for each user. Add the following SQL statement to the migration file:
+
+```sql title="20240730073842.sql"
+-- .. Redacted for brevity
+
+-- Back-fill the latest post timestamp for each user
+UPDATE "users"
+SET "latest_post_ts" = (
+  SELECT MAX("created_at")
+  FROM "posts"
+  WHERE "user_id" = "users"."id"
+);
+```
+
+After changing the contents of a migration file we need to recalculate the `atlas.sum` file to ensure
+directory integrity. Run the following command:
+
+```bash
+atlas migrate hash --env dev
+````
+
+## Testing Migrations
+
+Rewriting data on a production database can be risky (and scary) if not done correctly. Let's now write
+a test to ensure that our migration will run smoothly and leave our application in a consistent state.
+
+To do so, our test will have the following logic:
+1. Migrate to the first version, before creating the trigger, modifying the `users` table and back-filling the data.
+2. Seed the `users` and `posts` tables with data.
+3. Run our new migration file, which will create the trigger and back-fill the `latest_post_ts` column.
+4. Verify that the `latest_post_ts` column is correctly populated.
+
+```hcl title="migrate.test.hcl"
+test "migrate" "check_latest_post" {
+  migrate {
+    to = "20240807192632"
+  }
+  exec {
+    sql = <<-SQL
+      INSERT INTO users (id, email) VALUES (1, 'user1@example.com'), (2, 'user2@example.com');
+      INSERT INTO posts (id, title, created_at, user_id) VALUES (1, 'My First Post', '2024-01-23 00:51:54', 1), (2, 'Another Interesting Post', '2024-02-24 02:14:09', 2);
+    SQL
+  }
+  migrate {
+    to = "20240807192934"
+  }
+  exec {
+    sql = "select * from users"
+    format = table
+    output = <<TAB
+ id |       email       |   latest_post_ts
+----+-------------------+---------------------
+1  | user1@example.com | 2024-01-23 00:51:54
+2  | user2@example.com | 2024-02-24 02:14:09
+TAB
+  }
+  log {
+    message = "Data migrated successfully"
+  }
+}
+```
+
+Let's break down the test:
+1. We define a migration test named `check_latest_post`.
+2. We migrate to the first version of the schema, which does not include the trigger and the `latest_post_ts` column.
+3. We insert data into the `users` and `posts` tables. At this point, the `latest_post_ts` column does not exist.
+4. We migrate to the second version of the schema, which includes the trigger and the `latest_post_ts` column as well
+   as our back-fill.
+5. We assert that the `latest_post_ts` column is correctly populated with the latest post timestamp for each user.
+
+To run this test, we will run the [`migrate test`](/testing/migrate#migrate-command) command:
+
+```bash
+atlas migrate test --env dev
+```
+
+The output should be similar to:
+
+```testoutput title="Test Output"
+-- PASS: check_latest_post (50ms)
+    migrate.test.hcl:24: Data migrated successfully
+PASS
+```
+
+Great! Our test passed, and we can now confidently deploy our migration to production.
+
+## Integrating with CI
+
+Writing and running tests locally is a great start, but it's equally important to run these tests in a CI/CD pipeline.
+Atlas provides out-of-the-box integrations for running `migrate test` in popular CI/CD platforms such as
+[GitHub Actions](/integrations/github-actions#arigaatlas-actionmigratetest) and [CircleCI](/integrations/circleci-orbs#atlas-orbmigrate_test).
+However, you can easily integrate migration testing into any CI/CD platform by running the `atlas migrate test` command.
+
+## Wrapping Up
+
+In this guide we learned how to use the [`atlas migrate test`](/testing/migrate#migrate-command) command
+to test our migration files before deployment.
diff --git a/internal/integration/testdata/postgres/index-nulls-distinct.txt b/internal/integration/testdata/postgres/index-nulls-distinct.txt
index cc50801b..e0436fb7 100644
--- a/internal/integration/testdata/postgres/index-nulls-distinct.txt
+++ b/internal/integration/testdata/postgres/index-nulls-distinct.txt
@@ -22,6 +22,10 @@ table "users" {
     columns        = [column.c]
     nulls_distinct = false
   }
+  unique "nulls_not_distinct2" {
+    columns        = [column.c]
+    nulls_distinct = false
+  }
 }
 
 -- 1.inspect.hcl --
@@ -36,6 +40,10 @@ table "users" {
     columns        = [column.c]
     nulls_distinct = false
   }
+  unique "nulls_not_distinct2" {
+    columns        = [column.c]
+    nulls_distinct = false
+  }
 }
 schema "script_index_nulls_distinct" {
 }
@@ -46,6 +54,7 @@ schema "script_index_nulls_distinct" {
  c      | integer |           | not null |
 Indexes:
     "nulls_not_distinct" UNIQUE, btree (c) NULLS NOT DISTINCT
+    "nulls_not_distinct2" UNIQUE CONSTRAINT, btree (c) NULLS NOT DISTINCT
 
 -- 2.hcl --
 schema "script_index_nulls_distinct" {}
diff --git a/sql/internal/specutil/convert_test.go b/sql/internal/specutil/convert_test.go
index dabf339c..70dee7b3 100644
--- a/sql/internal/specutil/convert_test.go
+++ b/sql/internal/specutil/convert_test.go
@@ -180,49 +180,3 @@ func TestFromView(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(t, "<<-SQL\n  SELECT * FROM users\n   WHERE c NOT LIKE \"\\r\\n\"\n  SQL", s)
 }
-
-func TestMightHeredoc(t *testing.T) {
-	for _, tt := range []struct {
-		input    string
-		expected string
-	}{
-		{
-			input:    "SELECT * FROM users",
-			expected: "SELECT * FROM users",
-		},
-		{
-			input: `
-SELECT
-  *
-  FROM users
-  WHERE active`,
-			expected: `<<-SQL
-  SELECT
-    *
-    FROM users
-    WHERE active
-  SQL`,
-		},
-		{
-			input: `
--- The line below includes spaces.
-  
-	
-SELECT
-  *
-  FROM users
-  WHERE active`,
-			expected: `<<-SQL
-  -- The line below includes spaces.
-
-
-  SELECT
-    *
-    FROM users
-    WHERE active
-  SQL`,
-		},
-	} {
-		require.Equal(t, tt.expected, MightHeredoc(tt.input))
-	}
-}
diff --git a/sql/migrate/dir_test.go b/sql/migrate/dir_test.go
index f2d06cfe..3170fc14 100644
--- a/sql/migrate/dir_test.go
+++ b/sql/migrate/dir_test.go
@@ -13,6 +13,7 @@ import (
 	"os"
 	"path/filepath"
 	"testing"
+	"text/template"
 	"time"
 
 	"ariga.io/atlas/sql/migrate"
@@ -546,6 +547,34 @@ create table t2(c int);
 `, b.String())
 }
 
+func TestDefaultFormatter_FormatFile(t *testing.T) {
+	f, err := migrate.DefaultFormatter.FormatFile(&migrate.Plan{
+		Changes: []*migrate.Change{
+			{Cmd: "create table t1(c int)"},
+			{Cmd: "create table t2(c int)", Comment: "create table"},
+		},
+	})
+	require.NoError(t, err)
+	require.Equal(t, `create table t1(c int);
+-- Create table
+create table t2(c int);
+`, string(f.Bytes()))
+
+	ft := migrate.TemplateFormatter{
+		{
+			N: template.Must(template.New("").Parse("name-1")),
+			C: template.Must(template.New("").Parse("cmd-1")),
+		},
+		{
+			N: template.Must(template.New("").Parse("name-2")),
+			C: template.Must(template.New("").Parse("cmd-2")),
+		},
+	}
+	f, err = ft.FormatFile(&migrate.Plan{})
+	require.Error(t, err, "expect only one file")
+	require.Nil(t, f)
+}
+
 func fileNames(r io.Reader) ([]string, error) {
 	var out []string
 	tr := tar.NewReader(r)
diff --git a/sql/postgres/migrate_test.go b/sql/postgres/migrate_test.go
index 1f5d9df9..a74635fb 100644
--- a/sql/postgres/migrate_test.go
+++ b/sql/postgres/migrate_test.go
@@ -811,6 +811,19 @@ func TestPlanChanges(t *testing.T) {
 									},
 								},
 							},
+							&schema.AddIndex{
+								I: &schema.Index{
+									Unique: true,
+									Name:   "unique_const_nulls_not_distinct",
+									Parts: []*schema.IndexPart{
+										{C: users.Columns[0]},
+									},
+									Attrs: []schema.Attr{
+										&IndexNullsDistinct{V: false},
+										&Constraint{N: "unique_const", T: "u"},
+									},
+								},
+							},
 						},
 					}
 				}(),
@@ -824,8 +837,8 @@ func TestPlanChanges(t *testing.T) {
 						Reverse: `CREATE INDEX CONCURRENTLY "drop_con" ON "users" ("id")`,
 					},
 					{
-						Cmd:     `ALTER TABLE "users" DROP CONSTRAINT "id_nonzero", ADD COLUMN "name" character varying(255) NOT NULL DEFAULT 'logged_in', ADD COLUMN "last" character varying(255) NOT NULL DEFAULT 'logged_in', ADD CONSTRAINT "name_not_empty" CHECK ("name" <> ''), ADD CONSTRAINT "positive_id" CHECK ("id" > 0) NOT VALID, DROP CONSTRAINT "id_iseven", ADD CONSTRAINT "id_iseven" CHECK (("id") % 2 = 0)`,
-						Reverse: `ALTER TABLE "users" DROP CONSTRAINT "id_iseven", ADD CONSTRAINT "id_iseven" CHECK ("id" % 2 = 0), DROP CONSTRAINT "positive_id", DROP CONSTRAINT "name_not_empty", DROP COLUMN "last", DROP COLUMN "name", ADD CONSTRAINT "id_nonzero" CHECK ("id" <> 0)`,
+						Cmd:     `ALTER TABLE "users" DROP CONSTRAINT "id_nonzero", ADD COLUMN "name" character varying(255) NOT NULL DEFAULT 'logged_in', ADD COLUMN "last" character varying(255) NOT NULL DEFAULT 'logged_in', ADD CONSTRAINT "name_not_empty" CHECK ("name" <> ''), ADD CONSTRAINT "positive_id" CHECK ("id" > 0) NOT VALID, DROP CONSTRAINT "id_iseven", ADD CONSTRAINT "id_iseven" CHECK (("id") % 2 = 0), ADD CONSTRAINT "unique_const" UNIQUE NULLS NOT DISTINCT ("id")`,
+						Reverse: `ALTER TABLE "users" DROP CONSTRAINT "unique_const_nulls_not_distinct", DROP CONSTRAINT "id_iseven", ADD CONSTRAINT "id_iseven" CHECK ("id" % 2 = 0), DROP CONSTRAINT "positive_id", DROP CONSTRAINT "name_not_empty", DROP COLUMN "last", DROP COLUMN "name", ADD CONSTRAINT "id_nonzero" CHECK ("id" <> 0)`,
 					},
 					{
 						Cmd:     `CREATE INDEX "id_key" ON "users" ("id" DESC) WHERE success`,
@@ -1489,6 +1502,9 @@ func TestPlanChanges(t *testing.T) {
 					},
 				},
 			},
+			options: []migrate.PlanOption{
+				func(o *migrate.PlanOptions) { o.SchemaQualifier = new(string) },
+			},
 			wantPlan: &migrate.Plan{
 				Reversible:    true,
 				Transactional: true,
@@ -1500,6 +1516,29 @@ func TestPlanChanges(t *testing.T) {
 				},
 			},
 		},
+		{
+			changes: []schema.Change{
+				&schema.ModifyTable{
+					T: schema.NewTable("t1").SetSchema(schema.New("s1")),
+					Changes: []schema.Change{
+						&schema.RenameIndex{
+							From: schema.NewIndex("a"),
+							To:   schema.NewIndex("b"),
+						},
+					},
+				},
+			},
+			wantPlan: &migrate.Plan{
+				Reversible:    true,
+				Transactional: true,
+				Changes: []*migrate.Change{
+					{
+						Cmd:     `ALTER INDEX "s1"."a" RENAME TO "b"`,
+						Reverse: `ALTER INDEX "s1"."b" RENAME TO "a"`,
+					},
+				},
+			},
+		},
 		// Invalid serial type.
 		{
 			changes: []schema.Change{
diff --git a/sql/postgres/sqlspec_test.go b/sql/postgres/sqlspec_test.go
index f4df7db6..cb1923d5 100644
--- a/sql/postgres/sqlspec_test.go
+++ b/sql/postgres/sqlspec_test.go
@@ -1986,6 +1986,14 @@ func TestTypes(t *testing.T) {
 			typeExpr: "name",
 			expected: &schema.StringType{T: typeName},
 		},
+		{
+			typeExpr: "xid",
+			expected: &schema.IntegerType{T: TypeXID},
+		},
+		{
+			typeExpr: "xid8",
+			expected: &schema.IntegerType{T: TypeXID8},
+		},
 	} {
 		t.Run(tt.typeExpr, func(t *testing.T) {
 			var test schema.Schema
diff --git a/sql/sqlspec/sqlspec_test.go b/sql/sqlspec/sqlspec_test.go
new file mode 100644
index 00000000..a8425236
--- /dev/null
+++ b/sql/sqlspec/sqlspec_test.go
@@ -0,0 +1,57 @@
+// 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 sqlspec
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestMightHeredoc(t *testing.T) {
+	for _, tt := range []struct {
+		input    string
+		expected string
+	}{
+		{
+			input:    "SELECT * FROM users",
+			expected: "SELECT * FROM users",
+		},
+		{
+			input: `
+SELECT
+  *
+  FROM users
+  WHERE active`,
+			expected: `<<-SQL
+  SELECT
+    *
+    FROM users
+    WHERE active
+  SQL`,
+		},
+		{
+			input: `
+-- The line below includes spaces.
+  
+	
+SELECT
+  *
+  FROM users
+  WHERE active`,
+			expected: `<<-SQL
+  -- The line below includes spaces.
+
+
+  SELECT
+    *
+    FROM users
+    WHERE active
+  SQL`,
+		},
+	} {
+		require.Equal(t, tt.expected, MightHeredoc(tt.input))
+	}
+}