Repository: ariga/atlas
Pull request #831 (2022-05-30 18:27:29)

cmd/atlascmd/migrate: introduce SchemaChanger interface
Ent based schema revisions are stored in its own database schema (except SQLite). The responsibility for parsing and editing connection URLs lay at the drivers. Ent based revisions introduce a new interface for the drivers to implement in order to alter the originally given connection URL to connect to another schema to store revisions table in.


Patch:
diff --git a/cmd/atlascmd/migrate/migrate.go b/cmd/atlascmd/migrate/migrate.go
index 8fbaf51e..917e154f 100644
--- a/cmd/atlascmd/migrate/migrate.go
+++ b/cmd/atlascmd/migrate/migrate.go
@@ -6,32 +6,89 @@ package migrate
 
 import (
 	"context"
+	"errors"
 
 	"ariga.io/atlas/cmd/atlascmd/migrate/ent"
 	"ariga.io/atlas/cmd/atlascmd/migrate/ent/revision"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
 	"entgo.io/ent/dialect/sql"
 	entschema "entgo.io/ent/dialect/sql/schema"
 )
 
-// A EntRevisions provides implementation for the migrate.RevisionReadWriter interface.
-type EntRevisions struct{ c *ent.Client }
+type (
+	// A EntRevisions provides implementation for the migrate.RevisionReadWriter interface.
+	EntRevisions struct {
+		ac     *sqlclient.Client // underlying Atlas client
+		sc     *sqlclient.Client // underlying Atlas client connected to the named schema
+		ec     *ent.Client       // underlying Ent client
+		schema string            // name of the schema the revision table resides in
+	}
+
+	// Option allows to configure EntRevisions by using functional arguments.
+	Option func(*EntRevisions) error
+)
 
