feat: postgres migration table existence check ()

pull/865/head
Michael Fridman 2024-11-23 20:31:25 -05:00 committed by GitHub
parent 8d8822685a
commit 48c4946d69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 188 additions and 33 deletions

View File

@ -7,6 +7,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- Store implementations can **optionally** implement the `TableExists` method to provide optimized
table existence checks (#860)
- Default postgres Store implementation updated to use `pg_tables` system catalog, more to follow
- Backward compatible change - existing implementations will continue to work without modification
```go
TableExists(ctx context.Context, db database.DBTxConn) (bool, error)
```
## [v3.23.0]
- Add `WithLogger` to `NewProvider` to allow custom loggers (#833)

View File

@ -53,13 +53,13 @@ func NewStore(dialect Dialect, tablename string) (Store, error) {
}
return &store{
tablename: tablename,
querier: querier,
querier: dialectquery.NewQueryController(querier),
}, nil
}
type store struct {
tablename string
querier dialectquery.Querier
querier *dialectquery.QueryController
}
var _ Store = (*store)(nil)
@ -147,3 +147,26 @@ func (s *store) ListMigrations(
}
return migrations, nil
}
//
//
//
// Additional methods that are not part of the core Store interface, but are extended by the
// [controller.StoreController] type.
//
//
//
func (s *store) TableExists(ctx context.Context, db DBTxConn) (bool, error) {
q := s.querier.TableExists(s.tablename)
if q == "" {
return false, errors.ErrUnsupported
}
var exists bool
// Note, we do not pass the table name as an argument to the query, as the query should be
// pre-defined by the dialect.
if err := db.QueryRowContext(ctx, q).Scan(&exists); err != nil {
return false, fmt.Errorf("failed to check if table exists: %w", err)
}
return exists, nil
}

View File

@ -0,0 +1,33 @@
package database
import "context"
// StoreExtender is an extension of the Store interface that provides optional optimizations and
// database-specific features. While not required by the core goose package, implementing these
// methods can improve performance and functionality for specific databases.
//
// IMPORTANT: This interface may be expanded in future versions. Implementors MUST be prepared to
// update their implementations when new methods are added, either by implementing the new
// functionality or returning [errors.ErrUnsupported].
//
// The goose package handles these extended capabilities through a [controller.StoreController],
// which automatically uses optimized methods when available while falling back to default behavior
// when they're not implemented.
//
// Example usage to verify implementation:
//
// var _ StoreExtender = (*CustomStoreExtended)(nil)
//
// In short, it's exported to allows implementors to have a compile-time check that they are
// implementing the interface correctly.
type StoreExtender interface {
Store
// TableExists checks if the migrations table exists in the database. Implementing this method
// allows goose to optimize table existence checks by using database-specific system catalogs
// (e.g., pg_tables for PostgreSQL, sqlite_master for SQLite) instead of generic SQL queries.
//
// Return [errors.ErrUnsupported] if the database does not provide an efficient way to check
// table existence.
TableExists(ctx context.Context, db DBTxConn) (bool, error)
}

View File

@ -0,0 +1,37 @@
package controller
import (
"context"
"errors"
"github.com/pressly/goose/v3/database"
)
// A StoreController is used by the goose package to interact with a database. This type is a
// wrapper around the Store interface, but can be extended to include additional (optional) methods
// that are not part of the core Store interface.
type StoreController struct{ database.Store }
var _ database.StoreExtender = (*StoreController)(nil)
// NewStoreController returns a new StoreController that wraps the given Store.
//
// If the Store implements the following optional methods, the StoreController will call them as
// appropriate:
//
// - TableExists(context.Context, DBTxConn) (bool, error)
//
// If the Store does not implement a method, it will either return a [errors.ErrUnsupported] error
// or fall back to the default behavior.
func NewStoreController(store database.Store) *StoreController {
return &StoreController{store}
}
func (c *StoreController) TableExists(ctx context.Context, db database.DBTxConn) (bool, error) {
if t, ok := c.Store.(interface {
TableExists(ctx context.Context, db database.DBTxConn) (bool, error)
}); ok {
return t.TableExists(ctx, db)
}
return false, errors.ErrUnsupported
}

View File

@ -25,3 +25,25 @@ type Querier interface {
// table. Returns a nullable int64 value.
GetLatestVersion(tableName string) string
}
var _ Querier = (*QueryController)(nil)
type QueryController struct{ Querier }
// NewQueryController returns a new QueryController that wraps the given Querier.
func NewQueryController(querier Querier) *QueryController {
return &QueryController{Querier: querier}
}
// Optional methods
// TableExists returns the SQL query string to check if the version table exists. If the Querier
// does not implement this method, it will return an empty string.
//
// Returns a boolean value.
func (c *QueryController) TableExists(tableName string) string {
if t, ok := c.Querier.(interface{ TableExists(string) string }); ok {
return t.TableExists(tableName)
}
return ""
}

View File

