diff --git a/README.md b/README.md index 8f136d8..b278018 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,12 @@ echo "CREATE TABLE bar (id varchar(255), message TEXT NOT NULL);" > schema/bar.s 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). Setting the `PGPASSWORD` env var will override any password set in the connection string and is recommended. ```bash -pg-schema-diff apply --from-dsn "postgres://postgres:postgres@localhost:5432/postgres" --to-dir schema +pg-schema-diff apply --from-dsn "postgres://postgres:postgres@localhost:5432/postgres" --to-dir schema +``` + +Alternatively, if you have an existing database, you can dump its schema to use as a starting point: +```bash +mkdir -p schema && pg-schema-diff dump --dsn "postgres://postgres:postgres@localhost:5432/postgres" > schema/schema.sql ``` ## 2. Updating schema diff --git a/cmd/pg-schema-diff/dump_cmd.go b/cmd/pg-schema-diff/dump_cmd.go new file mode 100644 index 0000000..79bfac5 --- /dev/null +++ b/cmd/pg-schema-diff/dump_cmd.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/jackc/pgx/v4" + "github.com/spf13/cobra" + "github.com/stripe/pg-schema-diff/pkg/diff" + "github.com/stripe/pg-schema-diff/pkg/log" + "github.com/stripe/pg-schema-diff/pkg/tempdb" +) + +func buildDumpCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dump", + Short: "Dump the schema of a database as SQL DDL statements (effectively pg_dump)", + } + + connFlags := createConnectionFlags(cmd, "", "The database to dump") + + var includeSchemas []string + var excludeSchemas []string + cmd.Flags().StringArrayVar(&includeSchemas, "include-schema", nil, "Include the specified schema in the dump") + cmd.Flags().StringArrayVar(&excludeSchemas, "exclude-schema", nil, "Exclude the specified schema from the dump") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + connConfig, err := parseConnectionFlags(connFlags) + if err != nil { + return err + } + + cmd.SilenceUsage = true + + plan, err := generateDump(cmd.Context(), generateDumpParams{ + connConfig: connConfig, + includeSchemas: includeSchemas, + excludeSchemas: excludeSchemas, + }) + if err != nil { + return err + } + + cmdPrintln(cmd, dumpToSql(plan)) + return nil + } + + return cmd +} + +type generateDumpParams struct { + connConfig *pgx.ConnConfig + includeSchemas []string + excludeSchemas []string +} + +func generateDump(ctx context.Context, params generateDumpParams) (diff.Plan, error) { + connPool, err := openDbWithPgxConfig(params.connConfig) + if err != nil { + return diff.Plan{}, fmt.Errorf("opening database connection: %w", err) + } + defer connPool.Close() + connPool.SetMaxOpenConns(defaultMaxConnections) + + tempDbFactory, err := tempdb.NewOnInstanceFactory(ctx, func(ctx context.Context, dbName string) (*sql.DB, error) { + cfg := params.connConfig.Copy() + cfg.Database = dbName + return openDbWithPgxConfig(cfg) + }, tempdb.WithRootDatabase(params.connConfig.Database)) + if err != nil { + return diff.Plan{}, fmt.Errorf("creating temp db factory: %w", err) + } + defer func() { + if err := tempDbFactory.Close(); err != nil { + log.SimpleLogger().Errorf("error shutting down temp db factory: %v", err) + } + }() + + plan, err := diff.Generate(ctx, diff.DDLSchemaSource([]string{}), diff.DBSchemaSource(connPool), + diff.WithTempDbFactory(tempDbFactory), + diff.WithIncludeSchemas(params.includeSchemas...), + diff.WithExcludeSchemas(params.excludeSchemas...), + diff.WithDoNotValidatePlan(), + diff.WithNoConcurrentIndexOps(), + ) + if err != nil { + return diff.Plan{}, fmt.Errorf("generating plan: %w", err) + } + return plan, nil +} + +// dumpToSql converts the plan to clean SQL without metadata +func dumpToSql(plan diff.Plan) string { + sb := strings.Builder{} + for i, stmt := range plan.Statements { + sb.WriteString(stmt.DDL) + sb.WriteString(";") + if i < len(plan.Statements)-1 { + sb.WriteString("\n\n") + } + } + return sb.String() +} diff --git a/cmd/pg-schema-diff/dump_cmd_test.go b/cmd/pg-schema-diff/dump_cmd_test.go new file mode 100644 index 0000000..4d73a4b --- /dev/null +++ b/cmd/pg-schema-diff/dump_cmd_test.go @@ -0,0 +1,41 @@ +package main + +func (suite *cmdTestSuite) TestDumpCmd() { + type testCase struct { + name string + args []string + dynamicArgs []dArgGenerator + + // outputContains is a list of substrings that are expected to be contained in the stdout output of the command. + outputContains []string + // expectErrContains is a list of substrings that are expected to be contained in the error returned by + // cmd.RunE. This is DISTINCT from stdErr. + expectErrContains []string + } + + for _, tc := range []testCase{ + { + name: "dump database with table", + dynamicArgs: []dArgGenerator{ + tempDsnDArg(suite.pgEngine, "dsn", []string{ + "CREATE TABLE foobar(id INT PRIMARY KEY, name TEXT NOT NULL)", + }), + }, + outputContains: []string{ + "CREATE TABLE", + "foobar", + "id", + "name", + }, + }, + } { + suite.Run(tc.name, func() { + suite.runCmdWithAssertions(runCmdWithAssertionsParams{ + args: append([]string{"dump"}, tc.args...), + dynamicArgs: tc.dynamicArgs, + outputContains: tc.outputContains, + expectErrContains: tc.expectErrContains, + }) + }) + } +} diff --git a/cmd/pg-schema-diff/main.go b/cmd/pg-schema-diff/main.go index fac8a4e..a33ddbc 100644 --- a/cmd/pg-schema-diff/main.go +++ b/cmd/pg-schema-diff/main.go @@ -13,6 +13,7 @@ func buildRootCmd() *cobra.Command { } rootCmd.AddCommand(buildPlanCmd()) rootCmd.AddCommand(buildApplyCmd()) + rootCmd.AddCommand(buildDumpCmd()) rootCmd.AddCommand(buildVersionCmd()) return rootCmd }