Repository: ariga/atlas
Pull request #1280 (2022-11-10 13:06:48)

doc/md: add ariga cloud analyzer to doc


Patch:
diff --git a/cmd/atlas/go.mod b/cmd/atlas/go.mod
index d8c926a7..c546b591 100644
--- a/cmd/atlas/go.mod
+++ b/cmd/atlas/go.mod
@@ -3,7 +3,7 @@ module ariga.io/atlas/cmd/atlas
 go 1.18
 
 require (
-	ariga.io/atlas v0.8.1
+	ariga.io/atlas v0.8.2
 	entgo.io/ent v0.11.1
 	github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220816024939-bc8df83d7b9d
 	github.com/auxten/postgresql-parser v1.0.1
diff --git a/cmd/atlas/go.sum b/cmd/atlas/go.sum
index 00493309..f909bb2e 100644
--- a/cmd/atlas/go.sum
+++ b/cmd/atlas/go.sum
@@ -2,6 +2,8 @@ ariga.io/atlas v0.7.3-0.20221010133632-5e5aca152e8a h1:/5g/0+IO3umGAxt/ROhJ0/BaT
 ariga.io/atlas v0.7.3-0.20221010133632-5e5aca152e8a/go.mod h1:ft47uSh5hWGDCmQC9DsztZg6Xk+KagM5Ts/mZYKb9JE=
 ariga.io/atlas v0.8.1 h1:eTZS+qyBpqMTkCbatFquYXIqGPAIEzjQzkVXlOwkcEY=
 ariga.io/atlas v0.8.1/go.mod h1:ft47uSh5hWGDCmQC9DsztZg6Xk+KagM5Ts/mZYKb9JE=
+ariga.io/atlas v0.8.2 h1:uXRegk0Zd7nlCYC60tdx12xUvN2NmeGTc2MB5HnnbkA=
+ariga.io/atlas v0.8.2/go.mod h1:ft47uSh5hWGDCmQC9DsztZg6Xk+KagM5Ts/mZYKb9JE=
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 entgo.io/ent v0.11.1 h1:im67R+2W3Nee2bNS2YnoYz8oAF0Qz4AOlIvKRIAEISY=
diff --git a/cmd/atlas/internal/cmdapi/cmdapi.go b/cmd/atlas/internal/cmdapi/cmdapi.go
index df4a257a..59f97992 100644
--- a/cmd/atlas/internal/cmdapi/cmdapi.go
+++ b/cmd/atlas/internal/cmdapi/cmdapi.go
@@ -15,6 +15,7 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"time"
 
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
@@ -177,6 +178,7 @@ func (v *Vars) Type() string {
 
 const (
 	flagAllowDirty     = "allow-dirty"
+	flagEdit           = "edit"
 	flagAutoApprove    = "auto-approve"
 	flagBaseline       = "baseline"
 	flagConfig         = "config"
@@ -193,6 +195,7 @@ const (
 	flagGitBase        = "git-base"
 	flagGitDir         = "git-dir"
 	flagLatest         = "latest"
+	flagLockTimeout    = "lock-timeout"
 	flagLog            = "log"
 	flagRevisionSchema = "revisions-schema"
 	flagSchema         = "schema"
@@ -219,6 +222,10 @@ func addFlagDirFormat(set *pflag.FlagSet, target *string) {
 	set.StringVar(target, flagDirFormat, "atlas", "select migration file format")
 }
 
+func addFlagLockTimeout(set *pflag.FlagSet, target *time.Duration) {
+	set.DurationVar(target, flagLockTimeout, 10*time.Second, "set how long to wait for the database lock")
+}
+
 // addFlagURL adds a URL flag. If given, args[0] override the name, args[1] the shorthand, args[2] the default value.
 func addFlagDirURL(set *pflag.FlagSet, target *string, args ...string) {
 	name, short, val := flagDirURL, "", "file://migrations"
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index ed45bba7..dbaf37ba 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -14,6 +14,7 @@ import (
 	"io/fs"
 	"net/url"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"strconv"
 	"strings"
@@ -68,6 +69,7 @@ type migrateApplyFlags struct {
 	revisionSchema  string
 	dryRun          bool
 	logFormat       string
+	lockTimeout     time.Duration
 	allowDirty      bool   // allow working on a database that already has resources
 	fromVersion     string // compute pending files based on this version
 	baselineVersion string // apply with this version as baseline
@@ -107,6 +109,7 @@ If run with the "--dry-run" flag, atlas will not execute any SQL.`,
 	addFlagLog(cmd.Flags(), &flags.logFormat)
 	addFlagRevisionSchema(cmd.Flags(), &flags.revisionSchema)
 	addFlagDryRun(cmd.Flags(), &flags.dryRun)
+	addFlagLockTimeout(cmd.Flags(), &flags.lockTimeout)
 	cmd.Flags().StringVarP(&flags.fromVersion, flagFrom, "", "", "calculate pending files from the given version (including it)")
 	cmd.Flags().StringVarP(&flags.baselineVersion, flagBaseline, "", "", "start the first migration after the given baseline version")
 	cmd.Flags().StringVarP(&flags.txMode, flagTxMode, "", txModeFile, "set transaction mode [none, file, all]")
@@ -150,12 +153,12 @@ func migrateApplyRun(cmd *cobra.Command, args []string, flags migrateApplyFlags)
 	defer client.Close()
 	// Acquire a lock.
 	if l, ok := client.Driver.(schema.Locker); ok {
-		unlock, err := l.Lock(cmd.Context(), applyLockValue, 0)
+		unlock, err := l.Lock(cmd.Context(), applyLockValue, flags.lockTimeout)
 		if err != nil {
 			return fmt.Errorf("acquiring database lock: %w", err)
 		}
 		// If unlocking fails notify the user about it.
-		defer cobra.CheckErr(unlock())
+		defer func() { cobra.CheckErr(unlock()) }()
 	}
 	if err := checkRevisionSchemaClarity(cmd, client, flags.revisionSchema); err != nil {
 		return err
@@ -265,10 +268,12 @@ func reportApply(cmd *cobra.Command, flags migrateApplyFlags, r *cmdmigrate.Appl
 }
 
 type migrateDiffFlags struct {
+	edit              bool
 	desiredURLs       []string
 	dirURL, dirFormat string
 	devURL            string
 	schemas           []string
+	lockTimeout       time.Duration
 	revisionSchema    string // revision schema name
 	qualifier         string // optional table qualifier
 }
@@ -309,7 +314,9 @@ directory state to the desired schema. The desired state can be another connecte
 	addFlagDirFormat(cmd.Flags(), &flags.dirFormat)
 	addFlagRevisionSchema(cmd.Flags(), &flags.revisionSchema)
 	addFlagSchemas(cmd.Flags(), &flags.schemas)
+	addFlagLockTimeout(cmd.Flags(), &flags.lockTimeout)
 	cmd.Flags().StringVar(&flags.qualifier, flagQualifier, "", "qualify tables with custom qualifier when working on a single schema")
+	cmd.Flags().BoolVarP(&flags.edit, flagEdit, "", false, "edit the generated migration file(s)")
 	cobra.CheckErr(cmd.MarkFlagRequired(flagTo))
 	cobra.CheckErr(cmd.MarkFlagRequired(flagDevURL))
 	return cmd
@@ -324,12 +331,12 @@ func migrateDiffRun(cmd *cobra.Command, args []string, flags migrateDiffFlags) e
 	defer dev.Close()
 	// Acquire a lock.
 	if l, ok := dev.Driver.(schema.Locker); ok {
-		unlock, err := l.Lock(cmd.Context(), "atlas_migrate_diff", 0)
+		unlock, err := l.Lock(cmd.Context(), "atlas_migrate_diff", flags.lockTimeout)
 		if err != nil {
 			return fmt.Errorf("acquiring database lock: %w", err)
 		}
 		// If unlocking fails notify the user about it.
-		defer cobra.CheckErr(unlock())
+		defer func() { cobra.CheckErr(unlock()) }()
 	}
 	// Open the migration directory.
 	u, err := url.Parse(flags.dirURL)
@@ -340,6 +347,9 @@ func migrateDiffRun(cmd *cobra.Command, args []string, flags migrateDiffFlags) e
 	if err != nil {
 		return err
 	}
+	if flags.edit {
+		dir = &editDir{dir}
+	}
 	f, err := formatter(u)
 	if err != nil {
 		return err
@@ -642,7 +652,11 @@ func migrateLintRun(cmd *cobra.Command, _ []string, flags migrateLintFlags) erro
 	return err
 }
 
-type migrateNewFlags struct{ dirURL, dirFormat string }
+type migrateNewFlags struct {
+	edit      bool
+	dirURL    string
+	dirFormat string
+}
 
 // migrateNewCmd represents the 'atlas migrate new' subcommand.
 func migrateNewCmd() *cobra.Command {
@@ -671,6 +685,7 @@ func migrateNewCmd() *cobra.Command {
 	cmd.Flags().SortFlags = false
 	addFlagDirURL(cmd.Flags(), &flags.dirURL)
 	addFlagDirFormat(cmd.Flags(), &flags.dirFormat)
+	cmd.Flags().BoolVarP(&flags.edit, flagEdit, "", false, "edit the created migration file(s)")
 	return cmd
 }
 
@@ -683,6 +698,9 @@ func migrateNewRun(_ *cobra.Command, args []string, flags migrateNewFlags) error
 	if err != nil {
 		return err
 	}
+	if flags.edit {
+		dir = &editDir{dir}
+	}
 	f, err := formatter(u)
 	if err != nil {
 		return err
@@ -762,7 +780,7 @@ func migrateSetRun(cmd *cobra.Command, args []string, flags migrateSetFlags) err
 			return fmt.Errorf("acquiring database lock: %w", err)
 		}
 		// If unlocking fails notify the user about it.
-		defer cobra.CheckErr(unlock())
+		defer func() { cobra.CheckErr(unlock()) }()
 	}
 	if err := checkRevisionSchemaClarity(cmd, client, flags.revisionSchema); err != nil {
 		return err
@@ -1491,6 +1509,13 @@ func setFlagsFromEnv(cmd *cobra.Command, env *Env) error {
 		if err := maySetFlag(cmd, flagLog, env.Log.Migrate.Apply); err != nil {
 			return err
 		}
+		if err := maySetFlag(cmd, flagLockTimeout, env.Migration.LockTimeout); err != nil {
+			return err
+		}
+	case "diff":
+		if err := maySetFlag(cmd, flagLockTimeout, env.Migration.LockTimeout); err != nil {
+			return err
+		}
 	case "lint":
 		if err := maySetFlag(cmd, flagLog, env.Log.Migrate.Lint); err != nil {
 			return err
@@ -1566,6 +1591,43 @@ func migrateEnvsRun[F any](run func(*cobra.Command, []string, F) error, cmd *cob
 	return nil
 }
 
+type editDir struct{ migrate.Dir }
+
+// WriteFile implements the migrate.Dir.WriteFile method.
+func (d *editDir) WriteFile(name string, b []byte) (err error) {
+	if name != migrate.HashFileName {
+		if b, err = edit(name, b); err != nil {
+			return err
+		}
+	}
+	return d.Dir.WriteFile(name, b)
+}
+
+// edit allows editing the file content using editor.
+func edit(name string, src []byte) ([]byte, error) {
+	path := filepath.Join(os.TempDir(), name)
+	if err := os.WriteFile(path, src, 0644); err != nil {
+		return nil, fmt.Errorf("write source content to temp file: %w", err)
+	}
+	defer os.Remove(path)
+	editor := "vi"
+	if e := os.Getenv("EDITOR"); e != "" {
+		editor = e
+	}
+	cmd := exec.Command("sh", "-c", editor+" "+path)
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err != nil {
+		return nil, fmt.Errorf("exec edit: %w", err)
+	}
+	b, err := os.ReadFile(path)
+	if err != nil {
+		return nil, fmt.Errorf("read edited temp file: %w", err)
+	}
+	return b, nil
+}
+
 type (
 	// dryRunDriver wraps a migrate.Driver without executing any SQL statements.
 	dryRunDriver struct{ migrate.Driver }
diff --git a/cmd/atlas/internal/cmdapi/project.go b/cmd/atlas/internal/cmdapi/project.go
index 52d0b9fc..20592fd2 100644
--- a/cmd/atlas/internal/cmdapi/project.go
+++ b/cmd/atlas/internal/cmdapi/project.go
@@ -78,6 +78,7 @@ type (
 		Dir             string `spec:"dir"`
 		Format          string `spec:"format"`
 		Baseline        string `spec:"baseline"`
+		LockTimeout     string `spec:"lock_timeout"`
 		RevisionsSchema string `spec:"revisions_schema"`
 	}
 
diff --git a/doc/md/atlas-schema/projects.md b/doc/md/atlas-schema/projects.md
index 0a09adf1..275c0f07 100644
--- a/doc/md/atlas-schema/projects.md
+++ b/doc/md/atlas-schema/projects.md
@@ -226,6 +226,7 @@ set or map item. See the example [below](#multi-environment-example).
 - `migration` - A block defines the migration configuration of the env.
   - `dir` - The [URL](../concepts/url.mdx) to the migration directory.
   - `baseline` - An optional version to start the migration history from. Read more [here](../versioned/apply.mdx#existing-databases).
+  - `lock_timeout` - An optional timeout to wait for a database lock to be released. Defaults to `10s`. 
   - `revisions_schema` - An optional name to control the schema that the revisions table resides in.
 
 - `log` - A block defines the logging configuration of the env per command.
diff --git a/doc/md/atlas-schema/sql.mdx b/doc/md/atlas-schema/sql.mdx
index 28946d7e..f71aeca9 100644
--- a/doc/md/atlas-schema/sql.mdx
+++ b/doc/md/atlas-schema/sql.mdx
@@ -337,12 +337,29 @@ as references to columns in other tables.
 
 #### Example
 
-```hcl
-foreign_key "manager_fk" {
-  columns = [column.manager_id]
-  ref_columns = [column.id]
-  on_delete = CASCADE
-  on_update = NO_ACTION
+```hcl title="schema.hcl" {18-19}
+table "users" {
+  schema = schema.public
+  column "id" {
+    type = integer
+  }
+  primary_key {
+    columns = [column.id]
+  }
+}
+
+table "orders" {
+  schema = schema.market
+  // ...
+  column "owner_id" {
+    type = integer
+  }
+  foreign_key "owner_id" {
+    columns     = [column.owner_id]
+    ref_columns = [table.users.column.id]
+    on_update   = NO_ACTION
+    on_delete   = NO_ACTION
+  }
 }
 ```
 
@@ -351,12 +368,29 @@ foreign_key "manager_fk" {
 If a foreign key references a column in a [qualified](#table-qualification) table, it is referenced
 using `table.<qualifier>.<table_name>.column.<column_name>`:
 
-```hcl {3}
-foreign_key "manager_fk" {
-  columns = [column.manager_id]
-  ref_columns = [table.other_schema.users.column.id]
-  on_delete = CASCADE
-  on_update = NO_ACTION
+```hcl title="schema.hcl" {18-19}
+table "public" "users" {
+  schema = schema.public
+  column "id" {
+    type = integer
+  }
+  primary_key {
+    columns = [column.id]
+  }
+}
+
+table "admin" "users" {
+  schema = schema.admin
+  // ...
+  column "external_id" {
+    type = integer
+  }
+  foreign_key "external_id" {
+    columns     = [column.external_id]
+    ref_columns = [table.admin.users.column.id]
+    on_update   = NO_ACTION
+    on_delete   = NO_ACTION
+  }
 }
 ```
 
diff --git a/doc/md/integrations/terraform.md b/doc/md/integrations/terraform.md
index 79b6a7d3..47d6dc6d 100644
--- a/doc/md/integrations/terraform.md
+++ b/doc/md/integrations/terraform.md
@@ -3,6 +3,10 @@ title: Terraform Provider
 id: terraform-provider
 slug: /integrations/terraform-provider
 ---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
 ## Introduction
 
 The official [Atlas Terraform provider](https://registry.terraform.io/providers/ariga/atlas/latest)
@@ -24,9 +28,9 @@ terraform {
 }
 ```
 
-## Basic example
+## Declarative Migrations
 
-Currently, the Atlas Terraform provider uses an [HCL file](/atlas-schema/sql.mdx) to describe the
+In the declarative workflow, the Atlas Terraform provider uses an [HCL file](/atlas-schema/sql.mdx) to describe the
 desired state of the database, and performs migrations according to the state difference
 between the HCL file and the target database.
 
@@ -68,7 +72,7 @@ resource "atlas_schema" "market" {
 
 For the full documentation and examples of the provider visit the [registry page](https://registry.terraform.io/providers/ariga/atlas/latest/docs).
 
-## Working with an existing database
+#### Working with an existing database
 
 When you first run the Atlas Terraform Provider on a database, the database's state isn't yet present
 in Terraform's representation of the world (described in the [Terraform State](https://www.terraform.io/language/state)).
@@ -86,3 +90,76 @@ with the name of the file you want to write the output to. For example:
 ```
 atlas schema inspect -u mysql://user:pass@localhost:3306 > schema.hcl
 ```
+
+## Versioned Migrations
+
+In the versioned workflow, the Atlas Terraform provider uses the migrations directory to manage changes to the
+database across versions. To use this workflow, we first need to [create a migrations directory](/versioned/new.mdx) using the Atlas CLI.
+
+Here is the example of a migration directory that uses the versioned workflow:
+
+<Tabs
+defaultValue="migration_file2"
+values={[
+{label: '20220811074144_create_users.sql', value: 'migration_file1'},
+{label: '20220811074314_add_users_name.sql', value: 'migration_file2'},
+{label: 'atlas.sum', value: 'sum_file'},
+]}>
+<TabItem value="migration_file1">
+
+```sql
+-- create "users" table
+CREATE TABLE `users` (`id` int NOT NULL) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+```
+
+</TabItem>
+<TabItem value="migration_file2">
+
+```sql
+-- modify "users" table
+ALTER TABLE `users` ADD COLUMN `name` varchar(255) NOT NULL;
+```
+
+</TabItem>
+<TabItem value="sum_file">
+
+```text
+h1:w2ODzVxhTKdBVBdzqntHw7rHV8lKQF98TmNevOEZfIo=
+20220811074144_create_users_table.sql h1:KnMSZM/E4TBGidYCZ+UHxkHEWaRWeyuPIUjSHRybQqA=
+20220811074314_add_users_name.sql h1:jUpaANgD0SjI5DjaHuJxtHZ6Wq98act0MmE5oZ+NRU0=
+```
+
+</TabItem>
+</Tabs>
+
+### Configure Terraform
+
+Use the following configuration to apply the migration directory onto a target MySQL
+database (but you can specify any of the [supported databases](https://github.com/ariga/atlas#supported-databases)):
+
+```hcl title="main.tf"
+provider "atlas" {}
+
+// Inspect the target database and load its state.
+// This is used to determine which migrations to run.
+data "atlas_migration" "shop" {
+  dir = "migrations?format=atlas"
+  url = "mysql://root:pass@localhost:3306/shop"
+}
+
+// Sync the state of the target database with the migrations directory.
+resource "atlas_migration" "shop" {
+  dir     = "migrations?format=atlas"
+  version = data.atlas_migration.shop.latest # Use latest to run all migrations
+  url     = data.atlas_migration.shop.url
+  dev_url = "mysql://root:pass@localhost:3307/shop"
+}
+```
+
+For the full documentation and examples for using the provider, visit the [registry page](https://registry.terraform.io/providers/ariga/atlas/latest/docs).
+
+:::info
+
+Note, the dev_url is used to run the Lint on the migrations directory. See [Migration Lint](/versioned/lint.mdx) for more information.
+
+:::
\ No newline at end of file
diff --git a/doc/md/lint/analyzers.md b/doc/md/lint/analyzers.md
index c68a4a3c..a0b94204 100644
--- a/doc/md/lint/analyzers.md
+++ b/doc/md/lint/analyzers.md
@@ -109,7 +109,9 @@ The following schema change checks are provided by Atlas:
 | [MY101](#MY101)                    | Adding a non-nullable column without a `DEFAULT` value to an existing table |
 | [MY102](#MY102)                    | Adding a column with an inline `REFERENCES` clause has no actual effect     |
 | **LT**                             | SQLite specific checks                                                      |
-| [LT101](#LT101)                    | Modifying a nullable column to non-nullable without a `DEFAULT` value       |
+| [LT101](#LT101)                    | Modifying a nullable column to non-nullable without a `DEFAULT` value       |  
+| **AR**                             | Ariga cloud checks                                                          |
+| [AR101](#AR101)                    | Creating table with non-optimal data alignment                              |
 
 #### DS101 {#DS101}
 
@@ -239,4 +241,33 @@ DROP TABLE `users`;
 ALTER TABLE `new_users` RENAME TO `users`;
 -- enable back the enforcement of foreign-keys constraints
 PRAGMA foreign_keys = on;
+```
+
+#### AR101 {#AR101}
+
+Creating a table with optimal data alignment may help minimize the amount of required disk space. 
+For example consider the next Postgres table on a 64-bit system:
+
+```postgresql
+CREATE TABLE accounts (
+    id bigint PRIMARY KEY,
+    premium boolean,
+    balance integer,
+    age     smallint                  
+);
+```
+Each tuple in the table takes 24 bytes of successive memory without the header.
+the `id` attribute takes 8 bytes, the `premium` takes 1 byte and 3 bytes of padding, the `balance` takes 4 bytes and the `age` takes 2 bytes,
+and lastly 6 bytes of padding allocated for the end of the row.
+In total 9 bytes of padding are allocated for each row.
+
+Compared to same table with different ordering which only takes 16 bytes in memory with 1 byte of padding:
+
+```postgresql
+CREATE TABLE accounts (
+    id bigint PRIMARY KEY,
+    balance integer,
+    age smallint,
+    premium boolean             
+);
 ```
\ No newline at end of file
diff --git a/doc/md/reference.md b/doc/md/reference.md
index 4516f4c6..47133283 100644
--- a/doc/md/reference.md
+++ b/doc/md/reference.md
@@ -89,6 +89,7 @@ If run with the "--dry-run" flag, atlas will not execute any SQL.
       --log string                go template to use to format logs
       --revisions-schema string   name of the schema the revisions table resides in
       --dry-run                   print SQL without executing it
+      --lock-timeout duration     set how long to wait for the database lock (default 10s)
       --from string               calculate pending files from the given version (including it)
       --baseline string           start the first migration after the given baseline version
       --tx-mode string            set transaction mode [none, file, all] (default "file")
@@ -127,7 +128,9 @@ directory state to the desired schema. The desired state can be another connecte
       --dir-format string         select migration file format (default "atlas")
       --revisions-schema string   name of the schema the revisions table resides in
   -s, --schema strings            set schema names
+      --lock-timeout duration     set how long to wait for the database lock (default 10s)
       --qualifier string          qualify tables with custom qualifier when working on a single schema
+      --edit                      edit the generated migration file(s)
 
 ```
 
@@ -232,6 +235,7 @@ atlas migrate new [flags] [name]
 ```
       --dir string          select migration directory using URL format (default "file://migrations")
       --dir-format string   select migration file format (default "atlas")
+      --edit                edit the created migration file(s)
 
 ```
 
diff --git a/doc/website/blog/2022-11-17-terraform-v040.md b/doc/website/blog/2022-11-17-terraform-v040.md
new file mode 100644
index 00000000..ae9d51d2
--- /dev/null
+++ b/doc/website/blog/2022-11-17-terraform-v040.md
@@ -0,0 +1,238 @@
+---
+title: "Atlas Terraform Provider v0.4.0: HashiCorp partnerships, versioned migrations and more"
+authors: giautm
+tags: [terraform, versioned-migrations, release]
+---
+## Introduction
+
+Today we are very excited to announce the release of Atlas Terraform Provider v0.4.0.
+This release brings some exciting new features and improvements to the provider which
+we will describe in this post.
+
+In addition, this release is the first to be published under our new partnership with
+[HashiCorp](https://hashicorp.com) as a [Technology Partner](https://www.hashicorp.com/partners/become-a-partner).
+Atlas is sometimes described as a "Terraform for Databases", so we have high hopes that
+this partnership will help us to bring many opportunities to create
+better ways for integrating database schema management into IaC workflows.
+
+![](https://atlasgo.io/uploads/images/hashicorp-partners.png)
+
+## What's new
+
+When people first hear about integrating schema management into declarative workflows,
+many raise the concern that because making changes to the database is a high-risk operation,
+they would not trust a tool to do it automatically.
+
+This is a valid concern, and this release contains three new features that we believe will
+help to address it:
+* SQL plan printing
+* Versioned migrations support
+* Migration safety verification
+
+## SQL Plan Printing
+
+In previous versions of the provider, we displayed the plan
+as a textual diff showing which resources are added, removed or modified. With this version,
+the provider will also print the SQL statements that will be executed as part of the plan.
+
+For example, suppose we have the following schema:
+
+```hcl
+schema "market" {
+ charset = "utf8mb4"
+ collate = "utf8mb4_0900_ai_ci"
+ comment = "A schema comment"
+}
+
+table "users" {
+ schema = schema.market
+ column "id" {
+   type = int
+ }
+ column "name" {
+   type = varchar(255)
+ }
+ primary_key {
+   columns = [
+     column.id
+   ]
+ }
+}
+```
+
+And our Terraform module looks like this:
+
+```hcl
+terraform {
+ required_providers {
+   atlas = {
+     source  = "ariga/atlas"
+     version = "0.4.0"
+   }
+ }
+}
+
+provider "atlas" {}
+
+data "atlas_schema" "market" {
+ src        = file("${path.module}/schema.hcl")
+ dev_db_url = "mysql://root:pass@localhost:3307"
+}
+
+resource "atlas_schema" "market" {
+ hcl        = data.atlas_schema.market.hcl
+ url        = "mysql://root:pass@localhost:3306"
+ dev_db_url = "mysql://root:pass@localhost:3307"
+}
+```
+
+When we run `terraform plan` we will see the following output:
+
+```text
+Plan: 1 to add, 0 to change, 0 to destroy.
+╷
+│ Warning: Atlas Plan
+│
+│   with atlas_schema.market,
+│   on main.tf line 17, in resource "atlas_schema" "market":
+│   17: resource "atlas_schema" "market" {
+│
+│ The following SQL statements will be executed:
+│
+│
+│ -- add new schema named "market"
+│ CREATE DATABASE `market`
+│ -- create "users" table
+│ CREATE TABLE `market`.`users` (`id` int NOT NULL, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`)) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci
+│
+```
+
+## Versioned migrations
+
+Atlas supports two types of workflows: [Declarative and Versioned](/concepts/declarative-vs-versioned).
+With declarative workflows, the plan to migrate the database is generated automatically
+at runtime. Versioned migrations provide teams with a more controlled workflow where
+changes are planned, checked in to source control and reviewed ahead of time.
+Until today, the Terraform provider only supported the declarative workflow. This release
+adds support for versioned migrations as well.
+
+Suppose we have the following migration directory of two files:
+
+```sql title="20221101163823_create_users.sql"
+CREATE TABLE `users` (
+ `id` bigint(20) NOT NULL AUTO_INCREMENT,
+ `age` bigint(20) NOT NULL,
+ `name` varchar(255) COLLATE utf8mb4_bin NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `age` (`age`)
+);
+```
+
+```text title=atlas.sum
+h1:OlaV3+7xXEWc1uG/Ed2zICttHaS6ydHZmzI7Hpf2Fss=
+20221101163823_create_users.sql h1:mZirkpXBoLLm+M73EbHo07muxclifb70fhWQFfqxjD4=
+```
+
+We can use the Terraform Atlas provider to apply this migration directory to a database:
+
+```hcl
+terraform {
+ required_providers {
+   atlas = {
+     source  = "ariga/atlas"
+     version = "0.4.0"
+   }
+ }
+}
+
+provider "atlas" {}
+
+// The `atlas_migration` data source loads the current state of the given database
+// with regard to the migration directory.
+data "atlas_migration" "hello" {
+ dir = "migrations?format=atlas"
+ url = "mysql://root:pass@localhost:3306/hello"
+}
+
+// The `atlas_migration` resource applies the migration directory to the database.
+resource "atlas_migration" "hello" {
+ dir     = "migrations?format=atlas"
+ version = data.atlas_migration.hello.latest # Use latest to run all migrations
+ url     = data.atlas_migration.hello.url
+ dev_url = "mysql://root:pass@localhost:3307/test"
+}
+```
+
+Running `terraform plan` will show the following output:
+
+```text
+data.atlas_migration.hello: Reading...
+data.atlas_migration.hello: Read complete after 0s [id=migrations?format=atlas]
+
+Terraform used the selected providers to generate the following execution plan.
+Resource actions are indicated with the following symbols:
+  + create
+
+Terraform will perform the following actions:
+
+  # atlas_migration.hello will be created
+  + resource "atlas_migration" "hello" {
+      + dev_url = (sensitive value)
+      + dir     = "migrations?format=atlas"
+      + id      = (known after apply)
+      + status  = (known after apply)
+      + url     = (sensitive value)
+      + version = "20221101163823"
+    }
+
+Plan: 1 to add, 0 to change, 0 to destroy.
+```
+
+
+## Linting
+
+Atlas provides extensive support for [linting](/versioned/lint) database schemas.
+This release adds support for linting schemas as part of the Terraform plan. This means
+that you can now run `terraform plan` and see if there are any linting errors in your
+schema. This is especially useful when you are using the versioned migrations workflow, as you
+can now run `terraform plan` to see if there are any linting errors in your schema
+before you apply the changes.
+
+Suppose we add the following migration:
+
+```sql title="20221101165036_change_unique.sql"
+ALTER TABLE users
+ DROP KEY age,
+ ADD CONSTRAINT NAME UNIQUE (`name`);
+```
+
+If we run `terraform plan` on the above schema, Terraform prints the following
+warning:
+
+```text
+╷
+│ Warning: data dependent changes detected
+│
+│   with atlas_migration.hello,
+│   on main.tf line 20, in resource "atlas_migration" "hello":
+│   20: resource "atlas_migration" "hello" {
+│
+│ File: 20221101165036_change_unique.sql
+│
+│ - MF101: Adding a unique index "NAME" on table "users" might fail in case column
+│ "name" contains duplicate entries
+╵
+```
+
+Atlas detected that the migration may fail in case the column `name` contains duplicate
+entries! This is a very useful warning that can help you avoid unpredicted failed deployments.
+Atlas supports many more safety checks, which you can read about [here](/lint/analyzers).
+
+## Wrapping up
+
+In this blogpost we have discussed three new features that were added to the Terraform Atlas provider
+that are designed to make it safer and more predictable to manage your database schemas with Terraform.
+We hope you will enjoy this release!
+
+Have questions? Feedback? Feel free to reach out [on our Discord server](https://discord.gg/zZ6sWVg6NT).
+
diff --git a/doc/website/blog/authors.yml b/doc/website/blog/authors.yml
index 1a595a88..98a96fe5 100644
--- a/doc/website/blog/authors.yml
+++ b/doc/website/blog/authors.yml
@@ -20,4 +20,9 @@ masseelch:
   name: Jannik Clausen
   title: Building Atlas
   url: "https://github.com/masseelch"
-  image_url: "https://avatars.githubusercontent.com/u/12862103?v=4"
\ No newline at end of file
+  image_url: "https://avatars.githubusercontent.com/u/12862103?v=4"
+giautm:
+  name: Tran Minh Giau
+  title: Software Engineer
+  url: "https://github.com/giautm"
+  image_url: "https://avatars.githubusercontent.com/u/12751435?v=4"
diff --git a/doc/website/src/css/custom.css b/doc/website/src/css/custom.css
index e006c210..9d8f8253 100644
--- a/doc/website/src/css/custom.css
+++ b/doc/website/src/css/custom.css
@@ -18,6 +18,9 @@
   align-items: center;
 }
 
+html[data-theme='dark'] .footer__title {
+  color: #FFFFFF;
+}
 .footer__title {
   color: #000939;
   font-size: 22px;
@@ -235,4 +238,4 @@ div[class^='announcementBar_'] {
 .join-discord:hover  {
   color: white;
   text-decoration: none;
-}
\ No newline at end of file
+}
diff --git a/go.mod b/go.mod
index 9a69e23d..4e47bd4b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module ariga.io/atlas
 
-go 1.18
+go 1.19
 
 require (
 	github.com/DATA-DOG/go-sqlmock v1.5.0
diff --git a/internal/integration/go.mod b/internal/integration/go.mod
index 7078545b..0211be84 100644
--- a/internal/integration/go.mod
+++ b/internal/integration/go.mod
@@ -7,7 +7,7 @@ replace ariga.io/atlas => ../../
 replace ariga.io/atlas/cmd/atlas => ../../cmd/atlas
 
 require (
-	ariga.io/atlas v0.8.1
+	ariga.io/atlas v0.8.2
 	entgo.io/ent v0.11.1
 	github.com/go-sql-driver/mysql v1.6.0
 	github.com/hashicorp/hcl/v2 v2.13.0
@@ -20,7 +20,7 @@ require (
 )
 
 require (
-	ariga.io/atlas/cmd/atlas v0.8.1 // indirect
+	ariga.io/atlas/cmd/atlas v0.8.2-0.20221113160047-09851f798b12 // indirect
 	github.com/agext/levenshtein v1.2.3 // indirect
 	github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220816024939-bc8df83d7b9d // indirect
 	github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
diff --git a/schemahcl/extension.go b/schemahcl/extension.go
index 4b71c361..0067a33d 100644
--- a/schemahcl/extension.go
+++ b/schemahcl/extension.go
@@ -434,7 +434,7 @@ func (r *Resource) Scan(ext any) error {
 			r.Name = field.String()
 		case ft.isQualifier():
 			if field.Kind() != reflect.String {
-				return errors.New("schemahcl: extension qualifer field must be string")
+				return errors.New("schemahcl: extension qualifier field must be string")
 			}
 			r.Qualifier = field.String()
 		case isResourceSlice(field.Type()):
diff --git a/sql/internal/specutil/convert.go b/sql/internal/specutil/convert.go
index 5b039480..ac81a894 100644
--- a/sql/internal/specutil/convert.go
+++ b/sql/internal/specutil/convert.go
@@ -593,31 +593,39 @@ func externalRef(ref *schemahcl.Ref, sch *schema.Schema) (*schema.Table, *schema
 // is not in the provided schema.Schema other schemas in the connected schema.Realm
 // are searched as well.
 func findTable(ref *schemahcl.Ref, sch *schema.Schema) (*schema.Table, error) {
-	qualifier, tblName, err := tableName(ref)
+	qualifier, name, err := tableName(ref)
 	if err != nil {
 		return nil, err
 	}
-	// Search the same schema.
-	if qualifier == "" || qualifier == sch.Name {
-		tbl, ok := sch.Table(tblName)
-		if !ok {
-			return tbl, fmt.Errorf("sqlspec: table %q not found", tblName)
+	var (
+		matches []*schema.Table  // Found references.
+		schemas []*schema.Schema // Schemas to search.
+	)
+	switch {
+	case sch.Realm == nil || qualifier == sch.Name:
+		schemas = []*schema.Schema{sch}
+	case qualifier == "":
+		schemas = sch.Realm.Schemas
+	default:
+		s, ok := sch.Realm.Schema(qualifier)
+		if ok {
+			schemas = []*schema.Schema{s}
 		}
-		return tbl, nil
 	}
-	if sch.Realm == nil {
-		return nil, fmt.Errorf("sqlspec: table %s.%s not found", qualifier, tblName)
-	}
-	// Search for the table in another schemas in the realm.
-	sch, ok := sch.Realm.Schema(qualifier)
-	if !ok {
-		return nil, fmt.Errorf("sqlspec: schema %q not found", qualifier)
+	for _, s := range schemas {
+		t, ok := s.Table(name)
+		if ok {
+			matches = append(matches, t)
+		}
 	}
-	tbl, ok := sch.Table(tblName)
-	if !ok {
-		return tbl, fmt.Errorf("sqlspec: table %q not found", tblName)
+	switch len(matches) {
+	case 1:
+		return matches[0], nil
+	case 0:
+		return nil, fmt.Errorf("sqlspec: table %q not found", name)
+	default:
+		return nil, fmt.Errorf("specutil: multiple tables found for %q", name)
 	}
-	return tbl, nil
 }
 
 func tableName(ref *schemahcl.Ref) (qualifier, name string, err error) {
diff --git a/sql/mysql/inspect.go b/sql/mysql/inspect.go
index 171d9a7d..c22864d4 100644
--- a/sql/mysql/inspect.go
+++ b/sql/mysql/inspect.go
@@ -740,6 +740,7 @@ FROM
 	INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS t1
 	JOIN INFORMATION_SCHEMA.CHECK_CONSTRAINTS AS t2
 	ON t1.CONSTRAINT_NAME = t2.CONSTRAINT_NAME
+	AND t1.CONSTRAINT_SCHEMA = t2.CONSTRAINT_SCHEMA
 WHERE
 	t1.CONSTRAINT_TYPE = 'CHECK'
 	AND t1.TABLE_SCHEMA = ?
diff --git a/sql/schema/dsl.go b/sql/schema/dsl.go
index 4d937f6f..24399ce5 100644
--- a/sql/schema/dsl.go
+++ b/sql/schema/dsl.go
@@ -19,7 +19,7 @@ func New(name string) *Schema {
 // SetCharset sets or appends the Charset attribute
 // to the schema with the given value.
 func (s *Schema) SetCharset(v string) *Schema {
-	replaceOrAppend(&s.Attrs, &Charset{V: v})
+	ReplaceOrAppend(&s.Attrs, &Charset{V: v})
 	return s
 }
 
@@ -32,7 +32,7 @@ func (s *Schema) UnsetCharset() *Schema {
 // SetCollation sets or appends the Collation attribute
 // to the schema with the given value.
 func (s *Schema) SetCollation(v string) *Schema {
-	replaceOrAppend(&s.Attrs, &Collation{V: v})
+	ReplaceOrAppend(&s.Attrs, &Collation{V: v})
 	return s
 }
 
@@ -45,7 +45,7 @@ func (s *Schema) UnsetCollation() *Schema {
 // SetComment sets or appends the Comment attribute
 // to the schema with the given value.
 func (s *Schema) SetComment(v string) *Schema {
-	replaceOrAppend(&s.Attrs, &Comment{Text: v})
+	ReplaceOrAppend(&s.Attrs, &Comment{Text: v})
 	return s
 }
 
@@ -91,7 +91,7 @@ func (r *Realm) AddSchemas(schemas ...*Schema) *Realm {
 // SetCharset sets or appends the Charset attribute
 // to the realm with the given value.
 func (r *Realm) SetCharset(v string) *Realm {
-	replaceOrAppend(&r.Attrs, &Charset{V: v})
+	ReplaceOrAppend(&r.Attrs, &Charset{V: v})
 	return r
 }
 
@@ -104,7 +104,7 @@ func (r *Realm) UnsetCharset() *Realm {
 // SetCollation sets or appends the Collation attribute
 // to the realm with the given value.
 func (r *Realm) SetCollation(v string) *Realm {
-	replaceOrAppend(&r.Attrs, &Collation{V: v})
+	ReplaceOrAppend(&r.Attrs, &Collation{V: v})
 	return r
 }
 
@@ -122,7 +122,7 @@ func NewTable(name string) *Table {
 // SetCharset sets or appends the Charset attribute
 // to the table with the given value.
 func (t *Table) SetCharset(v string) *Table {
-	replaceOrAppend(&t.Attrs, &Charset{V: v})
+	ReplaceOrAppend(&t.Attrs, &Charset{V: v})
 	return t
 }
 
@@ -135,7 +135,7 @@ func (t *Table) UnsetCharset() *Table {
 // SetCollation sets or appends the Collation attribute
 // to the table with the given value.
 func (t *Table) SetCollation(v string) *Table {
-	replaceOrAppend(&t.Attrs, &Collation{V: v})
+	ReplaceOrAppend(&t.Attrs, &Collation{V: v})
 	return t
 }
 
@@ -148,7 +148,7 @@ func (t *Table) UnsetCollation() *Table {
 // SetComment sets or appends the Comment attribute
 // to the table with the given value.
 func (t *Table) SetComment(v string) *Table {
-	replaceOrAppend(&t.Attrs, &Comment{Text: v})
+	ReplaceOrAppend(&t.Attrs, &Comment{Text: v})
 	return t
 }
 
@@ -496,7 +496,7 @@ func (c *Column) SetDefault(x Expr) *Column {
 // SetCharset sets or appends the Charset attribute
 // to the column with the given value.
 func (c *Column) SetCharset(v string) *Column {
-	replaceOrAppend(&c.Attrs, &Charset{V: v})
+	ReplaceOrAppend(&c.Attrs, &Charset{V: v})
 	return c
 }
 
@@ -509,7 +509,7 @@ func (c *Column) UnsetCharset() *Column {
 // SetCollation sets or appends the Collation attribute
 // to the column with the given value.
 func (c *Column) SetCollation(v string) *Column {
-	replaceOrAppend(&c.Attrs, &Collation{V: v})
+	ReplaceOrAppend(&c.Attrs, &Collation{V: v})
 	return c
 }
 
@@ -522,13 +522,13 @@ func (c *Column) UnsetCollation() *Column {
 // SetComment sets or appends the Comment attribute
 // to the column with the given value.
 func (c *Column) SetComment(v string) *Column {
-	replaceOrAppend(&c.Attrs, &Comment{Text: v})
+	ReplaceOrAppend(&c.Attrs, &Comment{Text: v})
 	return c
 }
 
 // SetGeneratedExpr sets or appends the GeneratedExpr attribute.
 func (c *Column) SetGeneratedExpr(x *GeneratedExpr) *Column {
-	replaceOrAppend(&c.Attrs, x)
+	ReplaceOrAppend(&c.Attrs, x)
 	return c
 }
 
@@ -598,7 +598,7 @@ func (i *Index) SetTable(t *Table) *Index {
 // SetComment sets or appends the Comment attribute
 // to the index with the given value.
 func (i *Index) SetComment(v string) *Index {
-	replaceOrAppend(&i.Attrs, &Comment{Text: v})
+	ReplaceOrAppend(&i.Attrs, &Comment{Text: v})
 	return i
 }
 
@@ -737,9 +737,9 @@ func (f *ForeignKey) SetOnDelete(o ReferenceOption) *ForeignKey {
 	return f
 }
 
-// replaceOrAppend searches an attribute of the same type as v in
+// ReplaceOrAppend searches an attribute of the same type as v in
 // the list and replaces it. Otherwise, v is appended to the list.
-func replaceOrAppend(attrs *[]Attr, v Attr) {
+func ReplaceOrAppend(attrs *[]Attr, v Attr) {
 	t := reflect.TypeOf(v)
 	for i := range *attrs {
 		if reflect.TypeOf((*attrs)[i]) == t {
@@ -750,10 +750,15 @@ func replaceOrAppend(attrs *[]Attr, v Attr) {
 	*attrs = append(*attrs, v)
 }
 
-// ReplaceOrAppend searches an attribute of the same type as v in
-// the list and replaces it. Otherwise, v is appended to the list.
-func ReplaceOrAppend(attrs *[]Attr, v Attr) {
-	replaceOrAppend(attrs, v)
+// RemoveAttr returns a new slice where all attributes of type T are filtered.
+func RemoveAttr[T Attr](attrs []Attr) []Attr {
+	f := make([]Attr, 0, len(attrs))
+	for _, a := range attrs {
+		if _, ok := a.(T); !ok {
+			f = append(f, a)
+		}
+	}
+	return f
 }
 
 // del searches an attribute of the same type as v in


Test patch:
diff --git a/cmd/atlas/internal/cmdapi/migrate_test.go b/cmd/atlas/internal/cmdapi/migrate_test.go
index 6d85cc92..2cc69464 100644
--- a/cmd/atlas/internal/cmdapi/migrate_test.go
+++ b/cmd/atlas/internal/cmdapi/migrate_test.go
@@ -749,6 +749,34 @@ func TestMigrate_Diff(t *testing.T) {
 	)
 	require.True(t, strings.HasPrefix(s, "Error: acquiring database lock: "+errLock.Error()))
 	require.ErrorIs(t, err, errLock)
+
+	t.Run("Edit", func(t *testing.T) {
+		p := t.TempDir()
+		require.NoError(t, os.Setenv("EDITOR", "echo '-- Comment' >>"))
+		t.Cleanup(func() { require.NoError(t, os.Unsetenv("EDITOR")) })
+		args := []string{
+			"--edit",
+			"--dir", "file://" + p,
+			"--dev-url", openSQLite(t, ""),
+			"--to", to,
+		}
+		_, err := runCmd(migrateDiffCmd(), args...)
+		files, err := os.ReadDir(p)
+		require.NoError(t, err)
+		require.Len(t, files, 2)
+		b, err := os.ReadFile(filepath.Join(p, files[0].Name()))
+		require.NoError(t, err)
+		require.Contains(t, string(b), "CREATE")
+		require.True(t, strings.HasSuffix(string(b), "-- Comment\n"))
+		require.Equal(t, "atlas.sum", files[1].Name())
+
+		// Second run will have no effect.
+		_, err = runCmd(migrateDiffCmd(), args...)
+		require.NoError(t, err)
+		files, err = os.ReadDir(p)
+		require.NoError(t, err)
+		require.Len(t, files, 2)
+	})
 }
 
 func TestMigrate_StatusJSON(t *testing.T) {
@@ -826,6 +854,20 @@ func TestMigrate_New(t *testing.T) {
 	s, err = runCmd(migrateNewCmd(), "--dir", "file://testdata/mysql")
 	require.NotZero(t, s)
 	require.Error(t, err)
+
+	t.Run("Edit", func(t *testing.T) {
+		p := t.TempDir()
+		require.NoError(t, os.Setenv("EDITOR", "echo 'contents' >"))
+		t.Cleanup(func() { require.NoError(t, os.Unsetenv("EDITOR")) })
+		s, err = runCmd(migrateNewCmd(), "--dir", "file://"+p, "--edit")
+		files, err := os.ReadDir(p)
+		require.NoError(t, err)
+		require.Len(t, files, 2)
+		b, err := os.ReadFile(filepath.Join(p, files[0].Name()))
+		require.NoError(t, err)
+		require.Equal(t, "contents\n", string(b))
+		require.Equal(t, "atlas.sum", files[1].Name())
+	})
 }
 
 func TestMigrate_Validate(t *testing.T) {
diff --git a/cmd/atlas/internal/cmdapi/project_test.go b/cmd/atlas/internal/cmdapi/project_test.go
index fd728f31..000b0e71 100644
--- a/cmd/atlas/internal/cmdapi/project_test.go
+++ b/cmd/atlas/internal/cmdapi/project_test.go
@@ -43,6 +43,7 @@ env "local" {
 	migration {
 		dir = "file://migrations"
 		format = atlas
+		lock_timeout = "1s"
 		revisions_schema = "revisions"
 	}
 	lint {
@@ -88,6 +89,7 @@ env "multi" {
 			Migration: &Migration{
 				Dir:             "file://migrations",
 				Format:          formatAtlas,
+				LockTimeout:     "1s",
 				RevisionsSchema: "revisions",
 			},
 			Lint: &Lint{
diff --git a/internal/integration/mysql_test.go b/internal/integration/mysql_test.go
index 123b2b1a..60f15339 100644
--- a/internal/integration/mysql_test.go
+++ b/internal/integration/mysql_test.go
@@ -9,8 +9,10 @@ import (
 	"database/sql"
 	"fmt"
 	"log"
+	"os/exec"
 	"strings"
 	"sync"
+	"sync/atomic"
 	"testing"
 
 	"ariga.io/atlas/sql/migrate"
@@ -587,6 +589,41 @@ func TestMySQL_CLI_MigrateApplyBC(t *testing.T) {
 	})
 }
 
+func TestMySQL_CLI_MigrateApplyLock(t *testing.T) {
+	myRun(t, func(t *myTest) {
+		t.dropSchemas("mysqlock")
+		t.migrate(&schema.AddSchema{S: schema.New("mysqlock")})
+		var (
+			b  atomic.Bool
+			wg sync.WaitGroup
+		)
+		for i := 0; i < 5; i++ {
+			wg.Add(1)
+			go func(i int) {
+				defer wg.Done()
+				out, err := exec.Command(
+					"go", "run", "ariga.io/atlas/cmd/atlas",
+					"migrate", "apply",
+					"--dir", "file://testdata/migrations/mysqlock",
+					"--url", t.url("mysqlock"),
+				).CombinedOutput()
+				require.NoError(t, err, string(out))
+				switch {
+				// Nop.
+				case err == nil && strings.HasPrefix(string(out), "No migration files to execute"):
+				// Successful run.
+				case err == nil && strings.HasPrefix(string(out), "Migrating to version 3"):
+					if b.Swap(true) {
+						t.Errorf("migration ran twice: %s", out)
+					}
+				}
+			}(i)
+		}
+		wg.Wait()
+		require.True(t, b.Load(), "Migration should run successfully exactly once")
+	})
+}
+
 func TestMySQL_CLI(t *testing.T) {
 	h := `
 			schema "test" {
diff --git a/internal/integration/postgres_test.go b/internal/integration/postgres_test.go
index c7b1cca5..2a32f23b 100644
--- a/internal/integration/postgres_test.go
+++ b/internal/integration/postgres_test.go
@@ -729,31 +729,40 @@ func TestPostgres_CLI_MultiSchema(t *testing.T) {
 					type = integer
 				}
 				primary_key {
-					columns = [table.users.column.id]
+					columns = [column.id]
 				}
 			}
 			schema "test2" {	
 			}
-			table "users" {
+			table "pets" {
 				schema = schema.test2
 				column "id" {
 					type = integer
 				}
+				column "owner_id" {
+					type = integer
+				}
 				primary_key {
-					columns = [table.users.column.id]
+					columns = [column.id]
+				}
+				foreign_key "owner_id" {
+					columns     = [column.owner_id]
+					ref_columns = [table.users.column.id]
+					on_delete   = NO_ACTION
+					on_update   = NO_ACTION
 				}
 			}`
 	t.Run("SchemaInspect", func(t *testing.T) {
 		pgRun(t, func(t *pgTest) {
-			t.dropSchemas("test2")
 			t.dropTables("users")
+			t.dropSchemas("test2")
 			testCLIMultiSchemaInspect(t, h, t.url(""), []string{"public", "test2"}, postgres.EvalHCL)
 		})
 	})
 	t.Run("SchemaApply", func(t *testing.T) {
 		pgRun(t, func(t *pgTest) {
-			t.dropSchemas("test2")
 			t.dropTables("users")
+			t.dropSchemas("test2")
 			testCLIMultiSchemaApply(t, h, t.url(""), []string{"public", "test2"}, postgres.EvalHCL)
 		})
 	})
diff --git a/internal/integration/testdata/migrations/mysqlock/1.sql b/internal/integration/testdata/migrations/mysqlock/1.sql
new file mode 100644
index 00000000..47acfae1
--- /dev/null
+++ b/internal/integration/testdata/migrations/mysqlock/1.sql
@@ -0,0 +1,8 @@
+CREATE TABLE `t1` (`id` int);
+CREATE TABLE `t2` (`id` int);
+select sleep(0.1);
+CREATE TABLE `t3` (`id` int);
+CREATE TABLE `t4` (`id` int);
+CREATE TABLE `t5` (`id` int);
+select sleep(0.1);
+CREATE TABLE `t6` (`id` int);
diff --git a/internal/integration/testdata/migrations/mysqlock/2.sql b/internal/integration/testdata/migrations/mysqlock/2.sql
new file mode 100644
index 00000000..8aa130ae
--- /dev/null
+++ b/internal/integration/testdata/migrations/mysqlock/2.sql
@@ -0,0 +1,16 @@
+ALTER TABLE `t1` ADD COLUMN `c1` varchar(255) DEFAULT 'name';
+ALTER TABLE `t2` ADD COLUMN `c1` varchar(255) DEFAULT 'name';
+select sleep(0.1);
+ALTER TABLE `t3` ADD COLUMN `c1` varchar(255) DEFAULT 'name';
+ALTER TABLE `t4` ADD COLUMN `c1` varchar(255) DEFAULT 'name';
+select sleep(0.1);
+ALTER TABLE `t5` ADD COLUMN `c1` varchar(255) DEFAULT 'name';
+ALTER TABLE `t6` ADD COLUMN `c1` varchar(255) DEFAULT 'name';
+ALTER TABLE `t1` ADD COLUMN `c2` varchar(255) DEFAULT 'name';
+ALTER TABLE `t2` ADD COLUMN `c2` varchar(255) DEFAULT 'name';
+select sleep(0.1);
+ALTER TABLE `t3` ADD COLUMN `c2` varchar(255) DEFAULT 'name';
+ALTER TABLE `t4` ADD COLUMN `c2` varchar(255) DEFAULT 'name';
+select sleep(0.1);           
+ALTER TABLE `t5` ADD COLUMN `c2` varchar(255) DEFAULT 'name';
+ALTER TABLE `t6` ADD COLUMN `c2` varchar(255) DEFAULT 'name';
diff --git a/internal/integration/testdata/migrations/mysqlock/3.sql b/internal/integration/testdata/migrations/mysqlock/3.sql
new file mode 100644
index 00000000..bbdd149a
--- /dev/null
+++ b/internal/integration/testdata/migrations/mysqlock/3.sql
@@ -0,0 +1,4 @@
+CREATE TABLE `t7` (`id` int);
+select sleep(0.1);
+CREATE TABLE `t8` (`id` int);
+CREATE TABLE `t9` (`id` int);
diff --git a/internal/integration/testdata/migrations/mysqlock/atlas.sum b/internal/integration/testdata/migrations/mysqlock/atlas.sum
new file mode 100644
index 00000000..73575ef1
--- /dev/null
+++ b/internal/integration/testdata/migrations/mysqlock/atlas.sum
@@ -0,0 +1,4 @@
+h1:XiFkhbkD+gUR/ry9+AVfyLW4X/tYaVUHFul5q0Kvtpo=
+1.sql h1:+HoZslM3E59DllQlUXd5TFnPw+9de9IBNQEYdn77f7c=
+2.sql h1:OliSiXPsLoVoWixWDC/yBJUtjrxnMMm+hjCf2Sz66Rk=
+3.sql h1:Gb5M9ibe0COwTBrb08v/4VuWXxJ55+D2lSLXHLVa/Po=
diff --git a/internal/integration/testdata/mysql/column-charset.txt b/internal/integration/testdata/mysql/column-charset.txt
index ad89c960..aa8c394f 100644
--- a/internal/integration/testdata/mysql/column-charset.txt
+++ b/internal/integration/testdata/mysql/column-charset.txt
@@ -39,6 +39,11 @@ CREATE TABLE `users` (
   `name` varchar(255) CHARACTER SET hebrew NOT NULL
 )
 
+-- maria107/1.sql --
+CREATE TABLE `users` (
+  `name` varchar(255) CHARACTER SET hebrew COLLATE hebrew_general_ci NOT NULL
+)
+
 -- mysql8/1.sql --
 CREATE TABLE `users` (
   `name` varchar(255) CHARACTER SET hebrew COLLATE hebrew_general_ci NOT NULL
diff --git a/sql/schema/dsl_test.go b/sql/schema/dsl_test.go
index 0989dff1..5df273c4 100644
--- a/sql/schema/dsl_test.go
+++ b/sql/schema/dsl_test.go
@@ -204,3 +204,28 @@ func TestCheck(t *testing.T) {
 		Attrs: []schema.Attr{enforced},
 	}, tbl.Attrs[1])
 }
+
+func TestRemoveAttr(t *testing.T) {
+	u := schema.NewTable("users")
+	require.Empty(t, u.Attrs)
+	u.SetComment("users table")
+	require.Len(t, u.Attrs, 1)
+	u.Attrs = schema.RemoveAttr[*schema.Comment](u.Attrs)
+	require.Empty(t, u.Attrs)
+
+	u.AddAttrs(&schema.Comment{}, &schema.Comment{})
+	require.Len(t, u.Attrs, 2)
+	u.Attrs = schema.RemoveAttr[*schema.Comment](u.Attrs)
+	require.Empty(t, u.Attrs)
+
+	u.SetCharset("charset")
+	u.SetComment("users table")
+	u.SetCollation("collation")
+	u.Attrs = schema.RemoveAttr[*schema.Comment](u.Attrs)
+	require.Len(t, u.Attrs, 2)
+	require.Equal(t, &schema.Charset{V: "charset"}, u.Attrs[0])
+	require.Equal(t, &schema.Collation{V: "collation"}, u.Attrs[1])
+	u.Attrs = schema.RemoveAttr[*schema.Collation](u.Attrs)
+	require.Len(t, u.Attrs, 1)
+	require.Equal(t, &schema.Charset{V: "charset"}, u.Attrs[0])
+}