@ -1,6 +1,16 @@
package dialectquery
import "fmt"
import (
"fmt"
"strings"
)
const (
// defaultSchemaName is the default schema name for Postgres.
//
// https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PUBLIC
defaultSchemaName = "public"
)
type Postgres struct{}
@ -40,3 +50,17 @@ func (p *Postgres) GetLatestVersion(tableName string) string {
q := `SELECT max(version_id) FROM %s`
return fmt.Sprintf(q, tableName)
}
func (p *Postgres) TableExists(tableName string) string {
schemaName, tableName := parseTableIdentifier(tableName)
q := `SELECT EXISTS ( SELECT FROM pg_tables WHERE schemaname = '%s' AND tablename = '%s' )`
return fmt.Sprintf(q, schemaName, tableName)
}
func parseTableIdentifier(name string) (schema, table string) {
schema, table, found := strings.Cut(name, ".")
if !found {
return defaultSchemaName, name
}
return schema, table
}

View File

@ -12,6 +12,7 @@ import (
"sync"
"github.com/pressly/goose/v3/database"
"github.com/pressly/goose/v3/internal/controller"
"github.com/pressly/goose/v3/internal/gooseutil"
"github.com/pressly/goose/v3/internal/sqlparser"
"go.uber.org/multierr"
@ -24,7 +25,7 @@ type Provider struct {
mu sync.Mutex
db *sql.DB
store database.Store
store *controller.StoreController
versionTableOnce sync.Once
fsys fs.FS
@ -141,7 +142,7 @@ func newProvider(
db: db,
fsys: fsys,
cfg: cfg,
store: store,
store: controller.NewStoreController(store),
migrations: migrations,
}, nil
}

View File

@ -296,25 +296,29 @@ func (p *Provider) tryEnsureVersionTable(ctx context.Context, conn *sql.Conn) er
b := retry.NewConstant(1 * time.Second)
b = retry.WithMaxRetries(3, b)
return retry.Do(ctx, b, func(ctx context.Context) error {
if e, ok := p.store.(interface {
TableExists(context.Context, database.DBTxConn, string) (bool, error)
}); ok {
exists, err := e.TableExists(ctx, conn, p.store.Tablename())
if err != nil {
return fmt.Errorf("failed to check if version table exists: %w", err)
}
if exists {
return nil
}
} else {
// This chicken-and-egg behavior is the fallback for all existing implementations of the
// Store interface. We check if the version table exists by querying for the initial
// version, but the table may not exist yet. It's important this runs outside of a
// transaction to avoid failing the transaction.
exists, err := p.store.TableExists(ctx, conn)
if err == nil && exists {
return nil
} else if err != nil && errors.Is(err, errors.ErrUnsupported) {
// Fallback strategy for checking table existence:
//
// When direct table existence checks aren't supported, we attempt to query the initial
// migration (version 0). This approach has two implications:
//
// 1. If the table exists, the query succeeds and confirms existence
// 2. If the table doesn't exist, the query fails and generates an error log
//
// Note: This check must occur outside any transaction, as a failed query would
// otherwise cause the entire transaction to roll back. The error logs generated by this
// approach are expected and can be safely ignored.
if res, err := p.store.GetMigration(ctx, conn, 0); err == nil && res != nil {
return nil
}
// Fallthrough to create the table.
} else if err != nil {
return fmt.Errorf("failed to check if version table exists: %w", err)
}
if err := beginTx(ctx, conn, func(tx *sql.Tx) error {
if err := p.store.CreateVersionTable(ctx, tx); err != nil {
return err

View File

@ -745,15 +745,17 @@ func TestGoMigrationPanic(t *testing.T) {
func TestCustomStoreTableExists(t *testing.T) {
t.Parallel()
db := newDB(t)
store, err := database.NewStore(database.DialectSQLite3, goose.DefaultTablename)
require.NoError(t, err)
p, err := goose.NewProvider("", newDB(t), newFsys(),
goose.WithStore(&customStoreSQLite3{store}),
)
require.NoError(t, err)
_, err = p.Up(context.Background())
require.NoError(t, err)
for i := 0; i < 2; i++ {
p, err := goose.NewProvider("", db, newFsys(),
goose.WithStore(&customStoreSQLite3{store}),
)
require.NoError(t, err)
_, err = p.Up(context.Background())
require.NoError(t, err)
}
}
func TestProviderApply(t *testing.T) {
@ -842,14 +844,14 @@ func TestPending(t *testing.T) {
})
}
type customStoreSQLite3 struct {
database.Store
}
var _ database.StoreExtender = (*customStoreSQLite3)(nil)
func (s *customStoreSQLite3) TableExists(ctx context.Context, db database.DBTxConn, name string) (bool, error) {
q := `SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name=$1) AS table_exists`
type customStoreSQLite3 struct{ database.Store }
func (s *customStoreSQLite3) TableExists(ctx context.Context, db database.DBTxConn) (bool, error) {
q := `SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name=?) AS table_exists`
var exists bool
if err := db.QueryRowContext(ctx, q, name).Scan(&exists); err != nil {
if err := db.QueryRowContext(ctx, q, s.Tablename()).Scan(&exists); err != nil {
return false, err
}
return exists, nil