Repository: ariga/atlas
Pull request #848 (2022-06-08 08:44:26)

cmd/atlasci/internal/ci: add basic run structure
The general idea is to wrap these analyzers with top-level
runner that can be configured with custom reporter/writer
and setup driver-specific configuration.

On the execution part, the runner should be responsible for
scanning (and parsing, soon) the SQL files in the migration
directory, create a pass (check context) for each new one and
execute the registered and desired analyzers on it.

The reporting should be environment-aware. e.g. GitHub PR,
local development (HCL, Ent, plain SQL, etc).


Patch:
diff --git a/cmd/atlasci/internal/ci/ci.go b/cmd/atlasci/internal/ci/ci.go
index 8a08f9e5..02e6ebf4 100644
--- a/cmd/atlasci/internal/ci/ci.go
+++ b/cmd/atlasci/internal/ci/ci.go
@@ -14,7 +14,7 @@ import (
 	"sort"
 
 	"ariga.io/atlas/sql/migrate"
-	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlcheck"
 	"ariga.io/atlas/sql/sqlclient"
 
 	"github.com/go-git/go-git/v5"
@@ -34,7 +34,7 @@ type (
 	// It will also label migration files as either "generated" or "handcrafted".
 	ChangeLoader interface {
 		// LoadChanges converts each of the given migration files into one Changes.
-		LoadChanges(context.Context, []migrate.File) ([]*File, error)
+		LoadChanges(context.Context, []migrate.File) ([]*sqlcheck.File, error)
 	}
 
 	// DirScanner stitches migrate.Dir and migrate.Scanner into one interface.
@@ -42,20 +42,6 @@ type (
 		migrate.Dir
 		migrate.Scanner
 	}
-
-	// File represents a parsed version of a migration file.
-	File struct {
-		migrate.File
-		// Changes is the list of changes this file represents.
-		Changes []*Change
-	}
-
-	// A Change in a migration file.
-	Change struct {
-		schema.Changes        // The actual changes.
-		Stmt           string // The SQL statement generated this change.
-		Pos            int    // The position of the statement in the file.
-	}
 )
 
 type (
@@ -201,14 +187,14 @@ type DevLoader struct {
 }
 
 // LoadChanges implements the ChangesLoader interface.
-func (d *DevLoader) LoadChanges(ctx context.Context, files []migrate.File) ([]*File, error) {
-	diff := make([]*File, len(files))
+func (d *DevLoader) LoadChanges(ctx context.Context, files []migrate.File) ([]*sqlcheck.File, error) {
+	diff := make([]*sqlcheck.File, len(files))
 	current, err := d.Dev.InspectRealm(ctx, nil)
 	if err != nil {
 		return nil, err
 	}
 	for i, f := range files {
-		diff[i] = &File{
+		diff[i] = &sqlcheck.File{
 			File: f,
 		}
 		stmts, err := d.Scan.Stmts(f)
@@ -234,7 +220,7 @@ func (d *DevLoader) LoadChanges(ctx context.Context, files []migrate.File) ([]*F
 				if err != nil {
 					return nil, err
 				}
-				diff[i].Changes = append(diff[i].Changes, &Change{
+				diff[i].Changes = append(diff[i].Changes, &sqlcheck.Change{
 					Pos:     p,
 					Stmt:    s,
 					Changes: changes,
diff --git a/cmd/atlasci/internal/ci/run.go b/cmd/atlasci/internal/ci/run.go
new file mode 100644
index 00000000..5fe372bf
--- /dev/null
+++ b/cmd/atlasci/internal/ci/run.go
@@ -0,0 +1,64 @@
+// 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 ci
+
+import (
+	"context"
+
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/sqlcheck"
+	"ariga.io/atlas/sql/sqlclient"
+)
+
+// Runner is used to execute CI jobs.
+type Runner struct {
+	// DevClient configures the "dev driver" to calculate
+	// migration changes by the driver.
+	Dev *sqlclient.Client
+
+	// RunChangeDetector configures the ChangeDetector to
+	// be used by the runner.
+	ChangeDetector ChangeDetector
+
+	// Scan is used for scanning the migration directory.
+	Scan migrate.Scanner
+
+	// Analyzer defines the analysis to be run in the CI job.
+	Analyzer sqlcheck.Analyzer
+
+	// Reporter is used to report diagnostics in the CI.
+	Reporter sqlcheck.Reporter
+}
+
+// Run executes the CI job.
+func (r *Runner) Run(ctx context.Context) error {
+	base, feat, err := r.ChangeDetector.DetectChanges(ctx)
+	if err != nil {
+		return err
+	}
+	// Bring the dev environment to the base point.
+	for _, f := range base {
+		stmt, err := r.Scan.Stmts(f)
+		if err != nil {
+			return err
+		}
+		for _, s := range stmt {
+			if _, err := r.Dev.ExecContext(ctx, s); err != nil {
+				return err
+			}
+		}
+	}
+	l := &DevLoader{Dev: r.Dev, Scan: r.Scan}
+	files, err := l.LoadChanges(ctx, feat)
+	if err != nil {
+		return err
+	}
+	for _, f := range files {
+		if err := r.Analyzer.Analyze(ctx, &sqlcheck.Pass{File: f, Dev: r.Dev, Report: r.Reporter}); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/cmd/atlascmd/migrate.go b/cmd/atlascmd/migrate.go
index b2be3f41..432409ab 100644
--- a/cmd/atlascmd/migrate.go
+++ b/cmd/atlascmd/migrate.go
@@ -248,7 +248,7 @@ func CmdMigrateValidateRun(cmd *cobra.Command, _ []string) error {
 	if err != nil {
 		return err
 	}
-	ex, err := migrate.NewExecutor(dev.Driver, dir, migrate.NoopRevisionReadWriter{})
+	ex, err := migrate.NewExecutor(dev.Driver, dir, migrate.NopRevisionReadWriter{})
 	if err != nil {
 		return err
 	}
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index 1b32ed07..fd4ab488 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -262,7 +262,7 @@ func DisableChecksum() PlannerOption {
 // Plan calculates the migration Plan required for moving the current state (from) state to
 // the next state (to). A StateReader can be a directory, static schema elements or a Driver connection.
 func (p *Planner) Plan(ctx context.Context, name string, to StateReader) (*Plan, error) {
-	ex, err := NewExecutor(p.drv, p.dir, NoopRevisionReadWriter{})
+	ex, err := NewExecutor(p.drv, p.dir, NopRevisionReadWriter{})
 	if err != nil {
 		return nil, err
 	}
@@ -578,21 +578,21 @@ func (e *Executor) ReadState(ctx context.Context) (realm *schema.Realm, err erro
 	return
 }
 
-// NoopRevisionReadWriter is a RevisionsReadWriter that does nothing.
+// NopRevisionReadWriter is a RevisionsReadWriter that does nothing.
 // It is useful for one-time replay of the migration directory.
-type NoopRevisionReadWriter struct{}
+type NopRevisionReadWriter struct{}
 
 // ReadRevisions implements RevisionsReadWriter.ReadRevisions,
-func (NoopRevisionReadWriter) ReadRevisions(context.Context) (Revisions, error) {
+func (NopRevisionReadWriter) ReadRevisions(context.Context) (Revisions, error) {
 	return nil, nil
 }
 
 // WriteRevision implements RevisionsReadWriter.WriteRevision,
-func (NoopRevisionReadWriter) WriteRevision(context.Context, *Revision) error {
+func (NopRevisionReadWriter) WriteRevision(context.Context, *Revision) error {
 	return nil
 }
 
-var _ RevisionReadWriter = (*NoopRevisionReadWriter)(nil)
+var _ RevisionReadWriter = (*NopRevisionReadWriter)(nil)
 
 // done computes and sets the ExecutionTime.
 func (r *Revision) done(ok bool) {
diff --git a/sql/schema/schema.go b/sql/schema/schema.go
index 287e5089..5656388a 100644
--- a/sql/schema/schema.go
+++ b/sql/schema/schema.go
@@ -282,7 +282,7 @@ type (
 		Text string
 	}
 
-	// Charset describes a column or a table character set setting.
+	// Charset describes a column or a table character-set setting.
 	Charset struct {
 		V string
 	}
diff --git a/sql/sqlcheck/sqlcheck.go b/sql/sqlcheck/sqlcheck.go
new file mode 100644
index 00000000..c6a1d573
--- /dev/null
+++ b/sql/sqlcheck/sqlcheck.go
@@ -0,0 +1,83 @@
+// 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 sqlcheck
+
+import (
+	"context"
+
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
+)
+
+type (
+	// An Analyzer describes a migration file analyzer.
+	Analyzer interface {
+		// Analyze executes the analysis function.
+		Analyze(context.Context, *Pass) error
+	}
+
+	// A Pass provides information to the Run function that
+	// applies a specific analyzer to an SQL file.
+	Pass struct {
+		// A migration file and the changes it describes.
+		File *File
+
+		// Dev is a driver-specific environment used to execute analysis work.
+		Dev *sqlclient.Client
+
+		// Report reports a diagnostic
+		Report Reporter
+	}
+
+	// File represents a parsed version of a migration file.
+	File struct {
+		migrate.File
+		// Changes is the list of changes this file represents.
+		Changes []*Change
+	}
+
+	// A Change in a migration file.
+	Change struct {
+		schema.Changes        // The actual changes.
+		Stmt           string // The SQL statement generated this change.
+		Pos            int    // The position of the statement in the file.
+	}
+
+	// A Diagnostic is a message associated with a source location or range.
+	Diagnostic struct {
+		Pos     int    // Diagnostic position.
+		Message string // Diagnostic message.
+	}
+
+	// Reporter represents a diagnostic reporter.
+	Reporter interface {
+		Report(Diagnostic)
+	}
+)
+
+// Analyzers implements Analyzer.
+type Analyzers []Analyzer
+
+// Analyze implements Analyzer.
+func (a Analyzers) Analyze(ctx context.Context, p *Pass) error {
+	for _, a := range a {
+		if err := a.Analyze(ctx, p); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// ReporterFunc is a function that implements Reporter.
+type ReporterFunc func(Diagnostic)
+
+// Report calls f(d).
+func (f ReporterFunc) Report(d Diagnostic) {
+	f(d)
+}
+
+// NopReporter is a Reporter that does nothing.
+var NopReporter Reporter = ReporterFunc(func(Diagnostic) {})


Test patch:
diff --git a/cmd/atlasci/internal/ci/run_test.go b/cmd/atlasci/internal/ci/run_test.go
new file mode 100644
index 00000000..158dad3f
--- /dev/null
+++ b/cmd/atlasci/internal/ci/run_test.go
@@ -0,0 +1,62 @@
+// 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 ci_test
+
+import (
+	"context"
+	"testing"
+
+	"ariga.io/atlas/cmd/atlasci/internal/ci"
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/sqlcheck"
+	"ariga.io/atlas/sql/sqlclient"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestRunner_Run(t *testing.T) {
+	ctx := context.Background()
+	c, err := sqlclient.Open(ctx, "sqlite://run?mode=memory&cache=shared&_fk=1")
+	require.NoError(t, err)
+	r := &ci.Runner{
+		Scan: testDir{},
+		Dev:  c,
+		ChangeDetector: testDetector{
+			base: []migrate.File{
+				testFile{name: "1.sql", content: "CREATE TABLE users (id INT)"},
+			},
+			feat: []migrate.File{
+				testFile{name: "2.sql", content: "CREATE TABLE pets (id INT)\nDROP TABLE users"},
+			},
+		},
+		Analyzer: &testAnalyzer{},
+		Reporter: sqlcheck.NopReporter,
+	}
+	require.NoError(t, r.Run(ctx))
+
+	passes := r.Analyzer.(*testAnalyzer).passes
+	require.Len(t, passes, 1)
+	changes := passes[0].File.Changes
+	require.Len(t, changes, 2)
+	require.Equal(t, "CREATE TABLE pets (id INT)", changes[0].Stmt)
+	require.Equal(t, "DROP TABLE users", changes[1].Stmt)
+}
+
+type testAnalyzer struct {
+	passes []*sqlcheck.Pass
+}
+
+func (t *testAnalyzer) Analyze(_ context.Context, p *sqlcheck.Pass) error {
+	t.passes = append(t.passes, p)
+	return nil
+}
+
+type testDetector struct {
+	base, feat []migrate.File
+}
+
+func (t testDetector) DetectChanges(context.Context) ([]migrate.File, []migrate.File, error) {
+	return t.base, t.feat, nil
+}
diff --git a/sql/migrate/migrate_test.go b/sql/migrate/migrate_test.go
index 498cded3..621052e9 100644
--- a/sql/migrate/migrate_test.go
+++ b/sql/migrate/migrate_test.go
@@ -190,11 +190,11 @@ func TestExecutor_ReadState(t *testing.T) {
 	require.NoError(t, err)
 
 	// Locking not supported.
-	_, err = migrate.NewExecutor(&mockDriver{}, d, migrate.NoopRevisionReadWriter{})
+	_, err = migrate.NewExecutor(&mockDriver{}, d, migrate.NopRevisionReadWriter{})
 	require.ErrorIs(t, err, migrate.ErrLockUnsupported)
 
 	drv := &lockMockDriver{&mockDriver{}}
-	ex, err := migrate.NewExecutor(drv, d, migrate.NoopRevisionReadWriter{})
+	ex, err := migrate.NewExecutor(drv, d, migrate.NopRevisionReadWriter{})
 	require.NoError(t, err)
 
 	_, err = ex.ReadState(ctx)
@@ -223,7 +223,7 @@ func TestExecutor_ReadState(t *testing.T) {
 
 	// Works.
 	edrv := &emptyMockDriver{drv}
-	ex, err = migrate.NewExecutor(edrv, d, migrate.NoopRevisionReadWriter{})
+	ex, err = migrate.NewExecutor(edrv, d, migrate.NopRevisionReadWriter{})
 	require.NoError(t, err)
 	_, err = ex.ReadState(ctx)
 	require.NoError(t, err)