Skip to content

Commit f8bf96f

Browse files
kvchandrew-farries
andauthored
Add new subcommand to validate migration files (#856)
This PR adds a new subcommand to validate migration files called `validate`. ``` Usage: pgroll validate <file> [flags] Examples: validate migrations/03_my_migration.yaml Flags: -h, --help help for validate ``` It is first step towards providing a way to validate migration files before running them. At the moment it can validate a single migration file against the latest schema in the database. This can be added to your local workflow, or a linter to make sure that the newly added migration file is correct. Future improvements can be * validating multiple migrations * validating all unapplied migrations in a folder * verifying `up` migrations on a subset of the table #169 * run `jsonschema.Validate` during validate #673 The PR contains some minor changes to the migration phase `Start`. Now migration validation is decoupled from it. The previous implementation of `Start` started a new migration before validating the migration it got as a parameter. So this change does not interfere with the UX of pgroll. Closes #370 --------- Co-authored-by: Andrew Farries <[email protected]>
1 parent 7b932e9 commit f8bf96f

File tree

9 files changed

+120
-30
lines changed

9 files changed

+120
-30
lines changed

cli-definition.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,17 @@
239239
"flags": [],
240240
"subcommands": [],
241241
"args": []
242+
},
243+
{
244+
"name": "validate",
245+
"short": "Validate a migration file",
246+
"use": "validate <file>",
247+
"example": "validate migrations/03_my_migration.yaml",
248+
"flags": [],
249+
"subcommands": [],
250+
"args": [
251+
"file"
252+
]
242253
}
243254
],
244255
"flags": [

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ func Prepare() *cobra.Command {
107107
rootCmd.AddCommand(latestCmd())
108108
rootCmd.AddCommand(convertCmd())
109109
rootCmd.AddCommand(baselineCmd())
110+
rootCmd.AddCommand(validateCmd)
110111

111112
return rootCmd
112113
}

cmd/validate.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package cmd
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/xataio/pgroll/pkg/migrations"
11+
)
12+
13+
var validateCmd = &cobra.Command{
14+
Use: "validate <file>",
15+
Short: "Validate a migration file",
16+
Example: "validate migrations/03_my_migration.yaml",
17+
Args: cobra.ExactArgs(1),
18+
ValidArgs: []string{"file"},
19+
RunE: func(cmd *cobra.Command, args []string) error {
20+
ctx := cmd.Context()
21+
fileName := args[0]
22+
23+
m, err := NewRollWithInitCheck(ctx)
24+
if err != nil {
25+
return err
26+
}
27+
defer m.Close()
28+
29+
migration, err := migrations.ReadMigration(os.DirFS(filepath.Dir(fileName)), filepath.Base(fileName))
30+
if err != nil {
31+
return err
32+
}
33+
err = m.Validate(ctx, migration)
34+
if err != nil {
35+
return err
36+
}
37+
return nil
38+
},
39+
}

docs/cli/validate.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
title: Validate
3+
description: Validate a pgroll migration
4+
---
5+
6+
## Command
7+
8+
```
9+
$ pgroll validate sql/03_add_column.yaml
10+
```
11+
12+
This validates the migration defined in the `sql/03_add_column.yaml` file.
13+
14+
The command can detect the following errors:
15+
16+
* syntax error in pgroll migration format
17+
* unknown/invalid configuration options and settings in the migration file
18+
* reference to unknown database objects

