Repository: ariga/atlas
Pull request #742 (2022-04-27 17:57:52)

sql/sqlclient: initial work for sqlclient
This is the first step in the refactoring of cmd/actions. Docker code and rest of the API exists in cmd/actions will be moved to the sqlclient package. 


Patch:
diff --git a/cmd/action/apply.go b/cmd/action/apply.go
index 8cf76395..dda2ed58 100644
--- a/cmd/action/apply.go
+++ b/cmd/action/apply.go
@@ -11,6 +11,7 @@ import (
 	"strings"
 
 	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
 
 	"github.com/manifoldco/promptui"
 	"github.com/spf13/cobra"
@@ -78,26 +79,26 @@ func CmdApplyRun(cmd *cobra.Command, _ []string) {
 		schemaCmd.PrintErrln("The Atlas UI is not available in this release.")
 		return
 	}
-	d, err := DefaultMux.OpenAtlas(cmd.Context(), ApplyFlags.URL)
+	c, err := sqlclient.Open(cmd.Context(), ApplyFlags.URL)
 	cobra.CheckErr(err)
-	defer d.Close()
-	applyRun(cmd.Context(), d, ApplyFlags.URL, ApplyFlags.File, ApplyFlags.DryRun, ApplyFlags.AutoApprove, ApplyFlags.Verbose)
+	defer c.Close()
+	applyRun(cmd.Context(), c, ApplyFlags.URL, ApplyFlags.File, ApplyFlags.DryRun, ApplyFlags.AutoApprove)
 }
 
