Repository: ariga/atlas
Pull request #3415 (2025-03-06 09:43:55)

sql: infra changes for support atlas:import directive
Approved internally. 


Patch:
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index 9922aab6..e47c63e4 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -361,15 +361,20 @@ func (s *MigrateReportSet) Step(format string, args ...interface{}) {
 }
 
 // StepLog logs a line to the current reporting step.
-func (s *MigrateReportSet) StepLog(format string, args ...interface{}) {
+func (s *MigrateReportSet) StepLog(text string) {
 	if len(s.Log) == 0 {
 		s.Step("Unnamed step") // Unexpected.
 	}
 	s.Log[len(s.Log)-1].Log = append(s.Log[len(s.Log)-1].Log, cloudapi.ReportStepLog{
-		Text: fmt.Sprintf(format, args...),
+		Text: text,
 	})
 }
 
+// StepLogf logs a line to the current reporting step with formatting.
+func (s *MigrateReportSet) StepLogf(format string, args ...interface{}) {
+	s.StepLog(fmt.Sprintf(format, args...))
+}
+
 // StepLogError logs a line to the current reporting step.
 func (s *MigrateReportSet) StepLogError(text string) {
 	if !strings.HasPrefix(text, "Error") {
@@ -383,8 +388,8 @@ func (s *MigrateReportSet) StepLogError(text string) {
 // ReportFor returns a new MigrateReport for the given environment.
 func (s *MigrateReportSet) ReportFor(flags migrateApplyFlags, e *Env) *MigrateReport {
 	s.Step("Run migration: %d", s.done+1)
-	s.StepLog("Target URL: %s", s.RedactedURL(e.URL))
-	s.StepLog("Migration directory: %s", s.RedactedURL(flags.dirURL))
+	s.StepLogf("Target URL: %s", s.RedactedURL(e.URL))
+	s.StepLogf("Migration directory: %s", s.RedactedURL(flags.dirURL))
 	return &MigrateReport{
 		env: e,
 		done: func(r *cloudapi.ReportMigrationInput) {
diff --git a/cmd/atlas/internal/cmdext/cmdext_oss.go b/cmd/atlas/internal/cmdext/cmdext_oss.go
index 65fc0617..423837fb 100644
--- a/cmd/atlas/internal/cmdext/cmdext_oss.go
+++ b/cmd/atlas/internal/cmdext/cmdext_oss.go
@@ -7,10 +7,16 @@
 package cmdext
 
 import (
+	"bytes"
 	"context"
+	"errors"
 	"fmt"
+	"os"
+	"path/filepath"
 
+	cmdmigrate "ariga.io/atlas/cmd/atlas/internal/migrate"
 	"ariga.io/atlas/schemahcl"
+	"ariga.io/atlas/sql/migrate"
 
 	"github.com/hashicorp/hcl/v2"
 	"github.com/hashicorp/hcl/v2/hclsyntax"
@@ -58,3 +64,62 @@ func (c *AtlasConfig) InitBlock() schemahcl.Option {
 		return cty.NilVal, fmt.Errorf("atlas block is not supported by this release. See: https://atlasgo.io/getting-started")
 	})
 }
+
+// StateReaderSQL returns a migrate.StateReader from an SQL file or a directory of migrations.
+func StateReaderSQL(ctx context.Context, config *StateReaderConfig) (*StateReadCloser, error) {
+	if len(config.URLs) != 1 {
+		return nil, fmt.Errorf("the provided SQL state must be either a single schema file or a migration directory, but %d paths were found", len(config.URLs))
+	}
+	var (
+		dir  migrate.Dir
+		path = filepath.Join(config.URLs[0].Host, config.URLs[0].Path)
+	)
+	switch fi, err := os.Stat(path); {
+	case err != nil:
+		return nil, err
+	// A single schema file.
+	case !fi.IsDir():
+		b, err := os.ReadFile(path)
+		if err != nil {
+			return nil, err
+		}
+		if bytes.Contains(b, []byte("-- atlas:import ")) {
+			return nil, errors.New("atlas:import directive is not supported by this release. See: https://atlasgo.io/getting-started")
+		}
+		if dir, err = FilesAsDir(migrate.NewLocalFile(fi.Name(), b)); err != nil {
+			return nil, err
+		}
+		return stateSchemaSQL(ctx, config, dir)
+	// The sum file is optional when reading the directory state.
+	case isSchemaDir(config.URLs[0], path):
+		dirs, err := os.ReadDir(path)
+		if err != nil {
+			return nil, err
+		}
+		files := make([]migrate.File, 0, len(dirs))
+		for _, d := range dirs {
+			b, err := os.ReadFile(filepath.Join(path, d.Name()))
+			if err != nil {
+				return nil, err
+			}
+			if bytes.Contains(b, []byte("-- atlas:import ")) {
+				return nil, errors.New("atlas:import directive is not supported by this release. See: https://atlasgo.io/getting-started")
+			}
+			files = append(files, migrate.NewLocalFile(d.Name(), b))
+		}
+		if dir, err = FilesAsDir(files...); err != nil {
+			return nil, err
+		}
+		return stateSchemaSQL(ctx, config, dir)
+	// A migration directory.
+	default:
+		var opts []migrate.ReplayOption
+		if dir, err = cmdmigrate.DirURL(ctx, config.URLs[0], false); err != nil {
+			return nil, err
+		}
+		if v := config.URLs[0].Query().Get("version"); v != "" {
+			opts = append(opts, migrate.ReplayToVersion(v))
+		}
+		return stateReaderSQL(ctx, config, dir, nil, opts)
+	}
+}
diff --git a/cmd/atlas/internal/cmdext/reader.go b/cmd/atlas/internal/cmdext/reader.go
index 8314b3bc..f390da8f 100644
--- a/cmd/atlas/internal/cmdext/reader.go
+++ b/cmd/atlas/internal/cmdext/reader.go
@@ -53,59 +53,6 @@ func (r *StateReadCloser) Close() error {
 	return nil
 }
 
-// StateReaderSQL returns a migrate.StateReader from an SQL file or a directory of migrations.
-func StateReaderSQL(ctx context.Context, config *StateReaderConfig) (*StateReadCloser, error) {
-	if len(config.URLs) != 1 {
-		return nil, fmt.Errorf("the provided SQL state must be either a single schema file or a migration directory, but %d paths were found", len(config.URLs))
-	}
-	var (
-		dir  migrate.Dir
-		path = filepath.Join(config.URLs[0].Host, config.URLs[0].Path)
-	)
-	switch fi, err := os.Stat(path); {
-	case err != nil:
-		return nil, err
-	// A single schema file.
-	case !fi.IsDir():
-		b, err := os.ReadFile(path)
-		if err != nil {
-			return nil, err
-		}
-		if dir, err = FilesAsDir(migrate.NewLocalFile(fi.Name(), b)); err != nil {
-			return nil, err
-		}
-		return stateSchemaSQL(ctx, config, dir)
-	// The sum file is optional when reading the directory state.
-	case isSchemaDir(config.URLs[0], path):
-		dirs, err := os.ReadDir(path)
-		if err != nil {
-			return nil, err
-		}
-		files := make([]migrate.File, 0, len(dirs))
-		for _, d := range dirs {
-			b, err := os.ReadFile(filepath.Join(path, d.Name()))
-			if err != nil {
-				return nil, err
-			}
-			files = append(files, migrate.NewLocalFile(d.Name(), b))
-		}
-		if dir, err = FilesAsDir(files...); err != nil {
-			return nil, err
-		}
-		return stateSchemaSQL(ctx, config, dir)
-	// A migration directory.
-	default:
-		var opts []migrate.ReplayOption
-		if dir, err = cmdmigrate.DirURL(ctx, config.URLs[0], false); err != nil {
-			return nil, err
-		}
-		if v := config.URLs[0].Query().Get("version"); v != "" {
-			opts = append(opts, migrate.ReplayToVersion(v))
-		}
-		return stateReaderSQL(ctx, config, dir, nil, opts)
-	}
-}
-
 // isSchemaDir returns true if the given path is a schema directory (not a migration directory).
 func isSchemaDir(u *url.URL, path string) bool {
 	if q := u.Query(); q.Has("version") || q.Has("format") || filepath.Base(path) == cmdmigrate.DefaultDirName {
diff --git a/cmd/atlas/internal/cmdlog/cmdlog.go b/cmd/atlas/internal/cmdlog/cmdlog.go
index 6bc348d9..023c4523 100644
--- a/cmd/atlas/internal/cmdlog/cmdlog.go
+++ b/cmd/atlas/internal/cmdlog/cmdlog.go
@@ -612,7 +612,7 @@ func (a *MigrateApply) Summary(ident string) string {
 		}
 	}
 	// Execution time.
-	lines = append(lines, fmt.Sprintf(a.End.Sub(a.Start).String()))
+	lines = append(lines, a.End.Sub(a.Start).String())
 	// Executed files.
 	switch {
 	case passedF > 0 && failedF > 0:
diff --git a/sql/migrate/dir.go b/sql/migrate/dir.go
index 12e561c0..715c29a8 100644
--- a/sql/migrate/dir.go
+++ b/sql/migrate/dir.go
@@ -343,9 +343,10 @@ func (f *LocalFile) comments() []string {
 		comments = append(comments, strings.TrimSpace(content[:idx]))
 		content = content[idx+1:]
 	}
-	// File comments are separated by double newlines from
-	// file content (detached from actual statements).
-	if !strings.HasPrefix(strings.TrimLeft(content, " \t"), "\n") {
+	// File comments are separated by double newlines from file content (detached from actual statements).
+	// However, if the file does not contain any statements (only comments, such as atlas:import) then the
+	// collected comments should treat as file comments.
+	if !strings.HasPrefix(strings.TrimLeft(content, " \t"), "\n") && content != "" {
 		return nil
 	}
 	return comments


Test patch:
diff --git a/cmd/atlas/internal/cmdapi/schema_test.go b/cmd/atlas/internal/cmdapi/schema_test.go
index 13ad1fbb..007d2e77 100644
--- a/cmd/atlas/internal/cmdapi/schema_test.go
+++ b/cmd/atlas/internal/cmdapi/schema_test.go
@@ -863,7 +863,7 @@ func TestSchema_ApplySQL(t *testing.T) {
 			"--to", "file://testdata/sqlite",
 			"--to", "file://testdata/sqlite2",
 		)
-		require.EqualError(t, err, `the provided SQL state must be either a single schema file or a migration directory, but 2 paths were found`)
+		require.Error(t, err)
 
 		_, err = runCmd(
 			schemaApplyCmd(),
diff --git a/internal/integration/postgres_test.go b/internal/integration/postgres_test.go
index aed5cc37..2d1e87cc 100644
--- a/internal/integration/postgres_test.go
+++ b/internal/integration/postgres_test.go
@@ -1142,7 +1142,7 @@ table "posts" {
 		out, err := exec.Command(
 			bin, "schema", "apply",
 			"-u", fmt.Sprintf("postgres://postgres:pass@localhost:%d/test2?sslmode=disable", t.port),
-			"-f", fmt.Sprintf(filepath.Join(dir, "schema.hcl")),
+			"-f", filepath.Join(dir, "schema.hcl"),
 			"--auto-approve",
 		).CombinedOutput()
 		require.NoError(t, err, string(out))
diff --git a/schemahcl/stdlib_test.go b/schemahcl/stdlib_test.go
index 144e5daf..c6f101a5 100644
--- a/schemahcl/stdlib_test.go
+++ b/schemahcl/stdlib_test.go
@@ -281,7 +281,7 @@ func TestMakeFilesetFunc(t *testing.T) {
 	require.EqualError(t, err, "base directory must be an absolute path. got: testdata")
 }
 
-func Example_RegexpEscapeFunc() {
+func Example_regexpEscapeFunc() {
 	for _, f := range []string{
 		`v  = regexpescape("a|b|c")`,
 		`v  = regexpescape("abc")`,
@@ -311,7 +311,7 @@ TAB
 	// 1  \| foo
 }
 
-func Example_PrintFunc() {
+func Example_printFunc() {
 	for _, f := range []string{
 		`v  = print("a")`,
 		`v  = print("a&b")`,
diff --git a/sql/migrate/dir_test.go b/sql/migrate/dir_test.go
index 25d50950..79a529b8 100644
--- a/sql/migrate/dir_test.go
+++ b/sql/migrate/dir_test.go
@@ -422,10 +422,21 @@ alter table users drop column id;
 	require.Empty(t, f.Directive("lint"))
 	f = migrate.NewLocalFile("1.sql", []byte("-- atlas:lint ignore"))
 	require.Empty(t, f.Directive("lint"))
-	f = migrate.NewLocalFile("1.sql", []byte("-- atlas:lint ignore\n"))
-	require.Empty(t, f.Directive("lint"))
 	f = migrate.NewLocalFile("1.sql", []byte("-- atlas:lint ignore\n\n"))
 	require.Equal(t, []string{"ignore"}, f.Directive("lint"), "double newline as directive separator")
+
+	// Multiple occurrences of the same directive.
+	f = migrate.NewLocalFile("1.sql", []byte(`-- atlas:import foo
+-- atlas:import
+-- atlas:import bar baz
+-- atlas:import qux
+
+alter table users drop column id;
+`))
+	require.Equal(t, []string{"foo", "", "bar baz", "qux"}, f.Directive("import"))
+
+	f = migrate.NewLocalFile("1.sql", []byte("-- atlas:import foo\n"))
+	require.Equal(t, []string{"foo"}, f.Directive("import"))
 }
 
 func TestLocalFile_AddDirective(t *testing.T) {
diff --git a/sql/schema/migrate_test.go b/sql/schema/migrate_test.go
index 3f12dfae..3876ede1 100644
--- a/sql/schema/migrate_test.go
+++ b/sql/schema/migrate_test.go
@@ -91,7 +91,7 @@ func TestChanges_RemoveIndex(t *testing.T) {
 	require.Equal(t, "2", changes[0].(*schema.AddColumn).C.Name)
 }
 
-func ExampleChanges_Replace() {
+func ExampleChanges_RemoveIndex() {
 	changes := schema.Changes{
 		&schema.AddIndex{I: schema.NewIndex("id")},
 		&schema.AddColumn{C: schema.NewColumn("new_name")},