Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: postgres migration table existence check #860

Merged
merged 8 commits into from
Nov 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 25 additions & 2 deletions database/dialect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
33 changes: 33 additions & 0 deletions database/store_extended.go
Original file line number Diff line number Diff line change
@@ -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)
}
37 changes: 37 additions & 0 deletions internal/controller/controller.go
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 22 additions & 0 deletions internal/dialect/dialectquery/dialectquery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}
26 changes: 25 additions & 1 deletion internal/dialect/dialectquery/postgres.go
Original file line number Diff line number Diff line change
@@ -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{}

Expand Down Expand Up @@ -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)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should handle cases where the table name contains the schema name; otherwise, assumed public.

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
}
5 changes: 3 additions & 2 deletions provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -141,7 +142,7 @@ func newProvider(
db: db,
fsys: fsys,
cfg: cfg,
store: store,
store: controller.NewStoreController(store),
migrations: migrations,
}, nil
}
Expand Down
34 changes: 19 additions & 15 deletions provider_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Collaborator Author

@mfridman mfridman Nov 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole point of this PR was to make this a "well-defined" method part of the extended Store interface.

By having this inlined, makes it difficult to understand where else in the codebase there might be such hidden interfaces.

But with the concept of the "controller" there's a single place where users (and us) can look to discover these.

}); 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
Expand Down
28 changes: 15 additions & 13 deletions provider_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -842,14 +844,14 @@ func TestPending(t *testing.T) {
})
}

type customStoreSQLite3 struct {
database.Store
}
var _ database.StoreExtender = (*customStoreSQLite3)(nil)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels much nicer.


type customStoreSQLite3 struct{ database.Store }

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`
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
Expand Down