Skip to content

Commit 50d690a

Browse files
[baseline] Make start and migrate fail on non-empty schema without migration histories (#835)
Make the `pgroll start` and `pgroll migrate` commands fail when run against a non-empty schema without a migration history. The error message suggests running `pgroll baseline` before re-running `start` or `migrate`. Running an initial migration against a non-empty schema is not recommended becauses it can result in a migration that can't be rolled back if the migration references pre-existing objects in the schema. The recommended workflow is: 1. Create the initial baseline migration ``` $ pgroll baseline 00_initial_baseline migrations/ ``` 2. Run `migrate` as usual ``` $ pgroll migrate migrations/ --complete ``` This ensures that the full schema is brought into `pgroll` before running the first migration. Part of #364
1 parent 32bfe51 commit 50d690a

File tree

9 files changed

+125
-4
lines changed

9 files changed

+125
-4
lines changed

cmd/migrate.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,23 @@ func migrateCmd() *cobra.Command {
5959
return fmt.Errorf("migrations directory %q is not a directory", migrationsDir)
6060
}
6161

62+
// Check whether the schema needs an initial baseline migration
63+
needsBaseline, err := m.State().HasExistingSchemaWithoutHistory(ctx, m.Schema())
64+
if err != nil {
65+
return fmt.Errorf("failed to check for existing schema: %w", err)
66+
}
67+
if needsBaseline {
68+
fmt.Printf("Schema %q is non-empty but has no migration history. Run `pgroll baseline` first\n", m.Schema())
69+
return nil
70+
}
71+
6272
migs, err := m.UnappliedMigrations(ctx, os.DirFS(migrationsDir))
6373
if err != nil {
6474
return fmt.Errorf("failed to get migrations to apply: %w", err)
6575
}
6676

6777
if len(migs) == 0 {
68-
fmt.Println("database is up to date; no migrations to apply")
78+
fmt.Println("Database is up to date; no migrations to apply")
6979
return nil
7080
}
7181

cmd/start.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,21 +31,32 @@ func startCmd() *cobra.Command {
3131
Args: cobra.ExactArgs(1),
3232
ValidArgs: []string{"file"},
3333
RunE: func(cmd *cobra.Command, args []string) error {
34+
ctx := cmd.Context()
3435
fileName := args[0]
3536

3637
// Create a roll instance and check if pgroll is initialized
37-
m, err := NewRollWithInitCheck(cmd.Context())
38+
m, err := NewRollWithInitCheck(ctx)
3839
if err != nil {
3940
return err
4041
}
4142
defer m.Close()
4243

44+
// Check whether the schema needs an initial baseline migration
45+
needsBaseline, err := m.State().HasExistingSchemaWithoutHistory(ctx, m.Schema())
46+
if err != nil {
47+
return fmt.Errorf("failed to check for existing schema: %w", err)
48+
}
49+
if needsBaseline {
50+
fmt.Printf("Schema %q is non-empty but has no migration history. Run `pgroll baseline` first\n", m.Schema())
51+
return nil
52+
}
53+
4354
c := backfill.NewConfig(
4455
backfill.WithBatchSize(batchSize),
4556
backfill.WithBatchDelay(batchDelay),
4657
)
4758

48-
return runMigrationFromFile(cmd.Context(), m, fileName, complete, c)
59+
return runMigrationFromFile(ctx, m, fileName, complete, c)
4960
},
5061
}
5162

docs/cli/migrate.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,7 @@ $ pgroll migrate examples/
1414
will apply migrations from `41_add_enum_column` onwards to the target database.
1515

1616
If the `--complete` flag is passed to `pgroll migrate` the final migration to be applied will be completed. Otherwise the final migration will be left active (started but not completed).
17+
18+
## Existing Database Schema
19+
20+
If you attempt to run `pgroll migrate` against a database that has existing tables but no migration history, the command will fail with an error message. In this case, you should first run `pgroll baseline` to establish a baseline migration that captures the current schema state before applying any new migrations.

docs/cli/start.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ This is equivalent to running `pgroll start` immediately followed by `pgroll com
2929
workflow is to run `pgroll start`, then gracefully shut down old applications
3030
before running `pgroll complete` as a separate step.
3131
</Warning>
32+
33+
## Existing Database Schema
34+
35+
If you attempt to run `pgroll start` against a database that has existing tables but no migration history, the command will fail with an error message. In this case, you should first run `pgroll baseline` to establish a baseline migration that captures the current schema state before starting any new migrations.

internal/testutils/util.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,20 @@ func WithUninitializedState(t *testing.T, fn func(*state.State)) {
145145
fn(st)
146146
}
147147

148+
func WithUninitializedStateAndConnectionInfo(t *testing.T, fn func(*state.State, string, *sql.DB)) {
149+
t.Helper()
150+
ctx := context.Background()
151+
152+
db, connStr, _ := setupTestDatabase(t)
153+
154+
st, err := state.New(ctx, connStr, "pgroll")
155+
if err != nil {
156+
t.Fatal(err)
157+
}
158+
159+
fn(st, connStr, db)
160+
}
161+
148162
func WithMigratorInSchemaAndConnectionToContainerWithOptions(t testing.TB, schema string, opts []roll.Option, fn func(mig *roll.Roll, db *sql.DB)) {
149163
t.Helper()
150164
ctx := context.Background()

pkg/roll/execute.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ import (
1717

1818
// Start will apply the required changes to enable supporting the new schema version
1919
func (m *Roll) Start(ctx context.Context, migration *migrations.Migration, cfg *backfill.Config) error {
20+
// Fail early if we have existing schema without migration history
21+
hasExistingSchema, err := m.state.HasExistingSchemaWithoutHistory(ctx, m.schema)
22+
if err != nil {
23+
return fmt.Errorf("failed to check for existing schema: %w", err)
24+
}
25+
if hasExistingSchema {
26+
return ErrExistingSchemaWithoutHistory
27+
}
28+
2029
m.logger.LogMigrationStart(migration)
2130

2231
tablesToBackfill, err := m.StartDDLOperations(ctx, migration)

pkg/roll/execute_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -851,6 +851,35 @@ func TestConnectionsSetPostgresApplicationName(t *testing.T) {
851851
}
852852
}
853853

854+
func TestStartFailsWithExistingSchemaWithoutHistory(t *testing.T) {
855+
t.Parallel()
856+
857+
testutils.WithUninitializedStateAndConnectionInfo(t, func(st *state.State, connStr string, db *sql.DB) {
858+
ctx := context.Background()
859+
860+
// Create a table to before initializing `pgroll`
861+
_, err := db.ExecContext(ctx, "CREATE TABLE existing_table (id int)")
862+
require.NoError(t, err)
863+
864+
// Initialize `pgroll`
865+
err = st.Init(ctx)
866+
require.NoError(t, err)
867+
868+
// Create a Roll instance
869+
m, err := roll.New(ctx, connStr, "public", st)
870+
require.NoError(t, err)
871+
872+
// Attempt to start a migration
873+
err = m.Start(ctx, &migrations.Migration{
874+
Name: "01_create_table",
875+
Operations: migrations.Operations{createTableOp("new_table")},
876+
}, backfill.NewConfig())
877+
878+
// Verify that the error is ErrExistingSchemaWithoutHistory
879+
assert.ErrorIs(t, err, roll.ErrExistingSchemaWithoutHistory)
880+
})
881+
}
882+
854883
func addColumnOp(tableName string) *migrations.OpAddColumn {
855884
return &migrations.OpAddColumn{
856885
Table: tableName,

pkg/roll/roll.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ const (
2525
applicationName = "pgroll"
2626
)
2727

28-
var ErrMismatchedMigration = fmt.Errorf("remote migration does not match local migration")
28+
var (
29+
ErrMismatchedMigration = fmt.Errorf("remote migration does not match local migration")
30+
ErrExistingSchemaWithoutHistory = fmt.Errorf("schema has existing tables but no migration history - baseline required")
31+
)
2932

3033
type Roll struct {
3134
pgConn db.DB

pkg/state/state.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,43 @@ func (s *State) Schema() string {
104104
return s.schema
105105
}
106106

107+
// HasExistingSchemaWithoutHistory checks if there's an existing schema with
108+
// tables but no migration history. Returns true if the schema exists, has
109+
// tables, but has no pgroll migration history
110+
func (s *State) HasExistingSchemaWithoutHistory(ctx context.Context, schemaName string) (bool, error) {
111+
// Check if pgroll is initialized
112+
ok, err := s.IsInitialized(ctx)
113+
if err != nil {
114+
return false, err
115+
}
116+
if !ok {
117+
return false, nil
118+
}
119+
120+
// Check if there's any migration history for this schema
121+
var migrationCount int
122+
err = s.pgConn.QueryRowContext(ctx,
123+
fmt.Sprintf("SELECT COUNT(*) FROM %s.migrations WHERE schema=$1", pq.QuoteIdentifier(s.schema)),
124+
schemaName).Scan(&migrationCount)
125+
if err != nil {
126+
return false, fmt.Errorf("failed to check migration history: %w", err)
127+
}
128+
129+
// If there's migration history, return false
130+
if migrationCount > 0 {
131+
return false, nil
132+
}
133+
134+
// Check if the schema is empty or not, as determined by ReadSchema
135+
schema, err := s.ReadSchema(ctx, schemaName)
136+
if err != nil {
137+
return false, fmt.Errorf("failed to read schema: %w", err)
138+
}
139+
140+
// Return true if there are tables but no migration history
141+
return len(schema.Tables) > 0, nil
142+
}
143+
107144
// IsActiveMigrationPeriod returns true if there is an active migration
108145
func (s *State) IsActiveMigrationPeriod(ctx context.Context, schema string) (bool, error) {
109146
var isActive bool

0 commit comments

Comments
 (0)