Skip to content

Commit d5804a3

Browse files
[FEATURE] Add support for --from-dir and providing a dedicated temporary database server (#199)
1 parent ec957bc commit d5804a3

File tree

12 files changed

+635
-189
lines changed

12 files changed

+635
-189
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ echo "CREATE TABLE bar (id varchar(255), message TEXT NOT NULL);" > schema/bar.s
110110
Apply the schema to a fresh database. [The connection string spec can be found here](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING).
111111
Setting the `PGPASSWORD` env var will override any password set in the connection string and is recommended.
112112
```bash
113-
pg-schema-diff apply --dsn "postgres://postgres:postgres@localhost:5432/postgres" --schema-dir schema
113+
pg-schema-diff apply --from-dsn "postgres://postgres:postgres@localhost:5432/postgres" --to-dir schema
114114
```
115115

116116
## 2. Updating schema
@@ -121,7 +121,7 @@ echo "CREATE INDEX message_idx ON bar(message)" >> schema/bar.sql
121121

122122
Apply the schema. Any hazards in the generated plan must be approved
123123
```bash
124-
pg-schema-diff apply --dsn "postgres://postgres:postgres@localhost:5432/postgres" --schema-dir schema --allow-hazards INDEX_BUILD
124+
pg-schema-diff apply --from-dsn "postgres://postgres:postgres@localhost:5432/postgres" --to-dir schema --allow-hazards INDEX_BUILD
125125
```
126126

127127
# Using Library

cmd/pg-schema-diff/apply_cmd.go

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,37 +20,51 @@ func buildApplyCmd() *cobra.Command {
2020
Short: "Migrate your database to the match the inputted schema (apply the schema to the database)",
2121
}
2222

23-
connFlags := createConnFlags(cmd)
24-
planFlags := createPlanFlags(cmd)
23+
connFlags := createConnectionFlags(cmd, "from-", " The database to migrate")
24+
toSchemaFlags := createSchemaSourceFlags(cmd, "to-")
25+
planOptsFlags := createPlanOptionsFlags(cmd)
2526
allowedHazardsTypesStrs := cmd.Flags().StringSlice("allow-hazards", nil,
2627
"Specify the hazards that are allowed. Order does not matter, and duplicates are ignored. If the"+
2728
" migration plan contains unwanted hazards (hazards not in this list), then the migration will fail to run"+
2829
" (example: --allowed-hazards DELETES_DATA,INDEX_BUILD)")
2930
skipConfirmPrompt := cmd.Flags().Bool("skip-confirm-prompt", false, "Skips prompt asking for user to confirm before applying")
3031
cmd.RunE = func(cmd *cobra.Command, args []string) error {
3132
logger := log.SimpleLogger()
32-
connConfig, err := parseConnConfig(*connFlags, logger)
33+
34+
connConfig, err := parseConnectionFlags(connFlags)
35+
if err != nil {
36+
return err
37+
}
38+
fromSchema := dsnSchemaSource(connConfig)
39+
40+
toSchema, err := parseSchemaSource(*toSchemaFlags)
3341
if err != nil {
3442
return err
3543
}
3644

37-
planConfig, err := parsePlanConfig(*planFlags)
45+
planOptions, err := parsePlanOptions(*planOptsFlags)
3846
if err != nil {
3947
return err
4048
}
4149

4250
cmd.SilenceUsage = true
4351

44-
plan, err := generatePlan(context.Background(), logger, connConfig, planConfig)
52+
plan, err := generatePlan(cmd.Context(), generatePlanParameters{
53+
fromSchema: fromSchema,
54+
toSchema: toSchema,
55+
tempDbConnConfig: connConfig,
56+
planOptions: planOptions,
57+
logger: logger,
58+
})
4559
if err != nil {
4660
return err
4761
} else if len(plan.Statements) == 0 {
48-
fmt.Println("Schema matches expected. No plan generated")
62+
cmd.Println("Schema matches expected. No plan generated")
4963
return nil
5064
}
5165

52-
fmt.Println(header("Review plan"))
53-
fmt.Print(planToPrettyS(plan), "\n\n")
66+
cmd.Println(header("Review plan"))
67+
cmd.Print(planToPrettyS(plan), "\n\n")
5468

5569
if err := failIfHazardsNotAllowed(plan, *allowedHazardsTypesStrs); err != nil {
5670
return err
@@ -67,10 +81,10 @@ func buildApplyCmd() *cobra.Command {
6781
}
6882
}
6983

70-
if err := runPlan(context.Background(), connConfig, plan); err != nil {
84+
if err := runPlan(cmd.Context(), cmd, connConfig, plan); err != nil {
7185
return err
7286
}
73-
fmt.Println("Schema applied successfully")
87+
cmd.Println("Schema applied successfully")
7488
return nil
7589
}
7690

@@ -109,7 +123,7 @@ func failIfHazardsNotAllowed(plan diff.Plan, allowedHazardsTypesStrs []string) e
109123
return nil
110124
}
111125

112-
func runPlan(ctx context.Context, connConfig *pgx.ConnConfig, plan diff.Plan) error {
126+
func runPlan(ctx context.Context, cmd *cobra.Command, connConfig *pgx.ConnConfig, plan diff.Plan) error {
113127
connPool, err := openDbWithPgxConfig(connConfig)
114128
if err != nil {
115129
return err
@@ -129,8 +143,8 @@ func runPlan(ctx context.Context, connConfig *pgx.ConnConfig, plan diff.Plan) er
129143
// must be executed within its own transaction block. Postgres will error if you try to set a TRANSACTION-level
130144
// timeout for it. SESSION-level statement_timeouts are respected by `ADD INDEX CONCURRENTLY`
131145
for i, stmt := range plan.Statements {
132-
fmt.Println(header(fmt.Sprintf("Executing statement %d", getDisplayableStmtIdx(i))))
133-
fmt.Printf("%s\n\n", statementToPrettyS(stmt))
146+
cmd.Println(header(fmt.Sprintf("Executing statement %d", getDisplayableStmtIdx(i))))
147+
cmd.Printf("%s\n\n", statementToPrettyS(stmt))
134148
start := time.Now()
135149
if _, err := conn.ExecContext(ctx, fmt.Sprintf("SET SESSION statement_timeout = %d", stmt.Timeout.Milliseconds())); err != nil {
136150
return fmt.Errorf("setting statement timeout: %w", err)
@@ -141,9 +155,9 @@ func runPlan(ctx context.Context, connConfig *pgx.ConnConfig, plan diff.Plan) er
141155
if _, err := conn.ExecContext(ctx, stmt.ToSQL()); err != nil {
142156
return fmt.Errorf("executing migration statement. the database maybe be in a dirty state: %s: %w", stmt, err)
143157
}
144-
fmt.Printf("Finished executing statement. Duration: %s\n", time.Since(start))
158+
cmd.Printf("Finished executing statement. Duration: %s\n", time.Since(start))
145159
}
146-
fmt.Println(header("Complete"))
160+
cmd.Println(header("Complete"))
147161

148162
return nil
149163
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package main
2+
3+
import (
4+
"github.com/stripe/pg-schema-diff/internal/pgdump"
5+
"github.com/stripe/pg-schema-diff/internal/pgengine"
6+
)
7+
8+
func (suite *cmdTestSuite) TestApplyCmd() {
9+
// Non-comprehensive set of tests for the plan command. Not totally comprehensive to avoid needing to avoid
10+
// hindering developer velocity when updating the command.
11+
type testCase struct {
12+
name string
13+
// fromDbArg is an optional argument to override the default "--from-dsn" arg.
14+
fromDbArg func(db *pgengine.DB) []string
15+
args []string
16+
// dynamicArgs is function that can be used to build args that are dynamic, i.e.,
17+
// saving schemas to a randomly generated temporary directory.
18+
dynamicArgs []dArgGenerator
19+
20+
outputContains []string
21+
// expectedSchema is the schema that is expected to be in the database after the migration.
22+
// If nil, the expected schema will be the fromDDL.
23+
expectedSchemaDDL []string
24+
// expectErrContains is a list of substrings that are expected to be contained in the error returned by
25+
// cmd.RunE. This is DISTINCT from stdErr.
26+
expectErrContains []string
27+
}
28+
for _, tc := range []testCase{
29+
{
30+
name: "to dir",
31+
dynamicArgs: []dArgGenerator{tempSchemaDirDArg("to-dir", []string{"CREATE TABLE foobar();"})},
32+
33+
expectedSchemaDDL: []string{"CREATE TABLE foobar();"},
34+
},
35+
{
36+
name: "to dsn",
37+
dynamicArgs: []dArgGenerator{tempDsnDArg(suite.pgEngine, "to-dsn", []string{"CREATE TABLE foobar();"})},
38+
39+
expectedSchemaDDL: []string{"CREATE TABLE foobar();"},
40+
},
41+
{
42+
name: "from empty dsn",
43+
fromDbArg: func(db *pgengine.DB) []string {
44+
tempSetPqEnvVarsForDb(suite.T(), db)
45+
return []string{"--from-empty-dsn"}
46+
},
47+
dynamicArgs: []dArgGenerator{tempSchemaDirDArg("to-dir", []string{"CREATE TABLE foobar();"})},
48+
49+
expectedSchemaDDL: []string{"CREATE TABLE foobar();"},
50+
},
51+
{
52+
name: "no to schema provided",
53+
expectErrContains: []string{"must be set"},
54+
},
55+
{
56+
name: "two to schemas provided",
57+
args: []string{"--to-dir", "some-other-dir", "--to-dsn", "some-dsn"},
58+
expectErrContains: []string{"only one of"},
59+
},
60+
} {
61+
suite.Run(tc.name, func() {
62+
fromDb := tempDbWithSchema(suite.T(), suite.pgEngine, nil)
63+
if tc.fromDbArg == nil {
64+
tc.fromDbArg = func(db *pgengine.DB) []string {
65+
return []string{"--from-dsn", db.GetDSN()}
66+
}
67+
}
68+
args := append([]string{
69+
"apply",
70+
"--skip-confirm-prompt",
71+
}, tc.fromDbArg(fromDb)...)
72+
args = append(args, tc.args...)
73+
suite.runCmdWithAssertions(runCmdWithAssertionsParams{
74+
args: args,
75+
dynamicArgs: tc.dynamicArgs,
76+
outputContains: tc.outputContains,
77+
expectErrContains: tc.expectErrContains,
78+
})
79+
// The migration should have been successful. Assert it was.
80+
expectedDb := tempDbWithSchema(suite.T(), suite.pgEngine, tc.expectedSchemaDDL)
81+
expectedDbDump, err := pgdump.GetDump(expectedDb, pgdump.WithSchemaOnly())
82+
suite.Require().NoError(err)
83+
fromDbDump, err := pgdump.GetDump(fromDb, pgdump.WithSchemaOnly())
84+
suite.Require().NoError(err)
85+
86+
suite.Equal(expectedDbDump, fromDbDump)
87+
})
88+
}
89+
}

cmd/pg-schema-diff/flags.go

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,51 @@ import (
77
"github.com/go-logfmt/logfmt"
88
"github.com/jackc/pgx/v4"
99
"github.com/spf13/cobra"
10-
"github.com/stripe/pg-schema-diff/pkg/log"
1110
)
1211

13-
type connFlags struct {
14-
dsn string
12+
type connectionFlags struct {
13+
// dsn is the connection string for the database.
14+
dsn string
15+
dsnFlagName string
16+
17+
// isEmptyDsnUsingPq indicates to connect via DSN using the pq environment variables and defaults.
18+
isEmptyDsnUsingPq bool
19+
isEmptyDsnUsingPqFlagName string
1520
}
1621

17-
func createConnFlags(cmd *cobra.Command) *connFlags {
18-
flags := &connFlags{}
22+
func createConnectionFlags(cmd *cobra.Command, prefix string, additionalHelp string) *connectionFlags {
23+
var c connectionFlags
24+
25+
c.dsnFlagName = prefix + "dsn"
26+
dsnFlagHelp := "Connection string for the database (DB password can be specified through PGPASSWORD environment variable)."
27+
if additionalHelp != "" {
28+
dsnFlagHelp += " " + additionalHelp
29+
}
30+
cmd.Flags().StringVar(&c.dsn, c.dsnFlagName, "", dsnFlagHelp)
31+
32+
c.isEmptyDsnUsingPqFlagName = prefix + "empty-dsn"
33+
isEmptyDsnUsingPqFlagHelp := "Connect with an empty DSN using the pq environment variables and defaults."
34+
if additionalHelp != "" {
35+
isEmptyDsnUsingPqFlagHelp += " " + additionalHelp
36+
}
37+
cmd.Flags().BoolVar(&c.isEmptyDsnUsingPq, c.isEmptyDsnUsingPqFlagName, false, isEmptyDsnUsingPqFlagHelp)
1938

20-
cmd.Flags().StringVar(&flags.dsn, "dsn", "", "Connection string for the database (DB password can be specified through PGPASSWORD environment variable)")
21-
// Don't mark dsn as a required flag.
22-
// Allow users to use the "PGHOST" etc environment variables like `psql`.
39+
return &c
40+
}
2341

24-
return flags
42+
func (c *connectionFlags) IsSet() bool {
43+
return c.dsn != "" || c.isEmptyDsnUsingPq
2544
}
2645

27-
func parseConnConfig(c connFlags, logger log.Logger) (*pgx.ConnConfig, error) {
28-
if c.dsn == "" {
29-
logger.Warnf("DSN flag not set. Using libpq environment variables and default values.")
46+
func parseConnectionFlags(flags *connectionFlags) (*pgx.ConnConfig, error) {
47+
if !flags.isEmptyDsnUsingPq && flags.dsn == "" {
48+
return nil, fmt.Errorf("must specify either --%s or --%s", flags.dsnFlagName, flags.isEmptyDsnUsingPqFlagName)
3049
}
31-
32-
return pgx.ParseConfig(c.dsn)
50+
connConfig, err := pgx.ParseConfig(flags.dsn)
51+
if err != nil {
52+
return nil, fmt.Errorf("could not parse connection string %q: %w", flags.dsn, err)
53+
}
54+
return connConfig, nil
3355
}
3456

3557
// logFmtToMap parses all LogFmt key/value pairs from the provided string into a

cmd/pg-schema-diff/main.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,19 @@ import (
66
"github.com/spf13/cobra"
77
)
88

9-
// rootCmd represents the base command when called without any subcommands
10-
var rootCmd = &cobra.Command{
11-
Use: "pg-schema-diff",
12-
Short: "Diff two Postgres schemas and generate the SQL to get from one to the other",
13-
}
14-
15-
func init() {
9+
func buildRootCmd() *cobra.Command {
10+
rootCmd := &cobra.Command{
11+
Use: "pg-schema-diff",
12+
Short: "Diff two Postgres schemas and generate the SQL to get from one to the other",
13+
}
1614
rootCmd.AddCommand(buildPlanCmd())
1715
rootCmd.AddCommand(buildApplyCmd())
1816
rootCmd.AddCommand(buildVersionCmd())
17+
return rootCmd
1918
}
2019

2120
func main() {
22-
err := rootCmd.Execute()
23-
if err != nil {
21+
if err := buildRootCmd().Execute(); err != nil {
2422
os.Exit(1)
2523
}
2624
}

0 commit comments

Comments
 (0)