Repository: ariga/atlas
Pull request #2606 (2024-03-10 11:13:18)

doc/md: add doctrine provider guide


Patch:
diff --git a/cmd/atlas/internal/cmdapi/cmdapi.go b/cmd/atlas/internal/cmdapi/cmdapi.go
index f5bbd630..92d0ea8c 100644
--- a/cmd/atlas/internal/cmdapi/cmdapi.go
+++ b/cmd/atlas/internal/cmdapi/cmdapi.go
@@ -11,8 +11,6 @@ import (
 	"errors"
 	"fmt"
 	"net/url"
-	"os"
-	"path/filepath"
 	"sort"
 	"strings"
 	"time"
@@ -55,22 +53,22 @@ var (
 	// "-X 'ariga.io/atlas/cmd/atlas/internal/cmdapi.version=v0.1.2'"
 	version string
 
-	// schemaCmd represents the subcommand 'atlas version'.
+	// versionCmd represents the subcommand 'atlas version'.
 	versionCmd = &cobra.Command{
 		Use:   "version",
 		Short: "Prints this Atlas CLI version information.",
 		Run: func(cmd *cobra.Command, _ []string) {
 			var (
-				f    = "atlas "
+				f    = versionFmt
 				args []any
 			)
 			if flavor != "" {
 				f += "%s "
 				args = append(args, flavor)
 			}
-			f += "version %s\n%s\n"
+			f += "version %s\n%s\n%s"
 			v, u := parseV(version)
-			args = append(args, v, u)
+			args = append(args, v, u, versionInfo)
 			cmd.Printf(f, args...)
 		},
 	}
@@ -540,7 +538,7 @@ func stateReader(ctx context.Context, env *Env, config *stateReaderConfig) (*cmd
 	switch scheme {
 	// "file" scheme is valid for both migration directory and HCL paths.
 	case cmdext.SchemaTypeFile:
-		switch ext, err := filesExt(excfg.URLs); {
+		switch ext, err := cmdext.FilesExt(excfg.URLs); {
 		case err != nil:
 			return nil, err
 		case ext == cmdext.FileTypeHCL:
@@ -601,54 +599,3 @@ func stateReader(ctx context.Context, env *Env, config *stateReaderConfig) (*cmd
 		}, nil
 	}
 }
-
-func filesExt(urls []*url.URL) (string, error) {
-	var path, ext string
-	set := func(curr string) error {
-		switch e := filepath.Ext(curr); {
-		case e != cmdext.FileTypeHCL && e != cmdext.FileTypeSQL:
-			return fmt.Errorf("unknown schema file: %q", curr)
-		case ext != "" && ext != e:
-			return fmt.Errorf("ambiguous schema: both SQL and HCL files found: %q, %q", path, curr)
-		default:
-			path, ext = curr, e
-			return nil
-		}
-	}
-	for _, u := range urls {
-		path := filepath.Join(u.Host, u.Path)
-		switch fi, err := os.Stat(path); {
-		case err != nil:
-			return "", err
-		case fi.IsDir():
-			files, err := os.ReadDir(path)
-			if err != nil {
-				return "", err
-			}
-			for _, f := range files {
-				switch filepath.Ext(f.Name()) {
-				// Ignore unknown extensions in case we read directories.
-				case cmdext.FileTypeHCL, cmdext.FileTypeSQL:
-					if err := set(f.Name()); err != nil {
-						return "", err
-					}
-				}
-			}
-		default:
-			if err := set(fi.Name()); err != nil {
-				return "", err
-			}
-		}
-	}
-	switch {
-	case ext != "":
-	case len(urls) == 1 && (urls[0].Host != "" || urls[0].Path != ""):
-		return "", fmt.Errorf(
-			"%q contains neither SQL nor HCL files",
-			filepath.Base(filepath.Join(urls[0].Host, urls[0].Path)),
-		)
-	default:
-		return "", errors.New("schema contains neither SQL nor HCL files")
-	}
-	return ext, nil
-}
diff --git a/cmd/atlas/internal/cmdapi/cmdapi_oss.go b/cmd/atlas/internal/cmdapi/cmdapi_oss.go
index 6510b185..0ab40178 100644
--- a/cmd/atlas/internal/cmdapi/cmdapi_oss.go
+++ b/cmd/atlas/internal/cmdapi/cmdapi_oss.go
@@ -13,6 +13,7 @@ import (
 	"text/template"
 
 	"ariga.io/atlas/cmd/atlas/internal/cloudapi"
+	"ariga.io/atlas/cmd/atlas/internal/cmdext"
 	cmdmigrate "ariga.io/atlas/cmd/atlas/internal/migrate"
 	"ariga.io/atlas/cmd/atlas/internal/migratelint"
 	"ariga.io/atlas/schemahcl"
@@ -49,6 +50,14 @@ func init() {
 	Root.AddCommand(migrateCmd)
 }
 
+// Project represents an atlas.hcl project config file.
+type Project struct {
+	Envs  []*Env `spec:"env"`  // List of environments
+	Lint  *Lint  `spec:"lint"` // Optional global lint policy
+	Diff  *Diff  `spec:"diff"` // Optional global diff policy
+	cloud *cmdext.AtlasConfig
+}
+
 // migrateLintSetFlags allows setting extra flags for the 'migrate lint' command.
 func migrateLintSetFlags(*cobra.Command, *migrateLintFlags) {}
 
@@ -141,3 +150,8 @@ var specOptions []schemahcl.Option
 func diffOptions(_ *cobra.Command, env *Env) []schema.DiffOption {
 	return env.DiffOptions()
 }
+
+// openClient allows opening environment-aware clients.
+func (*Env) openClient(ctx context.Context, u string) (*sqlclient.Client, error) {
+	return sqlclient.Open(ctx, u)
+}
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index ee68e3fa..4c36d2f5 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -105,10 +105,16 @@ If run with the "--dry-run" flag, atlas will not execute any SQL.`,
 			RunE: RunE(func(cmd *cobra.Command, args []string) (cmdErr error) {
 				switch {
 				case GlobalFlags.SelectedEnv == "":
-					if err := migrateFlagsFromConfig(cmd); err != nil {
+					// Env not selected, but the
+					// -c flag might be set.
+					env, err := selectEnv(cmd)
+					if err != nil {
+						return err
+					}
+					if err := setMigrateEnvFlags(cmd, env); err != nil {
 						return err
 					}
-					return migrateApplyRun(cmd, args, flags, &MigrateReport{}) // nop reporter
+					return migrateApplyRun(cmd, args, flags, env, &MigrateReport{}) // nop reporter
 				default:
 					project, envs, err := EnvByName(cmd, GlobalFlags.SelectedEnv, GlobalFlags.Vars)
 					if err != nil {
@@ -129,7 +135,7 @@ If run with the "--dry-run" flag, atlas will not execute any SQL.`,
 						if u, err := url.Parse(flags.dirURL); err == nil && u.Scheme != cmdmigrate.DirTypeFile {
 							hasRemote = true
 						}
-						return migrateApplyRun(cmd, args, flags, set.ReportFor(flags, env))
+						return migrateApplyRun(cmd, args, flags, env, set.ReportFor(flags, env))
 					})
 				}
 			}),
@@ -153,8 +159,8 @@ If run with the "--dry-run" flag, atlas will not execute any SQL.`,
 	return cmd
 }
 
-// migrateApplyCmd represents the 'atlas migrate apply' subcommand.
-func migrateApplyRun(cmd *cobra.Command, args []string, flags migrateApplyFlags, mr *MigrateReport) (err error) {
+// migrateApplyRun represents the 'atlas migrate apply' subcommand.
+func migrateApplyRun(cmd *cobra.Command, args []string, flags migrateApplyFlags, env *Env, mr *MigrateReport) (err error) {
 	var (
 		count int
 		ctx   = cmd.Context()
@@ -180,7 +186,7 @@ func migrateApplyRun(cmd *cobra.Command, args []string, flags migrateApplyFlags,
 	if flags.url == "" {
 		return errors.New(`required flag "url" not set`)
 	}
-	client, err := sqlclient.Open(ctx, flags.url)
+	client, err := env.openClient(ctx, flags.url)
 	if err != nil {
 		return err
 	}
@@ -305,8 +311,8 @@ type (
 // NewReportProvider returns a new ReporterProvider.
 func NewReportProvider(ctx context.Context, p *Project, envs []*Env, flags *migrateApplyFlags) (*MigrateReportSet, error) {
 	c := cloudapi.FromContext(ctx)
-	if p.cfg.Client != nil {
-		c = p.cfg.Client
+	if p.cloud.Client != nil {
+		c = p.cloud.Client
 	}
 	s := &MigrateReportSet{
 		client: c,
@@ -485,7 +491,7 @@ func (r *MigrateReport) Done(cmd *cobra.Command, flags migrateApplyFlags) error
 		dirName = path.Join(u.Host, u.Path)
 	}
 	r.done(&cloudapi.ReportMigrationInput{
-		ProjectName:  r.env.cfg.Project,
+		ProjectName:  r.env.config.cloud.Project,
 		EnvName:      r.env.Name,
 		DirName:      dirName,
 		AtlasVersion: operatorVersion(),
@@ -545,7 +551,12 @@ func (r *MigrateReport) Done(cmd *cobra.Command, flags migrateApplyFlags) error
 
 // CloudEnabled reports if cloud reporting is enabled.
 func (r *MigrateReport) CloudEnabled(ctx context.Context) bool {
-	return r.env != nil && r.env.cfg != nil && r.env.cfg.Project != "" && (r.env.cfg.Client != nil || cloudapi.FromContext(ctx) != nil)
+	if r.env == nil || r.env.cloud == nil {
+		return false // The --env was not set.
+	}
+	cloud := r.env.cloud
+	// Cloud reporting is enabled only if there is a cloud connection.
+	return cloud.Project != "" && (cloud.Client != nil || cloudapi.FromContext(ctx) != nil)
 }
 
 func logApply(cmd *cobra.Command, w io.Writer, flags migrateApplyFlags, r *cmdlog.MigrateApply) error {
@@ -1163,7 +1174,7 @@ func migrateSetRun(cmd *cobra.Command, args []string, flags migrateSetFlags) (re
 	default:
 		return fmt.Errorf("accepts 1 arg(s), received %d", n)
 	}
-	log := &cmdlog.MigrateSet{Context: ctx}
+	log := cmdlog.NewMigrateSet(ctx)
 	for _, r := range revs {
 		// Check all existing revisions and ensure they precede the given version. If we encounter a partially
 		// applied revision, or one with errors, mark them "fixed".
diff --git a/cmd/atlas/internal/cmdapi/project.go b/cmd/atlas/internal/cmdapi/project.go
index 3a0dd947..366dfbf0 100644
--- a/cmd/atlas/internal/cmdapi/project.go
+++ b/cmd/atlas/internal/cmdapi/project.go
@@ -28,14 +28,6 @@ import (
 )
 
 type (
-	// Project represents an atlas.hcl project config file.
-	Project struct {
-		Envs []*Env `spec:"env"`  // List of environments
-		Lint *Lint  `spec:"lint"` // Optional global lint policy
-		Diff *Diff  `spec:"diff"` // Optional global diff policy
-		cfg  *cmdext.AtlasConfig
-	}
-
 	// Env represents an Atlas environment.
 	Env struct {
 		// Name for this environment.
@@ -68,7 +60,8 @@ type (
 		Format Format `spec:"format"`
 
 		schemahcl.DefaultExtension
-		cfg *cmdext.AtlasConfig
+		cloud  *cmdext.AtlasConfig
+		config *Project
 	}
 
 	// Migration represents the migration directory for the Env.
@@ -458,8 +451,8 @@ func EnvByName(cmd *cobra.Command, name string, vars map[string]cty.Value) (*Pro
 // maySetTokenContext sets the token context and cloud client if
 // defined in the project file.
 func maySetTokenContext(cmd *cobra.Command, p *Project) error {
-	if p.cfg.Token != "" && p.cfg.Client != nil {
-		ctx, err := withTokenContext(cmd.Context(), p.cfg.Token, p.cfg.Client)
+	if p.cloud.Token != "" && p.cloud.Client != nil {
+		ctx, err := withTokenContext(cmd.Context(), p.cloud.Token, p.cloud.Client)
 		if err != nil {
 			return err
 		}
@@ -512,13 +505,13 @@ func parseConfig(path, env string, vars map[string]cty.Value) (*Project, error)
 	if err != nil {
 		return nil, err
 	}
-	cfg := &cmdext.AtlasConfig{
+	cloud := &cmdext.AtlasConfig{
 		Project: cloudapi.DefaultProjectName,
 	}
 	state := schemahcl.New(
 		append(
 			append(cmdext.SpecOptions, specOptions...),
-			cfg.InitBlock(),
+			cloud.InitBlock(),
 			schemahcl.WithScopedEnums("env.migration.format", cmdmigrate.Formats...),
 			schemahcl.WithScopedEnums("env.migration.exec_order", "LINEAR", "LINEAR_SKIP", "NON_LINEAR"),
 			schemahcl.WithScopedEnums("env.lint.review", ReviewModes...),
@@ -545,12 +538,12 @@ func parseConfig(path, env string, vars map[string]cty.Value) (*Project, error)
 			}),
 		)...,
 	)
-	p := &Project{Lint: &Lint{}, Diff: &Diff{}, cfg: cfg}
+	p := &Project{Lint: &Lint{}, Diff: &Diff{}, cloud: cloud}
 	if err := state.Eval(pr, p, vars); err != nil {
 		return nil, err
 	}
 	for _, e := range p.Envs {
-		e.cfg = cfg
+		e.config, e.cloud = p, cloud
 	}
 	return p, nil
 }
diff --git a/cmd/atlas/internal/cmdapi/schema.go b/cmd/atlas/internal/cmdapi/schema.go
index d824a409..fbe61875 100644
--- a/cmd/atlas/internal/cmdapi/schema.go
+++ b/cmd/atlas/internal/cmdapi/schema.go
@@ -437,13 +437,9 @@ func schemaDiffRun(cmd *cobra.Command, _ []string, flags schemaDiffFlags, env *E
 	if err != nil {
 		return err
 	}
-	return format.Execute(cmd.OutOrStdout(), &cmdlog.SchemaDiff{
-		Client:  c,
-		Context: ctx,
-		From:    diff.from,
-		To:      diff.to,
-		Changes: diff.changes,
-	})
+	return format.Execute(cmd.OutOrStdout(),
+		cmdlog.NewSchemaDiff(ctx, c, diff.from, diff.to, diff.changes),
+	)
 }
 
 type schemaInspectFlags struct {
@@ -547,11 +543,7 @@ func schemaInspectRun(cmd *cobra.Command, _ []string, flags schemaInspectFlags,
 	if err != nil {
 		return err
 	}
-	return format.Execute(cmd.OutOrStdout(), &cmdlog.SchemaInspect{
-		Client:  client,
-		Context: ctx,
-		Realm:   s,
-	})
+	return format.Execute(cmd.OutOrStdout(), cmdlog.NewSchemaInspect(ctx, client, s))
 }
 
 // schemaFmtCmd represents the 'atlas schema fmt' subcommand.
@@ -609,7 +601,7 @@ func selectEnv(cmd *cobra.Command) (*Env, error) {
 		if len(envs) != 0 {
 			return nil, fmt.Errorf("unexpected number of envs found: %d", len(envs))
 		}
-		return &Env{Lint: p.Lint, Diff: p.Diff, cfg: p.cfg, Migration: &Migration{}}, nil
+		return &Env{Lint: p.Lint, Diff: p.Diff, Migration: &Migration{}, config: p}, nil
 	// No config nor env was passed.
 	case name == "":
 		return &Env{Lint: &Lint{}, Migration: &Migration{}}, nil
diff --git a/cmd/atlas/internal/cmdapi/version_oss.go b/cmd/atlas/internal/cmdapi/version_oss.go
new file mode 100644
index 00000000..2da8a4d1
--- /dev/null
+++ b/cmd/atlas/internal/cmdapi/version_oss.go
@@ -0,0 +1,12 @@
+// 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.
+
+//go:build !ent && !official
+
+package cmdapi
+
+const (
+	versionFmt  = "atlas unofficial "
+	versionInfo = "To download an official version, visit: https://atlasgo.io/getting-started#installation\n"
+)
diff --git a/cmd/atlas/internal/cmdext/reader.go b/cmd/atlas/internal/cmdext/reader.go
index 1b14b01b..1cffb0e0 100644
--- a/cmd/atlas/internal/cmdext/reader.go
+++ b/cmd/atlas/internal/cmdext/reader.go
@@ -233,6 +233,59 @@ func stateReaderHCL(_ context.Context, config *StateReaderConfig, paths []string
 	}, nil
 }
 
+// FilesExt returns the file extension of the given URLs.
+// Note, all URL must have the same extension.
+func FilesExt(urls []*url.URL) (string, error) {
+	var path, ext string
+	set := func(curr string) error {
+		switch e := filepath.Ext(curr); {
+		case e != FileTypeHCL && e != FileTypeSQL:
+			return fmt.Errorf("unknown schema file: %q", curr)
+		case ext != "" && ext != e:
+			return fmt.Errorf("ambiguous schema: both SQL and HCL files found: %q, %q", path, curr)
+		default:
+			path, ext = curr, e
+			return nil
+		}
+	}
+	for _, u := range urls {
+		path := filepath.Join(u.Host, u.Path)
+		switch fi, err := os.Stat(path); {
+		case err != nil:
+			return "", err
+		case fi.IsDir():
+			files, err := os.ReadDir(path)
+			if err != nil {
+				return "", err
+			}
+			for _, f := range files {
+				switch filepath.Ext(f.Name()) {
+				// Ignore unknown extensions in case we read directories.
+				case FileTypeHCL, FileTypeSQL:
+					if err := set(f.Name()); err != nil {
+						return "", err
+					}
+				}
+			}
+		default:
+			if err := set(fi.Name()); err != nil {
+				return "", err
+			}
+		}
+	}
+	switch {
+	case ext != "":
+	case len(urls) == 1 && (urls[0].Host != "" || urls[0].Path != ""):
+		return "", fmt.Errorf(
+			"%q contains neither SQL nor HCL files",
+			filepath.Base(filepath.Join(urls[0].Host, urls[0].Path)),
+		)
+	default:
+		return "", errors.New("schema contains neither SQL nor HCL files")
+	}
+	return ext, nil
+}
+
 // parseHCLPaths parses the HCL files in the given paths. If a path represents a directory,
 // its direct descendants will be considered, skipping any subdirectories. If a project file
 // is present in the input paths, an error is returned.
diff --git a/cmd/atlas/internal/cmdlog/cmdlog.go b/cmd/atlas/internal/cmdlog/cmdlog.go
index 3642a9f7..2f749438 100644
--- a/cmd/atlas/internal/cmdlog/cmdlog.go
+++ b/cmd/atlas/internal/cmdlog/cmdlog.go
@@ -158,19 +158,18 @@ Last migration attempt had errors:
 
 // MigrateStatus contains a summary of the migration status of a database.
 type MigrateStatus struct {
-	context.Context `json:"-"`
-	Env             `json:"Env"`
-	Available       Files               `json:"Available,omitempty"`  // Available migration files
-	OutOfOrder      Files               `json:"OutOfOrder,omitempty"` // OutOfOrder migration files
-	Pending         Files               `json:"Pending,omitempty"`    // Pending migration files
-	Applied         []*migrate.Revision `json:"Applied,omitempty"`    // Applied migration files
-	Current         string              `json:"Current,omitempty"`    // Current migration version
-	Next            string              `json:"Next,omitempty"`       // Next migration version
-	Count           int                 `json:"Count,omitempty"`      // Count of applied statements of the last revision
-	Total           int                 `json:"Total,omitempty"`      // Total statements of the last migration
-	Status          string              `json:"Status,omitempty"`     // Status of migration (OK, PENDING)
-	Error           string              `json:"Error,omitempty"`      // Last Error that occurred
-	SQL             string              `json:"SQL,omitempty"`        // SQL that caused the last Error
+	Env        `json:"Env"`
+	Available  Files               `json:"Available,omitempty"`  // Available migration files
+	OutOfOrder Files               `json:"OutOfOrder,omitempty"` // OutOfOrder migration files
+	Pending    Files               `json:"Pending,omitempty"`    // Pending migration files
+	Applied    []*migrate.Revision `json:"Applied,omitempty"`    // Applied migration files
+	Current    string              `json:"Current,omitempty"`    // Current migration version
+	Next       string              `json:"Next,omitempty"`       // Next migration version
+	Count      int                 `json:"Count,omitempty"`      // Count of applied statements of the last revision
+	Total      int                 `json:"Total,omitempty"`      // Total statements of the last migration
+	Status     string              `json:"Status,omitempty"`     // Status of migration (OK, PENDING)
+	Error      string              `json:"Error,omitempty"`      // Last Error that occurred
+	SQL        string              `json:"SQL,omitempty"`        // SQL that caused the last Error
 }
 
 // Left returns the amount of statements left to apply (if any).
@@ -205,7 +204,7 @@ Current version is {{ cyan .Current.Version }} ({{ .Summary }}):
 type (
 	// MigrateSet contains a summary of the migrate set command.
 	MigrateSet struct {
-		context.Context `json:"-"`
+		ctx context.Context
 		// Revisions that were added, removed or updated.
 		Revisions []RevisionOp `json:"Revisions,omitempty"`
 		// Current version in the revisions table.
@@ -218,6 +217,11 @@ type (
 	}
 )
 
+// NewMigrateSet returns a MigrateSet.
+func NewMigrateSet(ctx context.Context) *MigrateSet {
+	return &MigrateSet{ctx: ctx}
+}
+
 // ByVersion returns all revisions sorted by version.
 func (r *MigrateSet) ByVersion() []RevisionOp {
 	sort.Slice(r.Revisions, func(i, j int) bool {
@@ -326,14 +330,14 @@ Migrating to version {{ cyan .Target }}{{ with .Current }} from {{ cyan . }}{{ e
 type (
 	// MigrateApply contains a summary of a migration applying attempt on a database.
 	MigrateApply struct {
+		ctx context.Context
 		Env
-		context.Context `json:"-"`
-		Pending         Files          `json:"Pending,omitempty"` // Pending migration files
-		Applied         []*AppliedFile `json:"Applied,omitempty"` // Applied files
-		Current         string         `json:"Current,omitempty"` // Current migration version
-		Target          string         `json:"Target,omitempty"`  // Target migration version
-		Start           time.Time
-		End             time.Time
+		Pending Files          `json:"Pending,omitempty"` // Pending migration files
+		Applied []*AppliedFile `json:"Applied,omitempty"` // Applied files
+		Current string         `json:"Current,omitempty"` // Current migration version
+		Target  string         `json:"Target,omitempty"`  // Target migration version
+		Start   time.Time
+		End     time.Time
 		// Error is set even then, if it was not caused by a statement in a migration file,
 		// but by Atlas, e.g. when committing or rolling back a transaction.
 		Error string `json:"Error,omitempty"`
@@ -354,9 +358,9 @@ type (
 // NewMigrateApply returns an MigrateApply.
 func NewMigrateApply(ctx context.Context, client *sqlclient.Client, dir migrate.Dir) *MigrateApply {
 	return &MigrateApply{
-		Context: ctx,
-		Env:     NewEnv(client, dir),
-		Start:   time.Now(),
+		ctx:   ctx,
+		Env:   NewEnv(client, dir),
+		Start: time.Now(),
 	}
 }
 
@@ -575,9 +579,9 @@ Schema is synced, no changes to be made
 type (
 	// SchemaApply contains a summary of a 'schema apply' execution on a database.
 	SchemaApply struct {
+		ctx context.Context `json:"-"`
 		Env
-		context.Context `json:"-"`
-		Changes         Changes `json:"Changes,omitempty"`
+		Changes Changes `json:"Changes,omitempty"`
 		// General error that occurred during execution.
 		// e.g., when committing or rolling back a transaction.
 		Error string `json:"Error,omitempty"`
@@ -593,8 +597,8 @@ type (
 // NewSchemaApply returns a SchemaApply.
 func NewSchemaApply(ctx context.Context, env Env, applied, pending []*migrate.Change, err *StmtError) *SchemaApply {
 	return &SchemaApply{
-		Env:     env,
-		Context: ctx,
+		ctx: ctx,
+		Env: env,
 		Changes: Changes{
 			Applied: applied,
 			Pending: pending,
@@ -627,9 +631,9 @@ func (c Changes) MarshalJSON() ([]byte, error) {
 
 // SchemaInspect contains a summary of the 'schema inspect' command.
 type SchemaInspect struct {
-	context.Context   `json:"-"`
-	*sqlclient.Client `json:"-"`
-	Realm             *schema.Realm `json:"Schema,omitempty"` // Inspected realm.
+	ctx    context.Context
+	client *sqlclient.Client
+	Realm  *schema.Realm `json:"Schema,omitempty"` // Inspected realm.
 }
 
 var (
@@ -646,10 +650,20 @@ var (
 				Parse(`{{ $.MarshalHCL }}`))
 )
 
+// NewSchemaInspect returns a SchemaInspect.
+func NewSchemaInspect(ctx context.Context, client *sqlclient.Client, realm *schema.Realm) *SchemaInspect {
+	return &SchemaInspect{ctx: ctx, client: client, Realm: realm}
+}
+
+// Client returns the client used to inspect the schema.
+func (s *SchemaInspect) Client() *sqlclient.Client {
+	return s.client
+}
+
 // MarshalHCL returns the default HCL representation of the schema.
 // Used by the template declared above.
 func (s *SchemaInspect) MarshalHCL() (string, error) {
-	spec, err := s.MarshalSpec(s.Realm)
+	spec, err := s.client.MarshalSpec(s.Realm)
 	if err != nil {
 		return "", err
 	}
@@ -792,7 +806,7 @@ func sqlInspect(report *SchemaInspect, indent ...string) (string, error) {
 	}
 	for _, s := range report.Realm.Schemas {
 		// Generate commands for creating the schemas on realm-mode.
-		if report.Client.URL.Schema == "" {
+		if report.client.URL.Schema == "" {
 			changes = append(changes, &schema.AddSchema{S: s})
 		}
 		for _, o := range s.Objects {
@@ -817,15 +831,15 @@ func sqlInspect(report *SchemaInspect, indent ...string) (string, error) {
 			changes = append(changes, &schema.AddProc{P: p})
 		}
 	}
-	return fmtPlan(report.Context, report.Client, changes, indent)
+	return fmtPlan(report.ctx, report.client, changes, indent)
 }
 
 // SchemaDiff contains a summary of the 'schema diff' command.
 type SchemaDiff struct {
-	context.Context   `json:"-"`
-	*sqlclient.Client `json:"-"`
-	From, To          *schema.Realm
-	Changes           []schema.Change
+	ctx      context.Context
+	client   *sqlclient.Client
+	From, To *schema.Realm
+	Changes  []schema.Change
 }
 
 var (
@@ -845,13 +859,27 @@ Schemas are synced, no changes to be made.
 `))
 )
 