-// NewEntRevisions creates a new EntRevisions with the given ent.Client.
-func NewEntRevisions(db schema.ExecQuerier, dialect string, opts ...ent.Option) *EntRevisions {
-	opts = append(opts, ent.Driver(sql.NewDriver(dialect, sql.Conn{ExecQuerier: db})))
-	return &EntRevisions{ent.NewClient(opts...)}
+// NewEntRevisions creates a new EntRevisions with the given sqlclient.Client. It is important to call
+// EntRevisions.Init to initialize the underlying Ent client.
+func NewEntRevisions(ac *sqlclient.Client, opts ...Option) (*EntRevisions, error) {
+	r := &EntRevisions{ac: ac}
+	for _, opt := range opts {
+		if err := opt(r); err != nil {
+			return nil, err
+		}
+	}
+	if r.schema == "" {
+		r.schema = "atlas_schema_revisions"
+	}
+	return r, nil
+}
+
+// WithSchema configures the schema to use for the revision table.
+func WithSchema(s string) Option {
+	return func(r *EntRevisions) error {
+		r.schema = s
+		return nil
+	}
 }
 
 // Init makes sure the revision table does exist in the connected database.
 func (r *EntRevisions) Init(ctx context.Context) error {
-	return r.c.Schema.Create(ctx, entschema.WithAtlas(true))
+	// Try to open a connection to the schema we are storing the revision table in.
+	var err error
+	r.sc, err = sqlclient.OpenURL(ctx, r.ac.URL.URL, sqlclient.OpenSchema(r.schema))
+	// If the driver does not support changing the schema (most likely SQLite) use the existing connection.
+	if err != nil && errors.Is(err, sqlclient.ErrUnsupported) {
+		r.ec = ent.NewClient(ent.Driver(sql.OpenDB(r.ac.Name, r.ac.DB)))
+		return r.ec.Schema.Create(ctx, entschema.WithAtlas(true))
+	}
+	// Driver does support changing schemas. Make sure the schema does exist before proceeding.
+	_, err2 := r.ac.InspectSchema(ctx, r.schema, &schema.InspectOptions{Mode: schema.InspectSchemas})
+	if err2 != nil && !schema.IsNotExistError(err2) {
+		return err2
+	}
+	if schema.IsNotExistError(err2) {
+		if err := r.ac.ApplyChanges(ctx, []schema.Change{
+			&schema.AddSchema{S: &schema.Schema{Name: r.schema}},
+		}); err != nil {
+			return err
+		}
+	}
+	// If the previous connection attempt was unsuccessful, re-try with the schema present.
+	if r.sc == nil {
+		r.sc, err = sqlclient.OpenURL(ctx, r.ac.URL.URL, sqlclient.OpenSchema(r.schema))
+		if err != nil {
+			return err
+		}
+	}
+	r.ec = ent.NewClient(ent.Driver(sql.OpenDB(r.sc.Name, r.sc.DB)))
+	return r.ec.Schema.Create(ctx, entschema.WithAtlas(true))
 }
 
 // ReadRevisions reads the revisions from the revisions table.
 func (r *EntRevisions) ReadRevisions(ctx context.Context) (migrate.Revisions, error) {
-	revs, err := r.c.Revision.Query().Order(ent.Asc(revision.FieldID)).All(ctx)
+	revs, err := r.ec.Revision.Query().Order(ent.Asc(revision.FieldID)).All(ctx)
 	if err != nil {
 		return nil, err
 	}
@@ -55,7 +112,7 @@ func (r *EntRevisions) ReadRevisions(ctx context.Context) (migrate.Revisions, er
 func (r *EntRevisions) WriteRevisions(ctx context.Context, rs migrate.Revisions) error {
 	bulk := make([]*ent.RevisionCreate, len(rs))
 	for i, rev := range rs {
-		bulk[i] = r.c.Revision.Create().
+		bulk[i] = r.ec.Revision.Create().
 			SetID(rev.Version).
 			SetDescription(rev.Description).
 			SetExecutionState(revision.ExecutionState(rev.ExecutionState)).
@@ -65,7 +122,7 @@ func (r *EntRevisions) WriteRevisions(ctx context.Context, rs migrate.Revisions)
 			SetOperatorVersion(rev.OperatorVersion).
 			SetMeta(rev.Meta)
 	}
-	return r.c.Revision.CreateBulk(bulk...).
+	return r.ec.Revision.CreateBulk(bulk...).
 		OnConflict(
 			sql.ConflictColumns(revision.FieldID),
 		).
@@ -73,4 +130,11 @@ func (r *EntRevisions) WriteRevisions(ctx context.Context, rs migrate.Revisions)
 		Exec(ctx)
 }
 
+// Close closes the Atlas client connected to the revision schema (if any).
+func (r *EntRevisions) Close() {
+	if r.sc != nil {
+		r.sc.Close()
+	}
+}
+
 var _ migrate.RevisionReadWriter = (*EntRevisions)(nil)
diff --git a/sql/mysql/driver.go b/sql/mysql/driver.go
index ded8c798..cda7ce14 100644
--- a/sql/mysql/driver.go
+++ b/sql/mysql/driver.go
@@ -44,9 +44,7 @@ func init() {
 		sqlclient.DriverOpener(Open),
 		sqlclient.RegisterCodec(MarshalHCL, EvalHCL),
 		sqlclient.RegisterFlavours("maria", "mariadb"),
-		sqlclient.RegisterURLParser(func(u *url.URL) *sqlclient.URL {
-			return &sqlclient.URL{URL: u, DSN: dsn(u), Schema: strings.TrimPrefix(u.Path, "/")}
-		}),
+		sqlclient.RegisterURLParser(parser{}),
 	)
 }
 
@@ -235,6 +233,20 @@ func unescape(s string) string {
 	return b.String()
 }
 
+type parser struct{}
+
+// ParseURL implements the sqlclient.URLParser interface.
+func (parser) ParseURL(u *url.URL) *sqlclient.URL {
+	return &sqlclient.URL{URL: u, DSN: dsn(u), Schema: strings.TrimPrefix(u.Path, "/")}
+}
+
+// ChangeSchema implements the sqlclient.SchemaChanger interface.
+func (parser) ChangeSchema(u *url.URL, s string) *url.URL {
+	nu := *u
+	nu.Path = "/" + s
+	return &nu
+}
+
 // dsn returns the MySQL standard DSN for opening
 // the sql.DB from the user provided URL.
 func dsn(u *url.URL) string {
diff --git a/sql/postgres/crdb.go b/sql/postgres/crdb.go
index 2f248038..d904820f 100644
--- a/sql/postgres/crdb.go
+++ b/sql/postgres/crdb.go
@@ -8,7 +8,6 @@ import (
 	"context"
 	"database/sql"
 	"fmt"
-	"net/url"
 	"regexp"
 	"strings"
 
@@ -40,9 +39,7 @@ func init() {
 		sqlclient.DriverOpener(OpenCRDB),
 		sqlclient.RegisterCodec(MarshalHCL, EvalHCL),
 		sqlclient.RegisterFlavours("crdb"),
-		sqlclient.RegisterURLParser(func(u *url.URL) *sqlclient.URL {
-			return &sqlclient.URL{URL: u, DSN: u.String(), Schema: u.Query().Get("search_path")}
-		}),
+		sqlclient.RegisterURLParser(parser{}),
 	)
 }
 
diff --git a/sql/postgres/driver.go b/sql/postgres/driver.go
index 455af8c0..83bef4e0 100644
--- a/sql/postgres/driver.go
+++ b/sql/postgres/driver.go
@@ -45,9 +45,7 @@ func init() {
 		"postgres",
 		sqlclient.DriverOpener(Open),
 		sqlclient.RegisterCodec(MarshalHCL, EvalHCL),
-		sqlclient.RegisterURLParser(func(u *url.URL) *sqlclient.URL {
-			return &sqlclient.URL{URL: u, DSN: u.String(), Schema: u.Query().Get("search_path")}
-		}),
+		sqlclient.RegisterURLParser(parser{}),
 	)
 }
 
@@ -163,6 +161,22 @@ func acquire(ctx context.Context, conn schema.ExecQuerier, id uint32, timeout ti
 	}
 }
 
+type parser struct{}
+
+// ParseURL implements the sqlclient.URLParser interface.
+func (parser) ParseURL(u *url.URL) *sqlclient.URL {
+	return &sqlclient.URL{URL: u, DSN: u.String(), Schema: u.Query().Get("search_path")}
+}
+
+// ChangeSchema implements the sqlclient.SchemaChanger interface.
+func (parser) ChangeSchema(u *url.URL, s string) *url.URL {
+	nu := *u
+	q := nu.Query()
+	q.Set("search_path", s)
+	nu.RawQuery = q.Encode()
+	return &nu
+}
+
 // Standard column types (and their aliases) as defined in
 // PostgreSQL codebase/website.
 const (
diff --git a/sql/sqlclient/client.go b/sql/sqlclient/client.go
index 4eb623d1..436679c6 100644
--- a/sql/sqlclient/client.go
+++ b/sql/sqlclient/client.go
@@ -7,6 +7,7 @@ package sqlclient
 import (
 	"context"
 	"database/sql"
+	"errors"
 	"fmt"
 	"io"
 	"net/url"
@@ -85,10 +86,23 @@ type (
 	// OpenerFunc allows using a function as an Opener.
 	OpenerFunc func(context.Context, *url.URL) (*Client, error)
 
+	// URLParser parses an url.URL into an enriched URL and attaches additional info to it.
+	URLParser interface {
+		ParseURL(*url.URL) *URL
+	}
+
+	// URLParserFunc allows using a function as an URLParser.
+	URLParserFunc func(*url.URL) *URL
+
+	// SchemaChanger is implemented by a driver if it how to change the connection URL to represent another schema.
+	SchemaChanger interface {
+		ChangeSchema(*url.URL, string) *url.URL
+	}
+
 	driver struct {
 		Opener
-		name  string
-		parse func(*url.URL) *URL
+		name   string
+		parser URLParser
 	}
 )
 
@@ -97,31 +111,77 @@ func (f OpenerFunc) Open(ctx context.Context, u *url.URL) (*Client, error) {
 	return f(ctx, u)
 }
 
+// ParseURL calls f(u).
+func (f URLParserFunc) ParseURL(u *url.URL) *URL {
+	return f(u)
+}
+
 var drivers sync.Map
 
+type (
+	// openOptions holds additional configuration values for opening a Client.
+	openOptions struct {
+		schema string
+	}
+
+	// OpenOption allows to configure a openOptions using functional arguments.
+	OpenOption func(*openOptions) error
+)
+
+// ErrUnsupported is returned if a registered driver does not support changing the schema.
+var ErrUnsupported = errors.New("sql/sqlclient: driver does not support changing connected schema")
+
 // Open opens an Atlas client by its provided url string.
-func Open(ctx context.Context, s string) (*Client, error) {
+func Open(ctx context.Context, s string, opts ...OpenOption) (*Client, error) {
 	u, err := url.Parse(s)
 	if err != nil {
 		return nil, fmt.Errorf("sql/sqlclient: parse open url: %w", err)
 	}
+	return OpenURL(ctx, u, opts...)
+}
+
+// OpenURL opens an Atlas client by its provided url.URL.
+func OpenURL(ctx context.Context, u *url.URL, opts ...OpenOption) (*Client, error) {
+	cfg := &openOptions{}
+	for _, opt := range opts {
+		if err := opt(cfg); err != nil {
+			return nil, err
+		}
+	}
 	v, ok := drivers.Load(u.Scheme)
 	if !ok {
 		return nil, fmt.Errorf("sql/sqlclient: no opener was register with name %q", u.Scheme)
 	}
+	// If there is a schema given and the driver allows to change the schema for the url, do it.
+	if cfg.schema != "" {
+		sc, ok := v.(driver).parser.(SchemaChanger)
+		if !ok {
+			return nil, ErrUnsupported
+		}
+		u = sc.ChangeSchema(u, cfg.schema)
+	}
 	client, err := v.(driver).Open(ctx, u)
 	if err != nil {
 		return nil, err
 	}
 	if client.URL == nil {
-		client.URL = v.(driver).parse(u)
+		client.URL = v.(driver).parser.ParseURL(u)
 	}
 	return client, nil
 }
 
+// OpenSchema opens the connection to the given schema.
+// If the registered driver does not support this, ErrUnsupported is returned instead.
+func OpenSchema(s string) OpenOption {
+	return func(c *openOptions) error {
+		c.schema = s
+		return nil
+	}
+}
+
 type (
 	registerOptions struct {
-		parse    func(*url.URL) *URL
+		parser   URLParser
 		flavours []string
 		codec    interface {
 			schemaspec.Marshaler
@@ -143,9 +203,9 @@ func RegisterFlavours(flavours ...string) RegisterOption {
 
 // RegisterURLParser allows registering a function for parsing
 // the url.URL and attach additional info to the extended URL.
-func RegisterURLParser(p func(*url.URL) *URL) RegisterOption {
+func RegisterURLParser(p URLParser) RegisterOption {
 	return func(opts *registerOptions) {
-		opts.parse = p
+		opts.parser = p
 	}
 }
 
@@ -170,7 +230,7 @@ func DriverOpener(open func(schema.ExecQuerier) (migrate.Driver, error)) Opener
 		if !ok {
 			return nil, fmt.Errorf("sql/sqlclient: unexpected missing opener %q", u.Scheme)
 		}
-		ur := v.(driver).parse(u)
+		ur := v.(driver).parser.ParseURL(u)
 		db, err := sql.Open(v.(driver).name, ur.DSN)
 		if err != nil {
 			return nil, err
@@ -198,7 +258,7 @@ func Register(name string, opener Opener, opts ...RegisterOption) {
 	}
 	opt := &registerOptions{
 		// Default URL parser uses the URL as the DSN.
-		parse: func(u *url.URL) *URL { return &URL{URL: u, DSN: u.String()} },
+		parser: URLParserFunc(func(u *url.URL) *URL { return &URL{URL: u, DSN: u.String()} }),
 	}
 	for i := range opts {
 		opts[i](opt)
@@ -220,7 +280,7 @@ func Register(name string, opener Opener, opts ...RegisterOption) {
 		}
 		drivers.Store(f, driver{
 			name:   name,
-			parse:  opt.parse,
+			parser: opt.parser,
 			Opener: opener,
 		})
 	}
diff --git a/sql/sqlite/driver.go b/sql/sqlite/driver.go
index 7cd54b9f..4debfb47 100644
--- a/sql/sqlite/driver.go
+++ b/sql/sqlite/driver.go
@@ -48,9 +48,9 @@ func init() {
 		sqlclient.DriverOpener(Open),
 		sqlclient.RegisterCodec(MarshalHCL, EvalHCL),
 		sqlclient.RegisterFlavours("sqlite"),
-		sqlclient.RegisterURLParser(func(u *url.URL) *sqlclient.URL {
+		sqlclient.RegisterURLParser(sqlclient.URLParserFunc(func(u *url.URL) *sqlclient.URL {
 			return &sqlclient.URL{URL: u, DSN: strings.TrimPrefix(u.String(), u.Scheme+"://"), Schema: mainFile}
-		}),
+		})),
 	)
 }
 


Test patch:
diff --git a/cmd/atlascmd/migrate/migrate_test.go b/cmd/atlascmd/migrate/migrate_test.go
index 65d59863..142f41de 100644
--- a/cmd/atlascmd/migrate/migrate_test.go
+++ b/cmd/atlascmd/migrate/migrate_test.go
@@ -6,26 +6,31 @@ package migrate
 
 import (
 	"context"
-	"database/sql"
+	"fmt"
+	"path/filepath"
 	"testing"
 
 	"ariga.io/atlas/cmd/atlascmd/migrate/ent/revision"
-	"ariga.io/atlas/sql/sqlite"
-	"entgo.io/ent/dialect"
+	"ariga.io/atlas/sql/sqlclient"
 	_ "github.com/mattn/go-sqlite3"
 	"github.com/stretchr/testify/require"
 )
 
 func TestEntRevisions_Init(t *testing.T) {
-	db, err := sql.Open(dialect.SQLite, "file:revisions?cache=shared&mode=memory&_fk=true")
+	c, err := sqlclient.Open(
+		context.Background(),
+		fmt.Sprintf("sqlite://%s?cache=shared&mode=memory&_fk=true", filepath.Join(t.TempDir(), "revision")),
+	)
+	require.NoError(t, err)
+	r, err := NewEntRevisions(c)
+	t.Cleanup(func() {
+		r.Close()
+	})
 	require.NoError(t, err)
-	r := NewEntRevisions(db, dialect.SQLite)
 
 	require.NoError(t, r.Init(context.Background()))
 
-	drv, err := sqlite.Open(db)
-	require.NoError(t, err)
-	s, err := drv.InspectSchema(context.Background(), "main", nil)
+	s, err := c.Driver.InspectSchema(context.Background(), "", nil)
 	require.NoError(t, err)
 	require.Len(t, s.Tables, 1)
 	_, ok := s.Table(revision.Table)
diff --git a/internal/integration/cockroach_test.go b/internal/integration/cockroach_test.go
index fdb83900..bd2b428b 100644
--- a/internal/integration/cockroach_test.go
+++ b/internal/integration/cockroach_test.go
@@ -284,6 +284,10 @@ func TestCockroach_Enums(t *testing.T) {
 	})
 }
 
+func (t *crdbTest) url() string {
+	return t.dsn()
+}
+
 func (t *crdbTest) dsn() string {
 	return fmt.Sprintf("postgres://postgres:pass@localhost:%d/test?sslmode=disable", t.port)
 }
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
index cad5fea9..21a21450 100644
--- a/internal/integration/integration_test.go
+++ b/internal/integration/integration_test.go
@@ -20,9 +20,12 @@ import (
 	"text/template"
 	"time"
 
+	entmigrate "ariga.io/atlas/cmd/atlascmd/migrate"
+	"ariga.io/atlas/cmd/atlascmd/migrate/ent/revision"
 	"ariga.io/atlas/schema/schemaspec"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
 	entsql "entgo.io/ent/dialect/sql"
 	entschema "entgo.io/ent/dialect/sql/schema"
 	"entgo.io/ent/entc/integration/ent"
@@ -49,6 +52,7 @@ func TestMain(m *testing.M) {
 // T holds the elements common between dialect tests.
 type T interface {
 	testing.TB
+	url() string
 	driver() migrate.Driver
 	revisionsStorage() migrate.RevisionReadWriter
 	realm() *schema.Realm
@@ -60,6 +64,7 @@ type T interface {
 	revisions() *schema.Table
 	loadTable(string) *schema.Table
 	dropTables(...string)
+	dropSchemas(...string)
 	migrate(...schema.Change)
 	diff(*schema.Table, *schema.Table) []schema.Change
 	applyHcl(spec string)
@@ -266,7 +271,6 @@ func testCLIMultiSchemaApply(t T, h string, dsn string, schemas []string, unmars
 }
 
 func testCLIMultiSchemaInspect(t T, h string, dsn string, schemas []string, unmarshaler schemaspec.Unmarshaler) {
-
 	err := initCLI()
 	require.NoError(t, err)
 	var expected schema.Realm
@@ -293,7 +297,6 @@ func testCLIMultiSchemaInspect(t T, h string, dsn string, schemas []string, unma
 }
 
 func testCLISchemaApply(t T, h string, dsn string, args ...string) {
-
 	err := initCLI()
 	require.NoError(t, err)
 	t.dropTables("users")
@@ -329,7 +332,6 @@ func testCLISchemaApply(t T, h string, dsn string, args ...string) {
 }
 
 func testCLISchemaApplyDry(t T, h string, dsn string) {
-
 	err := initCLI()
 	require.NoError(t, err)
 	t.dropTables("users")
@@ -363,7 +365,6 @@ func testCLISchemaApplyDry(t T, h string, dsn string) {
 }
 
 func testCLISchemaApplyAutoApprove(t T, h string, dsn string) {
-
 	err := initCLI()
 	require.NoError(t, err)
 	t.dropTables("users")
@@ -391,7 +392,6 @@ func testCLISchemaApplyAutoApprove(t T, h string, dsn string) {
 }
 
 func testCLISchemaDiff(t T, dsn string) {
-
 	err := initCLI()
 
 	require.NoError(t, err)
@@ -466,6 +466,28 @@ func testAdvisoryLock(t *testing.T, l schema.Locker) {
 	})
 }
 
+// testEntRevisions will not be run for SQLite. It has its own version of this test.
+func testEntRevisions(t T) {
+	const name = "my_revisions_schema"
+
+	c, err := sqlclient.Open(context.Background(), t.url())
+	require.NoError(t, err)
+
+	r, err := entmigrate.NewEntRevisions(c, entmigrate.WithSchema(name))
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		r.Close()
+	})
+
+	require.NoError(t, r.Init(context.Background()))
+	t.dropSchemas(name)
+
+	s, err := c.InspectSchema(context.Background(), name, nil)
+	require.NoError(t, err)
+	_, ok := s.Table(revision.Table)
+	require.True(t, ok)
+}
+
 func testExecutor(t T) {
 	usersT, postsT := t.users(), t.posts()
 	petsT := &schema.Table{
diff --git a/internal/integration/mysql_test.go b/internal/integration/mysql_test.go
index a65c58fc..9ea2d7f7 100644
--- a/internal/integration/mysql_test.go
+++ b/internal/integration/mysql_test.go
@@ -79,6 +79,12 @@ func myRun(t *testing.T, fn func(*myTest)) {
 	}
 }
 
+func TestMySQL_EntRevisions(t *testing.T) {
+	myRun(t, func(t *myTest) {
+		testEntRevisions(t)
+	})
+}
+
 func TestMySQL_Executor(t *testing.T) {
 	myRun(t, func(t *myTest) {
 		testExecutor(t)
@@ -661,7 +667,7 @@ func TestMySQL_CLI_MultiSchema(t *testing.T) {
 			}`
 	t.Run("SchemaInspect", func(t *testing.T) {
 		myRun(t, func(t *myTest) {
-			t.dropDB("test2")
+			t.dropSchemas("test2")
 			t.dropTables("users")
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
@@ -670,7 +676,7 @@ func TestMySQL_CLI_MultiSchema(t *testing.T) {
 	})
 	t.Run("SchemaApply", func(t *testing.T) {
 		myRun(t, func(t *myTest) {
-			t.dropDB("test2")
+			t.dropSchemas("test2")
 			t.dropTables("users")
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
@@ -681,7 +687,7 @@ func TestMySQL_CLI_MultiSchema(t *testing.T) {
 
 func TestMySQL_HCL_Realm(t *testing.T) {
 	myRun(t, func(t *myTest) {
-		t.dropDB("second")
+		t.dropSchemas("second")
 		realm := t.loadRealm()
 		hcl, err := mysql.MarshalHCL(realm)
 		require.NoError(t, err)
@@ -1178,6 +1184,10 @@ create table atlas_types_sanity
 	})
 }
 
+func (t *myTest) url() string {
+	return t.dsn("")
+}
+
 func (t *myTest) dsn(dbname string) string {
 	d := "mysql"
 	pass := ":pass"
@@ -1240,7 +1250,7 @@ func (t *myTest) dropTables(names ...string) {
 	})
 }
 
-func (t *myTest) dropDB(names ...string) {
+func (t *myTest) dropSchemas(names ...string) {
 	t.Cleanup(func() {
 		for _, n := range names {
 			_, err := t.db.Exec("DROP DATABASE IF EXISTS " + n)
diff --git a/internal/integration/postgres_test.go b/internal/integration/postgres_test.go
index c9143a00..21720f0b 100644
--- a/internal/integration/postgres_test.go
+++ b/internal/integration/postgres_test.go
@@ -80,6 +80,12 @@ func pgRun(t *testing.T, fn func(*pgTest)) {
 	}
 }
 
+func TestPostgres_EntRevisions(t *testing.T) {
+	pgRun(t, func(t *pgTest) {
+		testEntRevisions(t)
+	})
+}
+
 func TestPostgres_Executor(t *testing.T) {
 	pgRun(t, func(t *pgTest) {
 		testExecutor(t)
@@ -1033,6 +1039,10 @@ create table atlas_types_sanity
 	})
 }
 
+func (t *pgTest) url() string {
+	return t.dsn()
+}
+
 func (t *pgTest) dsn() string {
 	return fmt.Sprintf("postgres://postgres:pass@localhost:%d/test?sslmode=disable", t.port)
 }
diff --git a/internal/integration/sqlite_test.go b/internal/integration/sqlite_test.go
index 8740dfbb..6cd1e0c8 100644
--- a/internal/integration/sqlite_test.go
+++ b/internal/integration/sqlite_test.go
@@ -15,9 +15,12 @@ import (
 	"strings"
 	"testing"
 
+	entmigrate "ariga.io/atlas/cmd/atlascmd/migrate"
+	"ariga.io/atlas/cmd/atlascmd/migrate/ent/revision"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/postgres"
 	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
 	"ariga.io/atlas/sql/sqlite"
 
 	"entgo.io/ent/dialect"
@@ -47,6 +50,26 @@ func liteRun(t *testing.T, fn func(test *liteTest)) {
 	fn(tt)
 }
 
+func TestSQLite_EntRevisions(t *testing.T) {
+	liteRun(t, func(t *liteTest) {
+		c, err := sqlclient.Open(context.Background(), t.url())
+		require.NoError(t, err)
+
+		r, err := entmigrate.NewEntRevisions(c)
+		require.NoError(t, err)
+		t.Cleanup(func() {
+			r.Close()
+		})
+
+		require.NoError(t, r.Init(context.Background()))
+
+		s, err := c.InspectSchema(context.Background(), "", nil)
+		require.NoError(t, err)
+		_, ok := s.Table(revision.Table)
+		require.True(t, ok)
+	})
+}
+
 func TestSQLite_Executor(t *testing.T) {
 	liteRun(t, func(t *liteTest) {
 		testExecutor(t)
@@ -785,6 +808,8 @@ func (t *liteTest) revisionsStorage() migrate.RevisionReadWriter {
 	return t.rrw
 }
 
+func (t *liteTest) dropSchemas(...string) {}
+
 func (t *liteTest) applyHcl(spec string) {
 	realm := t.loadRealm()
 	var desired schema.Schema
@@ -936,6 +961,10 @@ func (t *liteTest) dsn() string {
 	return fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", t.file)
 }
 
+func (t *liteTest) url() string {
+	return t.dsn()
+}
+
 func (t *liteTest) applyRealmHcl(spec string) {
 	t.applyHcl(spec)
 }
diff --git a/internal/integration/tidb_test.go b/internal/integration/tidb_test.go
index 0291f450..9d1535c2 100644
--- a/internal/integration/tidb_test.go
+++ b/internal/integration/tidb_test.go
@@ -67,6 +67,12 @@ func tidbRun(t *testing.T, fn func(*myTest)) {
 	}
 }
 
+func TestTiDB_EntRevisions(t *testing.T) {
+	tidbRun(t, func(t *myTest) {
+		testEntRevisions(t)
+	})
+}
+
 func TestTiDB_AddDropTable(t *testing.T) {
 	tidbRun(t, func(t *myTest) {
 		testAddDrop(t)
@@ -471,7 +477,7 @@ func TestTiDB_ForeignKey(t *testing.T) {
 
 func TestTiDB_HCL_Realm(t *testing.T) {
 	tidbRun(t, func(t *myTest) {
-		t.dropDB("second")
+		t.dropSchemas("second")
 		realm := t.loadRealm()
 		hcl, err := mysql.MarshalHCL(realm)
 		require.NoError(t, err)
@@ -547,7 +553,7 @@ func TestTiDB_CLI_MultiSchema(t *testing.T) {
 			}`
 	t.Run("SchemaInspect", func(t *testing.T) {
 		tidbRun(t, func(t *myTest) {
-			t.dropDB("test2")
+			t.dropSchemas("test2")
 			t.dropTables("users")
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
@@ -556,7 +562,7 @@ func TestTiDB_CLI_MultiSchema(t *testing.T) {
 	})
 	t.Run("SchemaApply", func(t *testing.T) {
 		tidbRun(t, func(t *myTest) {
-			t.dropDB("test2")
+			t.dropSchemas("test2")
 			t.dropTables("users")
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
diff --git a/sql/sqlclient/client_test.go b/sql/sqlclient/client_test.go
index 548826fd..4834100b 100644
--- a/sql/sqlclient/client_test.go
+++ b/sql/sqlclient/client_test.go
@@ -23,9 +23,9 @@ func TestRegisterOpen(t *testing.T) {
 			return c, nil
 		}),
 		sqlclient.RegisterFlavours("maria"),
-		sqlclient.RegisterURLParser(func(u *url.URL) *sqlclient.URL {
+		sqlclient.RegisterURLParser(sqlclient.URLParserFunc(func(u *url.URL) *sqlclient.URL {
 			return &sqlclient.URL{URL: u, DSN: "dsn", Schema: "schema"}
-		}),
+		})),
 	)
 	require.PanicsWithValue(
 		t,