Repository: ariga/atlas
Pull request #2991 (2024-07-22 08:09:27)

doc/md: add procedure testing example and doc


Patch:
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index 54e6beda..2ebf58b9 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -1226,7 +1226,7 @@ func migrateStatusRun(cmd *cobra.Command, _ []string, flags migrateStatusFlags)
 	if err := checkRevisionSchemaClarity(cmd, client, flags.revisionSchema); err != nil {
 		return err
 	}
-	report, err := (&cmdmigrate.StatusReporter{
+	report, err := (&cmdlog.StatusReporter{
 		Client: client,
 		Dir:    dir,
 		DirURL: dirURL,
diff --git a/cmd/atlas/internal/cmdapi/schema.go b/cmd/atlas/internal/cmdapi/schema.go
index 0a2c9624..51c1ba71 100644
--- a/cmd/atlas/internal/cmdapi/schema.go
+++ b/cmd/atlas/internal/cmdapi/schema.go
@@ -23,6 +23,7 @@ import (
 	"ariga.io/atlas/sql/sqlclient"
 
 	"github.com/1lann/promptui"
+	"github.com/chzyer/readline"
 	"github.com/hashicorp/hcl/v2/hclwrite"
 	"github.com/spf13/cobra"
 )
@@ -637,11 +638,7 @@ func setSchemaEnvFlags(cmd *cobra.Command, env *Env) error {
 	if err != nil {
 		return err
 	}
-	for i, s := range srcs {
-		if !isURL(s) {
-			srcs[i] = "file://" + s
-		}
-	}
+	srcs = fixFileURLs(srcs)
 	if err := maySetFlag(cmd, flagFile, strings.Join(srcs, ",")); err != nil {
 		return err
 	}
@@ -759,7 +756,7 @@ func cmdPrompt(cmd *cobra.Command) *promptui.Select {
 		Label:    "Are you sure?",
 		HideHelp: true,
 		Stdin:    io.NopCloser(cmd.InOrStdin()),
-		Stdout:   nopCloser{cmd.OutOrStdout()},
+		Stdout:   nopBellCloser{cmd.OutOrStdout()},
 	}
 }
 
@@ -774,9 +771,16 @@ func promptUser(cmd *cobra.Command) bool {
 	return result == answerApply
 }
 
-type nopCloser struct{ io.Writer }
+type nopBellCloser struct{ io.Writer }
+
+func (n nopBellCloser) Write(p []byte) (int, error) {
+	if len(p) == 1 && p[0] == readline.CharBell {
+		return 0, nil // Skip bell noise.
+	}
+	return n.Writer.Write(p)
+}
 
-func (nopCloser) Close() error { return nil }
+func (nopBellCloser) Close() error { return nil }
 
 func tasks(path string) ([]fmttask, error) {
 	var tasks []fmttask
@@ -832,3 +836,14 @@ func fmtFile(task fmttask) (bool, error) {
 	}
 	return false, nil
 }
+
+// fixFileURLs converts all file paths to a URL format, if not already.
+// For example, "schema.hcl" to "file://schema.hcl".
+func fixFileURLs(src []string) []string {
+	for i, s := range src {
+		if !isURL(s) {
+			src[i] = "file://" + s
+		}
+	}
+	return src
+}
diff --git a/cmd/atlas/internal/cmdext/reader.go b/cmd/atlas/internal/cmdext/reader.go
index 1f058eed..0957559f 100644
--- a/cmd/atlas/internal/cmdext/reader.go
+++ b/cmd/atlas/internal/cmdext/reader.go
@@ -14,11 +14,11 @@ import (
 	"path/filepath"
 	"strings"
 
-	"ariga.io/atlas/cmd/atlas/internal/cmdlog"
 	cmdmigrate "ariga.io/atlas/cmd/atlas/internal/migrate"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
+
 	"github.com/hashicorp/hcl/v2"
 	"github.com/hashicorp/hcl/v2/hclparse"
 	"github.com/hashicorp/hcl/v2/hclsyntax"
@@ -121,16 +121,30 @@ func stateSchemaSQL(ctx context.Context, cfg *StateReaderConfig, dir migrate.Dir
 	if cfg.Dev == nil {
 		return nil, errNoDevURL
 	}
-	log := cmdlog.NewMigrateApply(ctx, cfg.Dev, nil)
+	log := &errorRecorder{}
 	r, err := stateReaderSQL(ctx, cfg, dir, []migrate.ExecutorOption{migrate.WithLogger(log)}, nil)
-	if n := len(log.Applied); err != nil && n > 0 {
-		if serr := log.Applied[n-1].Error; serr != nil && serr.Stmt != "" && serr.Text != "" {
-			err = fmt.Errorf("read state from %q: executing statement: %q: %s", log.Applied[n-1].Name(), serr.Stmt, serr.Text)
-		}
+	if n := len(log.applied); err != nil && n > 0 && log.stmt != "" && log.text != "" {
+		err = fmt.Errorf("read state from %q: executing statement: %q: %s", log.applied[n-1], log.stmt, log.text)
 	}
 	return r, err
 }
 
+type errorRecorder struct {
+	applied    []string // applied files.
+	stmt, text string   // error statement and text.
+}
+
+// Log implements migrate.Logger.
+func (r *errorRecorder) Log(e migrate.LogEntry) {
+	switch e := e.(type) {
+	case migrate.LogFile:
+		r.applied = append(r.applied, e.File.Name())
+	case migrate.LogError:
+		r.stmt = e.SQL
+		r.text = e.Error.Error()
+	}
+}
+
 // stateReaderSQL returns a migrate.StateReader from an SQL file or a directory of migrations.
 func stateReaderSQL(ctx context.Context, cfg *StateReaderConfig, dir migrate.Dir, optsExec []migrate.ExecutorOption, optsReplay []migrate.ReplayOption) (*StateReadCloser, error) {
 	if cfg.Dev == nil {
diff --git a/cmd/atlas/internal/cmdlog/cmdlog.go b/cmd/atlas/internal/cmdlog/cmdlog.go
index 18900864..430dcb83 100644
--- a/cmd/atlas/internal/cmdlog/cmdlog.go
+++ b/cmd/atlas/internal/cmdlog/cmdlog.go
@@ -8,6 +8,7 @@ import (
 	"bytes"
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/url"
 	"slices"
@@ -16,6 +17,8 @@ import (
 	"text/template"
 	"time"
 
+	cmdmigrate "ariga.io/atlas/cmd/atlas/internal/migrate"
+	"ariga.io/atlas/cmd/atlas/internal/migrate/ent/revision"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
@@ -186,6 +189,111 @@ func (r *MigrateStatus) FromCheckpoint() bool {
 	return ok && ck.IsCheckpoint()
 }
 
+// StatusReporter is used to gather information about migration status.
+type StatusReporter struct {
+	// Client configures the connection to the database to file a MigrateStatus for.
+	Client *sqlclient.Client
+	// DirURL of the migration directory.
+	DirURL *url.URL
+	// Dir is used for scanning and validating the migration directory.
+	Dir migrate.Dir
+	// Schema name the revision table resides in.
+	Schema string
+}
+
+// Report creates and writes a MigrateStatus.
+func (r *StatusReporter) Report(ctx context.Context) (*MigrateStatus, error) {
+	rep := &MigrateStatus{Env: NewEnv(r.Client, r.DirURL)}
+	// Check if there already is a revision table in the defined schema.
+	// Inspect schema and check if the table does already exist.
+	sch, err := r.Client.InspectSchema(ctx, r.Schema, &schema.InspectOptions{Tables: []string{revision.Table}})
+	if err != nil && !schema.IsNotExistError(err) {
+		return nil, err
+	}
+	if schema.IsNotExistError(err) || func() bool { _, ok := sch.Table(revision.Table); return !ok }() {
+		// Either schema or table does not exist.
+		if rep.Available, err = migrate.FilesFromLastCheckpoint(r.Dir); err != nil {
+			return nil, err
+		}
+		rep.Pending = rep.Available
+	} else {
+		// Both exist, fetch their data.
+		rrw, err := cmdmigrate.RevisionsForClient(ctx, r.Client, r.Schema)
+		if err != nil {
+			return nil, err
+		}
+		if err := rrw.Migrate(ctx); err != nil {
+			return nil, err
+		}
+		ex, err := migrate.NewExecutor(r.Client.Driver, r.Dir, rrw)
+		if err != nil {
+			return nil, err
+		}
+		rep.Applied, err = rrw.ReadRevisions(ctx)
+		if err != nil {
+			return nil, err
+		}
+		if rep.Pending, err = ex.Pending(ctx); err != nil && !errors.Is(err, migrate.ErrNoPendingFiles) {
+			if err1 := (*migrate.HistoryNonLinearError)(nil); errors.As(err, &err1) {
+				rep.Error = err1.Error()
+				rep.Status = statusPending
+				rep.Pending = err1.Pending
+				rep.OutOfOrder = err1.OutOfOrder
+				// Non-linear error means at least one file was applied.
+				rep.Current = rep.Applied[len(rep.Applied)-1].Version
+				return rep, nil
+			}
+			return nil, err
+		}
+		// If no files were applied, all pending files are
+		// available. The first one might be a checkpoint.
+		if len(rep.Applied) == 0 {
+			rep.Available = rep.Pending
+		} else if rep.Available, err = r.Dir.Files(); err != nil {
+			return nil, err
+		}
+	}
+	switch len(rep.Pending) {
+	case len(rep.Available):
+		rep.Current = "No migration applied yet"
+	default:
+		rep.Current = rep.Applied[len(rep.Applied)-1].Version
+	}
+	if len(rep.Pending) == 0 {
+		rep.Status = statusOK
+		rep.Next = "Already at latest version"
+	} else {
+		rep.Status = statusPending
+		rep.Next = rep.Pending[0].Version()
+	}
+	// If the last one is partially applied (and not manually resolved).
+	if len(rep.Applied) != 0 {
+		last := rep.Applied[len(rep.Applied)-1]
+		if !last.Type.Has(migrate.RevisionTypeResolved) && last.Applied < last.Total {
+			rep.SQL = strings.ReplaceAll(last.ErrorStmt, "\n", " ")
+			rep.Error = strings.ReplaceAll(last.Error, "\n", " ")
+			rep.Count = last.Applied
+			idx := migrate.FilesLastIndex(rep.Available, func(f migrate.File) bool {
+				return f.Version() == last.Version
+			})
+			if idx == -1 {
+				return nil, fmt.Errorf("migration file with version %q not found", last.Version)
+			}
+			stmts, err := migrate.FileStmts(r.Client.Driver, rep.Available[idx])
+			if err != nil {
+				return nil, err
+			}
+			rep.Total = len(stmts)
+		}
+	}
+	return rep, nil
+}
+
+const (
+	statusOK      = "OK"
+	statusPending = "PENDING"
+)
+
 // MigrateSetTemplate holds the default template of the 'migrate set' command.
 var MigrateSetTemplate = template.Must(template.New("set").
 	Funcs(ColorTemplateFuncs).Parse(`
@@ -812,38 +920,7 @@ func (s *SchemaInspect) MarshalSQL(indent ...string) (string, error) {
 }
 
 func sqlInspect(report *SchemaInspect, indent ...string) (string, error) {
-	var changes schema.Changes
-	for _, o := range report.Realm.Objects {
-		changes = append(changes, &schema.AddObject{O: o})
-	}
-	for _, s := range report.Realm.Schemas {
-		// Generate commands for creating the schemas on realm-mode.
-		if report.client.URL.Schema == "" {
-			changes = append(changes, &schema.AddSchema{S: s})
-		}
-		for _, o := range s.Objects {
-			changes = append(changes, &schema.AddObject{O: o})
-		}
-		for _, t := range s.Tables {
-			changes = append(changes, &schema.AddTable{T: t})
-			for _, r := range t.Triggers {
-				changes = append(changes, &schema.AddTrigger{T: r})
-			}
-		}
-		for _, v := range s.Views {
-			changes = append(changes, &schema.AddView{V: v})
-			for _, r := range v.Triggers {
-				changes = append(changes, &schema.AddTrigger{T: r})
-			}
-		}
-		for _, f := range s.Funcs {
-			changes = append(changes, &schema.AddFunc{F: f})
-		}
-		for _, p := range s.Procs {
-			changes = append(changes, &schema.AddProc{P: p})
-		}
-	}
-	return fmtPlan(report.ctx, report.client, changes, indent)
+	return fmtPlan(report.ctx, report.client, cmdmigrate.ChangesToRealm(report.client, report.Realm), indent)
 }
 
 // SchemaDiff contains a summary of the 'schema diff' command.
@@ -900,7 +977,7 @@ func fmtPlan(ctx context.Context, client *sqlclient.Client, changes schema.Chang
 	}
 	plan, err := client.PlanChanges(ctx, "plan", changes, func(o *migrate.PlanOptions) {
 		o.Mode = migrate.PlanModeDump
-		// Disable tables qualifier in schema-mode.
+		// Disable object qualifier in schema-mode.
 		if client.URL.Schema != "" {
 			o.SchemaQualifier = new(string)
 		}
diff --git a/cmd/atlas/internal/migrate/migrate.go b/cmd/atlas/internal/migrate/migrate.go
index 5844ab95..4f864fb8 100644
--- a/cmd/atlas/internal/migrate/migrate.go
+++ b/cmd/atlas/internal/migrate/migrate.go
@@ -378,3 +378,39 @@ func DirURL(ctx context.Context, u *url.URL, create bool) (migrate.Dir, error) {
 	}
 	return d, err
 }
+
+// ChangesToRealm returns the schema changes for creating the given Realm.
+func ChangesToRealm(c *sqlclient.Client, r *schema.Realm) schema.Changes {
+	var changes schema.Changes
+	for _, o := range r.Objects {
+		changes = append(changes, &schema.AddObject{O: o})
+	}
+	for _, s := range r.Schemas {
+		// Generate commands for creating the schemas on realm-mode.
+		if c.URL.Schema == "" {
+			changes = append(changes, &schema.AddSchema{S: s})
+		}
+		for _, o := range s.Objects {
+			changes = append(changes, &schema.AddObject{O: o})
+		}
+		for _, t := range s.Tables {
+			changes = append(changes, &schema.AddTable{T: t})
+			for _, r := range t.Triggers {
+				changes = append(changes, &schema.AddTrigger{T: r})
+			}
+		}
+		for _, v := range s.Views {
+			changes = append(changes, &schema.AddView{V: v})
+			for _, r := range v.Triggers {
+				changes = append(changes, &schema.AddTrigger{T: r})
+			}
+		}
+		for _, f := range s.Funcs {
+			changes = append(changes, &schema.AddFunc{F: f})
+		}
+		for _, p := range s.Procs {
+			changes = append(changes, &schema.AddProc{P: p})
+		}
+	}
+	return changes
+}
diff --git a/cmd/atlas/internal/migrate/report.go b/cmd/atlas/internal/migrate/report.go
deleted file mode 100644
index f2b22685..00000000
--- a/cmd/atlas/internal/migrate/report.go
+++ /dev/null
@@ -1,124 +0,0 @@
-// Copyright 2021-present The Atlas Authors. All rights reserved.
-// This source code is licensed under the Apache 2.0 license found
-// in the LICENSE file in the root directory of this source tree.
-
-package migrate
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"net/url"
-	"strings"
-
-	"ariga.io/atlas/cmd/atlas/internal/cmdlog"
-	"ariga.io/atlas/cmd/atlas/internal/migrate/ent/revision"
-	"ariga.io/atlas/sql/migrate"
-	"ariga.io/atlas/sql/schema"
-	"ariga.io/atlas/sql/sqlclient"
-)
-
-// StatusReporter is used to gather information about migration status.
-type StatusReporter struct {
-	// Client configures the connection to the database to file a MigrateStatus for.
-	Client *sqlclient.Client
-	// DirURL of the migration directory.
-	DirURL *url.URL
-	// Dir is used for scanning and validating the migration directory.
-	Dir migrate.Dir
-	// Schema name the revision table resides in.
-	Schema string
-}
-
-// Report creates and writes a MigrateStatus.
-func (r *StatusReporter) Report(ctx context.Context) (*cmdlog.MigrateStatus, error) {
-	rep := &cmdlog.MigrateStatus{Env: cmdlog.NewEnv(r.Client, r.DirURL)}
-	// Check if there already is a revision table in the defined schema.
-	// Inspect schema and check if the table does already exist.
-	sch, err := r.Client.InspectSchema(ctx, r.Schema, &schema.InspectOptions{Tables: []string{revision.Table}})
-	if err != nil && !schema.IsNotExistError(err) {
-		return nil, err
-	}
-	if schema.IsNotExistError(err) || func() bool { _, ok := sch.Table(revision.Table); return !ok }() {
-		// Either schema or table does not exist.
-		if rep.Available, err = migrate.FilesFromLastCheckpoint(r.Dir); err != nil {
-			return nil, err
-		}
-		rep.Pending = rep.Available
-	} else {
-		// Both exist, fetch their data.
-		rrw, err := RevisionsForClient(ctx, r.Client, r.Schema)
-		if err != nil {
-			return nil, err
-		}
-		if err := rrw.Migrate(ctx); err != nil {
-			return nil, err
-		}
-		ex, err := migrate.NewExecutor(r.Client.Driver, r.Dir, rrw)
-		if err != nil {
-			return nil, err
-		}
-		rep.Applied, err = rrw.ReadRevisions(ctx)
-		if err != nil {
-			return nil, err
-		}
-		if rep.Pending, err = ex.Pending(ctx); err != nil && !errors.Is(err, migrate.ErrNoPendingFiles) {
-			if err1 := (*migrate.HistoryNonLinearError)(nil); errors.As(err, &err1) {
-				rep.Error = err1.Error()
-				rep.Status = statusPending
-				rep.Pending = err1.Pending
-				rep.OutOfOrder = err1.OutOfOrder
-				// Non-linear error means at least one file was applied.
-				rep.Current = rep.Applied[len(rep.Applied)-1].Version
-				return rep, nil
-			}
-			return nil, err
-		}
-		// If no files were applied, all pending files are
-		// available. The first one might be a checkpoint.
-		if len(rep.Applied) == 0 {
-			rep.Available = rep.Pending
-		} else if rep.Available, err = r.Dir.Files(); err != nil {
-			return nil, err
-		}
-	}
-	switch len(rep.Pending) {
-	case len(rep.Available):
-		rep.Current = "No migration applied yet"
-	default:
-		rep.Current = rep.Applied[len(rep.Applied)-1].Version
-	}
-	if len(rep.Pending) == 0 {
-		rep.Status = statusOK
-		rep.Next = "Already at latest version"
-	} else {
-		rep.Status = statusPending
-		rep.Next = rep.Pending[0].Version()
-	}
-	// If the last one is partially applied (and not manually resolved).
-	if len(rep.Applied) != 0 {
-		last := rep.Applied[len(rep.Applied)-1]
-		if !last.Type.Has(migrate.RevisionTypeResolved) && last.Applied < last.Total {
-			rep.SQL = strings.ReplaceAll(last.ErrorStmt, "\n", " ")
-			rep.Error = strings.ReplaceAll(last.Error, "\n", " ")
-			rep.Count = last.Applied
-			idx := migrate.FilesLastIndex(rep.Available, func(f migrate.File) bool {
-				return f.Version() == last.Version
-			})
-			if idx == -1 {
-				return nil, fmt.Errorf("migration file with version %q not found", last.Version)
-			}
-			stmts, err := migrate.FileStmts(r.Client.Driver, rep.Available[idx])
-			if err != nil {
-				return nil, err
-			}
-			rep.Total = len(stmts)
-		}
-	}
-	return rep, nil
-}
-
-const (
-	statusOK      = "OK"
-	statusPending = "PENDING"
-)
diff --git a/doc/md/atlas-schema/hcl.mdx b/doc/md/atlas-schema/hcl.mdx
index dff4995e..511cee3a 100644
--- a/doc/md/atlas-schema/hcl.mdx
+++ b/doc/md/atlas-schema/hcl.mdx
@@ -376,7 +376,7 @@ view "comedies" {
 
 :::info <a class="sticky-anchor" id={"#testing-views"} href={"#testing-views"}>Testing Views</a>
 
-Atlas testing framework allows you to write unit tests for your views. The following example demonstrates how
+Atlas's testing framework allows you to write unit tests for your views. The following example demonstrates how
 to write tests for the `clean_users` view defined above. For more detail, read the [schema testing docs](/testing/schema)
 or see the [full example](/guides/testing/views).
 
@@ -1450,7 +1450,7 @@ function "fn_return_table" {
 
 :::info <a class="sticky-anchor" id={"#testing-functions"} href={"#testing-functions"}>Testing Functions</a>
 
-Atlas testing framework allows you to write unit tests for your functions. The following example demonstrates how
+Atlas's testing framework allows you to write unit tests for your functions. The following example demonstrates how
 to write tests for the `positive` function defined above. For more detail, read the [schema testing docs](/testing/schema)
 or see the [full example](/guides/testing/functions).
 
@@ -1700,6 +1700,89 @@ procedure "p8" {
 </TabItem>
 </Tabs>
 
+
+:::info <a class="sticky-anchor" id={"#testing-procedures"} href={"#testing-procedures"}>Testing Procedures</a>
+
+Atlas's testing framework allows you to write unit tests for your procedures. The following example demonstrates how
+to write tests for a stored procedure, `archive_old_sales`, that moves old sales from the `sales` table to the `archive_sales` table according to a specified cutoff date.
+For more detail, read the [schema testing docs](/testing/schema)
+or see the [full example](/guides/testing/procedures).
+
+<Tabs>
+<TabItem label="Simple Test" value="simple">
+
+```hcl title="schema.test.hcl"
+test "schema" "procedure" {
+  # Seed data
+  exec {
+    sql = <<-SQL
+      INSERT INTO sales (id, sale_amount, sale_date) VALUES
+      (1, 150.00, '2024-07-18'),
+      (2, 200.00, '2024-06-20'),
+      (1, 350.00, '2024-07-10');
+    SQL
+  }
+  # Execute the procedure with a specific cutoff date
+  exec {
+    sql = "CALL archive_old_sales('2024-07-18')"  # Archive sales before this date
+  }
+  # Verify data in archive_sales table
+  exec {
+    sql = "SELECT COUNT(*) FROM archive_sales WHERE sale_date < '2024-07-18'"
+    output = "2" # Expect 2 archived sales
+  }
+  # Verify data in sales table
+  exec {
+    sql = "SELECT COUNT(*) FROM sales"
+    output = "1"  # Expect 1 sale remaining in the sales table after cutoff date
+  }
+}
+```
+
+</TabItem>
+<TabItem label="Table-driven Test" value="table-driven">
+
+```hcl title="schema.test.hcl"
+test "schema" "procedure" {
+  # Seed data
+  exec {
+    sql = <<-SQL
+      INSERT INTO sales (id, sale_amount, sale_date) VALUES
+      (1, 150.00, '2024-07-18'),
+      (2, 200.00, '2024-06-20'),
+      (1, 350.00, '2024-07-10');
+    SQL
+  }
+  for_each = [
+    {cutoff_date: "2024-07-18", expected_archived: "2", expected_remaining: "1"},
+    {cutoff_date: "2024-08-01", expected_archived: "3", expected_remaining: "0"}
+  ]
+  # Execute the procedure with the current test case cutoff date
+  exec {
+    sql = "CALL archive_old_sales('${each.value.cutoff_date}')"
+  }
+  # Verify data in archive_sales table
+  exec {
+    sql = "SELECT COUNT(*) FROM archive_sales WHERE sale_date < '${each.value.cutoff_date}'"
+    output = each.value.expected_archived
+  }
+  # Verify data in sales table
+  exec {
+    sql = "SELECT COUNT(*) FROM sales"
+    output = each.value.expected_remaining
+  }
+  log {
+    message = "Testing cutoff date:  ${each.value.cutoff_date} - Expected Archived: ${each.value.expected_archived}, Expected Remaining: ${each.value.expected_remaining}"
+  }
+}
+```
+
+</TabItem>
+</Tabs>
+
+:::
+
+
 ## Domain
 
 :::info [Atlas Pro Feature](/features#pro-plan)
@@ -1750,7 +1833,7 @@ schema "public" {
 
 :::info <a class="sticky-anchor" id={"#testing-domains"} href={"#testing-domains"}>Testing Domains</a>
 
-Atlas testing framework allows you to write unit tests for your domains. The following example demonstrates how
+Atlas's testing framework allows you to write unit tests for your domains. The following example demonstrates how
 to write tests for the `us_postal_code` domain defined above. For more detail, read the [schema testing docs](/testing/schema)
 or see the [full example](/guides/testing/domains).
 
diff --git a/doc/md/guides/orms/gorm/triggers.mdx b/doc/md/guides/orms/gorm/triggers.mdx
index 96dcb305..3c9e25ba 100644
--- a/doc/md/guides/orms/gorm/triggers.mdx
+++ b/doc/md/guides/orms/gorm/triggers.mdx
@@ -50,7 +50,7 @@ type User struct {
 }
 
 type UserAuditLogs struct {
-    gorm.Model 
+    gorm.Model
     OperationType   string
     OperationTime   string
     OldValue        string
@@ -139,7 +139,6 @@ the `users` and `user_audit_logs` tables:
 
 ```sql
 -- Create "user_audit_logs" table
--- Create "user_audit_logs" table
 CREATE TABLE "user_audit_logs" ("id" bigserial NOT NULL, "created_at" timestamptz NULL, "updated_at" timestamptz NULL, "deleted_at" timestamptz NULL, "operation_type" text NULL, "operation_time" text NULL, "old_value" text NULL, "new_value" text NULL, PRIMARY KEY ("id"));
 -- Create index "idx_user_audit_logs_deleted_at" to table: "user_audit_logs"
 CREATE INDEX "idx_user_audit_logs_deleted_at" ON "user_audit_logs" ("deleted_at");
diff --git a/doc/md/guides/orms/sequelize/triggers.mdx b/doc/md/guides/orms/sequelize/triggers.mdx
new file mode 100644
index 00000000..4723090e
--- /dev/null
+++ b/doc/md/guides/orms/sequelize/triggers.mdx
@@ -0,0 +1,266 @@
+---
+title: Using Database Triggers in Sequelize
+id: triggers
+slug: triggers
+---
+
+Triggers are useful tools in relational databases that allow you to execute custom code when specific events occur on a
+table. For instance, triggers can automatically populate the audit log table whenever a new mutation is applied to a different table.
+This way we ensure that all changes (including those made by other applications) are meticulously recorded, enabling the enforcement
+on the database-level and reducing the need for additional code in the applications.
+
+This guide explains how to attach triggers to your Sequelize models and configure the schema
+migration to manage both the triggers and the Sequelize models as a single migration unit using Atlas.
+
+:::info [Atlas Pro Feature](/features#pro-plan)
+Atlas support for [Triggers](/atlas-schema/hcl#trigger) used in this guide is available exclusively
+to Pro users. To use this feature, run:
+```
+atlas login
+```
+:::
+
+## Getting started with Atlas and Sequelize
+Before we continue, ensure you have installed the [Atlas Sequelize Provider](https://github.com/ariga/atlas-provider-sequelize)
+on your Sequelize project.
+
+To set up, follow along the [getting started guide](/guides/orms/sequelize) for Sequelize and Atlas.
+
+## Composite Schema
+
+The Sequelize models are mostly used for defining tables and interacting with the database. Triggers
+or any other database native objects do not have representation in Sequelize models.
+
+In order to extend our PostgreSQL schema to include both our Sequelize models and their policies, we configure Atlas to
+read the state of the schema from a [Composite Schema](/atlas-schema/projects#data-source-composite_schema)
+data source. Follow the steps below to configure this for your project:
+
+1\. Let's define a simple model with two types (tables): `users` and `user_audit_logs`:
+
+
+<Tabs>
+<TabItem value={"user"} label={"user.js"}>
+
+```javascript title="user.js"
+'use strict';
+module.exports = function(sequelize, DataTypes) {
+    const User = sequelize.define('User', {
+        name: {
+            type: DataTypes.STRING,
+            allowNull: false
+        }
+    });
+    return User;
+};
+```
+
+</TabItem>
+<TabItem value={"userAuditLog"} label={"userAuditLog.js"}>
+
+```javascript title="userAuditLog.js"
+'use strict';
+module.exports = function(sequelize, DataTypes) {
+    const UserAuditLog = sequelize.define('UserAuditLog', {
+        operationType: {
+            type: DataTypes.STRING,
+            allowNull: false
+        },
+        operationTime: {
+            type: DataTypes.STRING,
+            allowNull: false
+        },
+        newValue: {
+            type: DataTypes.STRING,
+            allowNull: true
+        },
+        oldValue: {
+            type: DataTypes.STRING,
+            allowNull: true
+        },
+    },
+    {
+         timestamps: false,
+    }
+    );
+    return UserAuditLog;
+};
+```
+
+</TabItem>
+</Tabs>
+
+Now, suppose we want to log every change to the `users` table and save it in the `user_audit_logs` table.
+To achieve this, we need to create a trigger function on `INSERT`, `UPDATE` and `DELETE` operations and attach it to
+the `users` table.
+
+2\. Next step, we define a trigger function ( `audit_users_changes`) and attach it to the `users` table using the `CREATE TRIGGER` commands:
+
+```sql title="schema.sql" {23,26,29}
+-- Function to audit changes in the users table.
+CREATE OR REPLACE FUNCTION audit_users_changes()
+RETURNS TRIGGER AS $$
+BEGIN
+    IF (TG_OP = 'INSERT') THEN
+        INSERT INTO "UserAuditLogs"("operationType", "operationTime", "newValue")
+        VALUES (TG_OP, CURRENT_TIMESTAMP, row_to_json(NEW));
+        RETURN NEW;
+    ELSIF (TG_OP = 'UPDATE') THEN
+        INSERT INTO "UserAuditLogs"("operationType", "operationTime", "oldValue", "newValue")
+        VALUES (TG_OP, CURRENT_TIMESTAMP, row_to_json(OLD), row_to_json(NEW));
+        RETURN NEW;
+    ELSIF (TG_OP = 'DELETE') THEN
+        INSERT INTO "UserAuditLogs"("operationType", "operationTime", "oldValue")
+        VALUES (TG_OP, CURRENT_TIMESTAMP, row_to_json(OLD));
+        RETURN OLD;
+    END IF;
+    RETURN NULL;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Trigger for INSERT operations.
+CREATE TRIGGER users_insert_audit AFTER INSERT ON "Users" FOR EACH ROW EXECUTE FUNCTION audit_users_changes();
+
+-- Trigger for UPDATE operations.
+CREATE TRIGGER users_update_audit AFTER UPDATE ON "Users" FOR EACH ROW EXECUTE FUNCTION audit_users_changes();
+
+-- Trigger for DELETE operations.
+CREATE TRIGGER users_delete_audit AFTER DELETE ON "Users" FOR EACH ROW EXECUTE FUNCTION audit_users_changes();
+```
+
+3\. In your [`atlas.hcl`](/guides/orms/sequelize#standalone-mode) config file, add a `composite_schema` that includes both your Sequelize models and
+your custom triggers defined in `schema.sql`:
+```hcl title="atlas.hcl"
+data "composite_schema" "app" {
+  // First, load the schema
+  schema "public" {
+    url = data.external_schema.sequelize.url
+  }
+  // Next, load the triggers
+  schema "public" {
+    url = "file://schema.sql"
+  }
+}
+
+env "local" {
+  src = data.composite_schema.app.url
+  dev = "docker://postgres/15/dev?search_path=public"
+}
+```
+
+## Usage
+
+After setting up our composite schema, we can get its representation using the `atlas schema inspect` command, generate
+schema migrations for it, apply them to a database, and more. Below are a few commands to get you started with Atlas:
+
+#### Inspect the Schema
+
+The `atlas schema inspect` command is commonly used to inspect databases. However, we can also use it to inspect our
+`composite_schema` and print the SQL representation of it:
+
+```shell
+atlas schema inspect \
+  --env local \
+  --url env://src \
+  --format '{{ sql . }}'
+```
+
+The command above prints the following SQL. Note, the `audit_users_changes` function and the triggers are defined after
+the `Users` and `UserAuditLogs` tables:
+
+```sql
+-- Create "UserAuditLogs" table
+CREATE TABLE "UserAuditLogs" ("id" serial NOT NULL, "operationType" character varying(255) NOT NULL, "operationTime" character varying(255) NOT NULL, "newValue" character varying(255) NOT NULL, "oldValue" character varying(255) NULL, "createdAt" timestamptz NOT NULL, "updatedAt" timestamptz NOT NULL, PRIMARY KEY ("id"));
+-- Create "Users" table
+CREATE TABLE "Users" ("id" serial NOT NULL, "name" character varying(255) NOT NULL, "createdAt" timestamptz NOT NULL, "updatedAt" timestamptz NOT NULL, PRIMARY KEY ("id"));
+-- Create "audit_users_changes" function
+CREATE FUNCTION "audit_users_changes" () RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+    IF (TG_OP = 'INSERT') THEN
+        INSERT INTO "UserAuditLogs"("operationType", "operationTime", "newValue")
+        VALUES (TG_OP, CURRENT_TIMESTAMP, row_to_json(NEW));
+        RETURN NEW;
+    ELSIF (TG_OP = 'UPDATE') THEN
+        INSERT INTO "UserAuditLogs"("operationType", "operationTime", "oldValue", "newValue")
+        VALUES (TG_OP, CURRENT_TIMESTAMP, row_to_json(OLD), row_to_json(NEW));
+        RETURN NEW;
+    ELSIF (TG_OP = 'DELETE') THEN
+        INSERT INTO "UserAuditLogs"("operationType", "operationTime", "oldValue")
+        VALUES (TG_OP, CURRENT_TIMESTAMP, row_to_json(OLD));
+        RETURN OLD;
+    END IF;
+    RETURN NULL;
+END;
+$$;
+-- Create trigger "users_delete_audit"
+CREATE TRIGGER "users_delete_audit" AFTER DELETE ON "Users" FOR EACH ROW EXECUTE FUNCTION "audit_users_changes"();
+-- Create trigger "users_insert_audit"
+CREATE TRIGGER "users_insert_audit" AFTER INSERT ON "Users" FOR EACH ROW EXECUTE FUNCTION "audit_users_changes"();
+-- Create trigger "users_update_audit"
+CREATE TRIGGER "users_update_audit" AFTER UPDATE ON "Users" FOR EACH ROW EXECUTE FUNCTION "audit_users_changes"();
+```
+
+#### Generate Migrations for the Schema
+
+To generate a migration for the schema, run the following command:
+
+```shell
+atlas migrate diff \
+  --env local
+```
+
+Note that a new migration file is created with the following contents:
+
+```sql title="migrations/20240712090543.sql"
+-- Create "UserAuditLogs" table
+CREATE TABLE "UserAuditLogs" ("id" serial NOT NULL, "operationType" character varying(255) NOT NULL, "operationTime" character varying(255) NOT NULL, "newValue" character varying(255) NOT NULL, "oldValue" character varying(255) NULL, "createdAt" timestamptz NOT NULL, "updatedAt" timestamptz NOT NULL, PRIMARY KEY ("id"));
+-- Create "Users" table
+CREATE TABLE "Users" ("id" serial NOT NULL, "name" character varying(255) NOT NULL, "createdAt" timestamptz NOT NULL, "updatedAt" timestamptz NOT NULL, PRIMARY KEY ("id"));
+-- Create "audit_users_changes" function
+CREATE FUNCTION "audit_users_changes" () RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+    IF (TG_OP = 'INSERT') THEN
+        INSERT INTO "UserAuditLogs"("operationType", "operationTime", "newValue")
+        VALUES (TG_OP, CURRENT_TIMESTAMP, row_to_json(NEW));
+        RETURN NEW;
+    ELSIF (TG_OP = 'UPDATE') THEN
+        INSERT INTO "UserAuditLogs"("operationType", "operationTime", "oldValue", "newValue")
+        VALUES (TG_OP, CURRENT_TIMESTAMP, row_to_json(OLD), row_to_json(NEW));
+        RETURN NEW;
+    ELSIF (TG_OP = 'DELETE') THEN
+        INSERT INTO "UserAuditLogs"("operationType", "operationTime", "oldValue")
+        VALUES (TG_OP, CURRENT_TIMESTAMP, row_to_json(OLD));
+        RETURN OLD;
+    END IF;
+    RETURN NULL;
+END;
+$$;
+-- Create trigger "users_delete_audit"
+CREATE TRIGGER "users_delete_audit" AFTER DELETE ON "Users" FOR EACH ROW EXECUTE FUNCTION "audit_users_changes"();
+-- Create trigger "users_insert_audit"
+CREATE TRIGGER "users_insert_audit" AFTER INSERT ON "Users" FOR EACH ROW EXECUTE FUNCTION "audit_users_changes"();
+-- Create trigger "users_update_audit"
+CREATE TRIGGER "users_update_audit" AFTER UPDATE ON "Users" FOR EACH ROW EXECUTE FUNCTION "audit_users_changes"();
+```
+
+#### Apply the Migrations
+
+To apply the migration generated above to a database, run the following command:
+
+```
+atlas migrate apply \
+  --env local \
+  --url "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable"
+```
+
+:::info Apply the Schema Directly on the Database
+
+Sometimes, there is a need to apply the schema directly to the database without generating a migration file. For example,
+when experimenting with schema changes, spinning up a database for testing, etc. In such cases, you can use the command
+below to apply the schema directly to the database:
+
+```shell
+atlas schema apply \
+  --env local \
+  --url "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable"
+```
+:::
diff --git a/doc/website/sidebars.js b/doc/website/sidebars.js
index 928669fd..db1cf9bf 100644
--- a/doc/website/sidebars.js
+++ b/doc/website/sidebars.js
@@ -315,6 +315,11 @@ module.exports = {
                     type: 'doc',
                     id: 'guides/testing/testing-domains',
                     label: 'Testing Domains'
+                },
+                {
+                    type: 'doc',
+                    id: 'guides/testing/testing-procedures',
+                    label: 'Testing Stored Procedures'
                 }
             ]
         },
@@ -413,6 +418,7 @@ module.exports = {
                         {type: 'doc', id: 'guides/orms/sequelize/composite-types', label: 'Composite Types'},
                         {type: 'doc', id: 'guides/orms/sequelize/domain-types', label: 'Domain Types'},
                         {type: 'doc', id: 'guides/orms/sequelize/rls', label: 'Row-Level Security'},
+                        {type: 'doc', id: 'guides/orms/sequelize/triggers', label: 'Triggers'},
                     ],
                 },
                 {
diff --git a/sql/internal/specutil/convert.go b/sql/internal/specutil/convert.go
index 7d47ab5b..5273ac03 100644
--- a/sql/internal/specutil/convert.go
+++ b/sql/internal/specutil/convert.go
@@ -5,6 +5,7 @@
 package specutil
 
 import (
+	"bytes"
 	"errors"
 	"fmt"
 	"slices"
@@ -680,12 +681,9 @@ func FromView(v *schema.View, colFn ViewColumnSpecFunc, idxFn IndexSpecFunc) (*s
 		}
 		spec.Indexes = append(spec.Indexes, i)
 	}
-	as := normalizeCRLF(v.Def)
 	// In case the view definition is multi-line,
 	// format it as indented heredoc with two spaces.
-	if lines := strings.Split(as, "\n"); len(lines) > 1 {
-		as = fmt.Sprintf("<<-SQL\n  %s\n  SQL", strings.Join(lines, "\n  "))
-	}
+	as := MightHeredoc(v.Def)
 	embed := &schemahcl.Resource{
 		Attrs: []*schemahcl.Attr{
 			schemahcl.StringAttr("as", as),
@@ -717,6 +715,30 @@ func normalizeCRLF(s string) string {
 	return s
 }
 
+// MightHeredoc returns the string as an indented heredoc if it has multiple lines.
+func MightHeredoc(s string) string {
+	s = normalizeCRLF(strings.TrimSpace(s))
+	// In case the given definition is multi-line,
+	// format it as indented heredoc with two spaces.
+	if lines := strings.Split(s, "\n"); len(lines) > 1 {
+		var b bytes.Buffer
+		b.Grow(len(s))
+		b.WriteString("<<-SQL\n")
+		for _, l := range lines {
+			// Skip spaces-only lines, as editors stripped these spaces off,
+			// and HCL parser results a different string for them.
+			if strings.TrimSpace(l) != "" {
+				b.WriteString("  ")
+				b.WriteString(l)
+			}
+			b.WriteByte('\n')
+		}
+		b.WriteString("  SQL")
+		s = b.String()
+	}
+	return s
+}
+
 // dependsOn returns the depends_on attribute for the given objects.
 func dependsOn(realm *schema.Realm, objects []schema.Object) (*schemahcl.Attr, bool) {
 	var (
diff --git a/sql/migrate/dir.go b/sql/migrate/dir.go
index d22265f3..6f0f89dc 100644
--- a/sql/migrate/dir.go
+++ b/sql/migrate/dir.go
@@ -561,6 +561,20 @@ func (t TemplateFormatter) Format(plan *Plan) ([]File, error) {
 	return files, nil
 }
 
+// FormatTo calls Format and writes the files' content to the given writer.
+func (t TemplateFormatter) FormatTo(plan *Plan, w io.Writer) error {
+	files, err := t.Format(plan)
+	if err != nil {
+		return err
+	}
+	for _, f := range files {
+		if _, err := w.Write(f.Bytes()); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 // HashFileName of the migration directory integrity sum file.
 const HashFileName = "atlas.sum"
 


Test patch:
diff --git a/cmd/atlas/internal/cmdlog/cmdlog_test.go b/cmd/atlas/internal/cmdlog/cmdlog_test.go
index 36a7ebd7..ccc6bb6f 100644
--- a/cmd/atlas/internal/cmdlog/cmdlog_test.go
+++ b/cmd/atlas/internal/cmdlog/cmdlog_test.go
@@ -5,9 +5,12 @@
 package cmdlog_test
 
 import (
+	cmdmigrate "ariga.io/atlas/cmd/atlas/internal/migrate"
 	"bytes"
 	"context"
 	"encoding/json"
+	"path/filepath"
+	"strings"
 	"testing"
 	"text/template"
 	"time"
@@ -631,3 +634,278 @@ func TestMigrateApply(t *testing.T) {
   -- 10ms
 `, b.String())
 }
+
+func TestReporter_Status(t *testing.T) {
+	var (
+		buf strings.Builder
+		ctx = context.Background()
+	)
+
+	// Clean.
+	dir, err := migrate.NewLocalDir(filepath.Join("../migrate/testdata", "broken"))
+	require.NoError(t, err)
+	c, err := sqlclient.Open(ctx, "sqlite://?mode=memory")
+	require.NoError(t, err)
+	defer c.Close()
+	rr := &cmdlog.StatusReporter{Client: c, Dir: dir}
+	report, err := rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: PENDING
+  -- Current Version: No migration applied yet
+  -- Next Version:    1
+  -- Executed Files:  0
+  -- Pending Files:   3
+`, buf.String())
+
+	// Applied one.
+	buf.Reset()
+	rrw, err := cmdmigrate.NewEntRevisions(ctx, c)
+	require.NoError(t, err)
+	require.NoError(t, rrw.Migrate(ctx))
+	ex, err := migrate.NewExecutor(c.Driver, dir, rrw)
+	require.NoError(t, err)
+	require.NoError(t, ex.ExecuteN(ctx, 1))
+	rr = &cmdlog.StatusReporter{Client: c, Dir: dir}
+	report, err = rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: PENDING
+  -- Current Version: 1
+  -- Next Version:    2
+  -- Executed Files:  1
+  -- Pending Files:   2
+`, buf.String())
+
+	// Applied two.
+	buf.Reset()
+	require.NoError(t, err)
+	require.NoError(t, ex.ExecuteN(ctx, 1))
+	rr = &cmdlog.StatusReporter{Client: c, Dir: dir}
+	report, err = rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: PENDING
+  -- Current Version: 2
+  -- Next Version:    3
+  -- Executed Files:  2
+  -- Pending Files:   1
+`, buf.String())
+
+	// Partial three.
+	buf.Reset()
+	require.NoError(t, err)
+	require.Error(t, ex.ExecuteN(ctx, 1))
+	rr = &cmdlog.StatusReporter{Client: c, Dir: dir}
+	report, err = rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: PENDING
+  -- Current Version: 3 (1 statements applied)
+  -- Next Version:    3 (1 statements left)
+  -- Executed Files:  3 (last one partially)
+  -- Pending Files:   1
+
+Last migration attempt had errors:
+  -- SQL:   THIS LINE ADDS A SYNTAX ERROR;
+  -- ERROR: near "THIS": syntax error
+`, buf.String())
+
+	// Fixed three - okay.
+	buf.Reset()
+	dir2, err := migrate.NewLocalDir(filepath.Join("../migrate/testdata", "fixed"))
+	require.NoError(t, err)
+	*dir = *dir2
+	require.NoError(t, err)
+	require.NoError(t, ex.ExecuteN(ctx, 1))
+	rr = &cmdlog.StatusReporter{Client: c, Dir: dir}
+	report, err = rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: OK
+  -- Current Version: 3
+  -- Next Version:    Already at latest version
+  -- Executed Files:  3
+  -- Pending Files:   0
+`, buf.String())
+}
+
+func TestReporter_FromCheckpoint(t *testing.T) {
+	var (
+		buf strings.Builder
+		ctx = context.Background()
+	)
+	dir, err := migrate.NewLocalDir(t.TempDir())
+	require.NoError(t, err)
+	require.NoError(t, dir.WriteFile("1.sql", []byte("create table t1(c int);")))
+	require.NoError(t, dir.WriteFile("2.sql", []byte("create table t2(c int);")))
+	require.NoError(t, dir.WriteCheckpoint("3_checkpoint.sql", "", []byte("create table t1(c int);\ncreate table t2(c int);")))
+	sum, err := dir.Checksum()
+	require.NoError(t, err)
+	require.NoError(t, migrate.WriteSumFile(dir, sum))
+	require.NoError(t, migrate.Validate(dir))
+	c, err := sqlclient.Open(ctx, "sqlite://?mode=memory")
+	require.NoError(t, err)
+	defer c.Close()
+	rr := &cmdlog.StatusReporter{Client: c, Dir: dir}
+
+	// Clean.
+	report, err := rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: PENDING
+  -- Current Version: No migration applied yet
+  -- Next Version:    3 (checkpoint)
+  -- Executed Files:  0
+  -- Pending Files:   1
+`, buf.String())
+
+	// Clean and revisions table exists.
+	buf.Reset()
+	rrw, err := cmdmigrate.NewEntRevisions(ctx, c)
+	require.NoError(t, err)
+	require.NoError(t, rrw.Migrate(ctx))
+	report, err = rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: PENDING
+  -- Current Version: No migration applied yet
+  -- Next Version:    3 (checkpoint)
+  -- Executed Files:  0
+  -- Pending Files:   1
+`, buf.String())
+
+	// Execute one.
+	buf.Reset()
+	ex, err := migrate.NewExecutor(c.Driver, dir, rrw)
+	require.NoError(t, err)
+	require.NoError(t, ex.ExecuteN(ctx, 1))
+	report, err = rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: OK
+  -- Current Version: 3
+  -- Next Version:    Already at latest version
+  -- Executed Files:  1
+  -- Pending Files:   0
+`, buf.String())
+
+	// Add a new file.
+	buf.Reset()
+	require.NoError(t, dir.WriteFile("4.sql", []byte("create table t3(c int);")))
+	sum, err = dir.Checksum()
+	require.NoError(t, err)
+	require.NoError(t, migrate.WriteSumFile(dir, sum))
+	require.NoError(t, migrate.Validate(dir))
+	report, err = rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: PENDING
+  -- Current Version: 3
+  -- Next Version:    4
+  -- Executed Files:  1
+  -- Pending Files:   1
+`, buf.String())
+
+	// Execute one.
+	buf.Reset()
+	require.NoError(t, ex.ExecuteN(ctx, 1))
+	report, err = rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: OK
+  -- Current Version: 4
+  -- Next Version:    Already at latest version
+  -- Executed Files:  2
+  -- Pending Files:   0
+`, buf.String())
+}
+
+func TestReporter_OutOfOrder(t *testing.T) {
+	var (
+		buf strings.Builder
+		ctx = context.Background()
+	)
+	dir, err := migrate.NewLocalDir(t.TempDir())
+	require.NoError(t, err)
+	require.NoError(t, dir.WriteFile("1.sql", []byte("create table t1(c int);")))
+	require.NoError(t, dir.WriteFile("2.sql", []byte("create table t2(c int);")))
+	sum, err := dir.Checksum()
+	require.NoError(t, err)
+	require.NoError(t, migrate.WriteSumFile(dir, sum))
+	c, err := sqlclient.Open(ctx, "sqlite://?mode=memory")
+	require.NoError(t, err)
+	defer c.Close()
+	rr := &cmdlog.StatusReporter{Client: c, Dir: dir}
+
+	rrw, err := cmdmigrate.NewEntRevisions(ctx, c)
+	require.NoError(t, err)
+	require.NoError(t, rrw.Migrate(ctx))
+	report, err := rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: PENDING
+  -- Current Version: No migration applied yet
+  -- Next Version:    1
+  -- Executed Files:  0
+  -- Pending Files:   2
+`, buf.String())
+
+	ex, err := migrate.NewExecutor(c.Driver, dir, rrw)
+	require.NoError(t, err)
+	require.NoError(t, ex.ExecuteN(ctx, 2))
+
+	// One file was added out of order.
+	buf.Reset()
+	require.NoError(t, dir.WriteFile("1.5.sql", []byte("create table t1_5(c int);")))
+	sum, err = dir.Checksum()
+	require.NoError(t, err)
+	require.NoError(t, migrate.WriteSumFile(dir, sum))
+	report, err = rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: PENDING
+  -- Current Version: 2
+  -- Next Version:    UNKNOWN
+  -- Executed Files:  2
+  -- Pending Files:   1 (out of order)
+
+  ERROR: migration file 1.5.sql was added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error
+`, buf.String())
+
+	// Multiple files were added our of order.
+	buf.Reset()
+	require.NoError(t, dir.WriteFile("1.6.sql", []byte("create table t1_6(c int);")))
+	sum, err = dir.Checksum()
+	require.NoError(t, err)
+	require.NoError(t, migrate.WriteSumFile(dir, sum))
+	report, err = rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: PENDING
+  -- Current Version: 2
+  -- Next Version:    UNKNOWN
+  -- Executed Files:  2
+  -- Pending Files:   2 (out of order)
+
+  ERROR: migration files 1.5.sql, 1.6.sql were added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error
+`, buf.String())
+
+	// A mix of pending and out of order files.
+	buf.Reset()
+	require.NoError(t, dir.WriteFile("3.sql", []byte("create table t3(c int);")))
+	sum, err = dir.Checksum()
+	require.NoError(t, err)
+	require.NoError(t, migrate.WriteSumFile(dir, sum))
+	report, err = rr.Report(ctx)
+	require.NoError(t, err)
+	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
+	require.Equal(t, `Migration Status: PENDING
+  -- Current Version: 2
+  -- Next Version:    UNKNOWN
+  -- Executed Files:  2
+  -- Pending Files:   3 (2 out of order)
+
+  ERROR: migration files 1.5.sql, 1.6.sql were added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error
+`, buf.String())
+}
diff --git a/cmd/atlas/internal/migrate/report_test.go b/cmd/atlas/internal/migrate/report_test.go
deleted file mode 100644
index 612af2ef..00000000
--- a/cmd/atlas/internal/migrate/report_test.go
+++ /dev/null
@@ -1,294 +0,0 @@
-// Copyright 2021-present The Atlas Authors. All rights reserved.
-// This source code is licensed under the Apache 2.0 license found
-// in the LICENSE file in the root directory of this source tree.
-
-package migrate
-
-import (
-	"context"
-	"path/filepath"
-	"strings"
-	"testing"
-
-	"ariga.io/atlas/cmd/atlas/internal/cmdlog"
-	"ariga.io/atlas/sql/migrate"
-	"ariga.io/atlas/sql/sqlclient"
-
-	_ "github.com/mattn/go-sqlite3"
-	"github.com/stretchr/testify/require"
-)
-
-func TestReporter_Status(t *testing.T) {
-	var (
-		buf strings.Builder
-		ctx = context.Background()
-	)
-
-	// Clean.
-	dir, err := migrate.NewLocalDir(filepath.Join("../migrate/testdata", "broken"))
-	require.NoError(t, err)
-	c, err := sqlclient.Open(ctx, "sqlite://?mode=memory")
-	require.NoError(t, err)
-	defer c.Close()
-	rr := &StatusReporter{Client: c, Dir: dir}
-	report, err := rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: PENDING
-  -- Current Version: No migration applied yet
-  -- Next Version:    1
-  -- Executed Files:  0
-  -- Pending Files:   3
-`, buf.String())
-
-	// Applied one.
-	buf.Reset()
-	rrw, err := NewEntRevisions(ctx, c)
-	require.NoError(t, err)
-	require.NoError(t, rrw.Migrate(ctx))
-	ex, err := migrate.NewExecutor(c.Driver, dir, rrw)
-	require.NoError(t, err)
-	require.NoError(t, ex.ExecuteN(ctx, 1))
-	rr = &StatusReporter{Client: c, Dir: dir}
-	report, err = rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: PENDING
-  -- Current Version: 1
-  -- Next Version:    2
-  -- Executed Files:  1
-  -- Pending Files:   2
-`, buf.String())
-
-	// Applied two.
-	buf.Reset()
-	require.NoError(t, err)
-	require.NoError(t, ex.ExecuteN(ctx, 1))
-	rr = &StatusReporter{Client: c, Dir: dir}
-	report, err = rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: PENDING
-  -- Current Version: 2
-  -- Next Version:    3
-  -- Executed Files:  2
-  -- Pending Files:   1
-`, buf.String())
-
-	// Partial three.
-	buf.Reset()
-	require.NoError(t, err)
-	require.Error(t, ex.ExecuteN(ctx, 1))
-	rr = &StatusReporter{Client: c, Dir: dir}
-	report, err = rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: PENDING
-  -- Current Version: 3 (1 statements applied)
-  -- Next Version:    3 (1 statements left)
-  -- Executed Files:  3 (last one partially)
-  -- Pending Files:   1
-
-Last migration attempt had errors:
-  -- SQL:   THIS LINE ADDS A SYNTAX ERROR;
-  -- ERROR: near "THIS": syntax error
-`, buf.String())
-
-	// Fixed three - okay.
-	buf.Reset()
-	dir2, err := migrate.NewLocalDir(filepath.Join("../migrate/testdata", "fixed"))
-	require.NoError(t, err)
-	*dir = *dir2
-	require.NoError(t, err)
-	require.NoError(t, ex.ExecuteN(ctx, 1))
-	rr = &StatusReporter{Client: c, Dir: dir}
-	report, err = rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: OK
-  -- Current Version: 3
-  -- Next Version:    Already at latest version
-  -- Executed Files:  3
-  -- Pending Files:   0
-`, buf.String())
-}
-
-func TestReporter_FromCheckpoint(t *testing.T) {
-	var (
-		buf strings.Builder
-		ctx = context.Background()
-	)
-	dir, err := migrate.NewLocalDir(t.TempDir())
-	require.NoError(t, err)
-	require.NoError(t, dir.WriteFile("1.sql", []byte("create table t1(c int);")))
-	require.NoError(t, dir.WriteFile("2.sql", []byte("create table t2(c int);")))
-	require.NoError(t, dir.WriteCheckpoint("3_checkpoint.sql", "", []byte("create table t1(c int);\ncreate table t2(c int);")))
-	sum, err := dir.Checksum()
-	require.NoError(t, err)
-	require.NoError(t, migrate.WriteSumFile(dir, sum))
-	require.NoError(t, migrate.Validate(dir))
-	c, err := sqlclient.Open(ctx, "sqlite://?mode=memory")
-	require.NoError(t, err)
-	defer c.Close()
-	rr := &StatusReporter{Client: c, Dir: dir}
-
-	// Clean.
-	report, err := rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: PENDING
-  -- Current Version: No migration applied yet
-  -- Next Version:    3 (checkpoint)
-  -- Executed Files:  0
-  -- Pending Files:   1
-`, buf.String())
-
-	// Clean and revisions table exists.
-	buf.Reset()
-	rrw, err := NewEntRevisions(ctx, c)
-	require.NoError(t, err)
-	require.NoError(t, rrw.Migrate(ctx))
-	report, err = rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: PENDING
-  -- Current Version: No migration applied yet
-  -- Next Version:    3 (checkpoint)
-  -- Executed Files:  0
-  -- Pending Files:   1
-`, buf.String())
-
-	// Execute one.
-	buf.Reset()
-	ex, err := migrate.NewExecutor(c.Driver, dir, rrw)
-	require.NoError(t, err)
-	require.NoError(t, ex.ExecuteN(ctx, 1))
-	report, err = rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: OK
-  -- Current Version: 3
-  -- Next Version:    Already at latest version
-  -- Executed Files:  1
-  -- Pending Files:   0
-`, buf.String())
-
-	// Add a new file.
-	buf.Reset()
-	require.NoError(t, dir.WriteFile("4.sql", []byte("create table t3(c int);")))
-	sum, err = dir.Checksum()
-	require.NoError(t, err)
-	require.NoError(t, migrate.WriteSumFile(dir, sum))
-	require.NoError(t, migrate.Validate(dir))
-	report, err = rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: PENDING
-  -- Current Version: 3
-  -- Next Version:    4
-  -- Executed Files:  1
-  -- Pending Files:   1
-`, buf.String())
-
-	// Execute one.
-	buf.Reset()
-	require.NoError(t, ex.ExecuteN(ctx, 1))
-	report, err = rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: OK
-  -- Current Version: 4
-  -- Next Version:    Already at latest version
-  -- Executed Files:  2
-  -- Pending Files:   0
-`, buf.String())
-}
-
-func TestReporter_OutOfOrder(t *testing.T) {
-	var (
-		buf strings.Builder
-		ctx = context.Background()
-	)
-	dir, err := migrate.NewLocalDir(t.TempDir())
-	require.NoError(t, err)
-	require.NoError(t, dir.WriteFile("1.sql", []byte("create table t1(c int);")))
-	require.NoError(t, dir.WriteFile("2.sql", []byte("create table t2(c int);")))
-	sum, err := dir.Checksum()
-	require.NoError(t, err)
-	require.NoError(t, migrate.WriteSumFile(dir, sum))
-	c, err := sqlclient.Open(ctx, "sqlite://?mode=memory")
-	require.NoError(t, err)
-	defer c.Close()
-	rr := &StatusReporter{Client: c, Dir: dir}
-
-	rrw, err := NewEntRevisions(ctx, c)
-	require.NoError(t, err)
-	require.NoError(t, rrw.Migrate(ctx))
-	report, err := rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: PENDING
-  -- Current Version: No migration applied yet
-  -- Next Version:    1
-  -- Executed Files:  0
-  -- Pending Files:   2
-`, buf.String())
-
-	ex, err := migrate.NewExecutor(c.Driver, dir, rrw)
-	require.NoError(t, err)
-	require.NoError(t, ex.ExecuteN(ctx, 2))
-
-	// One file was added out of order.
-	buf.Reset()
-	require.NoError(t, dir.WriteFile("1.5.sql", []byte("create table t1_5(c int);")))
-	sum, err = dir.Checksum()
-	require.NoError(t, err)
-	require.NoError(t, migrate.WriteSumFile(dir, sum))
-	report, err = rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: PENDING
-  -- Current Version: 2
-  -- Next Version:    UNKNOWN
-  -- Executed Files:  2
-  -- Pending Files:   1 (out of order)
-
-  ERROR: migration file 1.5.sql was added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error
-`, buf.String())
-
-	// Multiple files were added our of order.
-	buf.Reset()
-	require.NoError(t, dir.WriteFile("1.6.sql", []byte("create table t1_6(c int);")))
-	sum, err = dir.Checksum()
-	require.NoError(t, err)
-	require.NoError(t, migrate.WriteSumFile(dir, sum))
-	report, err = rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: PENDING
-  -- Current Version: 2
-  -- Next Version:    UNKNOWN
-  -- Executed Files:  2
-  -- Pending Files:   2 (out of order)
-
-  ERROR: migration files 1.5.sql, 1.6.sql were added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error
-`, buf.String())
-
-	// A mix of pending and out of order files.
-	buf.Reset()
-	require.NoError(t, dir.WriteFile("3.sql", []byte("create table t3(c int);")))
-	sum, err = dir.Checksum()
-	require.NoError(t, err)
-	require.NoError(t, migrate.WriteSumFile(dir, sum))
-	report, err = rr.Report(ctx)
-	require.NoError(t, err)
-	require.NoError(t, cmdlog.MigrateStatusTemplate.Execute(&buf, report))
-	require.Equal(t, `Migration Status: PENDING
-  -- Current Version: 2
-  -- Next Version:    UNKNOWN
-  -- Executed Files:  2
-  -- Pending Files:   3 (2 out of order)
-
-  ERROR: migration files 1.5.sql, 1.6.sql were added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error
-`, buf.String())
-}
diff --git a/doc/md/guides/testing/testing-domains.mdx b/doc/md/guides/testing/testing-domains.mdx
index f3e6ffbf..99797cc6 100644
--- a/doc/md/guides/testing/testing-domains.mdx
+++ b/doc/md/guides/testing/testing-domains.mdx
@@ -65,8 +65,8 @@ that the data entered conforms to the standard formats used for U.S. postal code
 such as "12345" or "12345-6789".
 
 
-### Project File
-Before we begin testing, create a [project file](/atlas-schema/projects#project-files) named
+### Config File
+Before we begin testing, create a [config file](/atlas-schema/projects#project-files) named
 `atlas.hcl`.
 
 In this file we will create an environment, specify the source of our schema,
diff --git a/doc/md/guides/testing/testing-functions.mdx b/doc/md/guides/testing/testing-functions.mdx
index 55cbcc70..28315c18 100644
--- a/doc/md/guides/testing/testing-functions.mdx
+++ b/doc/md/guides/testing/testing-functions.mdx
@@ -97,9 +97,9 @@ checks if the `amount` is positive by calling the `positive` function.
 Note that `is_income` is a [generated column](/atlas-schema/hcl#generated-columns), meaning its value is
 computed using other columns or deterministic expressions (in this case, via a function).
 
-### Project File
+### Config File
 
-Before we begin testing, create a [project file](/atlas-schema/projects#project-files) named
+Before we begin testing, create a [config file](/atlas-schema/projects#project-files) named
 `atlas.hcl`.
 
 In this file we will create an environment, specify the source of our schema,
diff --git a/doc/md/guides/testing/testing-procedures.mdx b/doc/md/guides/testing/testing-procedures.mdx
new file mode 100644
index 00000000..7edde251
--- /dev/null
+++ b/doc/md/guides/testing/testing-procedures.mdx
@@ -0,0 +1,230 @@
+---
+id: testing-procedures
+title: Testing Stored Procedures
+slug: /guides/testing/procedures
+---
+
+Testing your database schema and migrations is crucial to ensure code behaves as expected, catch bugs early,
+and prevent regressions. Databases enforce logic, constraints, and complex relationships,
+so testing ensures these elements work correctly and remain intact after changes.
+
+In this guide we will learn how to use Atlas's [`schema test`](/testing/schema) command to test database stored
+procedures.
+
+## Stored Procedures
+
+Stored procedures are sets of precompiled queries grouped together to perform specific tasks and are
+stored directly on the database.
+
+:::info [Atlas Pro Feature](/features#pro-plan)
+Stored procedures are currently available only to [Atlas Pro users](/features#pro-plan). To use this feature, run:
+```
+atlas login
+```
+:::
+
+## Project Setup
+
+### Schema File
+For this example, let's assume we have the following schema, including a stored procedure:
+
+```hcl title="schema.hcl"
+schema "public" {
+}
+
+table "sales" {
+  schema = schema.public
+  column "id" {
+    type = int
+  }
+  column "sale_amount" {
+    type =  numeric
+  }
+  column "sale_date" {
+    type = date
+  }
+}
+
+table "archive_sales" {
+  schema = schema.public
+  column "id" {
+    type = int
+  }
+  column "sale_amount" {
+    type = int
+  }
+  column "sale_date" {
+    type = date
+  }
+}
+
+// highlight-start
+procedure "archive_old_sales" {
+  schema = schema.public
+  lang   = PLpgSQL
+  arg "cutoff_date" {
+    type = date
+  }
+  as = <<-SQL
+  BEGIN
+      -- Insert old sales into archive_sales
+  INSERT INTO archive_sales (id, sale_amount, sale_date)
+  SELECT id, sale_amount, sale_date
+  FROM sales
+  WHERE sale_date < cutoff_date;
+
+  -- Delete old sales from sales
+  DELETE FROM sales
+  WHERE sale_date < cutoff_date;
+  END;
+  SQL
+}
+// highlight-end
+```
+
+In the schema above we have a `sales` table, an `archive_sales` table and an `archive_old_sales` procedure. The procedure
+moves old sales from the `sales` table to the `archive_sales` table based on a cutoff date that is given
+when calling the procedure.
+
+### Config File
+
+Before we begin testing, create a [config file](/atlas-schema/projects#project-files) named
+`atlas.hcl`.
+
+In this file we will create an environment, specify the source of our schema,
+and a URL for our [dev database](/concepts/dev-database).
+
+We will also create a file named `schema.test.hcl` to write our tests, and
+add it to the `atlas.hcl` file in the test block.
+
+```hcl title="atlas.hcl"
+env "dev" {
+  src = "file://schema.hcl"
+  dev = "docker://postgres/15/dev?search_path=public"
+
+  # Test configuration for local development.
+  test {
+    schema {
+      src = ["schema.test.hcl"]
+    }
+  }
+}
+```
+
+## Writing Tests
+
+### Simple Test
+
+Let's start off with a simple test that will:
+1. Seed data into the `sales` table.
+2. Call the `archive_old_sales` procedure with a cutoff date "2024-07-18".
+3. Check that the `archive_sales` table is correctly populated.
+4. Check that the archived sales no longer exist in the `sales` table.
+
+```hcl title="schema.test.hcl"
+test "schema" "procedure" {
+  # Seed data
+  exec {
+    sql = <<-SQL
+      INSERT INTO sales (id, sale_amount, sale_date) VALUES
+      (1, 150.00, '2024-07-18'),
+      (2, 200.00, '2024-06-20'),
+      (1, 350.00, '2024-07-10');
+    SQL
+  }
+  # Execute the procedure with a specific cutoff date
+  exec {
+    sql = "CALL archive_old_sales('2024-07-18')"  # Archive sales before this date
+  }
+
+  # Verify data in archive_sales table
+  exec {
+    sql = "SELECT COUNT(*) FROM archive_sales WHERE sale_date < '2024-07-18'"
+    output = "2" # Expect 2 archived sales
+  }
+
+  # Verify data in sales table
+  exec {
+    sql = "SELECT COUNT(*) FROM sales"
+    output = "1"  # Expect 1 sale remaining in the sales table after cutoff date
+  }
+}
+```
+
+Run the test by running:
+
+```bash
+atlas schema test --env dev
+```
+
+The output should look similar to:
+```testoutput title="Test Output"
+-- PASS: procedure (4ms)
+PASS
+```
+
+### Table Driven Test
+
+Another alternative is to write a [table driven test](/testing/schema#table-driven-tests). This
+test uses the `for_each` meta-argument, which accepts a map or a set of values and is used to generate
+a test case for each item in the set or map.
+
+Following similar logic to the test above, we will execute the following:
+1. Seed data into the `sales` table.
+2. Use a `for_each` meta-argument to iterate over two test cases.
+3. Check that the `archive_sales` table is correctly populated based on the cutoff date in the specific test case.
+4. Check that the archived sales no longer exist in the `sales` table based on the cutoff date in the specific test case.
+
+```hcl title="schema.test.hcl"
+test "schema" "procedure" {
+  # Seed data
+  exec {
+    sql = <<-SQL
+      INSERT INTO sales (id, sale_amount, sale_date) VALUES
+      (1, 150.00, '2024-07-18'),
+      (2, 200.00, '2024-06-20'),
+      (1, 350.00, '2024-07-10');
+    SQL
+  }
+  for_each = [
+    { cutoff_date: "2024-07-18", expected_archived: "2", expected_remaining: "1" },
+    { cutoff_date: "2024-08-01", expected_archived: "3", expected_remaining: "0" }
+  ]
+
+  # Execute the procedure with the current test case cutoff date
+  exec {
+    sql = "CALL archive_old_sales('${each.value.cutoff_date}')"
+  }
+
+  # Verify data in archive_sales table
+  exec {
+    sql = "SELECT COUNT(*) FROM archive_sales WHERE sale_date < '${each.value.cutoff_date}'"
+    output = each.value.expected_archived
+  }
+
+  # Verify data in sales table
+  exec {
+    sql = "SELECT COUNT(*) FROM sales"
+    output = each.value.expected_remaining
+  }
+
+  log {
+    message = "Testing cutoff date:  ${each.value.cutoff_date} - Expected Archived: ${each.value.expected_archived}, Expected Remaining: ${each.value.expected_remaining}"
+  }
+}
+```
+
+Run the test by running:
+
+```bash
+atlas schema test --env dev
+```
+
+The output should look similar to:
+```testoutput title="Test Output"
+-- PASS: procedure/1 (4ms)
+    schema.test.hcl:33: Testing cutoff date:  2024-07-15 - Expected Archived: 2, Expected Remaining: 1
+-- PASS: procedure/2 (1ms)
+    schema.test.hcl:33: Testing cutoff date:  2024-07-20 - Expected Archived: 3, Expected Remaining: 0
+PASS
+```
diff --git a/doc/md/guides/testing/testing-views.mdx b/doc/md/guides/testing/testing-views.mdx
index 7088d27c..bf203185 100644
--- a/doc/md/guides/testing/testing-views.mdx
+++ b/doc/md/guides/testing/testing-views.mdx
@@ -64,8 +64,8 @@ The schema has a simple `users` table, with `id`, `name`, and `ssn` as columns.
 The view, `clean_users`, selects the `id` and `name` columns from `users`, ensuring we
 aren't selecting any sensitive data (SSNs).
 
-### Project File
-Before we begin testing, create a [project file](/atlas-schema/projects#project-files) named
+### Config File
+Before we begin testing, create a [config file](/atlas-schema/projects#project-files) named
 `atlas.hcl`.
 
 In this file we will create an environment, specify the source of our schema,
diff --git a/sql/internal/specutil/convert_test.go b/sql/internal/specutil/convert_test.go
index 6b00d20d..c4fb8dea 100644
--- a/sql/internal/specutil/convert_test.go
+++ b/sql/internal/specutil/convert_test.go
@@ -159,3 +159,49 @@ func TestFromView(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(t, "<<-SQL\n  SELECT * FROM users\n   WHERE c NOT LIKE \"\\r\\n\"\n  SQL", s)
 }
+
+func TestMightHeredoc(t *testing.T) {
+	for _, tt := range []struct {
+		input    string
+		expected string
+	}{
+		{
+			input:    "SELECT * FROM users",
+			expected: "SELECT * FROM users",
+		},
+		{
+			input: `
+SELECT
+  *
+  FROM users
+  WHERE active`,
+			expected: `<<-SQL
+  SELECT
+    *
+    FROM users
+    WHERE active
+  SQL`,
+		},
+		{
+			input: `
+-- The line below includes spaces.
+  
+	
+SELECT
+  *
+  FROM users
+  WHERE active`,
+			expected: `<<-SQL
+  -- The line below includes spaces.
+
+
+  SELECT
+    *
+    FROM users
+    WHERE active
+  SQL`,
+		},
+	} {
+		require.Equal(t, tt.expected, MightHeredoc(tt.input))
+	}
+}
diff --git a/sql/migrate/dir_test.go b/sql/migrate/dir_test.go
index f9e47bbf..f2d06cfe 100644
--- a/sql/migrate/dir_test.go
+++ b/sql/migrate/dir_test.go
@@ -531,6 +531,21 @@ func TestDirTar(t *testing.T) {
 	require.Equal(t, "create table t(c int);", string(files[0].Bytes()))
 }
 
+func TestDefaultFormatter_FormatTo(t *testing.T) {
+	var b bytes.Buffer
+	err := migrate.DefaultFormatter.FormatTo(&migrate.Plan{
+		Changes: []*migrate.Change{
+			{Cmd: "create table t1(c int)"},
+			{Cmd: "create table t2(c int)", Comment: "create table"},
+		},
+	}, &b)
+	require.NoError(t, err)
+	require.Equal(t, `create table t1(c int);
+-- Create table
+create table t2(c int);
+`, b.String())
+}
+
 func fileNames(r io.Reader) ([]string, error) {
 	var out []string
 	tr := tar.NewReader(r)