+// NewSchemaDiff returns a SchemaDiff.
+func NewSchemaDiff(ctx context.Context, client *sqlclient.Client, from, to *schema.Realm, changes []schema.Change) *SchemaDiff {
+	return &SchemaDiff{
+		ctx:     ctx,
+		client:  client,
+		From:    from,
+		To:      to,
+		Changes: changes,
+	}
+}
+
+// Client returns the client used to inspect the schema.
+func (s *SchemaDiff) Client() *sqlclient.Client { return s.client }
+
 // MarshalSQL returns the default SQL representation of the schema.
 func (s *SchemaDiff) MarshalSQL(indent ...string) (string, error) {
 	return sqlDiff(s, indent...)
 }
 
 func sqlDiff(diff *SchemaDiff, indent ...string) (string, error) {
-	return fmtPlan(diff.Context, diff.Client, diff.Changes, indent)
+	return fmtPlan(diff.ctx, diff.client, diff.Changes, indent)
 }
 
 func fmtPlan(ctx context.Context, client *sqlclient.Client, changes schema.Changes, indent []string) (string, error) {
@@ -882,7 +910,7 @@ func fmtPlan(ctx context.Context, client *sqlclient.Client, changes schema.Chang
 }
 
 func mermaid(i *SchemaInspect, _ ...string) (string, error) {
-	ft, ok := i.Driver.(interface {
+	ft, ok := i.client.Driver.(interface {
 		FormatType(schema.Type) (string, error)
 	})
 	if !ok {
diff --git a/doc/md/atlas-schema/external.mdx b/doc/md/atlas-schema/external.mdx
index ca4ad2ff..f2e28a79 100644
--- a/doc/md/atlas-schema/external.mdx
+++ b/doc/md/atlas-schema/external.mdx
@@ -138,6 +138,7 @@ crafting them by hand. The supported ORMs are:
 | <span className="badge badge--secondary">Go</span>                                                                    | [Ent](https://entgo.io/docs/versioned-migrations), [Beego](/guides/orms/beego) | <span className="badge badge--primary">MySQL</span> <span className="badge badge--primary">MariaDB</span> <span className="badge badge--primary">PostgreSQL</span> <span className="badge badge--primary">SQLite</span>                                                          |
 | <span className="badge badge--secondary">Java</span>                                                                  | [Hibernate](/guides/orms/hibernate)                                            | <span className="badge badge--primary">MySQL</span> <span className="badge badge--primary">MariaDB</span> <span className="badge badge--primary">PostgreSQL</span> <span className="badge badge--primary">SQLite</span>                                                          |
 | <span className="badge badge--secondary">JavaScript</span> <span className="badge badge--secondary">TypeScript</span> | [Sequelize](/guides/orms/sequelize), [TypeORM](/guides/orms/typeorm)           | <span className="badge badge--primary">MySQL</span> <span className="badge badge--primary">MariaDB</span> <span className="badge badge--primary">PostgreSQL</span> <span className="badge badge--primary">SQLite</span> <span className="badge badge--primary">SQL Server</span> |
+| <span className="badge badge--secondary">PHP</span>                                                                   | [Doctine](/guides/orms/doctrine)                                               | <span className="badge badge--primary">MySQL</span> <span className="badge badge--primary">MariaDB</span> <span className="badge badge--primary">PostgreSQL</span> <span className="badge badge--primary">SQLite</span> <span className="badge badge--primary">SQL Server</span> |
 
 Coming soon are EF, Laravel, and Prisma. If you are using an ORM that is not listed here and would like to see
 it supported, [let us know!](https://github.com/ariga/atlas/issues/new)
diff --git a/doc/md/atlas-schema/hcl.mdx b/doc/md/atlas-schema/hcl.mdx
index d6cf4b40..bf5ecafb 100644
--- a/doc/md/atlas-schema/hcl.mdx
+++ b/doc/md/atlas-schema/hcl.mdx
@@ -777,6 +777,39 @@ concurrently first and then converted to a unique constraint. To achieve this, f
 4. Replace the `index` block with a `unique` block to create a new unique constraint using the existing index.
 :::
 
+### Exclude Constraints
+
+The `exclude` block allows defining a [exclusion constraint](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-EXCLUSION)
+supported by PostgreSQL:
+
+```hcl
+exclude "excl_speaker_during" {
+  type = GIST
+  on {
+    column = column.speaker
+    op     = "="
+  }
+  on {
+    column = column.during
+    op     = "&&"
+  }
+}
+
+# Include non-key columns.
+exclude "excl_speaker_during" {
+  type = GIST
+  on {
+    column = column.speaker
+    op     = "="
+  }
+  on {
+    column = column.during
+    op     = "&&"
+  }
+  include = [column.another]
+}
+```
+
 ## Trigger
 
 :::info LOGIN REQUIRED
@@ -938,6 +971,34 @@ trigger "audit_log_trigger" {
 }
 ```
 
+
+## Event Trigger
+
+:::info LOGIN REQUIRED
+Event Triggers are currently available to logged-in users only. To use this feature, run:
+```
+atlas login
+```
+:::
+
+The `event_trigger` block allows defining [PostgreSQL event trigger](https://www.postgresql.org/docs/current/event-trigger-definition.html) functions
+that automatically execute in response to specific events within the database system, like table creation or schema modifications.
+
+```hcl
+# Block table rewrites.
+event_trigger "block_table_rewrite" {
+  on      = table_rewrite
+  execute = function.no_rewrite_allowed
+}
+
+# Filter specific events.
+event_trigger "record_table_creation" {
+  on      = ddl_command_start
+  tags    = ["CREATE TABLE"]
+  execute = function.record_table_creation
+}
+```
+
 ## Function
 
 :::info LOGIN REQUIRED
@@ -1351,6 +1412,41 @@ schema "public" {
 }
 ```
 
+
+## Composite Type
+
+:::info LOGIN REQUIRED
+Composite types are currently available to logged-in users only. To use this feature, run:
+```
+atlas login
+```
+:::
+
+The `composite` type is a user-defined data type that represents the structure of a row or record. Supported by PostgreSQL.
+
+```hcl
+composite "address" {
+  schema = schema.public
+  field "street" {
+    type = text
+  }
+  field "city" {
+    type = text
+  }
+}
+
+table "users" {
+  schema = schema.public
+  column "address" {
+    type = composite.address
+  }
+}
+
+schema "public" {
+  comment = "standard public schema"
+}
+```
+
 ## Sequence
 
 :::info LOGIN REQUIRED
diff --git a/doc/md/cli-data-privacy.mdx b/doc/md/cli-data-privacy.mdx
new file mode 100644
index 00000000..6f4d970d
--- /dev/null
+++ b/doc/md/cli-data-privacy.mdx
@@ -0,0 +1,85 @@
+---
+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
+```
+
+After running this command, follow the instructions on the screen to log in. If needed, you will be prompted
+to create an account.
+
+## What data does the Atlas CLI collect?
+
+:::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.
+
+:::
+
+As a database management tool, Atlas has access to a lot of sensitive data. We take privacy and security very seriously,
+and we want to be transparent about what data we collect and how we use it.
+
+If you want the full details in formal and legal language you can read our [Privacy Policy](https://ariga.io/legal/privacy)
+and [SaaS Agreement](https://ariga.io/legal/saas) which you sign when you create an account, but here is a summary of
+the data we collect in layman's terms.
+
+### Data we DON'T collect
+
+* **Your Data**: We don't collect any data from your databases. We don't know what data you have in your databases,
+  and we don't have access to it.
+* **Your Database Passwords**: We don't collect, transmit or store your database passwords.
+* **Your database schema or migrations**: Unless you explicitly use a command or flag that sends us your schema or
+  migrations (as described below), we don't collect, transmit or store your database schema, migrations or execution logs.
+
+### Data we DO collect
+
+* **Errors and Crashes** - We collect error and crash reports to help us improve the CLI. This includes things like the
+  stack trace of the error, the command you were running, and the version of the CLI you were using. We are very careful
+  not to collect and transmit any sensitive data in these reports.
+* **Data you choose to report** - Some Atlas commands, such as `migrate push` (which is used to upload your migration
+  directory to the Atlas Schema Registry) or flags (like running `schema inspect` with the `--web` or `-w` flag), naturally
+  involve sending us data. See below for more details on these commands and flags.
+* **Basic Telemetry** - We collect basic telemetry data to help us understand how users are using the CLI and to enforce
+  license restrictions.
+
+  This includes things like the commands you run, general information about the runtime environment (like OS version
+  or if you're running on GitHub Actions), your local hostname, the type of database you're using, and the version
+  of the  CLI you're using. We are very careful not to collect and transmit any sensitive data in these reports.
+
+### We only collect what you send us
+
+In principle, aside from basic telemetry and error reporting (with no sensitive data), we only collect data that you
+explicitly choose to send us. Consider the following list of commands and flags that send us data:
+
+* `schema inspect` when used with the `--web` or `-w` flag: This flag opens a web browser to view the schema of your
+  database. This the schema of your database.
+* `migrate push`: This command uploads your migration directory to the Atlas Schema Registry. This includes your schema
+  and migrations.
+* `migrate apply` when used with a directory that is stored in the schema registry (e.g the url has a `atlas://` prefix)
+  AND the `--env` flag is used: This flag applies a migration from the Atlas Schema Registry to your database and uploads
+  the execution logs to Atlas Cloud, updating the status of the target database in your account. We also store the
+  hostname of the target database to help you identify which database the logs belong to.
+* `schema apply` when used with the interactive `Lint and Edit` mode: This mode allows you to edit your schema in a
+  visual editor and then apply the changes to your database. This sends your schema to Atlas Cloud for processing.
+* `migrate lint` when used with the `--web` or `-w` flag: This flag opens a web browser to view the linting results of
+  your migrations. This sends your migrations to Atlas Cloud for processing.
+
+## How do we use the data we collect?
+
+We use the data we collect to improve the CLI and provide better support to our users. This includes things like:
+
+* Fixing bugs and crashes
+* Improving the performance of the CLI
+* Understanding how users are using the CLI
+* Providing better experience to users
+
+We don't sell your data to third parties, and use it strictly in compliance with our [Privacy Policy](https://ariga.io/legal/privacy)
+as well as applicable laws and regulations.  We take data privacy and security very seriously and hold ourselves accountable
+to the highest standards.  To ensure this commitment is upheld we perform an annual SOC 2 audit which you can read more
+[about here](https://ariga.io/announcing-soc2-compliance-for-atlas-cloud/).
\ No newline at end of file
diff --git a/doc/md/components/login-required.mdx b/doc/md/components/login-required.mdx
index e45312d8..43890e83 100644
--- a/doc/md/components/login-required.mdx
+++ b/doc/md/components/login-required.mdx
@@ -1,3 +1,3 @@
-<span className="badge login badge--info">
+<span className="badge login badge--info" style={{fontSize: "12px"}}>
     <a style={{color: "white", textDecoration: "none"}} href="/cloud/getting-started">Login Required</a>
 </span>
\ No newline at end of file
diff --git a/doc/md/concepts/migration-directory-integrity.md b/doc/md/concepts/migration-directory-integrity.md
index 8c56c6f1..cc80b1e6 100644
--- a/doc/md/concepts/migration-directory-integrity.md
+++ b/doc/md/concepts/migration-directory-integrity.md
@@ -63,8 +63,7 @@ with the changes landed by Team B.
 
 Because of the merge conflict on the `atlas.sum` file, in order to land
 their changes, Team B would need to:
-1. Merge the latest changes into their branch
-2. Resolve any conflicts on the database schema (and application) level
-3. Re-compute the `atlas.sum` file (using the [`atlas migrate hash`](/cli-reference#atlas-migrate-hash))
- command.
-4. Merge their changes to the mainline branch. 
+1. Merge the latest changes from the master branch into their branch.
+2. If their migration file it not the latest one, they can use `atlas migrate rebase` to rebase their changes on top of all migrations.
+3. In case there are any conflicts on the database schema (and application) level, resolve them and run [`atlas migrate hash`](/cli-reference#atlas-migrate-hash).
+4. Update the PR, wait for Atlas CI to pass, and merge the changes.
diff --git a/doc/md/declarative/diff.mdx b/doc/md/declarative/diff.mdx
index 076ff5c3..7eb23775 100644
--- a/doc/md/declarative/diff.mdx
+++ b/doc/md/declarative/diff.mdx
@@ -19,6 +19,19 @@ To accommodate these types of use-cases, Atlas offers the `schema diff` that acc
 to migrate the "from" schema to the state defined by the "to" schema. A state can be specified using a database URL,
 an HCL or SQL schema, or a migration directory.
 
+:::info Login to Atlas
+By default, running `atlas schema diff` diffs only **schemas**, **tables**, and their associated **indexes** and
+constraints such as **foreign keys** and **checks**.
+
+**Views**, **materialized views**, **functions**, **procedures**, **triggers**, **sequences**, **domains**, **extensions**,
+and additional features are available to logged-in users only. To include these resources in the schema diffing, use the
+following command:
+
+```shell
+atlas login
+```
+:::
+
 ## Flags
 * `--from` - a list of [URLs](/concepts/url) to the current state: can be a database URL, an HCL or
 SQL schema, or a migration directory.
diff --git a/doc/md/features.mdx b/doc/md/features.mdx
index 9c3a350e..3ed4828e 100644
--- a/doc/md/features.mdx
+++ b/doc/md/features.mdx
@@ -31,16 +31,16 @@ This document compares the CLI features available to users who are logged in to
 
 ### CLI Features
 
-| Feature             | Availability     |
-|---------------------|------------------|
-| Database Inspection | Open |
-| Schema Diffing      | Open |
-| Versioned Migrations| Open |
-| Declarative Migrations | Open |
-| Migration Linting   | Open |
-| Migration Checkpoints   | Requires Login |
-| Schema Visualization| Requires Login   |
-| Interactive Migrations | Requires Login   |
+| Feature                | Availability   |
+|------------------------|----------------|
+| Database Inspection    | Open           |
+| Schema Diffing         | Open           |
+| Versioned Migrations   | Open           |
+| Declarative Migrations | Open           |
+| Migration Linting      | Open           |
+| Migration Checkpoints  | Requires Login |
+| Schema Visualization   | Requires Login |
+| Interactive Migrations | Requires Login |
 
 Schema Visualization as well as Interactive Migrations are features that require connectivity to the cloud and hence are not available in the open-source version of Atlas.
 
@@ -62,21 +62,22 @@ All common open-source RDBMS are supported in all versions of Atlas. In addition
 
 Most common database features are supported in all versions of Atlas. More advanced features are available by running `atlas login`.
 
-| Feature                 | Availability   |
-|-------------------------|----------------|
-| Tables and Columns      | Open           |
-| Indexes                 | Open           |
-| Foreign Keys            | Open           |
-| Constraints             | Open           |
-| Partitions              | Open           |
-| Views                   | Requires Login |
-| Materialized Views      | Requires Login |
-| Stored Procedures       | Requires Login |
-| Functions               | Requires Login |
-| Triggers                | Requires Login |
-| Sequences (Postgres)    | Requires Login |
-| Domain Types (Postgres) | Requires Login |
-| Extensions (Postgres)   | Requires Login |
+| Feature                    | Availability   |
+|----------------------------|----------------|
+| Tables and Columns         | Open           |
+| Indexes                    | Open           |
+| Foreign Keys               | Open           |
+| Constraints                | Open           |
+| Partitions                 | Open           |
+| Views                      | Requires Login |
+| Materialized Views         | Requires Login |
+| Stored Procedures          | Requires Login |
+| Functions                  | Requires Login |
+| Triggers                   | Requires Login |
+| Sequences (Postgres)       | Requires Login |
+| Composite Types (Postgres) | Requires Login |
+| Domain Types (Postgres)    | Requires Login |
+| Extensions (Postgres)      | Requires Login |
 
 ### Data Sources
 
diff --git a/doc/md/guides/guides.mdx b/doc/md/guides/guides.mdx
index cc5332d1..8fb7e527 100644
--- a/doc/md/guides/guides.mdx
+++ b/doc/md/guides/guides.mdx
@@ -280,6 +280,12 @@ import { Card, CardImage } from "../components/card";
     url="/guides/orms/django"
     image={CardImage.Tools}
   />
+  <Card
+    name="Doctrine"
+    description="Learn about automatic migration planning for Doctrine using Atlas"
+    url="/guides/orms/doctrine"
+    image={CardImage.Tools}
+  />
 </div>
 
 ## SQL Server
diff --git a/doc/md/guides/orms/doctrine.mdx b/doc/md/guides/orms/doctrine.mdx
new file mode 100644
index 00000000..e79fb1af
--- /dev/null
+++ b/doc/md/guides/orms/doctrine.mdx
@@ -0,0 +1,401 @@
+---
+id: doctrine
+title: Automatic migration planning for Doctrine
+slug: /guides/orms/doctrine
+---
+
+import InstallationInstructions from '../../components/_installation_instructions.mdx'
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+## TL;DR
+* [Doctrine](https://www.doctrine-project.org/) is an ORM library that's widely used in the PHP community.
+* [Atlas](https://atlasgo.io) is an open-source tool for inspecting, planning, linting and
+  executing schema changes to your database.
+* Developers using Doctrine can use Atlas to automatically plan schema migrations
+  for them, based on the desired state of their schema instead of crafting them by hand.
+
+## Automatic migration planning for Doctrine
+Doctrine is a popular ORM widely used in the PHP community, used by projects like [Symfony](https://symfony.com/).
+Doctrine allows users to manage their database schemas using its [orm:schema-tool](https://www.doctrine-project.org/projects/doctrine-orm/en/3.1/reference/tools.html#schematool) Console Command,
+which is usually sufficient during development and in many simple cases.
+
+However, at some point, teams need more control and decide to employ a [versioned migrations](/concepts/declarative-vs-versioned#versioned-migrations) methodology.
+Doctrine has a [migration tool](https://www.doctrine-project.org/projects/doctrine-migrations/en/3.7/index.html) that can be used to manage versioned migrations.
+Doctrine can also [generate a migration](https://www.doctrine-project.org/projects/doctrine-migrations/en/3.7/reference/generating-migrations.html)
+for you by comparing the current state of your database schema to the state defined by using the ORM.
+
+A downside of this approach is that in order for it to work, you must be connected to a pre-existing database with the current version of the schema.
+In many production environments, databases should generally not be reachable from developer workstations,
+which means this comparison is normally done against a local copy of the database which may have
+undergone some changes that aren't reflected in the existing migrations.
+
+More of the limitations of the Doctrine migration tool include:
+
+* **Ensuring Migration Safety.** Migrations are a risky business. If you're not careful, you can easily cause data loss or 
+  a production outage. The Doctrine migration tool does not provide a native way to ensure that a migration is safe to apply.
+* **Modern Deployments.**  Doctrine does not provide native integrations with modern deployment practices such as [GitOps](/blog/2023/12/06/gitops-for-databases-part-1)
+  or [Infrastructure-as-Code](/blog/2023/07/19/bridging-the-gap-between-iac-and-schema-management).
+
+Using Atlas, you can enjoy automatic migration planning, automatic code review and integrations with your favorite CI/CD tools.
+
+Atlas works in a similar way to the Doctrine migration tool, by calculating the diff between the _current_ state of the database.
+
+In the context of [versioned migrations](/concepts/declarative-vs-versioned#versioned-migrations),
+the current state can be thought of as the database schema that would have
+been created by applying all previous migration scripts.
+
+The desired schema of your application can be provided to Atlas via an [External Schema Datasource](/atlas-schema/projects#data-source-external_schema),
+which is any program that can output a SQL schema definition to stdout.
+
+To use Atlas with Doctrine, users can utilize the [Doctrine Atlas Provider](https://github.com/ariga/atlas-provider-doctrine),
+which is a small program that loads the schema of a Doctrine project into Atlas.
+
+In this guide, we will show how to use Atlas to automatically plan schema migrations for Doctrine users.
+
+## Prerequisites
+
+* A local [Doctrine](https://www.doctrine-project.org/projects/doctrine-orm/en/3.1/index.html) project.
+
+If you don't have a Doctrine project, you can use [symfony/demo](https://github.com/symfony/demo) as a starting point:
+
+```bash
+git clone git@github.com:symfony/demo.git
+```
+
+## Using the Atlas Doctrine Provider
+
+In this guide, we will use the [Doctrine Atlas Provider](https://github.com/ariga/atlas-provider-doctrine)
+to automatically plan schema migrations for a Doctrine project.
+
+### Installation
+
+<InstallationInstructions />
+
+Install the provider, depending on the [`doctrine/dbal`](https://www.doctrine-project.org/projects/dbal.html)
+version you are using in the `composer.json` file:
+<Tabs>
+<TabItem value="v4" label="Doctrine DBAL v4" default>
+
+```bash
+composer require ariga/atlas-provider-doctrine:^4
+```
+</TabItem>
+<TabItem value="v3" label="Dcotrine DBAL v3">
+
+```bash
+composer require ariga/atlas-provider-doctrine:^3
+```
+
+</TabItem>
+</Tabs>
+
+### Configuration
+
+The Atlas Doctrine Provider can be used in three modes:
+
+* **Doctrine Command** - If all of your Doctrine entities exist under a single directory,
+you can add the `atlas-provider` command to the [Doctrine Console](https://www.doctrine-project.org/projects/doctrine-orm/en/3.0/reference/tools.html) file.
+* **Symfony Bundle** - If you use a [Symfony](https://symfony.com/) project, you can register the provider as a Symfony bundle.
+* **Script** - In other cases, you can use the provider as a PHP script to load your Doctrine schema into Atlas.
+
+### Using the Doctrine Command
+
+Add the `atlas-provider` command to the Doctrine Console file:
+
+```diff
+#!/usr/bin/env php
+<?php
+
+use Doctrine\ORM\Tools\Console\ConsoleRunner;
+use Doctrine\ORM\Tools\Console\EntityManagerProvider\SingleManagerProvider;
+
+require 'bootstrap.php';
++ require "vendor/ariga/atlas-provider-doctrine/src/Command.php";
+
+ConsoleRunner::run(
+    new SingleManagerProvider($entityManager),
++   [new AtlasCommand()]
+);
+```
+
+Next, in your project directory, create a new file named `atlas.hcl` with the following contents:
+
+```hcl
+data "external_schema" "doctrine" {
+  program = [
+    "php",
+    "bin/doctrine", // path to your Doctrine Console file
+    "atlas:schema",
+    "--path", "./path/to/entities",
+    "--dialect", "mysql" // mariadb | postgres | sqlite | sqlserver
+  ]
+}
+
+env "doctrine" {
+  src = data.external_schema.doctrine.url
+  dev = "docker://mysql/8/dev"
+  migration {
+    dir = "file://migrations"
+  }
+  format {
+    migrate {
+      diff = "{{ sql . \"  \" }}"
+    }
+  }
+}
+```
+
+### As Symfony Bundle
+
+Add the following bundle to your `config/bundles.php` file:
+
+```diff
+<?php
+
+require "vendor/autoload.php";
+
+return [
+    ...
++  Ariga\AtlasDoctrineBundle::class => ['all' => true],
+];
+
+```
+
+Next, in your project directory, create a new file named `atlas.hcl` with the following contents:
+
+```hcl
+data "external_schema" "doctrine" {
+  program = [
+    "php",
+    "bin/console",
+    "atlas:schema"
+  ]
+}
+
+env "doctrine" {
+  src = data.external_schema.doctrine.url
+  dev = "docker://mysql/8/dev"
+  migration {
+    dir = "file://migrations"
+  }
+  format {
+    migrate {
+      diff = "{{ sql . \"  \" }}"
+    }
+  }
+}
+```
+
+:::note
+The provider does not support loading entities via [entity listeners](https://symfony.com/bundles/DoctrineBundle/current/entity-listeners.html).
+To load Symfony schema with entity listeners, use the [Doctrine Bundle](https://symfony.com/doc/current/reference/configuration/doctrine.html) directly:
+
+```diff
+data "external_schema" "doctrine" {
+  program = [
+    "php",
+    "bin/console",
+-   "atlas:schema"
++   "doctrine:schema:create",
++   "--dump-sql"
+  ]
+}
+```
+:::
+
+
+### As a PHP Script
+
+Create a new file named `atlas.php` with the following contents:
+
+```php
+<?php
+
+require "vendor/autoload.php";
+require "vendor/ariga/atlas-provider-doctrine/src/LoadEntities.php";
+
+print (DumpDDL(["./path/to/first/entities", "./path/to/more/entities"], "mysql"));
+```
+
+Next, in your project directory, create a new file named `atlas.hcl` with the following contents:
+
+```hcl
+data "external_schema" "doctrine" {
+  program = [
+    "php",
+    "atlas.php"
+  ]
+}
+
+env "doctrine" {
+  src = data.external_schema.doctrine.url
+  dev = "docker://mysql/8/dev"
+  migration {
+    dir = "file://migrations"
+  }
+  format {
+    migrate {
+      diff = "{{ sql . \"  \" }}"
+    }
+  }
+}
+```
+
+## Usage
+
+Atlas supports a versioned migrations workflow, where each change to the database is versioned and recorded in a migration file. You can use the
+`atlas migrate diff` command to automatically generate a migration file that will migrate the database
+from its latest revision to the current Doctrine schema.
+
+Suppose we have the following files in our `src/entities` directory:
+
+<Tabs>
+<TabItem value="user" label="User.php" default>
+
+```php
+<?php
+
+namespace entities;
+
+use Bug;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\ORM\Mapping as ORM;
+
+#[ORM\Entity]
+#[ORM\Table(name: 'users')]
+class User
+{
+    #[ORM\Id]
+    #[ORM\GeneratedValue]
+    #[ORM\Column(type: 'integer')]
+    private int|null $id = null;
+
+    #[ORM\Column(type: 'string')]
+    private string $name;
+
+    #[ORM\OneToMany(targetEntity: Bug::class, mappedBy: 'reporter')]
+    private Collection $reportedBugs;
+
+    #[ORM\OneToMany(targetEntity: Bug::class, mappedBy: 'engineer')]
+    private $assignedBugs;
+}
+
+```
+</TabItem>
+<TabItem value="bug" label="Bug.php">
+
+```php
+<?php
+
+namespace entities;
+
+use Doctrine\ORM\Mapping as ORM;
+use User;
+
+#[ORM\Entity]
+#[ORM\Table(name: 'bugs')]
+class Bug
+{
+    #[ORM\Id]
+    #[ORM\Column(type: 'integer')]
+    #[ORM\GeneratedValue]
+    private int|null $id = null;
+
+    #[ORM\Column(type: 'string')]
+    private string $description;
+
+    #[ORM\Column(type: 'datetime')]
+    private DateTime $created;
+
+    #[ORM\Column(type: 'string')]
+    private string $status;
+
+    #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'assignedBugs')]
+    private User|null $engineer = null;
+
+    #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'reportedBugs')]
+    private User|null $reporter;
+}
+
+```
+</TabItem>
+</Tabs>
+
+We can generate a migration file by running the following command:
+
+```bash
+atlas migrate diff --env doctrine
+```
+
+Running this command will generate files in the `migrations` directory, similarly to:
+
+```
+migrations
+|-- 20240310094824.sql
+`-- atlas.sum
+
+0 directories, 2 files
+```
+
+Examining the contents of the `20240310094824.sql`:
+
+
+```sql
+-- Create "users" table
+CREATE TABLE `users` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  PRIMARY KEY (`id`)
+) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+-- Create "bugs" table
+CREATE TABLE `bugs` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `description` varchar(255) NOT NULL,
+  `created` datetime NOT NULL,
+  `status` varchar(255) NOT NULL,
+  `engineer_id` int NULL,
+  `reporter_id` int NULL,
+  PRIMARY KEY (`id`),
+  INDEX `IDX_1E197C9E1CFE6F5` (`reporter_id`),
+  INDEX `IDX_1E197C9F8D8CDF1` (`engineer_id`),
+  CONSTRAINT `FK_1E197C9E1CFE6F5` FOREIGN KEY (`reporter_id`) REFERENCES `users` (`id`) ON UPDATE NO ACTION ON DELETE NO ACTION,
+  CONSTRAINT `FK_1E197C9F8D8CDF1` FOREIGN KEY (`engineer_id`) REFERENCES `users` (`id`) ON UPDATE NO ACTION ON DELETE NO ACTION
+) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+
+```
+
+Amazing! Atlas automatically generated a migration file that will create the `users` and `bugs` tables in our database.
+
+Next, alter the `User` entity to include a `$name` property.
+
+```diff
+    #[ORM\Id]
+    #[ORM\GeneratedValue]
+    #[ORM\Column(type: 'integer')]
+    private int|null $id = null;
+
++   #[ORM\Column(type: 'string')]
++   private string $name;
+```
+
+Re-run this command:
+
+```bash
+atlas migrate diff --env doctrine
+```
+
+Observe that a new migration file is generated:
+
+```sql
+-- Modify "users" table
+ALTER TABLE `users` ADD COLUMN `name` varchar(255) NOT NULL;
+```
+
+## Conclusion
+
+In this guide, we demonstrated how projects using Dcotrine can use Atlas to automatically
+plan schema migrations based only on their data model. To learn more about executing
+migrations against your production database, read the documentation about the
+[`migrate apply`](/versioned/apply) command.
+
+Have questions? Feedback? Find our team [on our Discord server](https://discord.gg/zZ6sWVg6NT)
diff --git a/doc/md/versioned/apply.mdx b/doc/md/versioned/apply.mdx
index 16ee2461..40b5f2d7 100644
--- a/doc/md/versioned/apply.mdx
+++ b/doc/md/versioned/apply.mdx
@@ -6,6 +6,7 @@ title: Applying Migrations
 
 import Tabs from '@theme/Tabs';
 import TabItem from '@theme/TabItem';
+import LoginRequired from '../components/login-required.mdx'
 
 With the `atlas migrate apply` command, users can apply pending migrations to database(s). The typical flow for introducing
 schema changes to databases is as follows: **Develop** ⇨ **Check (CI)** ⇨ **Push (CD)** ⇨ **Deploy**.
@@ -294,6 +295,32 @@ version is lower than the current database version. Below are multiple options t
   - Running `atlas migrate apply` (deployment) again should pass without this error.
 
 
+### Migration Hooks <LoginRequired /> {#migration-hooks}
+
+Atlas supports executing custom SQL statements in two stages of the migration process: after the transaction has been
+started, and before it is committed or rolled back. A typical use case for migration hooks is to control the `statement_timeout`
+or `lock_timeout` in PostgreSQL to prevent migrations from blocking other ongoing operations.
+
+```hcl title="atlas.hcl" {1-10}
+hook "sql" "timeout" {
+  transaction {
+    after_begin = [
+      "SET statement_timeout TO '50ms'",
+    ]
+    before_commit = [
+      // ...
+    ]
+  }
+}
+
+env {
+  name = atlas.env
+  migration {
+    dir = "file://migrations"
+  }
+}
+```
+
 ### Existing Databases
 
 #### Baseline migration
@@ -422,518 +449,8 @@ atlas migrate apply \
 Migrations that "roll back" or reverse changes made to the database schema are called "down migrations". These are often
 used during local development to undo the changes made by corresponding "up migrations". Atlas follows a linear
 migration history model, in which all migration files are "roll-forward". However, it is still possible to clean or
-revert schema changes made by specific migration files using the `schema clean` or `schema apply` commands. For example:
-
-#### Reverting deleted migration files
-
-1\. To undo the changes made by migration files that have already been deleted, you can use the [`schema apply`](../declarative/apply#migration-directory)
-command to roll back the database state to the state defined by the migration directory:
-
-<Tabs defaultValue="mysql">
-<TabItem value="mysql" label="MySQL">
-
-```shell
-atlas schema apply \
-  --url "mysql://root:pass@localhost:3306/example" \
-  --to "file://migrations" \
-  --dev-url "docker://mysql/8/example" \
-  --exclude "atlas_schema_revisions"
-```
-
-</TabItem>
-<TabItem value="maria" label="MariaDB">
-
-```shell
-atlas schema apply \
-  --url "maria://root:pass@localhost:3306/example" \
-  --to "file://migrations" \
-  --dev-url "docker://maria/latest/example" \
-  --exclude "atlas_schema_revisions"
-```
-
-</TabItem>
-<TabItem value="postgres" label="PostgreSQL">
-
-```shell
-atlas schema apply \
-  --url "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable" \
-  --to "file://migrations" \
-  --dev-url "docker://postgres/15" \
-  --exclude "atlas_schema_revisions"
-```
-
-</TabItem>
-<TabItem value="sqlite" label="SQLite">
-
-```shell
-atlas schema apply \
-  --url "sqlite://file.db" \
-  --to "file://migrations" \
-  --dev-url "sqlite://file?mode=memory" \
-  --exclude "atlas_schema_revisions"
-```
-
-</TabItem>
-<TabItem value="sqlserver" label="SQL Server">
-
-```shell
-atlas schema apply \
-  --url "sqlserver://sa:pass@remote:1433?database=master" \
-  --to "file://migrations" \
-  --dev-url "docker://sqlserver/2022-latest" \
-  --exclude "atlas_schema_revisions"
-```
-
-</TabItem>
-<TabItem value="clickhouse" label="ClickHouse">
-
-```shell
-atlas schema apply \
-  --url "clickhouse://user:pass@remote:9000/default" \
-  --to "file://migrations" \
-  --dev-url "clickhouse://localhost:9000/default" \
-  --exclude "atlas_schema_revisions"
-```
-
-</TabItem>
-</Tabs>
-
-2\. Remove the files/versions that were rolled back from the revision table:
-
-<Tabs defaultValue="mysql">
-<TabItem value="mysql" label="MySQL">
-
-```shell
-atlas migrate set \
-  --url "mysql://root:pass@localhost:3306/example" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="maria" label="MariaDB">
-
-```shell
-atlas migrate set \
-  --url "maria://root:pass@localhost:3306/example" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="postgres" label="PostgreSQL">
-
-```shell
-atlas migrate set \
-  --url "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="sqlite" label="SQLite">
-
-```shell
-atlas migrate set \
-  --url "sqlite://file.db" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="sqlserver" label="SQL Server">
-
-```shell
-atlas migrate set \
-  --url "sqlserver://sa:pass@remote:1433?database=master" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="clickhouse" label="ClickHouse">
-
-```shell
-atlas migrate set \
-  --url "clickhouse://user:pass@remote:9000/default" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-</Tabs>
-
-<details>
-  <summary>Example output</summary>
-  <div>
-
-  ```text
-  Current version is 20221207170547 (2 removed):
-
-    - 20221225113035 (add_users)
-    - 20221225113406 (add_blog_posts)
-
-  ```
-
-  </div>
-</details>
-
-3\. Verify the migration status by using the `atlas migrate status` command:
-
-<Tabs defaultValue="mysql">
-<TabItem value="mysql" label="MySQL">
-
-```shell
-atlas migrate status \
-  --url "mysql://root:pass@localhost:3306/example" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="maria" label="MariaDB">
-
-```shell
-atlas migrate status \
-  --url "maria://root:pass@localhost:3306/example" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="postgres" label="PostgreSQL">
-
-```shell
-atlas migrate status \
-  --url "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="sqlite" label="SQLite">
-
-```shell
-atlas migrate status \
-  --url "sqlite://file.db" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="sqlserver" label="SQL Server">
-
-```shell
-atlas migrate status \
-  --url "sqlserver://sa:pass@remote:1433?database=master" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="clickhouse" label="ClickHouse">
-
-```shell
-atlas migrate status \
-  --url "clickhouse://user:pass@remote:9000/default" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-</Tabs>
-
-<details>
-  <summary>Example output</summary>
-  <div>
-
-  ```text
-  Migration Status: OK
-    -- Current Version: 20221207170547
-    -- Next Version:    Already at latest version
-    -- Executed Files:  25
-    -- Pending Files:   0
-  ```
-
-  </div>
-</details>
-
-#### Reverting applied migration files
-
-1\. To undo the changes made by migration files that are still present in the migration directory, you can use the
-`version` parameter to specify that the database should be rolled back to the state defined in a specific version of
-the migration directory. For example:
-
-<Tabs defaultValue="mysql">
-<TabItem value="mysql" label="MySQL">
-
-```shell {3}
-atlas schema apply \
-  --url "mysql://root:pass@localhost:3306/example" \
-  --to "file://migrations?version=20220925094437" \
-  --dev-url "docker://mysql/8/example" \
-  --exclude "atlas_schema_revisions"
-```
-
-</TabItem>
-<TabItem value="maria" label="MariaDB">
-
-```shell {3}
-atlas schema apply \
-  --url "maria://root:pass@localhost:3306/example" \
-  --to "file://migrations?version=20220925094437" \
-  --dev-url "docker://maria/latest/example" \
-  --exclude "atlas_schema_revisions"
-```
-
-</TabItem>
-<TabItem value="postgres" label="PostgreSQL">
-
-```shell {3}
-atlas schema apply \
-  --url "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable" \
-  --to "file://migrations?version=20220925094437" \
-  --dev-url "docker://postgres/15" \
-  --exclude "atlas_schema_revisions"
-```
-
-</TabItem>
-<TabItem value="sqlite" label="SQLite">
-
-```shell {3}
-atlas schema apply \
-  --url "sqlite://file.db" \
-  --to "file://migrations?version=20220925094437" \
-  --dev-url "sqlite://file?mode=memory" \
-  --exclude "atlas_schema_revisions"
-```
-
-</TabItem>
-<TabItem value="sqlserver" label="SQL Server">
-
-```shell {3}
-atlas schema apply \
-  --url "sqlserver://sa:pass@remote:1433?database=master" \
-  --to "file://migrations?version=20220925094437" \
-  --dev-url "docker://sqlserver/2022-latest" \
-  --exclude "atlas_schema_revisions"
-```
-
-</TabItem>
-<TabItem value="clickhouse" label="ClickHouse">
-
-```shell {3}
-atlas schema apply \
-  --url "clickhouse://user:pass@remote:9000/default" \
-  --to "file://migrations?version=20220925094437" \
-  --dev-url "docker://clickhouse/23.11/default" \
-  --exclude "atlas_schema_revisions"
-```
-
-</TabItem>
-</Tabs>
-
-```shell title="tree ." {6}
-.
-└── migrations
-    ├── 20220925092817_initial.sql
-    ├── 20220925094021_second.sql
-    ├── 20220925094437_third.sql
-    ├── 20220925094438_fourth.sql # changes made by this file are reverted.
-    └── atlas.sum
-```
-
-2\. To remove the `20220925094438` version from the revision table, set the current version to the preceding one `20220925094437`:
-
-<Tabs defaultValue="mysql">
-<TabItem value="mysql" label="MySQL">
-
-```shell
-atlas migrate set 20220925094437 \
-  --url "mysql://root:pass@localhost:3306/example" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="maria" label="MariaDB">
-
-```shell
-atlas migrate set 20220925094437 \
-  --url "maria://root:pass@localhost:3306/example" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="postgres" label="PostgreSQL">
-
-```shell
-atlas migrate set 20220925094437 \
-  --url "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="sqlite" label="SQLite">
-
-```shell
-atlas migrate set 20220925094437 \
-  --url "sqlite://file.db" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="sqlserver" label="SQL Server">
-
-```shell
-atlas migrate set 20220925094437 \
-  --url "sqlserver://sa:pass@remote:1433?database=master" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="clickhouse" label="ClickHouse">
-
-```shell
-atlas migrate set 20220925094437 \
-  --url "clickhouse://user:pass@remote:9000/default" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-</Tabs>
-
-<details>
-  <summary>Example output</summary>
-  <div>
-
-  ```text
-  Current version is 20220925094437 (1 removed):
-
-    - 20220925094438 (fourth)
-
-  ```
-
-  </div>
-</details>
-
-3\. Verify the migration status by using the `atlas migrate status` command:
-
-<Tabs defaultValue="mysql">
-<TabItem value="mysql" label="MySQL">
-
-```shell
-atlas migrate status \
-  --url "mysql://root:pass@localhost:3306/example" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="maria" label="MariaDB">
-
-```shell
-atlas migrate status \
-  --url "maria://root:pass@localhost:3306/example" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="postgres" label="PostgreSQL">
-
-```shell
-atlas migrate status \
-  --url "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="sqlite" label="SQLite">
-
-```shell
-atlas migrate status \
-  --url "sqlite://file.db" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="sqlserver" label="SQL Server">
-
-```shell
-atlas migrate status \
-  --url "sqlserver://sa:pass@remote:1433?database=master" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="clickhouse" label="ClickHouse">
-
-```shell
-atlas migrate status \
-  --url "clickhouse://user:pass@remote:9000/default" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-</Tabs>
-
-<details>
-  <summary>Example output</summary>
-  <div>
-
-  ```text
-  Migration Status: PENDING
-    -- Current Version: 20220925094437
-    -- Next Version:    20220925094438
-    -- Executed Files:  3
-    -- Pending Files:   1
-  ```
-
-  </div>
-</details>
-
-#### Reverting all schema changes
-
-In order to clean the entire schema, use the [`schema clean`](../cli-reference#atlas-schema-clean) command, and then use
-[`migrate apply`](#flags) to replay the migration files. For example:
-
-<Tabs defaultValue="mysql">
-<TabItem value="mysql" label="MySQL">
-
-```shell
-atlas schema clean -u "mysql://root:pass@:3306/example"
-atlas migrate apply -u "mysql://root:pass@:3306/example"
-```
-
-</TabItem>
-<TabItem value="maria" label="MariaDB">
-
-```shell
-atlas schema clean -u "maria://root:pass@localhost:3306/example"
-atlas migrate apply -u "maria://root:pass@localhost:3306/example"
-```
-
-</TabItem>
-<TabItem value="postgres" label="PostgreSQL">
-
-```shell
-atlas schema clean -u "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable"
-atlas migrate apply -u "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable"
-```
-
-</TabItem>
-<TabItem value="sqlite" label="SQLite">
-
-```shell
-atlas migrate status \
-  --url "sqlite://file.db" \
-  --dir "file://migrations"
-```
-
-</TabItem>
-<TabItem value="sqlserver" label="SQL Server">
-
-```shell
-atlas schema clean -u "sqlserver://sa:pass@remote:1433?database=master"
-atlas migrate apply -u "sqlserver://sa:pass@remote:1433?database=master"
-```
-
-</TabItem>
-<TabItem value="clickhouse" label="ClickHouse">
-
-```shell
-atlas schema clean -u "clickhouse://user:pass@remote:9000/database"
-atlas migrate apply -u "clickhouse://user:pass@remote:9000/database"
-```
-
-</TabItem>
-</Tabs>
+revert schema changes made by specific migration files using the `atlas migrate down` command. For full details, see
+the [down migration](/versioned/down) documentation.
 
 ### Migration status
 
diff --git a/doc/website/blog/2024-02-02-atlas-v-0-19.mdx b/doc/website/blog/2024-02-02-atlas-v-0-19.mdx
index a2fffba3..e1393728 100644
--- a/doc/website/blog/2024-02-02-atlas-v-0-19.mdx
+++ b/doc/website/blog/2024-02-02-atlas-v-0-19.mdx
@@ -9,13 +9,13 @@ import InstallationInstructions from '../../md/components/_installation_instruct
 Hi everyone,
 
 We are excited to share our latest release with you! Here's what's new:
-* [__Pre-migration Checks__](#pre-migration-checks): Before migrating your schema, you can now add SQL checks that will be verified to help avoid
+* [Pre-migration Checks](#pre-migration-checks): Before migrating your schema, you can now add SQL checks that will be verified to help avoid
 risky migrations.
-* [__Schema Docs__](#schema-docs): Atlas lets you manage your database schema as code. One of the things we love most
+* [Schema Docs](#schema-docs): Atlas lets you manage your database schema as code. One of the things we love most
 about code, is that because of its formal structure, it's possible to automatically generate documentation from it.
 With this release, we're introducing a new feature that lets you generate code-grade documentation for your database schema.
-* [__SQL Server Trigger Support__](#sql-server-trigger-support): Atlas now supports managing triggers in SQL Server.
-* [__ClickHouse Materialized View Support__](#clickhouse-materialized-view-support): Atlas now supports managing materialized views in ClickHouse.
+* [SQL Server Trigger Support](#sql-server-trigger-support): Atlas now supports managing triggers in SQL Server.
+* [ClickHouse Materialized View Support](#clickhouse-materialized-view-support): Atlas now supports managing materialized views in ClickHouse.
 
 Let's dive in.
 
diff --git a/doc/website/blog/2024-03-18-atlas-v-0-20.mdx b/doc/website/blog/2024-03-18-atlas-v-0-20.mdx
index 3853fb61..beca344c 100644
--- a/doc/website/blog/2024-03-18-atlas-v-0-20.mdx
+++ b/doc/website/blog/2024-03-18-atlas-v-0-20.mdx
@@ -9,13 +9,13 @@ Hi everyone,
 It's been a few weeks since our last version announcement and today I'm happy to share with you v0.20, which includes
 some big changes and exciting features:
 
-* [__New Pricing Model__](#new-pricing-model) - As we announced earlier this month, beginning March 15th
+* [New Pricing Model](#new-pricing-model) - As we announced earlier this month, beginning March 15th
 the new pricing model took effect. The new pricing is usage-based, offering you more flexibility and cost
 efficiency. Read about what [prompted this change](/blog/2024/03/03/pricing-change) and view the new pricing plans [here](https://atlasgo.cloud/pricing).
-* [__Django ORM Integration__](#django-orm-integration) -  Atlas now supports [Django](https://www.djangoproject.com)! Django is a  popular ORM for Python. Developers using Django can now use Atlas to automatically plan schema migrations based on the desired state of their schema, instead of crafting them by hand.
-* [__Support for PostgreSQL Extensions__](#support-for-postgresql-extensions) - Atlas now supports installing and managing PostgreSQL extensions.
-* [__Dashboards in the Cloud__](#dashboards-in-the-cloud) - The dashboard (previously the 'Projects' page) got a whole new look in Atlas Cloud. Now you can view the state of your projects and environments at a glance.
-* [__SQL Server is out of Beta__](#sql-server-is-out-of-beta) - SQL Server is officially out of Beta! Along with this official support, we have included some new features:
+* [Django ORM Integration](#django-orm-integration) -  Atlas now supports [Django](https://www.djangoproject.com)! Django is a  popular ORM for Python. Developers using Django can now use Atlas to automatically plan schema migrations based on the desired state of their schema, instead of crafting them by hand.
+* [Support for PostgreSQL Extensions](#support-for-postgresql-extensions) - Atlas now supports installing and managing PostgreSQL extensions.
+* [Dashboards in the Cloud](#dashboards-in-the-cloud) - The dashboard (previously the 'Projects' page) got a whole new look in Atlas Cloud. Now you can view the state of your projects and environments at a glance.
+* _SQL Server is out of Beta](#sql-server-is-out-of-beta) - SQL Server is officially out of Beta! Along with this official support, we have included some new features:
     * __User-Defined Types support for SQL Server__ - Atlas now supports two [User-Defined Types](https://learn.microsoft.com/en-us/sql/t-sql/statements/create-type-transact-sql?view=sql-server-ver22):
     alias types and table types.
     * __Azure Active Directory (AAD) Authentication for SQL Server__ - Connect to your SQL Server database
diff --git a/doc/website/blog/2024-05-01-atlas-v-0-22.mdx b/doc/website/blog/2024-05-01-atlas-v-0-22.mdx
new file mode 100644
index 00000000..42937388
--- /dev/null
+++ b/doc/website/blog/2024-05-01-atlas-v-0-22.mdx
@@ -0,0 +1,198 @@
+---
+title: "Announcing v0.22: Rename Detection, Table Locking Checks, and more"
+authors: hilakashai
+tags: [release, postgresql, sql-server, ]
+---
+
+Hi everyone,
+
+It's been a few weeks since our [last release](https://github.com/ariga/atlas/releases/tag/v0.21.0), and we're happy to be back with a version packed with brand new and exciting
+features. Here's what's inside:
+
+* [`RENAME` Detection](#rename-detection) - This version includes a `RENAME` detector that identifies ambiguous situations of potential resource renames  and interactively asks the user for feedback before generating the changes.
+* [PostgreSQL Features](#postgresql-features)
+    * [UNIQUE and EXCLUDE](#unique-and-exclude-constraints) - Unique constraints and exclusion constraints were added.
+    * [Composite Types](#composite-types) - Added support for composite types, which are user-defined types that represent the structure of a row.
+    * [Table lock checks](#table-locking-checks) - Eight new checks that review migration plans and alert in cases of a potential table locks in different modes.
+* [SQL Server Sequence Support](#sql-server-sequence-support) -  Atlas now supports managing sequences in SQL Server.
+
+Let's dive in!
+
+## `RENAME` Detection
+
+One of the first things we were asked when we introduced Atlas's declarative approach to schema management was,
+“How are you going to deal with renames?” While column and table renames are a fairly rare event in the lifecycle of a project,
+the question arises from the fact that it's impossible to completely disambiguate between `RENAME` and `DROP` and `ADD` operations.
+While the end schema will be the same in both cases, the actual impact of an undesired `DROP` operation can be disastrous.
+
+To avoid this, Atlas now detects potential `RENAME` scenarios during the migration planning phase and prompts the user
+about their intent.
+
+Let's see this in action.
+
+Assume we have a `users` table with the column `first_name`, which we changed to `name`.
+
+After running the [`atlas migrate diff`](/cli-reference#atlas-migrate-diff) command to generate a migration, we will see the following:
+
+```bash
+? Did you rename "users" column from "first_name" to "name":
+▸ Yes
+  No
+```
+If this was our intention, we will click "Yes" and the SQL statement will be a `RENAME` statement.
+
+If we click "No", the SQL statement will drop the `first_name` column and create the `name` column instead.
+
+## PostgreSQL Features
+
+### Unique and Exclude Constraints
+
+Now, Atlas supports declaring unique and exclude constraints in your schema.
+
+For example, if we were to add a unique constraint on a
+`name` column, it would look similar to:
+
+```hcl title="schema.hcl"
+# Columns only.
+unique "name" {
+ columns = [column.name]
+}
+```
+Read more about unique constraints in Atlas [here](/atlas-schema/hcl#unique-constraints).
+
+[Exclude constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-EXCLUSION) ensure that if any
+two rows are compared using a specified operator, at least one of the specified conditions must hold true. This means
+that the constraint ensures that no two rows satisfy the specified operator at the same time.
+
+```hcl title="schema.hcl"
+exclude "excl_speaker_during" {
+  type = GIST
+  on {
+    column = column.speaker
+    op     = "="
+  }
+  on {
+    column = column.during
+    op     = "&&"
+  }
+}
+```
+
+
+### Composite Types
+[Composite Types](https://www.postgresql.org/docs/current/rowtypes.html#ROWTYPES) are user-defined data types that represent a structure of a row or record.
+Once defined, composite types can be used to declare columns in tables or used in functions and stored procedures.
+
+For example, let's say we have a `users` table where each user has an `address`. We can create a composite type `address`
+and add it as a column to the `users` table:
+
+```hcl title="schema.hcl"
+composite "address" {
+  schema = schema.public
+  field "street" {
+    type = text
+  }
+  field "city" {
+    type = text
+  }
+}
+
+table "users" {
+  schema = schema.public
+  column "address" {
+    type = composite.address
+  }
+}
+
+schema "public" {
+  comment = "standard public schema"
+}
+```
+Learn more about composite types [here](/atlas-schema/hcl#composite-type).
+
+### Table Locking Checks
+
+One of the common ways in which schema migration cause production outages is when a schema change requires the database to acquire a lock on a table,
+immediately causing read or write operations to fail. If you are dealing with small tables, these locks might be acquired for a
+short time which will not be noticeable. However, if you are managing a large and busy database, these situations can lead to a full-blown system outage.
+
+Many developers are not aware of these pitfalls only to discover them in the middle of a crisis, which is made even worse by the fact
+that once they happen, there's nothing you can do except quietly wait for the migration to complete.
+
+Teams looking to improve the reliability and stability of their systems, reach out to automation
+to prevent human errors like these. Atlas's [automatic analysis](/versioned/lint) capabilities can be utilized to detect such risky
+changes during the CI phase of the software development lifecycle.
+
+In this version, we have added eight new [analyzers](lint/analyzers#analyzers) to our PostgreSQL integration that check for cases where planned migrations
+can lead to locking a table. Here's a short rundown of these analyzers and what they detect:
+
+* [PG104](/lint/analyzers#PG104) - Adding a `PRIMARY KEY` constraint (with its index) acquires an `ACCESS EXCLUSIVE` lock on the table,
+blocking all access during the operation.
+* [PG105](lint/analyzers#PG105) - Adding a `UNIQUE` constraint (with its index) acquires an `ACCESS EXCLUSIVE` lock on the table,
+blocking all access during the operation.
+* [PG301](lint/analyzers#PG301) - A change to the column type that requires rewriting the table (and potentially its indexes) on
+disk.
+* [PG302](lint/analyzers#PG302) - Adding a column with a volatile DEFAULT value requires a rewrite of the table.
+* [PG303](lint/analyzers#PG303) - Modifying a column from NULL to NOT NULL requires a full table scan.
+    * If the table has a CHECK constraint that ensures NULL cannot exist, such as CHECK (c > 10 AND c IS NOT NULL), the table scan is skipped, and therefore this check is not reported.
+* [PG304](lint/analyzers#PG304) - Adding a PRIMARY KEY on a nullable column implicitly sets them to NOT NULL, resulting in a full table scan unless there is a CHECK constraint that ensures NULL cannot exist.
+* [PG305](lint/analyzers#PG305) - Adding a CHECK constraint without the NOT VALID clause requires scanning the table to verify that all rows satisfy the constraint.
+* [PG306](lint/analyzers#PG306) - Adding a FOREIGN KEY constraint without the NOT VALID clause requires a full table scan to verify that all rows satisfy the constraint.
+
+View a full list of all the checks [here](/lint/analyzers#checks).
+
+
+## SQL Server Sequence Support
+
+In SQL Server, [sequences](https://learn.microsoft.com/en-us/sql/relational-databases/sequence-numbers/sequence-numbers?view=sql-server-ver16) are
+objects that generate a sequence of numeric values according to specific properties.Sequences are often used to generate
+unique identifiers for rows in a table.
+
+Atlas supports the different types of sequences. For example, a simple sequence with default values can be declared like so:
+
+```hcl
+sequence "s1" {
+  schema = schema.dbo
+}
+```
+
+We can also create a sequence with a custom configuration.
+
+```hcl
+sequence "s2" {
+  schema    = schema.dbo
+  type      = int
+  start     = 1001
+  increment = 1
+  min_value = 1001
+  max_value = 9999
+  cycle     = true
+}
+```
+In the example above, we have a sequence that starts at 1001, and is incremented by 1 until it reaches the maximum value of 9999. Once it reaches
+its maximum value, it will start over because `cycle` is set to true.
+
+Another option is to create a sequence with an alias type. For example, if we were to create a sequence for
+Social Security Numbers, we would do the following:
+
+```hcl
+sequence "s3" {
+  schema    = schema.dbo
+  type      = type_alias.ssn
+  start     = 111111111
+  increment = 1
+  min_value = 111111111
+}
+type_alias "ssn" {
+  schema = schema.dbo
+  type   = dec(9, 0)
+  null   = false
+}
+```
+
+Read the [docs](/atlas-schema/hcl#sequence) for more information about sequences.
+
+## Wrapping Up
+
+That's all for this release! We hope you try out (and enjoy) all of these new features and find them useful.
+As always, we would love to hear your feedback and suggestions on our [Discord server](https://discord.com/invite/zZ6sWVg6NT).
diff --git a/doc/website/docusaurus.config.js b/doc/website/docusaurus.config.js
index 5bcf14dc..8da68f5f 100644
--- a/doc/website/docusaurus.config.js
+++ b/doc/website/docusaurus.config.js
@@ -11,7 +11,7 @@ module.exports = {
   themeConfig: {
     image: "https://og.atlasgo.io/image?title=Schema%20Migration%20Tool%20for%20any%20Language",
     prism: {
-      additionalLanguages: ['hcl', 'java', 'properties', 'gradle'],
+      additionalLanguages: ['hcl', 'java', 'properties', 'gradle', 'php'],
       magicComments: [
         {
           className: 'theme-code-block-highlighted-line',
@@ -117,6 +117,7 @@ module.exports = {
           "title": "Integrations",
           "items": [
             {"label": "GitHub Actions", "to": "/integrations/github-actions"},
+            {"label": "Kubernetes Operator", "to": "/integrations/kubernetes/operator"},
             {"label": "Terraform", "to": "/integrations/terraform-provider"},
             {"label": "Go API", "to": "/integrations/go-api"}
           ]
@@ -148,7 +149,7 @@ module.exports = {
     },
     announcementBar: {
       id: 'announcementBar-12', // Increment on change
-      content: `<strong>Atlas v0.21: Introducing Atlas Migrate Down </strong><a class="cta" href="https://atlasgo.io/blog/2024/04/01/migrate-down">Read it here</a>`,
+      content: `<strong>Atlas v0.22: Rename Detection, Table Locking Checks, and more. </strong><a class="cta" href="https://atlasgo.io/blog/2024/05/01/atlas-v-0-22">Read it here</a>`,
       isCloseable: true,
     },
   },
@@ -266,6 +267,7 @@ module.exports = {
     ],
     "./plugins/utm-plugin",
     "./plugins/intercom",
+    "./plugins/page-view",
   ],
   presets: [
     [
diff --git a/doc/website/package-lock.json b/doc/website/package-lock.json
index 0379a11e..2d39f286 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.32",
+        "@ariga/atlas-website": "0.0.37",
         "@docusaurus/core": "^2.0.0-rc.1",
         "@docusaurus/plugin-client-redirects": "^2.4.1",
         "@docusaurus/plugin-ideal-image": "^2.4.1",
@@ -296,9 +296,9 @@
       }
     },
     "node_modules/@ariga/atlas-website": {
-      "version": "0.0.32",
-      "resolved": "https://registry.npmjs.org/@ariga/atlas-website/-/atlas-website-0.0.32.tgz",
-      "integrity": "sha512-0eTDpnXnxccJOXfo3b4mQnGNZqTityQUt/pkfJq5ms0dXr1M0ouZV2mlbYu88YmPBKETHUqBizkiS14FPUMGTQ==",
+      "version": "0.0.37",
+      "resolved": "https://registry.npmjs.org/@ariga/atlas-website/-/atlas-website-0.0.37.tgz",
+      "integrity": "sha512-8Hq3m5qnq2X/hN6v5ZzBLunfts5oc6xAN8d2p6wPpco+ObxuaUO5YcKoXL7UWjV+UDd3d7eJeVf1HH8QgqyhGw==",
       "dependencies": {
         "@amplitude/analytics-browser": "^1.9.4",
         "@types/amplitude-js": "^8.16.2",
@@ -319,6 +319,7 @@
         "react-scroll-parallax": "^3.3.2",
         "react-use-intercom": "^5.0.0",
         "replace-in-file": "^6.3.5",
+        "tailwind-merge": "^2.2.1",
         "tailwind-scrollbar": "^2.1.0",
         "tailwindcss": "^3.2.6",
         "vanilla-cookieconsent": "^3.0.0-rc.17",
@@ -2283,11 +2284,11 @@
       "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA=="
     },
     "node_modules/@babel/runtime": {
-      "version": "7.22.3",
-      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.3.tgz",
-      "integrity": "sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==",
+      "version": "7.24.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz",
+      "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==",
       "dependencies": {
-        "regenerator-runtime": "^0.13.11"
+        "regenerator-runtime": "^0.14.0"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -2305,6 +2306,11 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@babel/runtime/node_modules/regenerator-runtime": {
+      "version": "0.14.1",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+      "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
+    },
     "node_modules/@babel/template": {
       "version": "7.21.9",
       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.21.9.tgz",
@@ -21605,6 +21611,18 @@
         "node": ">=4"
       }
     },
+    "node_modules/tailwind-merge": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.3.0.tgz",
+      "integrity": "sha512-vkYrLpIP+lgR0tQCG6AP7zZXCTLc1Lnv/CCRT3BqJ9CZ3ui2++GPaGb1x/ILsINIMSYqqvrpqjUFsMNLlW99EA==",
+      "dependencies": {
+        "@babel/runtime": "^7.24.1"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/dcastil"
+      }
+    },
     "node_modules/tailwind-scrollbar": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-2.1.0.tgz",
@@ -24580,9 +24598,9 @@
       }
     },
     "@ariga/atlas-website": {
-      "version": "0.0.32",
-      "resolved": "https://registry.npmjs.org/@ariga/atlas-website/-/atlas-website-0.0.32.tgz",
-      "integrity": "sha512-0eTDpnXnxccJOXfo3b4mQnGNZqTityQUt/pkfJq5ms0dXr1M0ouZV2mlbYu88YmPBKETHUqBizkiS14FPUMGTQ==",
+      "version": "0.0.37",
+      "resolved": "https://registry.npmjs.org/@ariga/atlas-website/-/atlas-website-0.0.37.tgz",
+      "integrity": "sha512-8Hq3m5qnq2X/hN6v5ZzBLunfts5oc6xAN8d2p6wPpco+ObxuaUO5YcKoXL7UWjV+UDd3d7eJeVf1HH8QgqyhGw==",
       "requires": {
         "@amplitude/analytics-browser": "^1.9.4",
         "@types/amplitude-js": "^8.16.2",
@@ -24603,6 +24621,7 @@
         "react-scroll-parallax": "^3.3.2",
         "react-use-intercom": "^5.0.0",
         "replace-in-file": "^6.3.5",
+        "tailwind-merge": "^2.2.1",
         "tailwind-scrollbar": "^2.1.0",
         "tailwindcss": "^3.2.6",
         "vanilla-cookieconsent": "^3.0.0-rc.17",
@@ -25938,11 +25957,18 @@
       "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA=="
     },
     "@babel/runtime": {
-      "version": "7.22.3",
-      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.3.tgz",
-      "integrity": "sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==",
+      "version": "7.24.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz",
+      "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==",
       "requires": {
-        "regenerator-runtime": "^0.13.11"
+        "regenerator-runtime": "^0.14.0"
+      },
+      "dependencies": {
+        "regenerator-runtime": {
+          "version": "0.14.1",
+          "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+          "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
+        }
       }
     },
     "@babel/runtime-corejs3": {
@@ -40253,6 +40279,14 @@
         }
       }
     },
+    "tailwind-merge": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.3.0.tgz",
+      "integrity": "sha512-vkYrLpIP+lgR0tQCG6AP7zZXCTLc1Lnv/CCRT3BqJ9CZ3ui2++GPaGb1x/ILsINIMSYqqvrpqjUFsMNLlW99EA==",
+      "requires": {
+        "@babel/runtime": "^7.24.1"
+      }
+    },
     "tailwind-scrollbar": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-2.1.0.tgz",
diff --git a/doc/website/package.json b/doc/website/package.json
index 21851186..1b7d669e 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.32",
+    "@ariga/atlas-website": "0.0.37",
     "@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/plugins/page-view/index.js b/doc/website/plugins/page-view/index.js
new file mode 100644
index 00000000..fc567d4e
--- /dev/null
+++ b/doc/website/plugins/page-view/index.js
@@ -0,0 +1,8 @@
+const path = require('path');
+
+module.exports = () => ({
+  name: "atlas-page-view-plugin",
+  getClientModules() {
+    return [path.resolve(__dirname, './plugin')];
+  }
+});
diff --git a/doc/website/plugins/page-view/plugin.js b/doc/website/plugins/page-view/plugin.js
new file mode 100644
index 00000000..b9ca0e56
--- /dev/null
+++ b/doc/website/plugins/page-view/plugin.js
@@ -0,0 +1,3 @@
+import { pageViewPlugin } from "@ariga/atlas-website";
+
+export default pageViewPlugin("https://content.atlasgo.io");
diff --git a/doc/website/plugins/utm-plugin/plugin.js b/doc/website/plugins/utm-plugin/plugin.js
index 625e0db9..9c78f430 100644
--- a/doc/website/plugins/utm-plugin/plugin.js
+++ b/doc/website/plugins/utm-plugin/plugin.js
@@ -1,40 +1,3 @@
-import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment";
+import { utmPlugin } from "@ariga/atlas-website";
 
-const CONFIG = {
-  selector: 'a[href*="atlasgo.cloud"]',
-  utmSource: "atlasgo",
-  utmMedium: "website",
-};
-
-// Correct link: host/example?utm_term=custom#hash
-
-export default (function () {
-  if (!ExecutionEnvironment.canUseDOM) {
-    return null;
-  }
-  return {
-    onRouteDidUpdate({ location: { pathname } }) {
-      const links = document.querySelectorAll(CONFIG.selector);
-
-      links.forEach(function (link) {
-        const url = new URL(link.href);
-
-        const locationPath = pathname.split("/").slice(1).join("_");
-
-        if (!url.searchParams.has("utm_source")) {
-          url.searchParams.set("utm_source", CONFIG.utmSource);
-        }
-
-        if (!url.searchParams.has("utm_medium")) {
-          url.searchParams.set("utm_medium", CONFIG.utmMedium);
-        }
-
-        if (!url.searchParams.has("utm_term")) {
-          url.searchParams.set("utm_term", locationPath || "main");
-        }
-
-        link.href = url.toString();
-      });
-    },
-  };
-})();
+export default utmPlugin();
diff --git a/doc/website/sidebars.js b/doc/website/sidebars.js
index 5521d74e..4f266d25 100644
--- a/doc/website/sidebars.js
+++ b/doc/website/sidebars.js
@@ -152,6 +152,11 @@ module.exports = {
                     type: 'doc',
                     id: 'community-edition'
                 },
+                {
+                    type: 'doc',
+                    id: 'cli-data-privacy',
+                    label: 'Data Privacy'
+                }
             ]
         },
         {
@@ -343,6 +348,11 @@ module.exports = {
                     id: 'guides/orms/django',
                     label: 'Django'
                 },
+                {
+                    type: 'doc',
+                    id: 'guides/orms/doctrine',
+                    label: 'Doctrine'
+                },
                 {
                     type: 'doc',
                     id: 'guides/frameworks/sqlc-declarative',
diff --git a/doc/website/src/css/custom.css b/doc/website/src/css/custom.css
index 783c7959..23bd0c40 100644
--- a/doc/website/src/css/custom.css
+++ b/doc/website/src/css/custom.css
@@ -276,15 +276,19 @@ div[class^='announcementBar_']  a.cta {
 }
 
 @media only screen and (min-width: 1000px) {
-  h4 {
+  h3, h4 {
     position: relative;
   }
-  h4 > .login {
+  h3 > .login, h4 > .login {
     right: 0;
     position: absolute;
   }
 }
 
+h3 > .login {
+  vertical-align: middle;
+}
+
 .language-applylog .version, .language-applylog .action2, .language-applylog .action3 {
   color: #62cece;
 }
diff --git a/sql/internal/sqlx/plan.go b/sql/internal/sqlx/plan.go
index da42416a..3c749f32 100644
--- a/sql/internal/sqlx/plan.go
+++ b/sql/internal/sqlx/plan.go
@@ -382,8 +382,16 @@ func byKeys[T any](m map[string]T) []struct {
 	return vs
 }
 
+// SortOptions allows drivers to customize the behavior of the SortChanges function.
+type SortOptions struct {
+	// FuncDepT reports if a function depends on the given table.
+	FuncDepT func(*schema.Func, *schema.Table) bool
+	// FuncDepO reports if a function depends on the given object.
+	FuncDepO func(*schema.Func, schema.Object) bool
+}
+
 // SortChanges is a helper function to sort to level changes based on their priority.
-func SortChanges(changes []schema.Change) []schema.Change {
+func SortChanges(changes []schema.Change, opts *SortOptions) []schema.Change {
 	var views, drop, other []schema.Change
 	for _, c := range changes {
 		switch c.(type) {
@@ -404,7 +412,7 @@ func SortChanges(changes []schema.Change) []schema.Change {
 	edges := make(map[schema.Change][]schema.Change)
 	for _, c := range changes {
 		for _, c2 := range changes {
-			if c != c2 && dependsOn(c, c2) {
+			if c != c2 && dependsOn(c, c2, V(opts)) {
 				edges[c] = append(edges[c], c2)
 			}
 		}
@@ -444,7 +452,7 @@ type Depender interface {
 }
 
 // dependsOn reports if the given change depends on the other change.
-func dependsOn(c1, c2 schema.Change) bool {
+func dependsOn(c1, c2 schema.Change, opts SortOptions) bool {
 	if dependOnOf(c1, c2) {
 		return true
 	}
@@ -452,13 +460,21 @@ func dependsOn(c1, c2 schema.Change) bool {
 	case *schema.DropSchema:
 		switch c2 := c2.(type) {
 		case *schema.DropFunc:
-			return c1.S.Name == c2.F.Schema.Name
+			return sameSchema(c1.S, c2.F.Schema)
 		case *schema.DropProc:
-			return c1.S.Name == c2.P.Schema.Name
+			return sameSchema(c1.S, c2.P.Schema)
 		case *schema.DropTable:
-			return c1.S.Name == c2.T.Schema.Name
+			// Schema must be dropped after all its tables and references to them.
+			return sameSchema(c1.S, c2.T.Schema) || slices.ContainsFunc(c2.T.ForeignKeys, func(fk *schema.ForeignKey) bool {
+				return sameSchema(c1.S, fk.RefTable.Schema)
+			})
+		case *schema.ModifyTable:
+			return sameSchema(c1.S, c2.T.Schema) || slices.ContainsFunc(c2.Changes, func(c schema.Change) bool {
+				fk, ok := c.(*schema.DropForeignKey)
+				return ok && sameSchema(c1.S, fk.F.RefTable.Schema)
+			})
 		case *schema.DropView:
-			return c1.S.Name == c2.V.Schema.Name
+			return sameSchema(c1.S, c2.V.Schema)
 		}
 	case *schema.AddTable:
 		switch c2 := c2.(type) {
@@ -495,7 +511,7 @@ func dependsOn(c1, c2 schema.Change) bool {
 		case *schema.ModifyTable:
 			return slices.ContainsFunc(c2.Changes, func(c schema.Change) bool {
 				fk, ok := c.(*schema.DropForeignKey)
-				return ok && fk.F.RefTable == c2.T
+				return ok && refTo([]*schema.ForeignKey{fk.F}, c1.T)
 			})
 		case *schema.DropTrigger:
 			if c2.T.Table == c1.T {
@@ -513,7 +529,7 @@ func dependsOn(c1, c2 schema.Change) bool {
 			// Tables need to be created before referencing them.
 			return slices.ContainsFunc(c1.Changes, func(c schema.Change) bool {
 				fk, ok := c.(*schema.AddForeignKey)
-				return ok && fk.F.RefTable == c2.T
+				return ok && refTo([]*schema.ForeignKey{fk.F}, c2.T)
 			})
 		case *schema.ModifyTable:
 			if c1.T != c2.T {
@@ -525,7 +541,7 @@ func dependsOn(c1, c2 schema.Change) bool {
 				}
 				return slices.ContainsFunc(c1.Changes, func(c schema.Change) bool {
 					fk, ok := c.(*schema.AddForeignKey)
-					return ok && fk.F.RefTable == c2.T && slices.ContainsFunc(fk.F.RefColumns, func(c *schema.Column) bool { return addC[c] })
+					return ok && refTo([]*schema.ForeignKey{fk.F}, c2.T) && slices.ContainsFunc(fk.F.Columns, func(c *schema.Column) bool { return addC[c] })
 				})
 			}
 		case *schema.AddObject:
@@ -578,6 +594,10 @@ func dependsOn(c1, c2 schema.Change) bool {
 			if funcDep(c1.F, c2.To) {
 				return true // Relies on the new definition.
 			}
+		case *schema.AddTable:
+			if opts.FuncDepT != nil && opts.FuncDepT(c1.F, c2.T) {
+				return true
+			}
 		case *schema.AddObject:
 			t, ok := c2.O.(schema.Type)
 			if ok && (c1.F.Ret == t || slices.ContainsFunc(c1.F.Args, func(f *schema.FuncArg) bool {
@@ -800,6 +820,8 @@ func depOfAdd(refs []schema.Object, c schema.Change) bool {
 		o = c.P
 	case *schema.AddObject:
 		o = c.O
+	case *schema.AddTrigger:
+		o = c.T
 	default:
 		return false
 	}
@@ -808,7 +830,9 @@ func depOfAdd(refs []schema.Object, c schema.Change) bool {
 
 // refTo reports if the given foreign keys reference the given table.
 func refTo(fks []*schema.ForeignKey, to *schema.Table) bool {
-	return slices.ContainsFunc(fks, func(fk *schema.ForeignKey) bool { return fk.RefTable == to })
+	return slices.ContainsFunc(fks, func(fk *schema.ForeignKey) bool {
+		return SameTable(fk.RefTable, to)
+	})
 }
 
 // dependsOnT reports if t1 depends on t2.
@@ -818,6 +842,14 @@ func dependsOnT(t1, t2 schema.Type) bool {
 	return t1 == t2 || schema.UnderlyingType(t1) == t2
 }
 
+// SameTable reports if the two objects represent the same table.
+func SameTable(t1, t2 *schema.Table) bool {
+	if t1 == nil || t2 == nil {
+		return t1 == t2
+	}
+	return t1.Name == t2.Name && sameSchema(t1.Schema, t2.Schema)
+}
+
 // sameSchema reports if the given schemas are the same.
 // Objects can be different as they might reside in two
 // different states (current and desired).
diff --git a/sql/migrate/lex.go b/sql/migrate/lex.go
index 02521a3d..775aea59 100644
--- a/sql/migrate/lex.go
+++ b/sql/migrate/lex.go
@@ -214,9 +214,11 @@ Scan:
 		// expressions until we make the lexer driver-aware.
 		case depth == 0 && r == '#':
 			s.comment("#", "\n")
-		case r == '-' && s.next() == '-':
+		case r == '-' && s.pick() == '-':
+			s.next()
 			s.comment("--", "\n")
-		case r == '/' && s.next() == '*':
+		case r == '/' && s.pick() == '*':
+			s.next()
 			s.comment("/*", "*/")
 		case s.delim == delimiter && s.MatchBeginAtomic && reBeginAtomic.MatchString(s.input[s.pos-1:]):
 			if err := s.skipBeginAtomic(); err == nil {
@@ -248,9 +250,9 @@ func (s *Scanner) next() rune {
 }
 
 func (s *Scanner) pick() rune {
-	p, w := s.pos, s.width
+	p, w, t := s.pos, s.width, s.total
 	r := s.next()
-	s.pos, s.width = p, w
+	s.pos, s.width, s.total = p, w, t
 	return r
 }
 
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index 895eb573..ae0bce60 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -819,7 +819,7 @@ func (e *Executor) Execute(ctx context.Context, m File) (err error) {
 	// Make sure to store the Revision information.
 	defer func(ctx context.Context, e *Executor, r *Revision) {
 		if err2 := e.writeRevision(ctx, r); err2 != nil {
-			err = wrap(err2, err)
+			err = errors.Join(err, err2)
 		}
 	}(ctx, e, r)
 	if r.Applied > 0 {
@@ -975,7 +975,7 @@ func (e *Executor) Replay(ctx context.Context, r StateReader, opts ...ReplayOpti
 	}
 	defer func() {
 		if err2 := restore(ctx); err2 != nil {
-			err = wrap(err2, err)
+			err = errors.Join(err, err2)
 		}
 	}()
 	// Replay the migration directory on the database.
@@ -1169,10 +1169,3 @@ func LogNoPendingFiles(l Logger, revs []*Revision) {
 	LogIntro(l, revs, nil)
 	l.Log(LogDone{})
 }
-
-func wrap(err1, err2 error) error {
-	if err2 != nil {
-		return fmt.Errorf("sql/migrate: %w: %v", err2, err1)
-	}
-	return err1
-}
diff --git a/sql/mysql/migrate.go b/sql/mysql/migrate.go
index 21e0335d..c627d7ee 100644
--- a/sql/mysql/migrate.go
+++ b/sql/mysql/migrate.go
@@ -85,7 +85,7 @@ func (s *state) plan(changes []schema.Change) error {
 	if err != nil {
 		return err
 	}
-	for _, c := range sqlx.SortChanges(planned) {
+	for _, c := range sqlx.SortChanges(planned, nil) {
 		switch c := c.(type) {
 		case *schema.AddTable:
 			err = s.addTable(c)
@@ -398,7 +398,12 @@ func (s *state) alterTable(t *schema.Table, changes []schema.Change) error {
 				if err := checkChangeGenerated(change.From, change.To); err != nil {
 					return err
 				}
-				b.P("MODIFY COLUMN")
+				// In case the column was both modified and renamed.
+				if change.To.Name != change.From.Name {
+					b.P("CHANGE COLUMN").Ident(change.From.Name)
+				} else {
+					b.P("MODIFY COLUMN")
+				}
 				if err := s.column(b, t, change.To); err != nil {
 					return err
 				}
diff --git a/sql/postgres/convert.go b/sql/postgres/convert.go
index 74a51f05..8803c2a9 100644
--- a/sql/postgres/convert.go
+++ b/sql/postgres/convert.go
@@ -37,6 +37,11 @@ func FormatType(t schema.Type) (string, error) {
 		f = strings.ToLower(t.T)
 	case *CurrencyType:
 		f = strings.ToLower(t.T)
+	case *CompositeType:
+		if t.T == "" {
+			return "", errors.New("postgres: missing composite type name")
+		}
+		f = t.T
 	case *DomainType:
 		if t.T == "" {
 			return "", errors.New("postgres: missing domain type name")
@@ -286,7 +291,7 @@ func columnType(c *columnDesc) (schema.Type, error) {
 	case TypeUserDefined:
 		typ = &UserDefinedType{T: c.fmtype}
 	case typeAny, typeAnyElement, typeAnyArray, typeAnyNonArray, typeAnyEnum, typeInternal,
-		typeRecord, typeTrigger, typeVoid, typeUnknown:
+		typeRecord, typeTrigger, typeEventTrigger, typeVoid, typeUnknown:
 		typ = &PseudoType{T: t}
 	default:
 		typ = &schema.UnsupportedType{T: t}
diff --git a/sql/postgres/diff.go b/sql/postgres/diff.go
index af220777..6e7d59eb 100644
--- a/sql/postgres/diff.go
+++ b/sql/postgres/diff.go
@@ -27,17 +27,28 @@ var DefaultDiff schema.Differ = &sqlx.Diff{DiffDriver: &diff{&conn{ExecQuerier:
 type diff struct{ *conn }
 
 // SchemaAttrDiff returns a changeset for migrating schema attributes from one state to the other.
-func (*diff) SchemaAttrDiff(from, to *schema.Schema) []schema.Change {
-	var changes []schema.Change
-	if change := sqlx.CommentDiff(skipDefaultComment(from), skipDefaultComment(to)); change != nil {
+func (d *diff) SchemaAttrDiff(from, to *schema.Schema) []schema.Change {
+	var (
+		changes    []schema.Change
+		fromA, toA []schema.Attr
+	)
+	// In schema scope, users might compare a "public" schema with a "non-public" schema.
+	// However, since the public standard comment is auto created and not set by the users
+	// this is unintentional in most cases, and this change will be rejected by the plan stage.
+	if d.schema != "" {
+		fromA, toA = skipDefaultComment(from, from.Name), skipDefaultComment(to, to.Name)
+	} else {
+		fromA, toA = skipDefaultComment(from, "public"), skipDefaultComment(to, "public")
+	}
+	if change := sqlx.CommentDiff(fromA, toA); change != nil {
 		changes = append(changes, change)
 	}
 	return changes
 }
 
-func skipDefaultComment(s *schema.Schema) []schema.Attr {
+func skipDefaultComment(s *schema.Schema, public string) []schema.Attr {
 	attrs := s.Attrs
-	if c := (schema.Comment{}); sqlx.Has(attrs, &c) && c.Text == "standard public schema" && (s.Name == "" || s.Name == "public") {
+	if c := (schema.Comment{}); sqlx.Has(attrs, &c) && c.Text == "standard public schema" && (s.Name == "" || s.Name == public) {
 		attrs = schema.RemoveAttr[*schema.Comment](attrs)
 	}
 	return attrs
@@ -189,7 +200,7 @@ func (*diff) IndexAttrChanged(from, to []schema.Attr) bool {
 	if indexNullsDistinct(to) != indexNullsDistinct(from) {
 		return true
 	}
-	if uniqueConstChanged(from, to) {
+	if uniqueConstChanged(from, to) || excludeConstChanged(from, to) {
 		return true
 	}
 	var p1, p2 IndexPredicate
@@ -214,6 +225,14 @@ func (*diff) IndexPartAttrChanged(fromI, toI *schema.Index, i int) bool {
 	if p1.NullsFirst != p2.NullsFirst || p1.NullsLast != p2.NullsLast {
 		return true
 	}
+	_, ok1 := excludeConst(from.Attrs)
+	_, ok2 := excludeConst(to.Attrs)
+	if ok1 && ok2 {
+		// In case the index(es) are EXCLUDE constraint, we compare its operator
+		// (and not its class) because the class is derived from the operator.
+		return sqlx.AttrOr(from.Attrs, &Operator{}).Name != sqlx.AttrOr(to.Attrs, &Operator{}).Name
+	}
+	// Op class for non-exclude.
 	var fromOp, toOp IndexOpClass
 	switch fromHas, toHas := sqlx.Has(from.Attrs, &fromOp), sqlx.Has(to.Attrs, &toOp); {
 	case fromHas && toHas:
@@ -509,6 +528,16 @@ func uniqueConst(attrs []schema.Attr) (*Constraint, bool) {
 	return nil, false
 }
 
+// excludeConst returns the first exclude constraint from the given attributes.
+func excludeConst(attrs []schema.Attr) (*Constraint, bool) {
+	for _, a := range attrs {
+		if c, ok := a.(*Constraint); ok && c.IsExclude() {
+			return c, true
+		}
+	}
+	return nil, false
+}
+
 func trimCast(s string) string {
 	i := strings.LastIndex(s, "::")
 	if i == -1 {
diff --git a/sql/postgres/driver.go b/sql/postgres/driver.go
index 98708cb3..5800b5c5 100644
--- a/sql/postgres/driver.go
+++ b/sql/postgres/driver.go
@@ -7,6 +7,7 @@ package postgres
 import (
 	"context"
 	"database/sql"
+	"errors"
 	"fmt"
 	"hash/fnv"
 	"math/rand"
@@ -237,7 +238,20 @@ func (d *Driver) SchemaRestoreFunc(desired *schema.Schema) migrate.RestoreFunc {
 
 // RealmRestoreFunc returns a function that restores the given realm to its desired state.
 func (d *Driver) RealmRestoreFunc(desired *schema.Realm) migrate.RestoreFunc {
-	return func(ctx context.Context) error {
+	return func(ctx context.Context) (err error) {
+		// Default behavior for Postgres is to have a single "public" schema.
+		// In that case, all other schemas are dropped, but this one is cleared
+		// object by object. To keep process faster, we drop the schema and recreate it.
+		if !d.crdb && len(desired.Schemas) == 1 && desired.Schemas[0].Name == "public" {
+			if pb := desired.Schemas[0]; len(pb.Tables)+len(pb.Views)+len(pb.Funcs)+len(pb.Procs)+len(pb.Objects) == 0 {
+				desired = &schema.Realm{Attrs: desired.Attrs, Objects: desired.Objects}
+				defer func() {
+					err = errors.Join(err, d.ApplyChanges(ctx, []schema.Change{
+						&schema.AddSchema{S: pb, Extra: []schema.Clause{&schema.IfExists{}}},
+					}))
+				}()
+			}
+		}
 		current, err := d.InspectRealm(ctx, nil)
 		if err != nil {
 			return err
@@ -501,16 +515,17 @@ const (
 	typeRegType       = "regtype"
 
 	// PostgreSQL of supported pseudo-types.
-	typeAny         = "any"
-	typeAnyElement  = "anyelement"
-	typeAnyArray    = "anyarray"
-	typeAnyNonArray = "anynonarray"
-	typeAnyEnum     = "anyenum"
-	typeInternal    = "internal"
-	typeRecord      = "record"
-	typeTrigger     = "trigger"
-	typeVoid        = "void"
-	typeUnknown     = "unknown"
+	typeAny          = "any"
+	typeAnyElement   = "anyelement"
+	typeAnyArray     = "anyarray"
+	typeAnyNonArray  = "anynonarray"
+	typeAnyEnum      = "anyenum"
+	typeInternal     = "internal"
+	typeRecord       = "record"
+	typeTrigger      = "trigger"
+	typeEventTrigger = "event_trigger"
+	typeVoid         = "void"
+	typeUnknown      = "unknown"
 )
 
 // List of supported index types.
diff --git a/sql/postgres/driver_oss.go b/sql/postgres/driver_oss.go
index a987e71d..787687e9 100644
--- a/sql/postgres/driver_oss.go
+++ b/sql/postgres/driver_oss.go
@@ -62,7 +62,7 @@ func (*inspect) inspectDeps(context.Context, *schema.Realm, *schema.InspectOptio
 	return nil // unimplemented.
 }
 
-func (*inspect) inspectExtensions(context.Context, *schema.Realm, *schema.InspectOptions) error {
+func (*inspect) inspectRealmObjects(context.Context, *schema.Realm, *schema.InspectOptions) error {
 	return nil // unimplemented.
 }
 
@@ -242,6 +242,13 @@ func convertExtensions(exs []*extension, _ *schema.Realm) error {
 	return nil
 }
 
+func convertEventTriggers(evs []*eventTrigger, _ *schema.Realm) error {
+	if len(evs) > 0 {
+		return fmt.Errorf("postgres: event triggers are not supported by this version. Use: https://atlasgo.io/getting-started")
+	}
+	return nil
+}
+
 func normalizeRealm(*schema.Realm) error {
 	return nil
 }
@@ -345,3 +352,25 @@ func uniqueConstChanged(_, _ []schema.Attr) bool {
 	// to keep BC with old versions.
 	return false
 }
+
+func excludeConstChanged(_, _ []schema.Attr) bool {
+	// Unsupported change in package mode (ariga.io/sql/postgres)
+	// to keep BC with old versions.
+	return false
+}
+
+func convertExclude(schemahcl.Resource, *schema.Table) error {
+	return nil // unimplemented.
+}
+
+func (*state) sortChanges(changes []schema.Change) []schema.Change {
+	return sqlx.SortChanges(changes, nil)
+}
+
+func detachCycles(changes []schema.Change) ([]schema.Change, error) {
+	return sqlx.DetachCycles(changes)
+}
+
+func excludeSpec(*sqlspec.Table, *sqlspec.Index, *schema.Index, *Constraint) error {
+	return nil // unimplemented.
+}
diff --git a/sql/postgres/inspect.go b/sql/postgres/inspect.go
index 09b49164..cddd175b 100644
--- a/sql/postgres/inspect.go
+++ b/sql/postgres/inspect.go
@@ -70,7 +70,7 @@ func (i *inspect) InspectRealm(ctx context.Context, opts *schema.InspectRealmOpt
 			if err := i.inspectSequences(ctx, r, nil); err != nil {
 				return nil, err
 			}
-			if err := i.inspectExtensions(ctx, r, nil); err != nil {
+			if err := i.inspectRealmObjects(ctx, r, nil); err != nil {
 				return nil, err
 			}
 		}
@@ -383,6 +383,8 @@ func (i *inspect) underlyingType(s *schema.Schema, u *UserDefinedType) schema.Ty
 				return e
 			} else if d, ok := o.(*DomainType); ok && d.T == name {
 				return d
+			} else if c, ok := o.(*CompositeType); ok && c.T == name {
+				return c
 			}
 		}
 	}
@@ -498,13 +500,13 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows, scope queryScope)
 	names := make(map[string]*schema.Index)
 	for rows.Next() {
 		var (
-			table, name, typ                                                      string
-			uniq, primary, included, nullsnotdistinct                             bool
-			desc, nullsfirst, nullslast, opcdefault                               sql.NullBool
-			column, constraints, pred, expr, comment, options, opcname, opcparams sql.NullString
+			table, name, typ                                                              string
+			uniq, primary, included, nullsnotdistinct                                     bool
+			desc, nullsfirst, nullslast, opcdefault                                       sql.NullBool
+			column, constraints, pred, expr, comment, options, opcname, opcparams, exoper sql.NullString
 		)
 		if err := rows.Scan(
-			&table, &name, &typ, &column, &included, &primary, &uniq, &constraints, &pred, &expr, &desc,
+			&table, &name, &typ, &column, &included, &primary, &uniq, &exoper, &constraints, &pred, &expr, &desc,
 			&nullsfirst, &nullslast, &comment, &options, &opcname, &opcdefault, &opcparams, &nullsnotdistinct,
 		); err != nil {
 			return fmt.Errorf("postgres: scanning indexes for schema %q: %w", s.Name, err)
@@ -564,6 +566,9 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows, scope queryScope)
 				NullsLast:  nullslast.Bool,
 			})
 		}
+		if sqlx.ValidString(exoper) {
+			part.AddAttrs(NewOperator(i.schema, exoper.String))
+		}
 		switch {
 		case included:
 			c, ok := scope.column(table, column.String)
@@ -897,6 +902,18 @@ type (
 		Deps    []schema.Object // Objects this domain depends on.
 	}
 
+	// CompositeType defines a composite type.
+	// https://www.postgresql.org/docs/current/rowtypes.html
+	CompositeType struct {
+		schema.Type
+		schema.Object
+		T      string           // Type name.
+		Schema *schema.Schema   // Optional schema.
+		Fields []*schema.Column // Type fields, also known as attributes/columns.
+		Attrs  []schema.Attr    // Extra attributes, such as OID.
+		Deps   []schema.Object  // Objects this domain depends on.
+	}
+
 	// IntervalType defines an interval type.
 	// https://postgresql.org/docs/current/datatype-datetime.html
 	IntervalType struct {
@@ -977,6 +994,21 @@ type (
 		T string // c, f, p, u, t, x.
 	}
 
+	// Operator describes an operator.
+	// https://www.postgresql.org/docs/current/sql-createoperator.html
+	Operator struct {
+		schema.Attr
+		schema.Object
+		// Schema where the operator is defined. If nil, the operator
+		// is not managed by the current scope.
+		Schema *schema.Schema
+		// Operator name. Might include the schema name if the schema
+		// is not managed by the current scope or extension based.
+		// e.g., "public.&&".
+		Name  string
+		Attrs []schema.Attr
+	}
+
 	// Sequence defines (the supported) sequence options.
 	// https://postgresql.org/docs/current/sql-createsequence.html
 	Sequence struct {
@@ -1149,6 +1181,16 @@ func (d *DomainType) Underlying() schema.Type {
 	return d.Type
 }
 
+// SpecType returns the type of the composite type.
+func (c *CompositeType) SpecType() string {
+	return "composite"
+}
+
+// SpecName returns the name of the composite type.
+func (c *CompositeType) SpecName() string {
+	return c.T
+}
+
 // Underlying returns the underlying type of the array.
 func (a *ArrayType) Underlying() schema.Type {
 	return a.Type
@@ -1182,14 +1224,35 @@ func (o *ReferenceOption) Scan(v any) error {
 	return nil
 }
 
-// IsUnique reports if the type is unique constraint.
+// IsUnique reports if the type is a unique constraint.
 func (c Constraint) IsUnique() bool { return strings.ToLower(c.T) == "u" }
 
+// IsExclude reports if the type is an exclude constraint.
+func (c Constraint) IsExclude() bool { return strings.ToLower(c.T) == "x" }
+
 // UniqueConstraint returns constraint with type "u".
 func UniqueConstraint(name string) *Constraint {
 	return &Constraint{T: "u", N: name}
 }
 
+// ExcludeConstraint returns constraint with type "x".
+func ExcludeConstraint(name string) *Constraint {
+	return &Constraint{T: "x", N: name}
+}
+
+// NewOperator returns the string representation of the operator.
+func NewOperator(scope string, name string) *Operator {
+	// When scanned from the database, the operator is returned as: "<schema>.<operator>".
+	// The common case is that operators are the default and defined in pg_catalog, or are
+	// installed by extensions.
+	if parts := strings.FieldsFunc(name, func(r rune) bool {
+		return r == '.'
+	}); len(parts) == 2 && (scope == "" || parts[0] == "pg_catalog" || parts[0] == scope) {
+		return &Operator{Name: parts[1]}
+	}
+	return &Operator{Name: name}
+}
+
 // IntegerType returns the underlying integer type this serial type represents.
 func (s *SerialType) IntegerType() *schema.IntegerType {
 	t := &schema.IntegerType{T: TypeInteger}
@@ -1591,6 +1654,7 @@ SELECT
 	%s AS included,
 	idx.indisprimary AS primary,
 	idx.indisunique AS unique,
+	(CASE WHEN idx.indisexclusion THEN (SELECT conexclop[idx.ord]::regoper FROM pg_constraint WHERE conindid = idx.indexrelid) END) AS excoper,
 	con.nametypes AS constraints,
 	pg_get_expr(idx.indpred, idx.indrelid) AS predicate,
 	pg_get_indexdef(idx.indexrelid, idx.ord, false) AS expression,
diff --git a/sql/postgres/migrate.go b/sql/postgres/migrate.go
index 66120b31..e5f87de6 100644
--- a/sql/postgres/migrate.go
+++ b/sql/postgres/migrate.go
@@ -63,7 +63,6 @@ type state struct {
 	*conn
 	migrate.Plan
 	migrate.PlanOptions
-	droppedT []*schema.Table
 }
 
 // Exec executes the changes on the database. An error is returned
@@ -78,10 +77,10 @@ func (s *state) plan(changes []schema.Change) error {
 	if err != nil {
 		return err
 	}
-	if planned, err = sqlx.DetachCycles(planned); err != nil {
+	if planned, err = detachCycles(planned); err != nil {
 		return err
 	}
-	for _, c := range sqlx.SortChanges(planned) {
+	for _, c := range s.sortChanges(planned) {
 		switch c := c.(type) {
 		case *schema.AddTable:
 			err = s.addTable(c)
@@ -238,9 +237,11 @@ func (s *state) addTable(add *schema.AddTable) error {
 			}
 		}
 		for _, idx := range add.T.Indexes {
-			if _, ok := uniqueConst(idx.Attrs); ok {
+			_, okU := uniqueConst(idx.Attrs)
+			_, okE := excludeConst(idx.Attrs)
+			if okU || okE {
 				b.Comma().NL()
-				if err := s.unique(b, idx); err != nil {
+				if err := s.constraint(b, idx); err != nil {
 					errs = append(errs, err.Error())
 				}
 			}
@@ -273,7 +274,9 @@ func (s *state) addTable(add *schema.AddTable) error {
 		Reverse: s.Build("DROP TABLE").Table(add.T).String(),
 	})
 	for _, idx := range add.T.Indexes {
-		if _, ok := uniqueConst(idx.Attrs); !ok {
+		_, okU := uniqueConst(idx.Attrs)
+		_, okE := excludeConst(idx.Attrs)
+		if !okU && !okE {
 			// Indexes do not need to be created concurrently on new tables.
 			if err := s.addIndexes(add, add.T, &schema.AddIndex{I: idx}); err != nil {
 				return err
@@ -287,7 +290,6 @@ func (s *state) addTable(add *schema.AddTable) error {
 // dropTable builds and executes the query for dropping a table from a schema.
 func (s *state) dropTable(drop *schema.DropTable) error {
 	cmd := &changeGroup{}
-	s.droppedT = append(s.droppedT, drop.T)
 	rs := &state{
 		conn:        s.conn,
 		PlanOptions: s.PlanOptions,
@@ -346,15 +348,21 @@ func (s *state) modifyTable(modify *schema.ModifyTable) error {
 			if c := (schema.Comment{}); sqlx.Has(change.I.Attrs, &c) {
 				changes = append(changes, s.indexComment(modify, modify.T, change.I, c.Text, ""))
 			}
-			if _, ok := uniqueConst(change.I.Attrs); ok {
+			_, okU := uniqueConst(change.I.Attrs)
+			_, okE := excludeConst(change.I.Attrs)
+			// Unlike ADD INDEX statements that are executed separately,
+			// ADD CONSTRAINT are added to the ALTER TABLE statement below.
+			if okU || okE {
 				alter = append(alter, change)
 			} else {
 				addI = append(addI, change)
 			}
 		case *schema.DropIndex:
+			_, okU := uniqueConst(change.I.Attrs)
+			_, okE := excludeConst(change.I.Attrs)
 			// Unlike DROP INDEX statements that are executed separately,
 			// DROP CONSTRAINT are added to the ALTER TABLE statement below.
-			if _, ok := uniqueConst(change.I.Attrs); ok {
+			if okU || okE {
 				alter = append(alter, change)
 			} else {
 				dropI = append(dropI, change)
@@ -384,13 +392,17 @@ func (s *state) modifyTable(modify *schema.ModifyTable) error {
 				alter = append(alter, addU)
 				continue
 			}
-			// Index modification requires rebuilding the index.
-			if _, ok := uniqueConst(change.From.Attrs); ok {
+			// Index (or constraint) modification requires rebuilding the index.
+			_, fromU := uniqueConst(change.From.Attrs)
+			_, fromE := excludeConst(change.From.Attrs)
+			if fromU || fromE {
 				alter = append(alter, &schema.DropIndex{I: change.From})
 			} else {
 				dropI = append(dropI, &schema.DropIndex{I: change.From})
 			}
-			if _, ok := uniqueConst(change.To.Attrs); ok {
+			_, toU := uniqueConst(change.To.Attrs)
+			_, toE := excludeConst(change.To.Attrs)
+			if toU || toE {
 				alter = append(alter, &schema.AddIndex{I: change.To})
 			} else {
 				addI = append(addI, &schema.AddIndex{I: change.To})
@@ -538,7 +550,7 @@ func (s *state) alterTable(t *schema.Table, changes []schema.Change) error {
 				reverse = append(reverse, &schema.DropPrimaryKey{P: drop})
 			case *schema.AddIndex:
 				b.P("ADD")
-				if err := s.unique(b, change.I); err != nil {
+				if err := s.constraint(b, change.I); err != nil {
 					return err
 				}
 				reverse = append(reverse, &schema.DropIndex{I: change.I})
@@ -561,7 +573,11 @@ func (s *state) alterTable(t *schema.Table, changes []schema.Change) error {
 				}
 				reverse = append(reverse, &schema.DropForeignKey{F: change.F})
 			case *schema.DropForeignKey:
-				b.P("DROP CONSTRAINT").Ident(change.F.Symbol)
+				b.P("DROP CONSTRAINT")
+				if sqlx.Has(change.Extra, &schema.IfExists{}) {
+					b.P("IF EXISTS")
+				}
+				b.Ident(change.F.Symbol)
 				reverse = append(reverse, &schema.AddForeignKey{F: change.F})
 			case *schema.AddCheck:
 				check(b.P("ADD"), change.C)
@@ -1079,10 +1095,22 @@ func (s *state) partAttrs(b *sqlx.Builder, idx *schema.Index, p *schema.IndexPar
 			}
 		// Handled above.
 		case *IndexOpClass, *schema.Collation:
+		// Handled below.
+		case *Operator:
 		default:
 			return fmt.Errorf("postgres: unexpected index part attribute: %T", attr)
 		}
 	}
+	if _, isE := excludeConst(idx.Attrs); isE {
+		switch op := (&Operator{}); {
+		case !sqlx.Has(p.Attrs, op):
+			return fmt.Errorf("missing operator for exclude constraint %q", idx.Name)
+		case op.Name == "":
+			return fmt.Errorf("empty operator for exclude constraint %q", idx.Name)
+		default:
+			b.P("WITH", op.Name)
+		}
+	}
 	return nil
 }
 
@@ -1159,6 +1187,16 @@ func (s *state) fks(b *sqlx.Builder, fks ...*schema.ForeignKey) {
 	})
 }
 
+func (s *state) constraint(b *sqlx.Builder, idx *schema.Index) error {
+	if _, isU := uniqueConst(idx.Attrs); isU {
+		return s.unique(b, idx)
+	}
+	if _, isE := excludeConst(idx.Attrs); isE {
+		return s.exclude(b, idx)
+	}
+	return fmt.Errorf("unexpected constraint type for index %q", idx.Name)
+}
+
 func (s *state) unique(b *sqlx.Builder, idx *schema.Index) error {
 	c, ok := uniqueConst(idx.Attrs)
 	if !ok {
@@ -1172,6 +1210,19 @@ func (s *state) unique(b *sqlx.Builder, idx *schema.Index) error {
 	return s.index(b, idx)
 }
 
+func (s *state) exclude(b *sqlx.Builder, idx *schema.Index) error {
+	c, ok := excludeConst(idx.Attrs)
+	if !ok {
+		return fmt.Errorf("index %q is not an exclude constraint", idx.Name)
+	}
+	name := c.N
+	if name == "" {
+		name = idx.Name
+	}
+	b.P("CONSTRAINT").Ident(name).P("EXCLUDE")
+	return s.index(b, idx)
+}
+
 func (s *state) append(c ...*migrate.Change) {
 	s.Changes = append(s.Changes, c...)
 }
@@ -1283,6 +1334,10 @@ func (s *state) domainIdent(d *DomainType) string {
 	return s.typeIdent(d.Schema, d.T)
 }
 
+func (s *state) compositeIdent(c *CompositeType) string {
+	return s.typeIdent(c.Schema, c.T)
+}
+
 func (s *state) typeIdent(ns *schema.Schema, name string) string {
 	switch {
 	// In case the plan uses a specific schema qualifier.
@@ -1317,12 +1372,16 @@ func (s *state) formatType(t schema.Type) (string, error) {
 		return s.enumIdent(t), nil
 	case *DomainType:
 		return s.domainIdent(t), nil
+	case *CompositeType:
+		return s.compositeIdent(t), nil
 	case *ArrayType:
 		switch t := t.Type.(type) {
 		case *schema.EnumType:
 			return s.enumIdent(t) + "[]", nil
 		case *DomainType:
 			return s.domainIdent(t) + "[]", nil
+		case *CompositeType:
+			return s.compositeIdent(t) + "[]", nil
 		}
 	}
 	return FormatType(t)
diff --git a/sql/postgres/sqlspec.go b/sql/postgres/sqlspec.go
index c9e4dbd8..fb9355af 100644
--- a/sql/postgres/sqlspec.go
+++ b/sql/postgres/sqlspec.go
@@ -23,17 +23,19 @@ import (
 
 type (
 	doc struct {
-		Tables       []*sqlspec.Table    `spec:"table"`
-		Views        []*sqlspec.View     `spec:"view"`
-		Materialized []*sqlspec.View     `spec:"materialized"`
-		Enums        []*enum             `spec:"enum"`
-		Domains      []*domain           `spec:"domain"`
-		Sequences    []*sqlspec.Sequence `spec:"sequence"`
-		Funcs        []*sqlspec.Func     `spec:"function"`
-		Procs        []*sqlspec.Func     `spec:"procedure"`
-		Triggers     []*sqlspec.Trigger  `spec:"trigger"`
-		Extensions   []*extension        `spec:"extension"`
-		Schemas      []*sqlspec.Schema   `spec:"schema"`
+		Tables        []*sqlspec.Table    `spec:"table"`
+		Views         []*sqlspec.View     `spec:"view"`
+		Materialized  []*sqlspec.View     `spec:"materialized"`
+		Enums         []*enum             `spec:"enum"`
+		Domains       []*domain           `spec:"domain"`
+		Composites    []*composite        `spec:"composite"`
+		Sequences     []*sqlspec.Sequence `spec:"sequence"`
+		Funcs         []*sqlspec.Func     `spec:"function"`
+		Procs         []*sqlspec.Func     `spec:"procedure"`
+		Triggers      []*sqlspec.Trigger  `spec:"trigger"`
+		EventTriggers []*eventTrigger     `spec:"event_trigger"`
+		Extensions    []*extension        `spec:"extension"`
+		Schemas       []*sqlspec.Schema   `spec:"schema"`
 	}
 
 	// Enum holds a specification for an enum type.
@@ -57,6 +59,23 @@ type (
 		schemahcl.DefaultExtension
 	}
 
+	// composite holds a specification for a composite type.
+	composite struct {
+		Name      string            `spec:",name"`
+		Qualifier string            `spec:",qualifier"`
+		Schema    *schemahcl.Ref    `spec:"schema"`
+		Fields    []*compositeField `spec:"field"`
+		schemahcl.DefaultExtension
+	}
+
+	// compositeField holds a specification for a field in a composite type.
+	// The extension might hold optional attributes such as collation.
+	compositeField struct {
+		Name string          `spec:",name"`
+		Type *schemahcl.Type `spec:"type"`
+		schemahcl.DefaultExtension
+	}
+
 	// extension holds a specification for a postgres extension.
 	// Note, extension names are unique within a realm (database).
 	extension struct {
@@ -65,6 +84,15 @@ type (
 		// added to the extension definition.
 		schemahcl.DefaultExtension
 	}
+
+	// eventTrigger holds a specification for a postgres event trigger.
+	// Note, event trigger names are unique within a realm (database).
+	eventTrigger struct {
+		Name string `spec:",name"`
+		// Schema, version and comment are conditionally
+		// added to the extension definition.
+		schemahcl.DefaultExtension
+	}
 )
 
 // merge merges the doc d1 into d.
@@ -75,10 +103,12 @@ func (d *doc) merge(d1 *doc) {
 	d.Views = append(d.Views, d1.Views...)
 	d.Tables = append(d.Tables, d1.Tables...)
 	d.Domains = append(d.Domains, d1.Domains...)
+	d.Composites = append(d.Composites, d1.Composites...)
 	d.Schemas = append(d.Schemas, d1.Schemas...)
 	d.Sequences = append(d.Sequences, d1.Sequences...)
 	d.Extensions = append(d.Extensions, d1.Extensions...)
 	d.Triggers = append(d.Triggers, d1.Triggers...)
+	d.EventTriggers = append(d.EventTriggers, d1.EventTriggers...)
 	d.Materialized = append(d.Materialized, d1.Materialized...)
 }
 
@@ -118,10 +148,24 @@ func (d *domain) SetQualifier(q string) { d.Qualifier = q }
 // SchemaRef returns the schema reference for the domain.
 func (d *domain) SchemaRef() *schemahcl.Ref { return d.Schema }
 
+// Label returns the defaults label used for the composite resource.
+func (c *composite) Label() string { return c.Name }
+
+// QualifierLabel returns the qualifier label used for the composite resource, if any.
+func (c *composite) QualifierLabel() string { return c.Qualifier }
+
+// SetQualifier sets the qualifier label used for the composite resource.
+func (c *composite) SetQualifier(q string) { c.Qualifier = q }
+
+// SchemaRef returns the schema reference for the composite.
+func (c *composite) SchemaRef() *schemahcl.Ref { return c.Schema }
+
 func init() {
 	schemahcl.Register("enum", &enum{})
 	schemahcl.Register("domain", &domain{})
+	schemahcl.Register("composite", &composite{})
 	schemahcl.Register("extension", &extension{})
+	schemahcl.Register("event_trigger", &eventTrigger{})
 }
 
 // evalSpec evaluates an Atlas DDL document into v using the input.
@@ -144,6 +188,9 @@ func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
 		if err := convertExtensions(d.Extensions, v); err != nil {
 			return err
 		}
+		if err := convertEventTriggers(d.EventTriggers, v); err != nil {
+			return err
+		}
 		if err := normalizeRealm(v); err != nil {
 			return err
 		}
@@ -219,6 +266,9 @@ func MarshalSpec(v any, marshaler schemahcl.Marshaler) ([]byte, error) {
 		if err := specutil.QualifyObjects(d.Domains); err != nil {
 			return nil, err
 		}
+		if err := specutil.QualifyObjects(d.Composites); err != nil {
+			return nil, err
+		}
 		if err := specutil.QualifyObjects(d.Sequences); err != nil {
 			return nil, err
 		}
@@ -282,7 +332,10 @@ func convertTable(spec *sqlspec.Table, parent *schema.Schema) (*schema.Table, er
 	if err != nil {
 		return nil, err
 	}
-	if err := convertUniques(spec.Extra, t); err != nil {
+	if err := convertUnique(spec.Extra, t); err != nil {
+		return nil, err
+	}
+	if err := convertExclude(spec.Extra, t); err != nil {
 		return nil, err
 	}
 	if err := convertPartition(spec.Extra, t); err != nil {
@@ -313,8 +366,8 @@ func convertView(spec *sqlspec.View, parent *schema.Schema) (*schema.View, error
 	return v, nil
 }
 
-// convertUniques converts the unique constraints into indexes.
-func convertUniques(spec schemahcl.Resource, t *schema.Table) error {
+// convertUnique converts the unique constraints into indexes.
+func convertUnique(spec schemahcl.Resource, t *schema.Table) error {
 	rs := spec.Resources("unique")
 	for _, r := range rs {
 		var sx sqlspec.Index
@@ -626,6 +679,7 @@ func schemaSpec(s *schema.Schema) (*doc, []*schema.Trigger, error) {
 		Schemas:      []*sqlspec.Schema{spec.Schema},
 		Enums:        make([]*enum, 0, len(s.Objects)),
 		Domains:      make([]*domain, 0, len(s.Objects)),
+		Composites:   make([]*composite, 0, len(s.Objects)),
 	}
 	if err := objectSpec(d, spec, s); err != nil {
 		return nil, nil, err
@@ -660,8 +714,10 @@ func tableSpec(t *schema.Table) (*sqlspec.Table, error) {
 					idx1.Extra.Attrs...,
 				),
 			})
-		} else {
+		} else if ex, ok := excludeConst(t.Indexes[i].Attrs); !ok {
 			idxs = append(idxs, idx1)
+		} else if err := excludeSpec(spec, idx1, t.Indexes[i], ex); err != nil {
+			return nil, err
 		}
 	}
 	spec.Indexes = idxs
@@ -797,6 +853,12 @@ func columnTypeSpec(t schema.Type) (*sqlspec.Column, error) {
 				IsRef: true,
 				T:     specutil.ObjectRef(o.Schema, o).V},
 		}, nil
+	case *CompositeType:
+		return &sqlspec.Column{
+			Type: &schemahcl.Type{
+				IsRef: true,
+				T:     specutil.ObjectRef(o.Schema, o).V},
+		}, nil
 	case *DomainType:
 		return &sqlspec.Column{
 			Type: &schemahcl.Type{
@@ -891,7 +953,7 @@ var TypeRegistry = schemahcl.NewRegistry(
 			typeOID, typeRegClass, typeRegCollation, typeRegConfig, typeRegDictionary, typeRegNamespace,
 			typeName, typeRegOper, typeRegOperator, typeRegProc, typeRegProcedure, typeRegRole, typeRegType,
 			typeAny, typeAnyElement, typeAnyArray, typeAnyNonArray, typeAnyEnum, typeInternal, typeRecord,
-			typeTrigger, typeVoid, typeUnknown,
+			typeTrigger, typeEventTrigger, typeVoid, typeUnknown,
 		} {
 			specs = append(specs, schemahcl.NewTypeSpec(t))
 		}
diff --git a/sql/schema/migrate.go b/sql/schema/migrate.go
index 78c7ef24..6335c40e 100644
--- a/sql/schema/migrate.go
+++ b/sql/schema/migrate.go
@@ -281,6 +281,7 @@ type (
 	// DropForeignKey describes a foreign-key removal change.
 	DropForeignKey struct {
 		F *ForeignKey
+		Extra []Clause // Extra clauses and options.
 	}
 
 	// ModifyForeignKey describes a change that modifies a foreign-key.
diff --git a/sql/sqlcheck/incompatible/incompatible.go b/sql/sqlcheck/incompatible/incompatible.go
index 52c604c3..d17cca9f 100644
--- a/sql/sqlcheck/incompatible/incompatible.go
+++ b/sql/sqlcheck/incompatible/incompatible.go
@@ -60,13 +60,23 @@ func (a *Analyzer) Analyze(_ context.Context, p *sqlcheck.Pass) error {
 				}
 			case *schema.ModifyTable:
 				for j := range c.Changes {
-					r, ok := c.Changes[j].(*schema.RenameColumn)
-					if ok && p.File.TableSpan(c.T)&sqlcheck.SpanAdded == 0 && !wasAddedBack(p.File.Changes[i:], r.From) {
-						diags = append(diags, sqlcheck.Diagnostic{
-							Code: codeRenameC,
-							Pos:  sc.Stmt.Pos,
-							Text: fmt.Sprintf("Renaming column %q to %q", r.From.Name, r.To.Name),
-						})
+					switch mc := c.Changes[j].(type) {
+					case *schema.RenameColumn:
+						if p.File.TableSpan(c.T)&sqlcheck.SpanAdded == 0 && !wasAddedBack(p.File.Changes[i:], mc.From) {
+							diags = append(diags, sqlcheck.Diagnostic{
+								Code: codeRenameC,
+								Pos:  sc.Stmt.Pos,
+								Text: fmt.Sprintf("Renaming column %q to %q", mc.From.Name, mc.To.Name),
+							})
+						}
+					case *schema.ModifyColumn:
+						if p.File.TableSpan(c.T)&sqlcheck.SpanAdded == 0 && mc.From.Name != mc.To.Name && !wasAddedBack(p.File.Changes[i:], mc.From) {
+							diags = append(diags, sqlcheck.Diagnostic{
+								Code: codeRenameC,
+								Pos:  sc.Stmt.Pos,
+								Text: fmt.Sprintf("Renaming column %q to %q", mc.From.Name, mc.To.Name),
+							})
+						}
 					}
 				}
 			}
diff --git a/sql/sqlclient/client.go b/sql/sqlclient/client.go
index 81f51716..1171fbae 100644
--- a/sql/sqlclient/client.go
+++ b/sql/sqlclient/client.go
@@ -45,6 +45,7 @@ type (
 		// Functions registered by the drivers and used for opening transactions and their clients.
 		openDriver func(schema.ExecQuerier) (migrate.Driver, error)
 		openTx     TxOpener
+		hooks      []*Hook
 	}
 
 	// TxClient is returned by calling Client.Tx. It behaves the same as Client,
@@ -53,7 +54,8 @@ type (
 		*Client
 
 		// The transaction this Client wraps.
-		Tx *Tx
+		Tx    *Tx
+		hooks []*Hook
 	}
 
 	// URL extends the standard url.URL with additional
@@ -67,6 +69,20 @@ type (
 		// The Schema this client is connected to.
 		Schema string
 	}
+
+	// Hook groups all possible hooks in
+	// connection and transaction lifecycle.
+	Hook struct {
+		Conn struct {
+			AfterOpen   func(context.Context, *Client) error
+			BeforeClose func(*Client) error
+		}
+		Tx struct {
+			AfterBegin func(context.Context, *TxClient) error
+			BeforeCommit,
+			BeforeRollback func(*TxClient) error
+		}
+	}
 )
 
 // Tx returns a transactional client.
@@ -95,17 +111,23 @@ func (c *Client) Tx(ctx context.Context, opts *sql.TxOptions) (*TxClient, error)
 	}
 	ic := *c
 	ic.Driver = drv
-	return &TxClient{Client: &ic, Tx: tx}, nil
+	tc := &TxClient{Client: &ic, Tx: tx, hooks: c.hooks}
+	if len(tc.hooks) > 0 {
+		if err := tc.afterBegin(ctx); err != nil {
+			return nil, errors.Join(err, tx.Rollback())
+		}
+	}
+	return tc, nil
 }
 
 // Commit the transaction.
 func (c *TxClient) Commit() error {
-	return c.Tx.Commit()
+	return errors.Join(c.beforeCommit(), c.Tx.Commit())
 }
 
 // Rollback the transaction.
 func (c *TxClient) Rollback() error {
-	return c.Tx.Rollback()
+	return errors.Join(c.beforeRollback(), c.Tx.Rollback())
 }
 
 // AddClosers adds list of closers to close at the end of the client lifetime.
@@ -115,16 +137,88 @@ func (c *Client) AddClosers(closers ...io.Closer) {
 
 // Close closes the underlying database connection and the migration
 // driver in case it implements the io.Closer interface.
-func (c *Client) Close() (err error) {
+func (c *Client) Close() error {
+	err := c.beforeClose()
 	for _, closer := range append(c.closers, c.DB) {
-		if cerr := closer.Close(); cerr != nil {
-			if err != nil {
-				cerr = fmt.Errorf("%v: %v", err, cerr)
+		err = errors.Join(err, closer.Close())
+	}
+	return err
+}
+
+type hookCtxKey struct{}
+
+// hookCtx marks the context as being in a hook.
+func hookCtx(ctx context.Context) context.Context {
+	if ctx.Value(hookCtxKey{}) == nil {
+		return context.WithValue(ctx, hookCtxKey{}, true)
+	}
+	return ctx
+}
+
+// afterOpen calls the AfterOpen hooks.
+func (c *Client) afterOpen(ctx context.Context) error {
+	if ctx.Value(hookCtxKey{}) != nil {
+		return errors.New("sql/sqlclient: cannot open a connection inside a hook")
+	}
+	for _, h := range c.hooks {
+		if f := h.Conn.AfterOpen; f != nil {
+			if err := f(hookCtx(ctx), c); err != nil {
+				return err
 			}
-			err = cerr
 		}
 	}
-	return err
+	return nil
+}
+
+// beforeClose calls the BeforeClose hooks.
+func (c *Client) beforeClose() error {
+	for _, h := range c.hooks {
+		if f := h.Conn.BeforeClose; f != nil {
+			if err := f(c); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// afterBegin calls the AfterBegin hooks.
+func (c *TxClient) afterBegin(ctx context.Context) error {
+	if ctx.Value(hookCtxKey{}) != nil {
+		return errors.New("sql/sqlclient: cannot begin a transaction inside a hook")
+	}
+	for _, h := range c.hooks {
+		if f := h.Tx.AfterBegin; f != nil {
+			if err := f(hookCtx(ctx), c); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// beforeCommit calls the BeforeCommit hooks.
+func (c *TxClient) beforeCommit() error {
+	for _, h := range c.hooks {
+		if f := h.Tx.BeforeCommit; f != nil {
+			if err := f(c); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// beforeRollback calls the BeforeRollback hooks.
+func (c *TxClient) beforeRollback() error {
+	for _, h := range c.hooks {
+		if f := h.Tx.BeforeRollback; f != nil {
+			if err := f(c); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
 }
 
 type (
@@ -186,8 +280,8 @@ type (
 	// openOptions holds additional configuration values for opening a Client.
 	openOptions struct {
 		schema *string
+		hooks  []*Hook
 	}
-
 	// OpenOption allows to configure a openOptions using functional arguments.
 	OpenOption func(*openOptions) error
 )
@@ -238,6 +332,12 @@ func OpenURL(ctx context.Context, u *url.URL, opts ...OpenOption) (*Client, erro
 	if client.openTx == nil && drv.txOpener != nil {
 		client.openTx = drv.txOpener
 	}
+	if len(cfg.hooks) > 0 {
+		client.hooks = cfg.hooks
+		if err := client.afterOpen(ctx); err != nil {
+			return nil, errors.Join(err, client.DB.Close())
+		}
+	}
 	return client, nil
 }
 
@@ -250,6 +350,15 @@ func OpenSchema(s string) OpenOption {
 	}
 }
 
+// OpenWithHooks returns an OpenOption that sets
+// the hooks for the client after opening.
+func OpenWithHooks(hks ...*Hook) OpenOption {
+	return func(c *openOptions) error {
+		c.hooks = append(c.hooks, hks...)
+		return nil
+	}
+}
+
 type (
 	registerOptions struct {
 		openDriver func(schema.ExecQuerier) (migrate.Driver, error)


Test patch:
diff --git a/cmd/atlas/internal/cmdapi/cmdapi_test.go b/cmd/atlas/internal/cmdapi/cmdapi_test.go
index 92198891..248fb7da 100644
--- a/cmd/atlas/internal/cmdapi/cmdapi_test.go
+++ b/cmd/atlas/internal/cmdapi/cmdapi_test.go
@@ -10,7 +10,6 @@ import (
 	"database/sql"
 	"fmt"
 	"os"
-	"os/exec"
 	"testing"
 
 	"ariga.io/atlas/sql/sqlite"
@@ -18,63 +17,6 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestCLI_Version(t *testing.T) {
-	// Required to have a clean "stderr" while running first time.
-	tests := []struct {
-		name     string
-		cmd      *exec.Cmd
-		expected string
-	}{
-		{
-			name: "dev mode",
-			cmd: exec.Command("go", "run", "ariga.io/atlas/cmd/atlas",
-				"version",
-			),
-			expected: "atlas version - development\nhttps://github.com/ariga/atlas/releases/latest\n",
-		},
-		{
-			name: "release",
-			cmd: exec.Command("go", "run",
-				"-ldflags",
-				"-X ariga.io/atlas/cmd/atlas/internal/cmdapi.version=v1.2.3",
-				"ariga.io/atlas/cmd/atlas",
-				"version",
-			),
-			expected: "atlas version v1.2.3\nhttps://github.com/ariga/atlas/releases/tag/v1.2.3\n",
-		},
-		{
-			name: "canary",
-			cmd: exec.Command("go", "run",
-				"-ldflags",
-				"-X ariga.io/atlas/cmd/atlas/internal/cmdapi.version=v0.3.0-6539f2704b5d-canary",
-				"ariga.io/atlas/cmd/atlas",
-				"version",
-			),
-			expected: "atlas version v0.3.0-6539f2704b5d-canary\nhttps://github.com/ariga/atlas/releases/latest\n",
-		},
-		{
-			name: "flavor",
-			cmd: exec.Command("go", "run",
-				"-ldflags",
-				"-X ariga.io/atlas/cmd/atlas/internal/cmdapi.flavor=flavor",
-				"ariga.io/atlas/cmd/atlas",
-				"version",
-			),
-			expected: "atlas flavor version - development\nhttps://github.com/ariga/atlas/releases/latest\n",
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			t.Setenv("ATLAS_NO_UPDATE_NOTIFIER", "true")
-			stdout := bytes.NewBuffer(nil)
-			tt.cmd.Stdout = stdout
-			tt.cmd.Stderr = os.Stderr
-			require.NoError(t, tt.cmd.Run())
-			require.Equal(t, tt.expected, stdout.String())
-		})
-	}
-}
-
 func TestVars_String(t *testing.T) {
 	var vs Vars
 	require.Equal(t, "[]", vs.String())
diff --git a/cmd/atlas/internal/cmdapi/project_test.go b/cmd/atlas/internal/cmdapi/project_test.go
index 64792d17..d478240f 100644
--- a/cmd/atlas/internal/cmdapi/project_test.go
+++ b/cmd/atlas/internal/cmdapi/project_test.go
@@ -114,6 +114,8 @@ env "multi" {
 		sort.Slice(env.Extra.Attrs, func(i, j int) bool {
 			return env.Extra.Attrs[i].K < env.Extra.Attrs[j].K
 		})
+		require.NotNil(t, env.config)
+		env.config = nil
 		require.EqualValues(t, &Env{
 			Name:    "local",
 			URL:     "mysql://root:pass@localhost:3306/",
@@ -160,7 +162,7 @@ env "multi" {
 					},
 				},
 			},
-			cfg: &cmdext.AtlasConfig{
+			cloud: &cmdext.AtlasConfig{
 				Project: cloudapi.DefaultProjectName,
 			},
 		}, env)
diff --git a/cmd/atlas/internal/cmdapi/version_oss_test.go b/cmd/atlas/internal/cmdapi/version_oss_test.go
new file mode 100644
index 00000000..88e706c4
--- /dev/null
+++ b/cmd/atlas/internal/cmdapi/version_oss_test.go
@@ -0,0 +1,72 @@
+// 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.
+
+//go:build !ent && !official
+
+package cmdapi
+
+import (
+	"bytes"
+	"os"
+	"os/exec"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestCLI_Version(t *testing.T) {
+	tests := []struct {
+		name     string
+		cmd      *exec.Cmd
+		expected string
+	}{
+		{
+			name: "dev mode",
+			cmd: exec.Command("go", "run", "ariga.io/atlas/cmd/atlas",
+				"version",
+			),
+			expected: "atlas unofficial version - development\nhttps://github.com/ariga/atlas/releases/latest\n",
+		},
+		{
+			name: "release",
+			cmd: exec.Command("go", "run",
+				"-ldflags",
+				"-X ariga.io/atlas/cmd/atlas/internal/cmdapi.version=v1.2.3",
+				"ariga.io/atlas/cmd/atlas",
+				"version",
+			),
+			expected: "atlas unofficial version v1.2.3\nhttps://github.com/ariga/atlas/releases/tag/v1.2.3\n",
+		},
+		{
+			name: "canary",
+			cmd: exec.Command("go", "run",
+				"-ldflags",
+				"-X ariga.io/atlas/cmd/atlas/internal/cmdapi.version=v0.3.0-6539f2704b5d-canary",
+				"ariga.io/atlas/cmd/atlas",
+				"version",
+			),
+			expected: "atlas unofficial version v0.3.0-6539f2704b5d-canary\nhttps://github.com/ariga/atlas/releases/latest\n",
+		},
+		{
+			name: "flavor",
+			cmd: exec.Command("go", "run",
+				"-ldflags",
+				"-X ariga.io/atlas/cmd/atlas/internal/cmdapi.flavor=flavor",
+				"ariga.io/atlas/cmd/atlas",
+				"version",
+			),
+			expected: "atlas unofficial flavor version - development\nhttps://github.com/ariga/atlas/releases/latest\n",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Setenv("ATLAS_NO_UPDATE_NOTIFIER", "true")
+			stdout := bytes.NewBuffer(nil)
+			tt.cmd.Stdout = stdout
+			tt.cmd.Stderr = os.Stderr
+			require.NoError(t, tt.cmd.Run())
+			require.Equal(t, tt.expected+versionInfo, stdout.String())
+		})
+	}
+}
diff --git a/cmd/atlas/internal/cmdlog/cmdlog_test.go b/cmd/atlas/internal/cmdlog/cmdlog_test.go
index 97fba72e..68257d6e 100644
--- a/cmd/atlas/internal/cmdlog/cmdlog_test.go
+++ b/cmd/atlas/internal/cmdlog/cmdlog_test.go
@@ -110,10 +110,10 @@ func TestSchemaInspect_MarshalSQL(t *testing.T) {
 	client, err := sqlclient.Open(context.Background(), "sqlite://ci?mode=memory&_fk=1")
 	require.NoError(t, err)
 	defer client.Close()
-	report := &cmdlog.SchemaInspect{
-		Context: context.Background(),
-		Client:  client,
-		Realm: schema.NewRealm(
+	report := cmdlog.NewSchemaInspect(
+		context.Background(),
+		client,
+		schema.NewRealm(
 			schema.New("main").
 				AddTables(
 					schema.NewTable("users").
@@ -122,7 +122,7 @@ func TestSchemaInspect_MarshalSQL(t *testing.T) {
 						),
 				),
 		),
-	}
+	)
 	b, err := report.MarshalSQL()
 	require.NoError(t, err)
 	require.Equal(t, "-- Create \"users\" table\nCREATE TABLE `users` (`id` int NOT NULL);\n", b)
@@ -150,7 +150,7 @@ func TestSchemaInspect_EncodeSQL(t *testing.T) {
 		b    bytes.Buffer
 		tmpl = template.Must(template.New("format").Funcs(cmdlog.InspectTemplateFuncs).Parse(`{{ sql . }}`))
 	)
-	require.NoError(t, tmpl.Execute(&b, &cmdlog.SchemaInspect{Context: ctx, Client: client, Realm: realm}))
+	require.NoError(t, tmpl.Execute(&b, cmdlog.NewSchemaInspect(ctx, client, realm)))
 	require.Equal(t, "-- Create \"users\" table\nCREATE TABLE `users` (`id` int NOT NULL, `name` text NOT NULL);\n", b.String())
 }
 
@@ -168,10 +168,10 @@ func TestSchemaInspect_Mermaid(t *testing.T) {
 			)
 		tmpl = template.Must(template.New("format").Funcs(cmdlog.InspectTemplateFuncs).Parse(`{{ mermaid . }}`))
 	)
-	require.NoError(t, tmpl.Execute(&b, &cmdlog.SchemaInspect{
-		Client: client,
-		Realm:  schema.NewRealm(schema.New("main").AddTables(users)),
-	}))
+	require.NoError(t, tmpl.Execute(&b, cmdlog.NewSchemaInspect(context.Background(),
+		client,
+		schema.NewRealm(schema.New("main").AddTables(users))),
+	))
 	require.Equal(t, `erDiagram
     users {
       int id
@@ -198,10 +198,10 @@ func TestSchemaInspect_Mermaid(t *testing.T) {
 			AddRefColumns(users.Columns[0]),
 	)
 
-	require.NoError(t, tmpl.Execute(&b, &cmdlog.SchemaInspect{
-		Client: client,
-		Realm:  schema.NewRealm(schema.New("main").AddTables(users, posts)),
-	}))
+	require.NoError(t, tmpl.Execute(&b, cmdlog.NewSchemaInspect(
+		context.Background(),
+		client, schema.NewRealm(schema.New("main").AddTables(users, posts)))),
+	)
 	require.Equal(t, `erDiagram
     users {
       int id PK
@@ -215,13 +215,14 @@ func TestSchemaInspect_Mermaid(t *testing.T) {
 `, b.String())
 
 	b.Reset()
-	require.NoError(t, tmpl.Execute(&b, &cmdlog.SchemaInspect{
-		Client: client,
-		Realm: schema.NewRealm(
+	require.NoError(t, tmpl.Execute(&b, cmdlog.NewSchemaInspect(
+		context.Background(),
+		client,
+		schema.NewRealm(
 			schema.New("main").AddTables(users),
 			schema.New("temp").AddTables(posts),
 		),
-	}))
+	)))
 	require.Equal(t, `erDiagram
     main_users["main.users"] {
       int id PK
@@ -249,10 +250,11 @@ func TestSchemaInspect_Mermaid(t *testing.T) {
 				SetRefTable(users).
 				AddRefColumns(users.Columns[0]),
 		)
-	require.NoError(t, tmpl.Execute(&b, &cmdlog.SchemaInspect{
-		Client: client,
-		Realm:  schema.NewRealm(schema.New("main").AddTables(users)),
-	}))
+	require.NoError(t, tmpl.Execute(&b, cmdlog.NewSchemaInspect(
+		context.Background(),
+		client,
+		schema.NewRealm(schema.New("main").AddTables(users)),
+	)))
 	require.Equal(t, `erDiagram
     users {
       int id PK
@@ -267,10 +269,11 @@ func TestSchemaInspect_Mermaid(t *testing.T) {
 		AddColumns(
 			schema.NewFloatColumn("time duration", "double precision"),
 		)
-	require.NoError(t, tmpl.Execute(&b, &cmdlog.SchemaInspect{
-		Client: client,
-		Realm:  schema.NewRealm(schema.New("main").AddTables(users)),
-	}))
+	require.NoError(t, tmpl.Execute(&b, cmdlog.NewSchemaInspect(
+		context.Background(),
+		client,
+		schema.NewRealm(schema.New("main").AddTables(users)),
+	)))
 	require.Equal(t, `erDiagram
     users {
       int id PK
@@ -286,18 +289,14 @@ func TestSchemaDiff_MarshalSQL(t *testing.T) {
 	client, err := sqlclient.Open(context.Background(), "sqlite://ci?mode=memory&_fk=1")
 	require.NoError(t, err)
 	defer client.Close()
-	diff := &cmdlog.SchemaDiff{
-		Context: context.Background(),
-		Client:  client,
-		Changes: schema.Changes{
-			&schema.AddTable{
-				T: schema.NewTable("users").
-					AddColumns(
-						schema.NewIntColumn("id", "int"),
-					),
-			},
+	diff := cmdlog.NewSchemaDiff(context.Background(), client, nil, nil, schema.Changes{
+		&schema.AddTable{
+			T: schema.NewTable("users").
+				AddColumns(
+					schema.NewIntColumn("id", "int"),
+				),
 		},
-	}
+	})
 	b, err := diff.MarshalSQL()
 	require.NoError(t, err)
 	require.Equal(t, "-- Create \"users\" table\nCREATE TABLE `users` (`id` int NOT NULL);\n", b)
diff --git a/schemahcl/schemahcl_test.go b/schemahcl/schemahcl_test.go
index 5119a795..c08a92a4 100644
--- a/schemahcl/schemahcl_test.go
+++ b/schemahcl/schemahcl_test.go
@@ -76,7 +76,6 @@ func TestResource(t *testing.T) {
 				Value string `spec:"value"`
 			} `spec:"tag"`
 		}
-
 		Endpoint struct {
 			Name        string   `spec:",name"`
 			Description string   `spec:"description"`
diff --git a/sql/internal/sqlx/plan_test.go b/sql/internal/sqlx/plan_test.go
index 90ab3849..274b6a45 100644
--- a/sql/internal/sqlx/plan_test.go
+++ b/sql/internal/sqlx/plan_test.go
@@ -164,7 +164,7 @@ func TestBadTypeComparison(t *testing.T) {
 	SortChanges([]schema.Change{
 		&schema.DropObject{O: o1},
 		&schema.DropTable{T: schema.NewTable("t1").AddColumns(schema.NewColumn("c1").SetType(o1))},
-	})
+	}, nil)
 }
 
 func TestSortChanges(t *testing.T) {
@@ -186,7 +186,7 @@ func TestSortChanges(t *testing.T) {
 		&schema.AddTable{T: t1},
 		&schema.AddFunc{F: f1},
 	}
-	planned := SortChanges(changes)
+	planned := SortChanges(changes, nil)
 	require.Equal(t, []schema.Change{changes[2], changes[0], changes[3], changes[1]}, planned)
 
 	changes = []schema.Change{
@@ -194,14 +194,14 @@ func TestSortChanges(t *testing.T) {
 		&schema.DropFunc{F: f1},
 		&schema.DropTrigger{T: tr1},
 	}
-	planned = SortChanges(changes)
+	planned = SortChanges(changes, nil)
 	require.Equal(t, []schema.Change{changes[2], changes[1], changes[0]}, planned)
 
 	// No changes.
 	planned = SortChanges([]schema.Change{
 		&schema.DropFunc{F: f1},
 		&schema.DropTable{T: t1},
-	})
+	}, nil)
 	require.Equal(t, []schema.Change{&schema.DropFunc{F: f1}, &schema.DropTable{T: t1}}, planned)
 
 	// The table must be dropped before the enum type if one of its triggers depends on the enum type.
@@ -210,7 +210,7 @@ func TestSortChanges(t *testing.T) {
 		&schema.DropObject{O: e1},
 		&schema.DropTable{T: t1},
 	}
-	planned = SortChanges(changes)
+	planned = SortChanges(changes, nil)
 	require.Equal(t, []schema.Change{changes[1], changes[0]}, planned)
 
 	// The table must be dropped before the function if one of its triggers depends on the function.
@@ -218,7 +218,7 @@ func TestSortChanges(t *testing.T) {
 	planned = SortChanges([]schema.Change{
 		&schema.DropFunc{F: f1},
 		&schema.DropTable{T: t1},
-	})
+	}, nil)
 	require.Equal(t, []schema.Change{&schema.DropTable{T: t1}, &schema.DropFunc{F: f1}}, planned)
 
 	// Ignore functions that do not reside on the same schema.
@@ -227,7 +227,7 @@ func TestSortChanges(t *testing.T) {
 	planned = SortChanges([]schema.Change{
 		&schema.AddFunc{F: f1},
 		&schema.DropFunc{F: f2},
-	})
+	}, nil)
 	require.Equal(t, []schema.Change{&schema.AddFunc{F: f1}, &schema.DropFunc{F: f2}}, planned)
 
 	// Order functions that reside on the same schema.
@@ -235,6 +235,19 @@ func TestSortChanges(t *testing.T) {
 	planned = SortChanges([]schema.Change{
 		&schema.AddFunc{F: f1},
 		&schema.DropFunc{F: f2},
-	})
+	}, nil)
 	require.Equal(t, []schema.Change{&schema.DropFunc{F: f2}, &schema.AddFunc{F: f1}}, planned)
 }
+
+func TestSameTable(t *testing.T) {
+	t1 := schema.NewTable("t1")
+	require.True(t, SameTable(t1, t1))
+	require.True(t, SameTable(t1, schema.NewTable("t1")), "same copy")
+	require.False(t, SameTable(t1, schema.NewTable("t2")))
+
+	t1.SetSchema(schema.New("public"))
+	require.True(t, SameTable(t1, t1))
+	require.True(t, SameTable(t1, schema.NewTable("t1").SetSchema(schema.New("public"))), "same copy")
+	require.False(t, SameTable(t1, schema.NewTable("t1")))
+	require.False(t, SameTable(t1, schema.NewTable("t1").SetSchema(schema.New("private"))))
+}
diff --git a/sql/migrate/testdata/lex/17_paren.sql b/sql/migrate/testdata/lex/17_paren.sql
new file mode 100644
index 00000000..37064d63
--- /dev/null
+++ b/sql/migrate/testdata/lex/17_paren.sql
@@ -0,0 +1,6 @@
+ALTER TABLE ONLY t ADD CONSTRAINT name1 EXCLUDE USING gist (id WITH =, cid WITH -|-);
+ALTER TABLE ONLY t
+    ADD CONSTRAINT name2 EXCLUDE USING gist (id WITH =, cid WITH -|-);
+ALTER TABLE ONLY t
+    ADD CONSTRAINT name3 EXCLUDE USING gist (id WITH =, cid WITH -|-),
+    ADD CONSTRAINT name4 EXCLUDE USING gist (id WITH =, cid WITH -|-);
diff --git a/sql/migrate/testdata/lex/17_paren.sql.golden b/sql/migrate/testdata/lex/17_paren.sql.golden
new file mode 100644
index 00000000..0d8add5d
--- /dev/null
+++ b/sql/migrate/testdata/lex/17_paren.sql.golden
@@ -0,0 +1,8 @@
+ALTER TABLE ONLY t ADD CONSTRAINT name1 EXCLUDE USING gist (id WITH =, cid WITH -|-);
+-- end --
+ALTER TABLE ONLY t
+    ADD CONSTRAINT name2 EXCLUDE USING gist (id WITH =, cid WITH -|-);
+-- end --
+ALTER TABLE ONLY t
+    ADD CONSTRAINT name3 EXCLUDE USING gist (id WITH =, cid WITH -|-),
+    ADD CONSTRAINT name4 EXCLUDE USING gist (id WITH =, cid WITH -|-);
\ No newline at end of file
diff --git a/sql/mysql/migrate_test.go b/sql/mysql/migrate_test.go
index a86e293c..39130459 100644
--- a/sql/mysql/migrate_test.go
+++ b/sql/mysql/migrate_test.go
@@ -959,13 +959,21 @@ func TestPlanChanges(t *testing.T) {
 			changes: []schema.Change{
 				&schema.ModifyTable{
 					T: schema.NewTable("users").
-						AddColumns(schema.NewIntColumn("c", "int").SetGeneratedExpr(&schema.GeneratedExpr{Expr: "1", Type: "STORED"})),
+						AddColumns(
+							schema.NewIntColumn("c", "int").SetGeneratedExpr(&schema.GeneratedExpr{Expr: "1", Type: "STORED"}),
+							schema.NewIntColumn("c1", "int").SetGeneratedExpr(&schema.GeneratedExpr{Expr: "1", Type: "STORED"}),
+						),
 					Changes: []schema.Change{
 						&schema.ModifyColumn{
 							Change: schema.ChangeGenerated,
 							From:   schema.NewIntColumn("c", "int"),
 							To:     schema.NewIntColumn("c", "int").SetGeneratedExpr(&schema.GeneratedExpr{Expr: "1", Type: "STORED"}),
 						},
+						&schema.ModifyColumn{
+							Change: schema.ChangeGenerated,
+							From:   schema.NewIntColumn("c0", "int"),
+							To:     schema.NewIntColumn("c1", "int").SetGeneratedExpr(&schema.GeneratedExpr{Expr: "1", Type: "STORED"}),
+						},
 					},
 				},
 			},
@@ -973,8 +981,8 @@ func TestPlanChanges(t *testing.T) {
 				Reversible: true,
 				Changes: []*migrate.Change{
 					{
-						Cmd:     "ALTER TABLE `users` MODIFY COLUMN `c` int AS (1) STORED NOT NULL",
-						Reverse: "ALTER TABLE `users` MODIFY COLUMN `c` int NOT NULL",
+						Cmd:     "ALTER TABLE `users` MODIFY COLUMN `c` int AS (1) STORED NOT NULL, CHANGE COLUMN `c0` `c1` int AS (1) STORED NOT NULL",
+						Reverse: "ALTER TABLE `users` CHANGE COLUMN `c1` `c0` int NOT NULL, MODIFY COLUMN `c` int NOT NULL",
 					},
 				},
 			},
diff --git a/sql/postgres/diff_test.go b/sql/postgres/diff_test.go
index e8763f84..fe74c7ca 100644
--- a/sql/postgres/diff_test.go
+++ b/sql/postgres/diff_test.go
@@ -587,6 +587,38 @@ func TestDiff_SchemaDiff(t *testing.T) {
 				},
 			}},
 		}, changes)
+
+		// In schema scope, auto created
+		// public comment should be ignored.
+		drv.(*Driver).schema = "public"
+		from.Name, to.Name = "other", "other"
+		from.SetComment("standard public schema")
+		to.SetComment("standard public schema")
+		changes, err = drv.SchemaDiff(from, to)
+		require.NoError(t, err)
+		require.Empty(t, changes)
+
+		to.SetComment("")
+		changes, err = drv.SchemaDiff(from, to)
+		require.NoError(t, err)
+		require.Empty(t, changes)
+
+		from.Name, to.Name = "public", "public"
+		changes, err = drv.SchemaDiff(from, to)
+		require.NoError(t, err)
+		require.Empty(t, changes)
+
+		from.SetComment("custom public schema")
+		changes, err = drv.SchemaDiff(from, to)
+		require.NoError(t, err)
+		require.EqualValues(t, []schema.Change{
+			&schema.ModifySchema{S: to, Changes: schema.Changes{
+				&schema.ModifyAttr{
+					From: &schema.Comment{Text: "custom public schema"},
+					To:   &schema.Comment{Text: ""},
+				},
+			}},
+		}, changes)
 	})
 }
 
diff --git a/sql/postgres/inspect_test.go b/sql/postgres/inspect_test.go
index c9994783..2e0b6541 100644
--- a/sql/postgres/inspect_test.go
+++ b/sql/postgres/inspect_test.go
@@ -178,23 +178,23 @@ users      | ts          | tsvector            | tsvector  |  NO          |
 				m.ExpectQuery(queryIndexes).
 					WithArgs("public", "users").
 					WillReturnRows(sqltest.Rows(`
-   table_name   |    index_name   | index_type  | column_name | included | primary | unique |   constraints   | predicate             |   expression              | desc | nulls_first | nulls_last | comment   |                 options               |   opclass_name    | opclass_default | opclass_params | indnullsnotdistinct 
-----------------+-----------------+-------------+-------------+----------+---------+--------+-----------------+-----------------------+---------------------------+------+-------------+------------+-----------+---------------------------------------+-------------------+-----------------+----------------+---------------------
-users           | idx             | hash        |             | f        | f       | f      |                 |                       | "left"((c11)::text, 100)  | t    | t           | f          | boring    |                                       |     int4_ops      |        t        |                | f
-users           | idx1            | btree       |             | f        | f       | f      |                 | (id <> NULL::integer) | "left"((c11)::text, 100)  | t    | t           | f          |           |                                       |     int4_ops      |        t        |                | f
-users           | t1_c1_key       | btree       | c1          | f        | f       | t      | {"name": "u"}   |                       | c1                        | t    | t           | f          |           |                                       |     int4_ops      |        t        |                | f
-users           | t1_pkey         | btree       | id          | f        | t       | t      | {"t_pkey": "p"} |                       | id                        | t    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
-users           | idx4            | btree       | c1          | f        | f       | t      |                 |                       | c1                        | f    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
-users           | idx4            | btree       | id          | f        | f       | t      |                 |                       | id                        | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
-users           | idx5            | btree       | c1          | f        | f       | t      |                 |                       | c1                        | f    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
-users           | idx5            | btree       |             | f        | f       | t      |                 |                       | coalesce(parent_id, 0)    | f    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
-users           | idx6            | brin        | c1          | f        | f       | t      |                 |                       |                           | f    | f           | f          |           | {autosummarize=true,pages_per_range=2}|     int4_ops      |        t        |                | f
-users           | idx2            | btree       |             | f        | f       | f      |                 |                       | ((c * 2))                 | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
-users           | idx2            | btree       | c1          | f        | f       | f      |                 |                       | c                         | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
-users           | idx2            | btree       | id          | f        | f       | f      |                 |                       | d                         | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
-users           | idx2            | btree       | c1          | t        | f       | f      |                 |                       | c                         |      |             |            |           |                                       |     int4_ops      |        t        |                | f
-users           | idx2            | btree       | parent_id   | t        | f       | f      |                 |                       | d                         |      |             |            |           |                                       |     int4_ops      |        t        |                | f
-users           | tsx             | gist        | ts          | f        | f       | f      |                 |                       | ts                        |      |             |            |           |                                       |     tsvector_ops  |        f        | {siglen=1}     | f
+   table_name   |    index_name   | index_type  | column_name | included | primary | unique | opexpr |   constraints   | predicate             |   expression              | desc | nulls_first | nulls_last | comment   |                 options               |   opclass_name    | opclass_default | opclass_params | indnullsnotdistinct 
+----------------+-----------------+-------------+-------------+----------+---------+--------+--------+-----------------+-----------------------+---------------------------+------+-------------+------------+-----------+---------------------------------------+-------------------+-----------------+----------------+---------------------
+users           | idx             | hash        |             | f        | f       | f      |        |                 |                       | "left"((c11)::text, 100)  | t    | t           | f          | boring    |                                       |     int4_ops      |        t        |                | f
+users           | idx1            | btree       |             | f        | f       | f      |        |                 | (id <> NULL::integer) | "left"((c11)::text, 100)  | t    | t           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | t1_c1_key       | btree       | c1          | f        | f       | t      |        | {"name": "u"}   |                       | c1                        | t    | t           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | t1_pkey         | btree       | id          | f        | t       | t      |        | {"t_pkey": "p"} |                       | id                        | t    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx4            | btree       | c1          | f        | f       | t      |        |                 |                       | c1                        | f    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx4            | btree       | id          | f        | f       | t      |        |                 |                       | id                        | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx5            | btree       | c1          | f        | f       | t      |        |                 |                       | c1                        | f    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx5            | btree       |             | f        | f       | t      |        |                 |                       | coalesce(parent_id, 0)    | f    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx6            | brin        | c1          | f        | f       | t      |        |                 |                       |                           | f    | f           | f          |           | {autosummarize=true,pages_per_range=2}|     int4_ops      |        t        |                | f
+users           | idx2            | btree       |             | f        | f       | f      |        |                 |                       | ((c * 2))                 | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx2            | btree       | c1          | f        | f       | f      |        |                 |                       | c                         | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx2            | btree       | id          | f        | f       | f      |        |                 |                       | d                         | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx2            | btree       | c1          | t        | f       | f      |        |                 |                       | c                         |      |             |            |           |                                       |     int4_ops      |        t        |                | f
+users           | idx2            | btree       | parent_id   | t        | f       | f      |        |                 |                       | d                         |      |             |            |           |                                       |     int4_ops      |        t        |                | f
+users           | tsx             | gist        | ts          | f        | f       | f      |        |                 |                       | ts                        |      |             |            |           |                                       |     tsvector_ops  |        f        | {siglen=1}     | f
 `))
 				m.noFKs()
 				m.noChecks()
diff --git a/sql/postgres/migrate_test.go b/sql/postgres/migrate_test.go
index 470d6bcb..686dac8a 100644
--- a/sql/postgres/migrate_test.go
+++ b/sql/postgres/migrate_test.go
@@ -96,6 +96,8 @@ func TestPlanChanges(t *testing.T) {
 						Columns:    []*schema.Column{pets.Columns[1]},
 						RefColumns: []*schema.Column{users.Columns[0]},
 					}
+					fk1 := *fk
+					fk1.Symbol += "1"
 					pets.ForeignKeys = []*schema.ForeignKey{fk}
 					return &schema.ModifyTable{
 						T: pets,
@@ -103,6 +105,12 @@ func TestPlanChanges(t *testing.T) {
 							&schema.DropForeignKey{
 								F: fk,
 							},
+							&schema.DropForeignKey{
+								F: &fk1,
+								Extra: []schema.Clause{
+									&schema.IfExists{},
+								},
+							},
 						},
 					}
 				}(),
@@ -112,8 +120,8 @@ func TestPlanChanges(t *testing.T) {
 				Transactional: true,
 				Changes: []*migrate.Change{
 					{
-						Cmd:     `ALTER TABLE "pets" DROP CONSTRAINT "pets_user_id_fkey"`,
-						Reverse: `ALTER TABLE "pets" ADD CONSTRAINT "pets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON UPDATE NO ACTION ON DELETE CASCADE`,
+						Cmd:     `ALTER TABLE "pets" DROP CONSTRAINT "pets_user_id_fkey", DROP CONSTRAINT IF EXISTS "pets_user_id_fkey1"`,
+						Reverse: `ALTER TABLE "pets" ADD CONSTRAINT "pets_user_id_fkey1" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON UPDATE NO ACTION ON DELETE CASCADE, ADD CONSTRAINT "pets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON UPDATE NO ACTION ON DELETE CASCADE`,
 					},
 				},
 			},
diff --git a/sql/sqlclient/client_test.go b/sql/sqlclient/client_test.go
index 353ead77..e6100beb 100644
--- a/sql/sqlclient/client_test.go
+++ b/sql/sqlclient/client_test.go
@@ -7,6 +7,7 @@ package sqlclient_test
 import (
 	"context"
 	"database/sql"
+	"errors"
 	"net/url"
 	"testing"
 
@@ -168,3 +169,109 @@ type mockDriver struct {
 func (m *mockDriver) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
 	return m.db.ExecContext(ctx, query, args...)
 }
+
+func TestClientHooks(t *testing.T) {
+	db, mock, err := sqlmock.New()
+	require.NoError(t, err)
+	sqlclient.Register(
+		"hook",
+		sqlclient.OpenerFunc(func(context.Context, *url.URL) (*sqlclient.Client, error) {
+			return &sqlclient.Client{Name: "tx", DB: db, Driver: &mockDriver{db: db}}, nil
+		}),
+		sqlclient.RegisterDriverOpener(func(db schema.ExecQuerier) (migrate.Driver, error) {
+			return &mockDriver{db: db}, nil
+		}),
+	)
+	var (
+		calls [5]int
+		hk    = &sqlclient.Hook{}
+	)
+	hk.Conn.AfterOpen = func(context.Context, *sqlclient.Client) error {
+		calls[0]++
+		return nil
+	}
+	hk.Conn.BeforeClose = func(*sqlclient.Client) error {
+		calls[1]++
+		return nil
+	}
+	hk.Tx.AfterBegin = func(context.Context, *sqlclient.TxClient) error {
+		calls[2]++
+		return nil
+	}
+	hk.Tx.BeforeCommit = func(*sqlclient.TxClient) error {
+		calls[3]++
+		return nil
+	}
+	hk.Tx.BeforeRollback = func(*sqlclient.TxClient) error {
+		calls[4]++
+		return nil
+	}
+	mock.ExpectClose()
+	oc, err := sqlclient.Open(context.Background(), "hook://", sqlclient.OpenWithHooks(hk))
+	require.NoError(t, err)
+	require.NoError(t, oc.Close())
+	require.Equal(t, [5]int{1, 1, 0, 0, 0}, calls)
+	require.NoError(t, mock.ExpectationsWereMet())
+
+	db, mock, err = sqlmock.New()
+	require.NoError(t, err)
+	mock.ExpectBegin()
+	mock.ExpectCommit()
+	mock.ExpectClose()
+	oc, err = sqlclient.Open(context.Background(), "hook://", sqlclient.OpenWithHooks(hk))
+	require.NoError(t, err)
+	tc, err := oc.Tx(context.Background(), nil)
+	require.NoError(t, err)
+	require.NoError(t, tc.Commit())
+	require.NoError(t, oc.Close())
+	require.Equal(t, [5]int{2, 2, 1, 1, 0}, calls)
+	require.NoError(t, mock.ExpectationsWereMet())
+
+	db, mock, err = sqlmock.New()
+	require.NoError(t, err)
+	mock.ExpectBegin()
+	mock.ExpectRollback()
+	mock.ExpectClose()
+	oc, err = sqlclient.Open(context.Background(), "hook://", sqlclient.OpenWithHooks(hk))
+	require.NoError(t, err)
+	tc, err = oc.Tx(context.Background(), nil)
+	require.NoError(t, err)
+	require.NoError(t, tc.Rollback())
+	require.NoError(t, oc.Close())
+	require.Equal(t, [5]int{3, 3, 2, 1, 1}, calls)
+	require.NoError(t, mock.ExpectationsWereMet())
+
+	// Open hook failed.
+	hk.Conn.AfterOpen = func(context.Context, *sqlclient.Client) error {
+		calls[0]++
+		return errors.New("open failed")
+	}
+	db, mock, err = sqlmock.New()
+	require.NoError(t, err)
+	mock.ExpectClose()
+	oc, err = sqlclient.Open(context.Background(), "hook://", sqlclient.OpenWithHooks(hk))
+	require.EqualError(t, err, "open failed")
+	require.Equal(t, [5]int{4, 3, 2, 1, 1}, calls, "close hooks should not be called")
+	require.NoError(t, mock.ExpectationsWereMet())
+
+	// After begin hook failed.
+	hk.Conn.AfterOpen = func(context.Context, *sqlclient.Client) error {
+		calls[0]++
+		return nil
+	}
+	hk.Tx.AfterBegin = func(context.Context, *sqlclient.TxClient) error {
+		calls[2]++
+		return errors.New("after begin failed")
+	}
+	db, mock, err = sqlmock.New()
+	require.NoError(t, err)
+	mock.ExpectBegin()
+	mock.ExpectRollback()
+	mock.ExpectClose()
+	oc, err = sqlclient.Open(context.Background(), "hook://", sqlclient.OpenWithHooks(hk))
+	require.NoError(t, err)
+	tc, err = oc.Tx(context.Background(), nil)
+	require.EqualError(t, err, "after begin failed")
+	require.NoError(t, oc.Close())
+	require.Equal(t, [5]int{5, 4, 3, 1, 1}, calls, "rollback hooks should not be called")
+}