docs/config.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@
7474
"href": "/cli/rollback",
7575
"file": "docs/cli/rollback.mdx"
7676
},
77+
{
78+
"title": "Validate",
79+
"href": "/cli/validate",
80+
"file": "docs/cli/validate.mdx"
81+
},
7782
{
7883
"title": "Create",
7984
"href": "/cli/create",

pkg/roll/execute.go

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,21 @@ import (
1515
"github.com/xataio/pgroll/pkg/schema"
1616
)
1717

18+
func (m *Roll) Validate(ctx context.Context, migration *migrations.Migration) error {
19+
if m.skipValidation {
20+
return nil
21+
}
22+
lastSchema, err := m.state.LastSchema(ctx, m.schema)
23+
if err != nil {
24+
return err
25+
}
26+
err = migration.Validate(ctx, lastSchema)
27+
if err != nil {
28+
return fmt.Errorf("migration '%s' is invalid: %w", migration.Name, err)
29+
}
30+
return nil
31+
}
32+
1833
// Start will apply the required changes to enable supporting the new schema version
1934
func (m *Roll) Start(ctx context.Context, migration *migrations.Migration, cfg *backfill.Config) error {
2035
// Fail early if we have existing schema without migration history
@@ -28,6 +43,10 @@ func (m *Roll) Start(ctx context.Context, migration *migrations.Migration, cfg *
2843

2944
m.logger.LogMigrationStart(migration)
3045

46+
if err := m.Validate(ctx, migration); err != nil {
47+
return err
48+
}
49+
3150
tablesToBackfill, err := m.StartDDLOperations(ctx, migration)
3251
if err != nil {
3352
return err
@@ -50,22 +69,10 @@ func (m *Roll) StartDDLOperations(ctx context.Context, migration *migrations.Mig
5069
}
5170

5271
// create a new active migration (guaranteed to be unique by constraints)
53-
newSchema, err := m.state.Start(ctx, m.schema, migration)
54-
if err != nil {
72+
if err = m.state.Start(ctx, m.schema, migration); err != nil {
5573
return nil, fmt.Errorf("unable to start migration: %w", err)
5674
}
5775

58-
// validate migration
59-
if !m.skipValidation {
60-
err = migration.Validate(ctx, newSchema)
61-
if err != nil {
62-
if err := m.state.Rollback(ctx, m.schema, migration.Name); err != nil {
63-
fmt.Printf("failed to rollback migration: %s\n", err)
64-
}
65-
return nil, fmt.Errorf("migration is invalid: %w", err)
66-
}
67-
}
68-
6976
// run any BeforeStartDDL hooks
7077
if m.migrationHooks.BeforeStartDDL != nil {
7178
if err := m.migrationHooks.BeforeStartDDL(m); err != nil {
@@ -89,7 +96,7 @@ func (m *Roll) StartDDLOperations(ctx context.Context, migration *migrations.Mig
8996

9097
// Reread the latest schema as validation may have updated the schema object
9198
// in memory.
92-
newSchema, err = m.state.ReadSchema(ctx, m.schema)
99+
newSchema, err := m.state.ReadSchema(ctx, m.schema)
93100
if err != nil {
94101
return nil, fmt.Errorf("unable to read schema: %w", err)
95102
}

pkg/state/history_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func TestSchemaHistoryReturnsFullSchemaHistory(t *testing.T) {
5555

5656
// Start and complete both migrations
5757
for _, mig := range migs {
58-
_, err := state.Start(ctx, "public", &mig)
58+
err := state.Start(ctx, "public", &mig)
5959
require.NoError(t, err)
6060
err = state.Complete(ctx, "public", mig.Name)
6161
require.NoError(t, err)

pkg/state/state.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -236,24 +236,29 @@ func (s *State) Status(ctx context.Context, schema string) (*Status, error) {
236236
// this will effectively activate a new migration period, so `IsActiveMigrationPeriod` will return true
237237
// until the migration is completed
238238
// This method will return the current schema (before the migration is applied)
239-
func (s *State) Start(ctx context.Context, schemaname string, migration *migrations.Migration) (*schema.Schema, error) {
239+
func (s *State) Start(ctx context.Context, schemaname string, migration *migrations.Migration) error {
240240
rawMigration, err := json.Marshal(migration)
241241
if err != nil {
242-
return nil, fmt.Errorf("unable to marshal migration: %w", err)
242+
return fmt.Errorf("unable to marshal migration: %w", err)
243243
}
244244

245245
// create a new migration object and return the previous known schema
246246
// if there is no previous migration, read the schema from postgres
247-
stmt := fmt.Sprintf(`
248-
INSERT INTO %[1]s.migrations (schema, name, parent, migration) VALUES ($1, $2, %[1]s.latest_version($1), $3)
249-
RETURNING (
250-
SELECT COALESCE(
251-
(SELECT resulting_schema FROM %[1]s.migrations WHERE schema=$1 AND name=%[1]s.latest_version($1)),
252-
%[1]s.read_schema($1))
253-
)`, pq.QuoteIdentifier(s.schema))
247+
stmt := fmt.Sprintf(`INSERT INTO %[1]s.migrations (schema, name, parent, migration) VALUES ($1, $2, %[1]s.latest_version($1), $3)`,
248+
pq.QuoteIdentifier(s.schema))
249+
250+
_, err = s.pgConn.ExecContext(ctx, stmt, schemaname, migration.Name, rawMigration)
251+
return err
252+
}
253+
254+
func (s *State) LastSchema(ctx context.Context, schemaname string) (*schema.Schema, error) {
255+
stmt := fmt.Sprintf(`SELECT COALESCE(
256+
(SELECT resulting_schema FROM %[1]s.migrations WHERE schema=$1 AND name=%[1]s.latest_version($1)),
257+
%[1]s.read_schema($1))`,
258+
pq.QuoteIdentifier(s.schema))
254259

255260
var rawSchema string
256-
err = s.pgConn.QueryRowContext(ctx, stmt, schemaname, migration.Name, rawMigration).Scan(&rawSchema)
261+
err := s.pgConn.QueryRowContext(ctx, stmt, schemaname).Scan(&rawSchema)
257262
if err != nil {
258263
return nil, err
259264
}

pkg/state/state_test.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,15 @@ func TestSchemaOptionIsRespected(t *testing.T) {
3535
t.Fatal(err)
3636
}
3737

38-
// check that starting a new migration returns the already existing table
39-
currentSchema, err := state.Start(ctx, "public", &migrations.Migration{
38+
// check that we can retrieve the already existing table
39+
currentSchema, err := state.LastSchema(ctx, "public")
40+
assert.NoError(t, err)
41+
42+
assert.Equal(t, 1, len(currentSchema.Tables))
43+
assert.Equal(t, "public", currentSchema.Name)
44+
45+
// check that we can start the migration
46+
err = state.Start(ctx, "public", &migrations.Migration{
4047
Name: "1_add_column",
4148
Operations: migrations.Operations{
4249
&migrations.OpAddColumn{
@@ -49,9 +56,6 @@ func TestSchemaOptionIsRespected(t *testing.T) {
4956
},
5057
})
5158
assert.NoError(t, err)
52-
53-
assert.Equal(t, 1, len(currentSchema.Tables))
54-
assert.Equal(t, "public", currentSchema.Name)
5559
})
5660
}
5761

0 commit comments

Comments
 (0)