-func applyRun(ctx context.Context, d *Driver, url string, file string, dryRun bool, autoApprove bool, verbose bool) {
+func applyRun(ctx context.Context, client *sqlclient.Client, url string, file string, dryRun bool, autoApprove bool) {
 	schemas := ApplyFlags.Schema
 	if n, err := SchemaNameFromURL(ctx, url); n != "" {
 		cobra.CheckErr(err)
 		schemas = append(schemas, n)
 	}
-	realm, err := d.InspectRealm(ctx, &schema.InspectRealmOption{
+	realm, err := client.InspectRealm(ctx, &schema.InspectRealmOption{
 		Schemas: schemas,
 	})
 	cobra.CheckErr(err)
 	f, err := ioutil.ReadFile(file)
 	cobra.CheckErr(err)
 	desired := &schema.Realm{}
-	cobra.CheckErr(d.UnmarshalSpec(f, desired))
+	cobra.CheckErr(client.UnmarshalSpec(f, desired))
 	if len(schemas) > 0 {
 		// Validate all schemas in file were selected by user.
 		sm := make(map[string]bool, len(schemas))
@@ -111,24 +112,20 @@ func applyRun(ctx context.Context, d *Driver, url string, file string, dryRun bo
 			}
 		}
 	}
-	if _, ok := d.Driver.(schema.Normalizer); ok && ApplyFlags.DevURL != "" {
-		var opts []ProviderOption
-		if verbose {
-			opts = append(opts, &VerboseLogging{})
-		}
-		dev, err := DefaultMux.OpenAtlas(ctx, ApplyFlags.DevURL, opts...)
+	if _, ok := client.Driver.(schema.Normalizer); ok && ApplyFlags.DevURL != "" {
+		dev, err := sqlclient.Open(ctx, ApplyFlags.DevURL)
 		cobra.CheckErr(err)
-		defer d.Close()
+		defer dev.Close()
 		desired, err = dev.Driver.(schema.Normalizer).NormalizeRealm(ctx, desired)
 		cobra.CheckErr(err)
 	}
-	changes, err := d.RealmDiff(realm, desired)
+	changes, err := client.RealmDiff(realm, desired)
 	cobra.CheckErr(err)
 	if len(changes) == 0 {
 		schemaCmd.Println("Schema is synced, no changes to be made")
 		return
 	}
-	p, err := d.PlanChanges(ctx, "plan", changes)
+	p, err := client.PlanChanges(ctx, "plan", changes)
 	cobra.CheckErr(err)
 	schemaCmd.Println("-- Planned Changes:")
 	for _, c := range p.Changes {
@@ -141,7 +138,7 @@ func applyRun(ctx context.Context, d *Driver, url string, file string, dryRun bo
 		return
 	}
 	if autoApprove || promptUser() {
-		cobra.CheckErr(d.ApplyChanges(ctx, changes))
+		cobra.CheckErr(client.ApplyChanges(ctx, changes))
 	}
 }
 
diff --git a/cmd/action/diff.go b/cmd/action/diff.go
index acfcb4c7..20d930d9 100644
--- a/cmd/action/diff.go
+++ b/cmd/action/diff.go
@@ -7,6 +7,8 @@ package action
 import (
 	"strings"
 
+	"ariga.io/atlas/sql/sqlclient"
+
 	"github.com/spf13/cobra"
 )
 
@@ -44,19 +46,19 @@ func init() {
 // the "from" schema to the "to" schema.
 func cmdDiffRun(cmd *cobra.Command, flags *diffCmdOpts) {
 	ctx := cmd.Context()
-	fromDriver, err := DefaultMux.OpenAtlas(cmd.Context(), flags.fromURL)
+	fromC, err := sqlclient.Open(cmd.Context(), flags.fromURL)
 	cobra.CheckErr(err)
-	defer fromDriver.Close()
-	toDriver, err := DefaultMux.OpenAtlas(cmd.Context(), flags.toURL)
+	defer fromC.Close()
+	toC, err := sqlclient.Open(cmd.Context(), flags.toURL)
 	cobra.CheckErr(err)
-	defer toDriver.Close()
+	defer toC.Close()
 	fromName, err := SchemaNameFromURL(ctx, flags.fromURL)
 	cobra.CheckErr(err)
 	toName, err := SchemaNameFromURL(ctx, flags.toURL)
 	cobra.CheckErr(err)
-	fromSchema, err := fromDriver.InspectSchema(ctx, fromName, nil)
+	fromSchema, err := fromC.InspectSchema(ctx, fromName, nil)
 	cobra.CheckErr(err)
-	toSchema, err := toDriver.InspectSchema(ctx, toName, nil)
+	toSchema, err := toC.InspectSchema(ctx, toName, nil)
 	cobra.CheckErr(err)
 	// SchemaDiff checks for name equality which is irrelevant in the case
 	// the user wants to compare their contents, if the names are different
@@ -65,9 +67,9 @@ func cmdDiffRun(cmd *cobra.Command, flags *diffCmdOpts) {
 		toSchema.Name = ""
 		fromSchema.Name = ""
 	}
-	diff, err := toDriver.SchemaDiff(fromSchema, toSchema)
+	diff, err := toC.SchemaDiff(fromSchema, toSchema)
 	cobra.CheckErr(err)
-	p, err := toDriver.PlanChanges(ctx, "plan", diff)
+	p, err := toC.PlanChanges(ctx, "plan", diff)
 	cobra.CheckErr(err)
 	if len(p.Changes) == 0 {
 		cmd.Println("Schemas are synced, no changes to be made.")
diff --git a/cmd/action/docker.go b/cmd/action/docker.go
index 591a46aa..a5c1d042 100644
--- a/cmd/action/docker.go
+++ b/cmd/action/docker.go
@@ -14,12 +14,16 @@ import (
 	"io/ioutil"
 	"log"
 	"net"
+	"net/url"
 	"os"
 	"os/exec"
 	"strconv"
 	"strings"
 	"time"
 
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/sqlclient"
+
 	mysqld "github.com/go-sql-driver/mysql"
 )
 
@@ -195,8 +199,8 @@ func (c *DockerConfig) Run(ctx context.Context) (*Container, error) {
 	}, nil
 }
 
-// Down stops and removes this container.
-func (c *Container) Down() error {
+// Close stops and removes this container.
+func (c *Container) Close() error {
 	return exec.Command("docker", "stop", c.ID).Run() //nolint:gosec
 }
 
@@ -296,6 +300,58 @@ func freePort() (string, error) {
 	if err != nil {
 		return "", err
 	}
-	l.Close()
+	if err := l.Close(); err != nil {
+		return "", err
+	}
 	return strconv.Itoa(l.Addr().(*net.TCPAddr).Port), nil
 }
+
+func init() {
+	sqlclient.Register("docker", sqlclient.OpenerFunc(dockerClient))
+}
+
+func dockerClient(ctx context.Context, u *url.URL) (client *sqlclient.Client, err error) {
+	var cfg *DockerConfig
+	switch img, tag := u.Host, strings.TrimPrefix(u.Path, "/"); img {
+	case "mysql", "tidb":
+		cfg, err = MySQL(tag)
+	case "mariadb":
+		cfg, err = MariaDB(tag)
+	case "postgres":
+		cfg, err = PostgreSQL(tag)
+	default:
+		return nil, fmt.Errorf("unsupported docker image %q", img)
+	}
+	if err != nil {
+		return nil, err
+	}
+	c, err := cfg.Run(ctx)
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		if err != nil {
+			if cerr := c.Close(); err != nil {
+				err = fmt.Errorf("%w: %v", err, cerr)
+			}
+		}
+	}()
+	if err = c.Wait(ctx, time.Minute); err != nil {
+		return nil, err
+	}
+	u1, err := c.URL()
+	if err != nil {
+		return nil, err
+	}
+	if client, err = sqlclient.Open(ctx, u1); err != nil {
+		return nil, err
+	}
+	client.Driver = struct {
+		io.Closer
+		migrate.Driver
+	}{
+		Closer: c,
+		Driver: client.Driver,
+	}
+	return client, nil
+}
diff --git a/cmd/action/inspect.go b/cmd/action/inspect.go
index cb496272..dabd5279 100644
--- a/cmd/action/inspect.go
+++ b/cmd/action/inspect.go
@@ -8,6 +8,7 @@ import (
 	"context"
 
 	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
 
 	"github.com/spf13/cobra"
 )
@@ -61,24 +62,24 @@ func CmdInspectRun(cmd *cobra.Command, _ []string) {
 		schemaCmd.PrintErrln("The Atlas UI is not available in this release.")
 		return
 	}
-	d, err := DefaultMux.OpenAtlas(cmd.Context(), InspectFlags.URL)
+	d, err := sqlclient.Open(cmd.Context(), InspectFlags.URL)
 	cobra.CheckErr(err)
 	defer d.Close()
 	inspectRun(cmd.Context(), d, InspectFlags.URL)
 }
 
-func inspectRun(ctx context.Context, d *Driver, url string) {
+func inspectRun(ctx context.Context, client *sqlclient.Client, url string) {
 	schemas := InspectFlags.Schema
 	n, err := SchemaNameFromURL(ctx, url)
 	cobra.CheckErr(err)
 	if n != "" {
 		schemas = append(schemas, n)
 	}
-	s, err := d.InspectRealm(ctx, &schema.InspectRealmOption{
+	s, err := client.InspectRealm(ctx, &schema.InspectRealmOption{
 		Schemas: schemas,
 	})
 	cobra.CheckErr(err)
-	ddl, err := d.MarshalSpec(s)
+	ddl, err := client.MarshalSpec(s)
 	cobra.CheckErr(err)
 	schemaCmd.Print(string(ddl))
 }
diff --git a/cmd/action/migrate.go b/cmd/action/migrate.go
index 429efe8d..80eb5a40 100644
--- a/cmd/action/migrate.go
+++ b/cmd/action/migrate.go
@@ -16,6 +16,7 @@ import (
 
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
 
 	"github.com/spf13/cobra"
 )
@@ -121,12 +122,8 @@ func init() {
 
 // CmdMigrateDiffRun is the command executed when running the CLI with 'migrate diff' args.
 func CmdMigrateDiffRun(cmd *cobra.Command, args []string) error {
-	var opts []ProviderOption
-	if MigrateFlags.Verbose {
-		opts = append(opts, &VerboseLogging{})
-	}
 	// Open a dev driver.
-	dev, err := DefaultMux.OpenAtlas(cmd.Context(), MigrateFlags.DevURL, opts...)
+	dev, err := sqlclient.Open(cmd.Context(), MigrateFlags.DevURL)
 	if err != nil {
 		return err
 	}
@@ -201,7 +198,7 @@ func dir() (migrate.Dir, error) {
 }
 
 // to returns a migrate.StateReader for the given to flag.
-func to(ctx context.Context, d *Driver) (migrate.StateReader, error) {
+func to(ctx context.Context, client *sqlclient.Client) (migrate.StateReader, error) {
 	parts := strings.SplitN(MigrateFlags.ToURL, "://", 2)
 	if len(parts) != 2 {
 		return nil, fmt.Errorf("invalid driver url %q", MigrateFlags.ToURL)
@@ -220,7 +217,7 @@ func to(ctx context.Context, d *Driver) (migrate.StateReader, error) {
 			return nil, err
 		}
 		realm := &schema.Realm{}
-		if err := d.UnmarshalSpec(f, realm); err != nil {
+		if err := client.UnmarshalSpec(f, realm); err != nil {
 			return nil, err
 		}
 		if len(schemas) > 0 {
@@ -235,7 +232,7 @@ func to(ctx context.Context, d *Driver) (migrate.StateReader, error) {
 				}
 			}
 		}
-		if norm, ok := d.Driver.(schema.Normalizer); ok {
+		if norm, ok := client.Driver.(schema.Normalizer); ok {
 			realm, err = norm.NormalizeRealm(ctx, realm)
 			if err != nil {
 				return nil, err
@@ -243,13 +240,16 @@ func to(ctx context.Context, d *Driver) (migrate.StateReader, error) {
 		}
 		return migrate.Realm(realm), nil
 	default: // database connection
-		drv, err := DefaultMux.OpenAtlas(ctx, MigrateFlags.ToURL)
+		client, err := sqlclient.Open(ctx, MigrateFlags.ToURL)
 		if err != nil {
 			return nil, err
 		}
-		return &stateReadCloser{
-			StateReader: migrate.Conn(drv, &schema.InspectRealmOption{Schemas: schemas}),
-			drv:         drv,
+		return struct {
+			migrate.StateReader
+			io.Closer
+		}{
+			Closer:      client,
+			StateReader: migrate.Conn(client, &schema.InspectRealmOption{Schemas: schemas}),
 		}, nil
 	}
 }
@@ -271,12 +271,3 @@ var (
 		"{{ range .Changes }}{{ with .Comment }}-- {{ println . }}{{ end }}{{ println (sem .Cmd) }}{{ end }}",
 	))
 )
-
-type stateReadCloser struct {
-	migrate.StateReader
-	drv *Driver
-}
-
-func (s *stateReadCloser) Close() error {
-	return s.drv.Close()
-}
diff --git a/cmd/action/mux.go b/cmd/action/mux.go
index 39972e04..5ce6d668 100644
--- a/cmd/action/mux.go
+++ b/cmd/action/mux.go
@@ -8,85 +8,21 @@ import (
 	"context"
 	"database/sql"
 	"fmt"
-	"io"
 	"net/url"
 	"os"
 	"path/filepath"
 	"regexp"
 	"strings"
 
-	"ariga.io/atlas/schema/schemaspec"
-	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/sqlite"
 
 	"github.com/go-sql-driver/mysql"
 )
 
-type (
-	// Mux is used for routing URLs to their correct provider.
-	Mux struct {
-		providers map[string]Provider
-	}
-
-	// Driver implements the Atlas interface.
-	Driver struct {
-		migrate.Driver
-		schemaspec.Marshaler
-		schemaspec.Unmarshaler
-		io.Closer
-	}
-
-	// Provider creates a Driver from a given URL and a set of options.
-	Provider func(context.Context, string, ...ProviderOption) (*Driver, error)
-
-	// ProviderOption is an interface to mark custom provider configuration.
-	ProviderOption interface{ providerOption() }
-)
-
-// NewMux returns a new Mux.
-func NewMux() *Mux {
-	return &Mux{
-		providers: make(map[string]Provider),
-	}
-}
-
 var (
-	// DefaultMux is the default Mux that is used by the different commands.
-	DefaultMux = NewMux()
-	reMemMode  = regexp.MustCompile(":memory:|^file:.*mode=memory.*")
+	reMemMode = regexp.MustCompile(":memory:|^file:.*mode=memory.*")
 )
 
-// RegisterProvider is used to register a Driver provider by key.
-func (u *Mux) RegisterProvider(key string, p Provider) {
-	if _, ok := u.providers[key]; ok {
-		panic("provider is already initialized")
-	}
-	u.providers[key] = p
-}
-
-// OpenAtlas is used for opening an atlas driver on a specific data source.
-func (u *Mux) OpenAtlas(ctx context.Context, url string, opts ...ProviderOption) (*Driver, error) {
-	key, dsn, err := urlParts(url)
-	if err != nil {
-		return nil, fmt.Errorf("failed to init atlas driver, %s", err)
-	}
-	p, ok := u.providers[key]
-	if !ok {
-		return nil, fmt.Errorf("could not find provider: %s", key)
-	}
-	if key == "sqlite" {
-		if err := sqliteFileExists(dsn); err != nil {
-			return nil, err
-		}
-	}
-	return p(ctx, dsn, opts...)
-}
-
-// VerboseLogging is a ProviderOption passed to the different providers to enable more verbose logging.
-type VerboseLogging struct{}
-
-func (VerboseLogging) providerOption() {}
-
 func urlParts(url string) (string, string, error) {
 	a := strings.SplitN(url, "://", 2)
 	if len(a) != 2 {
diff --git a/cmd/action/provider.go b/cmd/action/provider.go
deleted file mode 100644
index eb56b483..00000000
--- a/cmd/action/provider.go
+++ /dev/null
@@ -1,161 +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 action
-
-import (
-	"context"
-	"database/sql"
-	"fmt"
-	"os"
-	"regexp"
-	"time"
-
-	"ariga.io/atlas/sql/mysql"
-	"ariga.io/atlas/sql/postgres"
-	"ariga.io/atlas/sql/sqlite"
-)
-
-func init() {
-	DefaultMux.RegisterProvider("mysql", mysqlProvider)
-	DefaultMux.RegisterProvider("maria", mysqlProvider)
-	DefaultMux.RegisterProvider("mariadb", mysqlProvider)
-	DefaultMux.RegisterProvider("postgres", postgresProvider)
-	DefaultMux.RegisterProvider("sqlite", sqliteProvider)
-	DefaultMux.RegisterProvider("docker", dockerProvider)
-}
-
-func mysqlProvider(_ context.Context, dsn string, _ ...ProviderOption) (*Driver, error) {
-	cfg, err := mysqlConfig(dsn)
-	if err != nil {
-		return nil, err
-	}
-	db, err := sql.Open("mysql", cfg.FormatDSN())
-	if err != nil {
-		return nil, err
-	}
-	drv, err := mysql.Open(db)
-	if err != nil {
-		return nil, err
-	}
-	return &Driver{
-		Driver:      drv,
-		Marshaler:   mysql.MarshalHCL,
-		Unmarshaler: mysql.UnmarshalHCL,
-		Closer:      db,
-	}, nil
-}
-
-func postgresProvider(_ context.Context, dsn string, _ ...ProviderOption) (*Driver, error) {
-	u := "postgres://" + dsn
-	db, err := sql.Open("postgres", u)
-	if err != nil {
-		return nil, err
-	}
-	drv, err := postgres.Open(db)
-	if err != nil {
-		return nil, err
-	}
-	return &Driver{
-		Driver:      drv,
-		Marshaler:   postgres.MarshalHCL,
-		Unmarshaler: postgres.UnmarshalHCL,
-		Closer:      db,
-	}, nil
-}
-
-func sqliteProvider(_ context.Context, dsn string, _ ...ProviderOption) (*Driver, error) {
-	db, err := sql.Open("sqlite3", dsn)
-	if err != nil {
-		return nil, err
-	}
-	drv, err := sqlite.Open(db)
-	if err != nil {
-		return nil, err
-	}
-	return &Driver{
-		Driver:      drv,
-		Marshaler:   sqlite.MarshalHCL,
-		Unmarshaler: sqlite.UnmarshalHCL,
-		Closer:      db,
-	}, nil
-}
-
-var reDockerConfig = regexp.MustCompile("(mysql|mariadb|tidb|postgres)(?::([0-9a-zA-Z.-]+)?(\\?.*)?)?")
-
-type dockerCloser struct {
-	c   *Container
-	drv *Driver
-}
-
-func (dc *dockerCloser) Close() (err error) {
-	derr, cerr := dc.drv.Close(), dc.c.Down()
-	if derr != nil {
-		err = derr
-	}
-	if cerr != nil {
-		err = fmt.Errorf("%w: %v", derr, cerr)
-	}
-	return
-}
-
-func dockerProvider(ctx context.Context, dsn string, opts ...ProviderOption) (*Driver, error) {
-	// The DSN has the driver part (docker:// removed already.
-	// Get rid of the query arguments, and we have the image name.
-	m := reDockerConfig.FindStringSubmatch(dsn)
-	if len(m) != 4 {
-		return nil, fmt.Errorf("invalid docker url %q", dsn)
-	}
-	img, v := m[1], m[2]
-	var (
-		cfg *DockerConfig
-		err error
-	)
-	switch img {
-	case "mysql":
-		cfg, err = MySQL(v)
-	case "mariadb":
-		cfg, err = MariaDB(v)
-	case "postgres":
-		cfg, err = PostgreSQL(v)
-	default:
-		return nil, fmt.Errorf("unsupported docker image %q", img)
-	}
-	if err != nil {
-		return nil, err
-	}
-	for _, opt := range opts {
-		switch opt.(type) {
-		case VerboseLogging, *VerboseLogging:
-			err = Out(os.Stdout)(cfg)
-		}
-		if err != nil {
-			return nil, err
-		}
-	}
-	c, err := cfg.Run(ctx)
-	if err != nil {
-		return nil, err
-	}
-	if err := c.Wait(ctx, time.Minute); err != nil {
-		_ = c.Down()
-		return nil, err
-	}
-	u, err := c.URL()
-	if err != nil {
-		_ = c.Down()
-		return nil, err
-	}
-	drv, err := DefaultMux.OpenAtlas(ctx, u)
-	if err != nil {
-		_ = c.Down()
-		return nil, err
-	}
-	return &Driver{
-		Driver:      drv.Driver,
-		Marshaler:   drv.Marshaler,
-		Unmarshaler: drv.Unmarshaler,
-		Closer:      &dockerCloser{c, drv},
-	}, nil
-}
diff --git a/cmd/atlas/main.go b/cmd/atlas/main.go
index bc0a0580..d49abfcd 100644
--- a/cmd/atlas/main.go
+++ b/cmd/atlas/main.go
@@ -6,6 +6,9 @@ import (
 	"os/signal"
 
 	"ariga.io/atlas/cmd/action"
+	_ "ariga.io/atlas/sql/mysql"
+	_ "ariga.io/atlas/sql/postgres"
+	_ "ariga.io/atlas/sql/sqlite"
 
 	_ "github.com/go-sql-driver/mysql"
 	_ "github.com/lib/pq"
diff --git a/doc/md/cli/url.mdx b/doc/md/cli/url.mdx
index 5db7433d..43a01d36 100644
--- a/doc/md/cli/url.mdx
+++ b/doc/md/cli/url.mdx
@@ -81,9 +81,9 @@ consider spinning up a longer lived container by yourself.
 ```
 docker://postgres
 
-docker://mysql:8
+docker://mysql/8
 
-docker://mariadb:10.8.2-rc-focal
+docker://mariadb/10.8.2-rc-focal
 ```
 
 </TabItem>
diff --git a/sql/mysql/driver.go b/sql/mysql/driver.go
index 41d44868..0398b487 100644
--- a/sql/mysql/driver.go
+++ b/sql/mysql/driver.go
@@ -7,12 +7,14 @@ package mysql
 import (
 	"context"
 	"fmt"
+	"net/url"
 	"strings"
 	"time"
 
 	"ariga.io/atlas/sql/internal/sqlx"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
 
 	"golang.org/x/mod/semver"
 )
@@ -37,8 +39,17 @@ type (
 	}
 )
 
+func init() {
+	sqlclient.Register(
+		"mysql",
+		sqlclient.DriverOpener(Open, dsn),
+		sqlclient.RegisterCodec(MarshalHCL, UnmarshalHCL),
+		sqlclient.RegisterFlavours("maria", "mariadb"),
+	)
+}
+
 // Open opens a new MySQL driver.
-func Open(db schema.ExecQuerier) (*Driver, error) {
+func Open(db schema.ExecQuerier) (migrate.Driver, error) {
 	c := conn{ExecQuerier: db}
 	rows, err := db.QueryContext(context.Background(), variablesQuery)
 	if err != nil {
@@ -222,6 +233,35 @@ func unescape(s string) string {
 	return b.String()
 }
 
+// dsn returns the MySQL standard DSN for opening
+// the sql.DB from the user provided URL.
+func dsn(u *url.URL) string {
+	var b strings.Builder
+	b.WriteString(u.User.Username())
+	if p, ok := u.User.Password(); ok {
+		b.WriteByte(':')
+		b.WriteString(p)
+	}
+	if b.Len() > 0 {
+		b.WriteByte('@')
+	}
+	if u.Host != "" {
+		b.WriteString("tcp(")
+		b.WriteString(u.Host)
+		b.WriteByte(')')
+	}
+	if u.Path != "" {
+		b.WriteString(u.Path)
+	} else {
+		b.WriteByte('/')
+	}
+	if u.RawQuery != "" {
+		b.WriteByte('?')
+		b.WriteString(u.RawQuery)
+	}
+	return b.String()
+}
+
 // MySQL standard column types as defined in its codebase. Name and order
 // is organized differently than MySQL.
 //
diff --git a/sql/postgres/driver.go b/sql/postgres/driver.go
index 5f347ccf..10323ef1 100644
--- a/sql/postgres/driver.go
+++ b/sql/postgres/driver.go
@@ -9,11 +9,13 @@ import (
 	"errors"
 	"fmt"
 	"hash/fnv"
+	"net/url"
 	"time"
 
 	"ariga.io/atlas/sql/internal/sqlx"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
 
 	"golang.org/x/mod/semver"
 )
@@ -38,8 +40,18 @@ type (
 	}
 )
 
+func init() {
+	sqlclient.Register(
+		"postgres",
+		sqlclient.DriverOpener(Open, func(u *url.URL) string {
+			return u.String()
+		}),
+		sqlclient.RegisterCodec(MarshalHCL, UnmarshalHCL),
+	)
+}
+
 // Open opens a new PostgreSQL driver.
-func Open(db schema.ExecQuerier) (*Driver, error) {
+func Open(db schema.ExecQuerier) (migrate.Driver, error) {
 	c := conn{ExecQuerier: db}
 	rows, err := db.QueryContext(context.Background(), paramsQuery)
 	if err != nil {
diff --git a/sql/sqlclient/client.go b/sql/sqlclient/client.go
new file mode 100644
index 00000000..c7c45ea1
--- /dev/null
+++ b/sql/sqlclient/client.go
@@ -0,0 +1,176 @@
+// 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 sqlclient
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"io"
+	"net/url"
+	"sync"
+
+	"ariga.io/atlas/sql/schema"
+
+	"ariga.io/atlas/schema/schemaspec"
+	"ariga.io/atlas/sql/migrate"
+)
+
+// Client provides the common functionalities for working with Atlas from different
+// applications (e.g. CLI and TF). Note, the Client is dialect specific and should
+// be instantiated using a call to Open.
+type Client struct {
+	// DB used for creating the client.
+	DB *sql.DB
+
+	// A migration driver for the attached dialect.
+	migrate.Driver
+
+	// Marshal and Unmarshal functions for decoding
+	// and encoding the schema documents.
+	schemaspec.Marshaler
+	schemaspec.Unmarshaler
+}
+
+// Close closes the underlying database connection and the migration
+// driver in case it implements the io.Closer interface.
+func (c *Client) Close() (err error) {
+	if c, ok := c.Driver.(io.Closer); ok {
+		err = c.Close()
+	}
+	if cerr := c.DB.Close(); cerr != nil {
+		if err != nil {
+			cerr = fmt.Errorf("%w: %v", err, cerr)
+		}
+		err = cerr
+	}
+	return err
+}
+
+type (
+	// Opener opens a migration driver by the given URL.
+	Opener interface {
+		Open(ctx context.Context, u *url.URL) (*Client, error)
+	}
+
+	// OpenerFunc allows using a function as an Opener.
+	OpenerFunc func(context.Context, *url.URL) (*Client, error)
+
+	namedOpener struct {
+		Opener
+		name string
+	}
+)
+
+// Open calls f(ctx, u).
+func (f OpenerFunc) Open(ctx context.Context, u *url.URL) (*Client, error) {
+	return f(ctx, u)
+}
+
+var drivers sync.Map
+
+// Open opens an Atlas client by its provided url string.
+func Open(ctx context.Context, s string) (*Client, error) {
+	u, err := url.Parse(s)
+	if err != nil {
+		return nil, fmt.Errorf("sql/sqlclient: parse open url: %w", err)
+	}
+	v, ok := drivers.Load(u.Scheme)
+	if !ok {
+		return nil, fmt.Errorf("sql/sqlclient: no opener was register with name %q", u.Scheme)
+	}
+	return v.(namedOpener).Open(ctx, u)
+}
+
+type (
+	registerOptions struct {
+		flavours []string
+		codec    interface {
+			schemaspec.Marshaler
+			schemaspec.Unmarshaler
+		}
+	}
+	// RegisterOption allows configuring the Opener
+	// registration using functional options.
+	RegisterOption func(*registerOptions)
+)
+
+// RegisterFlavours allows registering additional flavours
+// (i.e. names), accepted by Atlas to open clients.
+func RegisterFlavours(flavours ...string) RegisterOption {
+	return func(opts *registerOptions) {
+		opts.flavours = flavours
+	}
+}
+
+// RegisterCodec registers static codec for attaching into
+// the client after it is opened.
+func RegisterCodec(m schemaspec.Marshaler, u schemaspec.Unmarshaler) RegisterOption {
+	return func(opts *registerOptions) {
+		opts.codec = struct {
+			schemaspec.Marshaler
+			schemaspec.Unmarshaler
+		}{
+			Marshaler:   m,
+			Unmarshaler: u,
+		}
+	}
+}
+
+// DriverOpener is a helper Opener creator for sharing between all drivers.
+func DriverOpener(open func(schema.ExecQuerier) (migrate.Driver, error), dsn func(*url.URL) string) Opener {
+	return OpenerFunc(func(ctx context.Context, u *url.URL) (*Client, error) {
+		v, ok := drivers.Load(u.Scheme)
+		if !ok {
+			return nil, fmt.Errorf("sql/sqlclient: unexpected missing opener %q", u.Scheme)
+		}
+		db, err := sql.Open(v.(namedOpener).name, dsn(u))
+		if err != nil {
+			return nil, err
+		}
+		drv, err := open(db)
+		if err != nil {
+			if cerr := db.Close(); cerr != nil {
+				err = fmt.Errorf("%w: %v", err, cerr)
+			}
+			return nil, err
+		}
+		return &Client{
+			DB:     db,
+			Driver: drv,
+		}, nil
+	})
+}
+
+// Register registers a client Opener (i.e. creator) with the given name.
+func Register(name string, opener Opener, opts ...RegisterOption) {
+	if opener == nil {
+		panic("sql/sqlclient: Register opener is nil")
+	}
+	opt := &registerOptions{}
+	for i := range opts {
+		opts[i](opt)
+	}
+	if opt.codec != nil {
+		f := opener
+		opener = OpenerFunc(func(ctx context.Context, u *url.URL) (*Client, error) {
+			c, err := f.Open(ctx, u)
+			if err != nil {
+				return nil, err
+			}
+			c.Marshaler, c.Unmarshaler = opt.codec, opt.codec
+			return c, nil
+		})
+	}
+	for _, f := range append(opt.flavours, name) {
+		if _, ok := drivers.Load(f); ok {
+			panic("sql/sqlclient: Register called twice for " + f)
+		}
+		drivers.Store(f, namedOpener{
+			name:   name,
+			Opener: opener,
+		})
+	}
+}
diff --git a/sql/sqlite/driver.go b/sql/sqlite/driver.go
index 2cd6cd1b..e2bbc554 100644
--- a/sql/sqlite/driver.go
+++ b/sql/sqlite/driver.go
@@ -7,10 +7,13 @@ package sqlite
 import (
 	"context"
 	"fmt"
+	"net/url"
+	"strings"
 
 	"ariga.io/atlas/sql/internal/sqlx"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
 )
 
 type (
@@ -33,8 +36,19 @@ type (
 	}
 )
 
+func init() {
+	sqlclient.Register(
+		"sqlite3",
+		sqlclient.DriverOpener(Open, func(u *url.URL) string {
+			return strings.TrimPrefix(u.String(), u.Scheme+"://")
+		}),
+		sqlclient.RegisterCodec(MarshalHCL, UnmarshalHCL),
+		sqlclient.RegisterFlavours("sqlite"),
+	)
+}
+
 // Open opens a new SQLite driver.
-func Open(db schema.ExecQuerier) (*Driver, error) {
+func Open(db schema.ExecQuerier) (migrate.Driver, error) {
 	var (
 		c   = conn{ExecQuerier: db}
 		ctx = context.Background()


Test patch:
diff --git a/cmd/action/diff_test.go b/cmd/action/diff_test.go
index 41a27087..729eaee0 100644
--- a/cmd/action/diff_test.go
+++ b/cmd/action/diff_test.go
@@ -15,6 +15,7 @@ import (
 
 	"ariga.io/atlas/sql/sqlite"
 
+	_ "github.com/mattn/go-sqlite3"
 	"github.com/spf13/cobra"
 	"github.com/stretchr/testify/require"
 )
diff --git a/cmd/action/migrate_test.go b/cmd/action/migrate_test.go
index 16046481..3dd7de8b 100644
--- a/cmd/action/migrate_test.go
+++ b/cmd/action/migrate_test.go
@@ -10,6 +10,7 @@ import (
 	"fmt"
 	"io"
 	"io/ioutil"
+	"net/url"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -19,6 +20,10 @@ import (
 
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
+	_ "ariga.io/atlas/sql/sqlite"
+
+	_ "github.com/mattn/go-sqlite3"
 	"github.com/stretchr/testify/require"
 )
 
@@ -55,14 +60,14 @@ func TestMigrate_Diff(t *testing.T) {
 	require.FileExists(t, filepath.Join(p, "atlas.sum"))
 
 	// A lock will prevent diffing.
-	DefaultMux.RegisterProvider("sqlitelock", func(ctx context.Context, s string, _ ...ProviderOption) (*Driver, error) {
-		drv, err := sqliteProvider(ctx, s)
+	sqlclient.Register("sqlitelock", sqlclient.OpenerFunc(func(ctx context.Context, u *url.URL) (*sqlclient.Client, error) {
+		client, err := sqlclient.Open(ctx, strings.Replace(u.String(), u.Scheme, "sqlite", 1))
 		if err != nil {
 			return nil, err
 		}
-		drv.Driver = &sqliteLockerDriver{Driver: drv.Driver}
-		return drv, nil
-	})
+		client.Driver = &sqliteLockerDriver{Driver: client.Driver}
+		return client, nil
+	}))
 	f, err := os.Create(filepath.Join(p, "test.db"))
 	require.NoError(t, err)
 	require.NoError(t, f.Close())
diff --git a/cmd/action/mux_test.go b/cmd/action/mux_test.go
index 1ed100c5..02932d8e 100644
--- a/cmd/action/mux_test.go
+++ b/cmd/action/mux_test.go
@@ -6,54 +6,16 @@ package action_test
 
 import (
 	"context"
-	"fmt"
 	"io/ioutil"
-	"log"
-	"net"
 	"os"
-	"sync/atomic"
 	"testing"
 
 	"ariga.io/atlas/cmd/action"
 
-	mysqld "github.com/go-sql-driver/mysql"
 	_ "github.com/mattn/go-sqlite3"
 	"github.com/stretchr/testify/require"
 )
 
-func Test_ProviderNotSupported(t *testing.T) {
-	u := action.NewMux()
-	_, err := u.OpenAtlas(context.Background(), "fake://open")
-	require.Error(t, err)
-}
-
-func Test_RegisterProvider(t *testing.T) {
-	u := action.NewMux()
-	p := func(context.Context, string, ...action.ProviderOption) (*action.Driver, error) { return nil, nil }
-	require.NotPanics(t, func() { u.RegisterProvider("key", p) })
-}
-
-func Test_RegisterTwiceSameKeyFails(t *testing.T) {
-	u := action.NewMux()
-	p := func(context.Context, string, ...action.ProviderOption) (*action.Driver, error) { return nil, nil }
-	require.NotPanics(t, func() { u.RegisterProvider("key", p) })
-	require.Panics(t, func() { u.RegisterProvider("key", p) })
-}
-
-func Test_GetDriverFails(t *testing.T) {
-	u := action.NewMux()
-	_, err := u.OpenAtlas(context.Background(), "key://open")
-	require.Error(t, err)
-}
-
-func Test_GetDriverSuccess(t *testing.T) {
-	u := action.NewMux()
-	p := func(context.Context, string, ...action.ProviderOption) (*action.Driver, error) { return nil, nil }
-	u.RegisterProvider("key", p)
-	_, err := u.OpenAtlas(context.Background(), "key://open")
-	require.NoError(t, err)
-}
-
 func Test_SQLiteFileDoestNotExist(t *testing.T) {
 	var tests = []struct {
 		url      string
@@ -163,41 +125,3 @@ func Test_PostgresSchemaDSN(t *testing.T) {
 		})
 	}
 }
-
-func TestMux_OpenAtlas(t *testing.T) {
-	t.Run("MySQL", func(t *testing.T) {
-		for _, u := range []string{
-			"mysql://root:pass@tcp(%s)/",
-			"mysql://root:pass@tcp(%s)/test",
-			"mysql://root:pass@%s",
-			"mysql://root:pass@%s/",
-			"mysql://root:pass@%s/test",
-			"mysql://%s/test",
-		} {
-			calls, l := mockServer(t)
-			require.NoError(t, mysqld.SetLogger(log.New(ioutil.Discard, "", 1)))
-			_, err := action.DefaultMux.OpenAtlas(context.Background(), fmt.Sprintf(u, l.Addr()))
-			require.Error(t, err, "mock server rejects all incoming connections")
-			require.NotZero(t, atomic.LoadInt64(calls))
-		}
-	})
-}
-
-func mockServer(t *testing.T) (*int64, net.Listener) {
-	var (
-		calls  int64
-		l, err = net.Listen("tcp", "localhost:")
-	)
-	require.NoError(t, err)
-	go func() {
-		for {
-			conn, err := l.Accept()
-			if err != nil {
-				return
-			}
-			atomic.AddInt64(&calls, 1)
-			require.NoError(t, conn.Close())
-		}
-	}()
-	return &calls, l
-}
diff --git a/cmd/action/provider_test.go b/cmd/action/provider_test.go
deleted file mode 100644
index 1ef79c04..00000000
--- a/cmd/action/provider_test.go
+++ /dev/null
@@ -1,28 +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 action
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/require"
-)
-
-func Test_ReDockerConfig(t *testing.T) {
-	for _, tt := range []struct{ input, img, version, args string }{
-		{"mysql", "mysql", "", ""},
-		{"mysql:8", "mysql", "8", ""},
-		{"mysql:8.0", "mysql", "8.0", ""},
-		{"mysql:8.0-debian", "mysql", "8.0-debian", ""},
-		{"mysql:8.0-debian?arg1=one&arg2=two", "mysql", "8.0-debian", "?arg1=one&arg2=two"},
-		{"mariadb:10.8.2-rc-focal?port=3000&db=database", "mariadb", "10.8.2-rc-focal", "?port=3000&db=database"},
-	} {
-		t.Run(tt.input, func(t *testing.T) {
-			m := reDockerConfig.FindStringSubmatch(tt.input)
-			require.Len(t, m, 4)
-			require.Equal(t, []string{tt.img, tt.version, tt.args}, m[1:])
-		})
-	}
-}
diff --git a/internal/integration/mysql_test.go b/internal/integration/mysql_test.go
index 46e335fe..cbadba74 100644
--- a/internal/integration/mysql_test.go
+++ b/internal/integration/mysql_test.go
@@ -13,6 +13,7 @@ import (
 	"strings"
 	"testing"
 
+	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/mysql"
 	"ariga.io/atlas/sql/schema"
 
@@ -24,7 +25,7 @@ import (
 type myTest struct {
 	*testing.T
 	db      *sql.DB
-	drv     *mysql.Driver
+	drv     migrate.Driver
 	version string
 	port    int
 }
@@ -488,7 +489,7 @@ func TestMySQL_Ent(t *testing.T) {
 
 func TestMySQL_AdvisoryLock(t *testing.T) {
 	myRun(t, func(t *myTest) {
-		testAdvisoryLock(t.T, t.drv)
+		testAdvisoryLock(t.T, t.drv.(schema.Locker))
 	})
 }
 
@@ -1141,7 +1142,7 @@ func (t *myTest) dsn(dbname string) string {
 	if t.mariadb() {
 		d = "mariadb"
 	}
-	return fmt.Sprintf("%s://root%s@tcp(localhost:%d)/%s", d, pass, t.port, dbname)
+	return fmt.Sprintf("%s://root%s@localhost:%d/%s", d, pass, t.port, dbname)
 }
 
 func (t *myTest) applyHcl(spec string) {
diff --git a/internal/integration/postgres_test.go b/internal/integration/postgres_test.go
index 70fcac64..617894a8 100644
--- a/internal/integration/postgres_test.go
+++ b/internal/integration/postgres_test.go
@@ -13,6 +13,7 @@ import (
 	"strings"
 	"testing"
 
+	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/mysql"
 	"ariga.io/atlas/sql/postgres"
 	"ariga.io/atlas/sql/schema"
@@ -25,7 +26,7 @@ import (
 type pgTest struct {
 	*testing.T
 	db      *sql.DB
-	drv     *postgres.Driver
+	drv     migrate.Driver
 	version string
 	port    int
 }
@@ -409,7 +410,7 @@ func TestPostgres_Ent(t *testing.T) {
 
 func TestPostgres_AdvisoryLock(t *testing.T) {
 	pgRun(t, func(t *pgTest) {
-		testAdvisoryLock(t.T, t.drv)
+		testAdvisoryLock(t.T, t.drv.(schema.Locker))
 	})
 }
 
diff --git a/internal/integration/script_test.go b/internal/integration/script_test.go
index 9976c681..1af395a6 100644
--- a/internal/integration/script_test.go
+++ b/internal/integration/script_test.go
@@ -384,7 +384,7 @@ func (t *myTest) hclDiff(ts *testscript.TestScript, name string) ([]schema.Chang
 	ts.Check(mysql.UnmarshalHCL([]byte(r.Replace(f)), desired))
 	current, err := t.drv.InspectSchema(ctx, desired.Name, nil)
 	ts.Check(err)
-	desired, err = t.drv.NormalizeSchema(ctx, desired)
+	desired, err = t.drv.(schema.Normalizer).NormalizeSchema(ctx, desired)
 	// Normalization and diffing errors should
 	// be returned to the caller.
 	if err != nil {
@@ -414,7 +414,7 @@ func (t *pgTest) hclDiff(ts *testscript.TestScript, name string) ([]schema.Chang
 	ts.Check(postgres.UnmarshalHCL([]byte(f), desired))
 	current, err := t.drv.InspectSchema(ctx, desired.Name, nil)
 	ts.Check(err)
-	desired, err = t.drv.NormalizeSchema(ctx, desired)
+	desired, err = t.drv.(schema.Normalizer).NormalizeSchema(ctx, desired)
 	// Normalization and diffing errors should
 	// be returned to the caller.
 	if err != nil {
diff --git a/internal/integration/sqlite_test.go b/internal/integration/sqlite_test.go
index f78e0c78..6010bc09 100644
--- a/internal/integration/sqlite_test.go
+++ b/internal/integration/sqlite_test.go
@@ -13,6 +13,8 @@ import (
 	"strings"
 	"testing"
 
+	"ariga.io/atlas/sql/migrate"
+
 	"ariga.io/atlas/sql/postgres"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlite"
@@ -25,7 +27,7 @@ import (
 type liteTest struct {
 	*testing.T
 	db   *sql.DB
-	drv  *sqlite.Driver
+	drv  migrate.Driver
 	file string
 }
 
diff --git a/sql/sqlclient/client_test.go b/sql/sqlclient/client_test.go
new file mode 100644
index 00000000..a352c74e
--- /dev/null
+++ b/sql/sqlclient/client_test.go
@@ -0,0 +1,48 @@
+// 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 sqlclient_test
+
+import (
+	"context"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"ariga.io/atlas/sql/sqlclient"
+)
+
+func TestRegisterOpen(t *testing.T) {
+	c := &sqlclient.Client{}
+	sqlclient.Register(
+		"mysql",
+		sqlclient.OpenerFunc(func(ctx context.Context, url *url.URL) (*sqlclient.Client, error) {
+			return c, nil
+		}),
+		sqlclient.RegisterFlavours("maria"),
+	)
+	require.PanicsWithValue(
+		t,
+		"sql/sqlclient: Register opener is nil",
+		func() { sqlclient.Register("mysql", nil) },
+	)
+	require.PanicsWithValue(
+		t,
+		"sql/sqlclient: Register called twice for mysql",
+		func() {
+			sqlclient.Register("mysql", sqlclient.OpenerFunc(func(ctx context.Context, url *url.URL) (*sqlclient.Client, error) {
+				return c, nil
+			}))
+		},
+	)
+	c1, err := sqlclient.Open(context.Background(), "mysql://:3306")
+	require.NoError(t, err)
+	require.True(t, c == c1)
+	c1, err = sqlclient.Open(context.Background(), "maria://:3306")
+	require.NoError(t, err)
+	require.True(t, c == c1)
+	c1, err = sqlclient.Open(context.Background(), "postgres://:3306")
+	require.EqualError(t, err, `sql/sqlclient: no opener was register with name "postgres"`)
+}