diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index c3bd6485a2..e9e5f1dbe5 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -257,6 +257,9 @@ func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w if err != nil { return DatabaseDiff{}, err } + if !usePgDelta { + output = appendViewReloptionDiff(ctx, output, shadowConfig, config, schema, options...) + } return DatabaseDiff{SQL: output}, nil } diff --git a/apps/cli-go/internal/db/diff/view_reloptions.go b/apps/cli-go/internal/db/diff/view_reloptions.go new file mode 100644 index 0000000000..49bb18c088 --- /dev/null +++ b/apps/cli-go/internal/db/diff/view_reloptions.go @@ -0,0 +1,198 @@ +package diff + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "github.com/jackc/pgconn" + "github.com/jackc/pgx/v4" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/pgxv5" +) + +const SELECT_VIEW_RELOPTIONS = `SELECT n.nspname AS nspname, + c.relname AS relname, + c.relkind::text AS relkind, + COALESCE(c.reloptions, ARRAY[]::text[]) AS reloptions + FROM pg_class c + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE c.relkind IN ('v','m') + ORDER BY n.nspname, c.relname` + +type viewReloptionKey struct { + schema string + name string + relkind string +} + +type viewReloptionRow struct { + Nspname string `db:"nspname"` + Relname string `db:"relname"` + Relkind string `db:"relkind"` + Reloptions []string `db:"reloptions"` +} + +func appendViewReloptionDiff(ctx context.Context, sql string, source, target pgconn.Config, schema []string, options ...func(*pgx.ConnConfig)) string { + sourceConn, err := utils.ConnectByConfig(ctx, source, options...) + if err != nil { + fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "could not connect to source database to diff view reloptions:", err) + return sql + } + defer sourceConn.Close(context.Background()) + targetConn, err := utils.ConnectByConfig(ctx, target, options...) + if err != nil { + fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "could not connect to target database to diff view reloptions:", err) + return sql + } + defer targetConn.Close(context.Background()) + sourceReloptions, err := selectViewReloptions(ctx, sourceConn) + if err != nil { + fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "could not read source view reloptions:", err) + return sql + } + targetReloptions, err := selectViewReloptions(ctx, targetConn) + if err != nil { + fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "could not read target view reloptions:", err) + return sql + } + return appendDiffSQL(sql, buildViewReloptionDiff(sourceReloptions, targetReloptions, schema)) +} + +func selectViewReloptions(ctx context.Context, conn *pgx.Conn) (map[viewReloptionKey][]string, error) { + rows, err := conn.Query(ctx, SELECT_VIEW_RELOPTIONS) + if err != nil { + return nil, err + } + collected, err := pgxv5.CollectRows[viewReloptionRow](rows) + if err != nil { + return nil, err + } + out := make(map[viewReloptionKey][]string, len(collected)) + for _, r := range collected { + out[viewReloptionKey{schema: r.Nspname, name: r.Relname, relkind: r.Relkind}] = r.Reloptions + } + return out, nil +} + +func buildViewReloptionDiff(source, target map[viewReloptionKey][]string, schema []string) string { + if len(source) == 0 || len(target) == 0 { + return "" + } + includeSchema := schemaFilter(schema) + keys := make([]viewReloptionKey, 0, len(target)) + for key := range target { + if !includeSchema(key.schema) { + continue + } + if _, ok := source[key]; ok { + keys = append(keys, key) + } + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].schema != keys[j].schema { + return keys[i].schema < keys[j].schema + } + if keys[i].name != keys[j].name { + return keys[i].name < keys[j].name + } + return keys[i].relkind < keys[j].relkind + }) + var statements []string + for _, key := range keys { + statements = append(statements, buildAlterViewReloptions(key, source[key], target[key])...) + } + return strings.Join(statements, "") +} + +func schemaFilter(schema []string) func(string) bool { + if len(schema) > 0 { + included := make(map[string]bool, len(schema)) + for _, name := range schema { + included[name] = true + } + return func(name string) bool { + return included[name] + } + } + excluded := make(map[string]bool, len(managedSchemas)+2) + for _, name := range managedSchemas { + excluded[name] = true + } + excluded["information_schema"] = true + excluded["pg_catalog"] = true + return func(name string) bool { + return !excluded[name] && !strings.HasPrefix(name, "pg_") + } +} + +func buildAlterViewReloptions(key viewReloptionKey, source, target []string) []string { + sourceOpts := reloptionsByName(source) + targetOpts := reloptionsByName(target) + var setNames []string + for name, targetOpt := range targetOpts { + if sourceOpt, ok := sourceOpts[name]; !ok || sourceOpt.raw != targetOpt.raw { + setNames = append(setNames, name) + } + } + var resetNames []string + for name := range sourceOpts { + if _, ok := targetOpts[name]; !ok { + resetNames = append(resetNames, name) + } + } + sort.Strings(setNames) + sort.Strings(resetNames) + alterPrefix := "ALTER VIEW " + if key.relkind == "m" { + alterPrefix = "ALTER MATERIALIZED VIEW " + } + viewName := quoteIdentifier(key.schema) + "." + quoteIdentifier(key.name) + var statements []string + if len(setNames) > 0 { + opts := make([]string, len(setNames)) + for i, name := range setNames { + opts[i] = targetOpts[name].raw + } + statements = append(statements, fmt.Sprintf("%s%s SET (%s);\n", alterPrefix, viewName, strings.Join(opts, ", "))) + } + if len(resetNames) > 0 { + statements = append(statements, fmt.Sprintf("%s%s RESET (%s);\n", alterPrefix, viewName, strings.Join(resetNames, ", "))) + } + return statements +} + +type reloption struct { + raw string +} + +func reloptionsByName(options []string) map[string]reloption { + out := make(map[string]reloption, len(options)) + for _, raw := range options { + name, _, _ := strings.Cut(raw, "=") + if name == "" { + continue + } + out[name] = reloption{raw: raw} + } + return out +} + +func appendDiffSQL(sql, extra string) string { + if extra == "" { + return sql + } + if strings.TrimSpace(sql) == "" { + return extra + } + if strings.HasSuffix(sql, "\n") { + return sql + extra + } + return sql + "\n" + extra +} + +func quoteIdentifier(identifier string) string { + return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"` +} diff --git a/apps/cli-go/internal/db/diff/view_reloptions_test.go b/apps/cli-go/internal/db/diff/view_reloptions_test.go new file mode 100644 index 0000000000..315f716cd6 --- /dev/null +++ b/apps/cli-go/internal/db/diff/view_reloptions_test.go @@ -0,0 +1,199 @@ +package diff + +import ( + "context" + "testing" + + "github.com/jackc/pgconn" + "github.com/jackc/pgx/v4" + "github.com/stretchr/testify/assert" + "github.com/supabase/cli/pkg/pgtest" +) + +func TestBuildViewReloptionDiff(t *testing.T) { + t.Run("emits ALTER VIEW for reloption-only changes", func(t *testing.T) { + key := viewReloptionKey{schema: "public", name: "user_details", relkind: "v"} + out := buildViewReloptionDiff( + map[viewReloptionKey][]string{ + key: {"security_invoker=true"}, + }, + map[viewReloptionKey][]string{ + key: {"security_invoker=false"}, + }, + []string{"public"}, + ) + assert.Equal(t, `ALTER VIEW "public"."user_details" SET (security_invoker=false); +`, out) + }) + + t.Run("emits RESET for reloptions removed from existing views", func(t *testing.T) { + key := viewReloptionKey{schema: "public", name: "user_details", relkind: "v"} + out := buildViewReloptionDiff( + map[viewReloptionKey][]string{ + key: {"security_invoker=true", "check_option=local"}, + }, + map[viewReloptionKey][]string{ + key: {"check_option=local"}, + }, + []string{"public"}, + ) + assert.Equal(t, `ALTER VIEW "public"."user_details" RESET (security_invoker); +`, out) + }) + + t.Run("emits RESET when all reloptions are removed", func(t *testing.T) { + key := viewReloptionKey{schema: "public", name: "user_details", relkind: "v"} + out := buildViewReloptionDiff( + map[viewReloptionKey][]string{ + key: {"security_invoker=true", "check_option=local"}, + }, + map[viewReloptionKey][]string{ + key: {}, + }, + []string{"public"}, + ) + assert.Equal(t, `ALTER VIEW "public"."user_details" RESET (check_option, security_invoker); +`, out) + }) + + t.Run("emits SET and RESET in stable order", func(t *testing.T) { + key := viewReloptionKey{schema: "public", name: "user_details", relkind: "v"} + out := buildViewReloptionDiff( + map[viewReloptionKey][]string{ + key: {"security_invoker=true", "check_option=local"}, + }, + map[viewReloptionKey][]string{ + key: {"security_barrier=true", "security_invoker=false"}, + }, + []string{"public"}, + ) + assert.Equal(t, `ALTER VIEW "public"."user_details" SET (security_barrier=true, security_invoker=false); +ALTER VIEW "public"."user_details" RESET (check_option); +`, out) + }) + + t.Run("batches multiple changed reloptions into one SET", func(t *testing.T) { + key := viewReloptionKey{schema: "public", name: "user_details", relkind: "v"} + out := buildViewReloptionDiff( + map[viewReloptionKey][]string{ + key: {"security_invoker=true", "check_option=local"}, + }, + map[viewReloptionKey][]string{ + key: {"security_invoker=false", "check_option=cascaded"}, + }, + []string{"public"}, + ) + assert.Equal(t, `ALTER VIEW "public"."user_details" SET (check_option=cascaded, security_invoker=false); +`, out) + }) + + t.Run("emits ALTER MATERIALIZED VIEW for materialized views", func(t *testing.T) { + key := viewReloptionKey{schema: "public", name: "cached_details", relkind: "m"} + out := buildViewReloptionDiff( + map[viewReloptionKey][]string{ + key: {"autovacuum_enabled=true"}, + }, + map[viewReloptionKey][]string{ + key: {"autovacuum_enabled=false"}, + }, + []string{"public"}, + ) + assert.Equal(t, `ALTER MATERIALIZED VIEW "public"."cached_details" SET (autovacuum_enabled=false); +`, out) + }) + + t.Run("skips target-only views because CREATE VIEW diff owns them", func(t *testing.T) { + key := viewReloptionKey{schema: "public", name: "new_view", relkind: "v"} + out := buildViewReloptionDiff( + map[viewReloptionKey][]string{}, + map[viewReloptionKey][]string{ + key: {"security_invoker=true"}, + }, + []string{"public"}, + ) + assert.Empty(t, out) + }) + + t.Run("skips no-op diff when neither side has reloptions", func(t *testing.T) { + key := viewReloptionKey{schema: "public", name: "user_details", relkind: "v"} + out := buildViewReloptionDiff( + map[viewReloptionKey][]string{ + key: {}, + }, + map[viewReloptionKey][]string{ + key: {}, + }, + []string{"public"}, + ) + assert.Empty(t, out) + }) + + t.Run("skips source-only dropped views", func(t *testing.T) { + dropped := viewReloptionKey{schema: "public", name: "dropped_view", relkind: "v"} + kept := viewReloptionKey{schema: "public", name: "kept_view", relkind: "v"} + out := buildViewReloptionDiff( + map[viewReloptionKey][]string{ + dropped: {"security_invoker=true"}, + kept: {"security_invoker=true"}, + }, + map[viewReloptionKey][]string{ + kept: {"security_invoker=false"}, + }, + []string{"public"}, + ) + assert.Equal(t, `ALTER VIEW "public"."kept_view" SET (security_invoker=false); +`, out) + }) + + t.Run("respects requested schema filter", func(t *testing.T) { + key := viewReloptionKey{schema: "private", name: "user_details", relkind: "v"} + out := buildViewReloptionDiff( + map[viewReloptionKey][]string{ + key: {"security_invoker=true"}, + }, + map[viewReloptionKey][]string{ + key: {"security_invoker=false"}, + }, + []string{"public"}, + ) + assert.Empty(t, out) + }) +} + +func TestAppendDiffSQL(t *testing.T) { + assert.Equal(t, "ALTER VIEW v SET (security_invoker=true);\n", appendDiffSQL("", "ALTER VIEW v SET (security_invoker=true);\n")) + assert.Equal(t, "CREATE TABLE t();\nALTER VIEW v SET (security_invoker=true);\n", appendDiffSQL("CREATE TABLE t();", "ALTER VIEW v SET (security_invoker=true);\n")) + assert.Equal(t, "CREATE TABLE t();\nALTER VIEW v SET (security_invoker=true);\n", appendDiffSQL("CREATE TABLE t();\n", "ALTER VIEW v SET (security_invoker=true);\n")) +} + +func TestAppendViewReloptionDiff(t *testing.T) { + sourceConn := pgtest.NewConn() + defer sourceConn.Close(t) + targetConn := pgtest.NewConn() + defer targetConn.Close(t) + sourceConn.Query(SELECT_VIEW_RELOPTIONS). + Reply("SELECT 1", viewReloptionRow{ + Nspname: "public", + Relname: "user_details", + Relkind: "v", + Reloptions: []string{"security_invoker=true"}, + }) + targetConn.Query(SELECT_VIEW_RELOPTIONS). + Reply("SELECT 1", viewReloptionRow{ + Nspname: "public", + Relname: "user_details", + Relkind: "v", + Reloptions: []string{"security_invoker=false"}, + }) + source := pgconn.Config{Host: "source.example", Port: 5432, User: "postgres", Database: "postgres"} + target := pgconn.Config{Host: "target.example", Port: 5432, User: "postgres", Database: "postgres"} + out := appendViewReloptionDiff(context.Background(), "", source, target, []string{"public"}, func(cc *pgx.ConnConfig) { + if cc.Host == source.Host { + sourceConn.Intercept(cc) + } else { + targetConn.Intercept(cc) + } + }) + assert.Equal(t, `ALTER VIEW "public"."user_details" SET (security_invoker=false); +`, out) +}