Skip to content

Commit 22d0685

Browse files
committed
fix(diff): include view reloptions changes
1 parent 72675e2 commit 22d0685

3 files changed

Lines changed: 339 additions & 0 deletions

File tree

apps/cli-go/internal/db/diff/diff.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,9 @@ func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w
257257
if err != nil {
258258
return DatabaseDiff{}, err
259259
}
260+
if !usePgDelta {
261+
output = appendViewReloptionDiff(ctx, output, shadowConfig, config, schema, options...)
262+
}
260263
return DatabaseDiff{SQL: output}, nil
261264
}
262265

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package diff
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"sort"
8+
"strings"
9+
10+
"github.com/jackc/pgconn"
11+
"github.com/jackc/pgx/v4"
12+
"github.com/supabase/cli/internal/utils"
13+
"github.com/supabase/cli/pkg/pgxv5"
14+
)
15+
16+
const SELECT_VIEW_RELOPTIONS = `SELECT n.nspname AS nspname,
17+
c.relname AS relname,
18+
c.relkind::text AS relkind,
19+
COALESCE(c.reloptions, ARRAY[]::text[]) AS reloptions
20+
FROM pg_class c
21+
JOIN pg_namespace n ON c.relnamespace = n.oid
22+
WHERE c.relkind IN ('v','m')
23+
ORDER BY n.nspname, c.relname`
24+
25+
type viewReloptionKey struct {
26+
schema string
27+
name string
28+
relkind string
29+
}
30+
31+
type viewReloptionRow struct {
32+
Nspname string `db:"nspname"`
33+
Relname string `db:"relname"`
34+
Relkind string `db:"relkind"`
35+
Reloptions []string `db:"reloptions"`
36+
}
37+
38+
func appendViewReloptionDiff(ctx context.Context, sql string, source, target pgconn.Config, schema []string, options ...func(*pgx.ConnConfig)) string {
39+
sourceConn, err := utils.ConnectByConfig(ctx, source, options...)
40+
if err != nil {
41+
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "could not connect to source database to diff view reloptions:", err)
42+
return sql
43+
}
44+
defer sourceConn.Close(context.Background())
45+
targetConn, err := utils.ConnectByConfig(ctx, target, options...)
46+
if err != nil {
47+
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "could not connect to target database to diff view reloptions:", err)
48+
return sql
49+
}
50+
defer targetConn.Close(context.Background())
51+
sourceReloptions, err := selectViewReloptions(ctx, sourceConn)
52+
if err != nil {
53+
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "could not read source view reloptions:", err)
54+
return sql
55+
}
56+
targetReloptions, err := selectViewReloptions(ctx, targetConn)
57+
if err != nil {
58+
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "could not read target view reloptions:", err)
59+
return sql
60+
}
61+
return appendDiffSQL(sql, buildViewReloptionDiff(sourceReloptions, targetReloptions, schema))
62+
}
63+
64+
func selectViewReloptions(ctx context.Context, conn *pgx.Conn) (map[viewReloptionKey][]string, error) {
65+
rows, err := conn.Query(ctx, SELECT_VIEW_RELOPTIONS)
66+
if err != nil {
67+
return nil, err
68+
}
69+
collected, err := pgxv5.CollectRows[viewReloptionRow](rows)
70+
if err != nil {
71+
return nil, err
72+
}
73+
out := make(map[viewReloptionKey][]string, len(collected))
74+
for _, r := range collected {
75+
out[viewReloptionKey{schema: r.Nspname, name: r.Relname, relkind: r.Relkind}] = r.Reloptions
76+
}
77+
return out, nil
78+
}
79+
80+
func buildViewReloptionDiff(source, target map[viewReloptionKey][]string, schema []string) string {
81+
if len(source) == 0 || len(target) == 0 {
82+
return ""
83+
}
84+
includeSchema := schemaFilter(schema)
85+
keys := make([]viewReloptionKey, 0, len(target))
86+
for key := range target {
87+
if !includeSchema(key.schema) {
88+
continue
89+
}
90+
if _, ok := source[key]; ok {
91+
keys = append(keys, key)
92+
}
93+
}
94+
sort.Slice(keys, func(i, j int) bool {
95+
if keys[i].schema != keys[j].schema {
96+
return keys[i].schema < keys[j].schema
97+
}
98+
if keys[i].name != keys[j].name {
99+
return keys[i].name < keys[j].name
100+
}
101+
return keys[i].relkind < keys[j].relkind
102+
})
103+
var statements []string
104+
for _, key := range keys {
105+
statements = append(statements, buildAlterViewReloptions(key, source[key], target[key])...)
106+
}
107+
return strings.Join(statements, "")
108+
}
109+
110+
func schemaFilter(schema []string) func(string) bool {
111+
if len(schema) > 0 {
112+
included := make(map[string]bool, len(schema))
113+
for _, name := range schema {
114+
included[name] = true
115+
}
116+
return func(name string) bool {
117+
return included[name]
118+
}
119+
}
120+
excluded := make(map[string]bool, len(managedSchemas)+2)
121+
for _, name := range managedSchemas {
122+
excluded[name] = true
123+
}
124+
excluded["information_schema"] = true
125+
excluded["pg_catalog"] = true
126+
return func(name string) bool {
127+
return !excluded[name] && !strings.HasPrefix(name, "pg_")
128+
}
129+
}
130+
131+
func buildAlterViewReloptions(key viewReloptionKey, source, target []string) []string {
132+
sourceOpts := reloptionsByName(source)
133+
targetOpts := reloptionsByName(target)
134+
var setNames []string
135+
for name, targetOpt := range targetOpts {
136+
if sourceOpt, ok := sourceOpts[name]; !ok || sourceOpt.raw != targetOpt.raw {
137+
setNames = append(setNames, name)
138+
}
139+
}
140+
var resetNames []string
141+
for name := range sourceOpts {
142+
if _, ok := targetOpts[name]; !ok {
143+
resetNames = append(resetNames, name)
144+
}
145+
}
146+
sort.Strings(setNames)
147+
sort.Strings(resetNames)
148+
alterPrefix := "ALTER VIEW "
149+
if key.relkind == "m" {
150+
alterPrefix = "ALTER MATERIALIZED VIEW "
151+
}
152+
viewName := quoteIdentifier(key.schema) + "." + quoteIdentifier(key.name)
153+
var statements []string
154+
if len(setNames) > 0 {
155+
opts := make([]string, len(setNames))
156+
for i, name := range setNames {
157+
opts[i] = targetOpts[name].raw
158+
}
159+
statements = append(statements, fmt.Sprintf("%s%s SET (%s);\n", alterPrefix, viewName, strings.Join(opts, ", ")))
160+
}
161+
if len(resetNames) > 0 {
162+
statements = append(statements, fmt.Sprintf("%s%s RESET (%s);\n", alterPrefix, viewName, strings.Join(resetNames, ", ")))
163+
}
164+
return statements
165+
}
166+
167+
type reloption struct {
168+
raw string
169+
}
170+
171+
func reloptionsByName(options []string) map[string]reloption {
172+
out := make(map[string]reloption, len(options))
173+
for _, raw := range options {
174+
name, _, _ := strings.Cut(raw, "=")
175+
if name == "" {
176+
continue
177+
}
178+
out[name] = reloption{raw: raw}
179+
}
180+
return out
181+
}
182+
183+
func appendDiffSQL(sql, extra string) string {
184+
if extra == "" {
185+
return sql
186+
}
187+
if strings.TrimSpace(sql) == "" {
188+
return extra
189+
}
190+
if strings.HasSuffix(sql, "\n") {
191+
return sql + extra
192+
}
193+
return sql + "\n" + extra
194+
}
195+
196+
func quoteIdentifier(identifier string) string {
197+
return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"`
198+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package diff
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/jackc/pgconn"
8+
"github.com/jackc/pgx/v4"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/supabase/cli/pkg/pgtest"
11+
)
12+
13+
func TestBuildViewReloptionDiff(t *testing.T) {
14+
t.Run("emits ALTER VIEW for reloption-only changes", func(t *testing.T) {
15+
key := viewReloptionKey{schema: "public", name: "user_details", relkind: "v"}
16+
out := buildViewReloptionDiff(
17+
map[viewReloptionKey][]string{
18+
key: {"security_invoker=true"},
19+
},
20+
map[viewReloptionKey][]string{
21+
key: {"security_invoker=false"},
22+
},
23+
[]string{"public"},
24+
)
25+
assert.Equal(t, `ALTER VIEW "public"."user_details" SET (security_invoker=false);
26+
`, out)
27+
})
28+
29+
t.Run("emits RESET for reloptions removed from existing views", func(t *testing.T) {
30+
key := viewReloptionKey{schema: "public", name: "user_details", relkind: "v"}
31+
out := buildViewReloptionDiff(
32+
map[viewReloptionKey][]string{
33+
key: {"security_invoker=true", "check_option=local"},
34+
},
35+
map[viewReloptionKey][]string{
36+
key: {"check_option=local"},
37+
},
38+
[]string{"public"},
39+
)
40+
assert.Equal(t, `ALTER VIEW "public"."user_details" RESET (security_invoker);
41+
`, out)
42+
})
43+
44+
t.Run("emits SET and RESET in stable order", func(t *testing.T) {
45+
key := viewReloptionKey{schema: "public", name: "user_details", relkind: "v"}
46+
out := buildViewReloptionDiff(
47+
map[viewReloptionKey][]string{
48+
key: {"security_invoker=true", "check_option=local"},
49+
},
50+
map[viewReloptionKey][]string{
51+
key: {"security_barrier=true", "security_invoker=false"},
52+
},
53+
[]string{"public"},
54+
)
55+
assert.Equal(t, `ALTER VIEW "public"."user_details" SET (security_barrier=true, security_invoker=false);
56+
ALTER VIEW "public"."user_details" RESET (check_option);
57+
`, out)
58+
})
59+
60+
t.Run("emits ALTER MATERIALIZED VIEW for materialized views", func(t *testing.T) {
61+
key := viewReloptionKey{schema: "public", name: "cached_details", relkind: "m"}
62+
out := buildViewReloptionDiff(
63+
map[viewReloptionKey][]string{
64+
key: {"autovacuum_enabled=true"},
65+
},
66+
map[viewReloptionKey][]string{
67+
key: {"autovacuum_enabled=false"},
68+
},
69+
[]string{"public"},
70+
)
71+
assert.Equal(t, `ALTER MATERIALIZED VIEW "public"."cached_details" SET (autovacuum_enabled=false);
72+
`, out)
73+
})
74+
75+
t.Run("skips target-only views because CREATE VIEW diff owns them", func(t *testing.T) {
76+
key := viewReloptionKey{schema: "public", name: "new_view", relkind: "v"}
77+
out := buildViewReloptionDiff(
78+
map[viewReloptionKey][]string{},
79+
map[viewReloptionKey][]string{
80+
key: {"security_invoker=true"},
81+
},
82+
[]string{"public"},
83+
)
84+
assert.Empty(t, out)
85+
})
86+
87+
t.Run("respects requested schema filter", func(t *testing.T) {
88+
key := viewReloptionKey{schema: "private", name: "user_details", relkind: "v"}
89+
out := buildViewReloptionDiff(
90+
map[viewReloptionKey][]string{
91+
key: {"security_invoker=true"},
92+
},
93+
map[viewReloptionKey][]string{
94+
key: {"security_invoker=false"},
95+
},
96+
[]string{"public"},
97+
)
98+
assert.Empty(t, out)
99+
})
100+
}
101+
102+
func TestAppendDiffSQL(t *testing.T) {
103+
assert.Equal(t, "ALTER VIEW v SET (security_invoker=true);\n", appendDiffSQL("", "ALTER VIEW v SET (security_invoker=true);\n"))
104+
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"))
105+
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"))
106+
}
107+
108+
func TestAppendViewReloptionDiff(t *testing.T) {
109+
sourceConn := pgtest.NewConn()
110+
defer sourceConn.Close(t)
111+
targetConn := pgtest.NewConn()
112+
defer targetConn.Close(t)
113+
sourceConn.Query(SELECT_VIEW_RELOPTIONS).
114+
Reply("SELECT 1", viewReloptionRow{
115+
Nspname: "public",
116+
Relname: "user_details",
117+
Relkind: "v",
118+
Reloptions: []string{"security_invoker=true"},
119+
})
120+
targetConn.Query(SELECT_VIEW_RELOPTIONS).
121+
Reply("SELECT 1", viewReloptionRow{
122+
Nspname: "public",
123+
Relname: "user_details",
124+
Relkind: "v",
125+
Reloptions: []string{"security_invoker=false"},
126+
})
127+
source := pgconn.Config{Host: "source.example", Port: 5432, User: "postgres", Database: "postgres"}
128+
target := pgconn.Config{Host: "target.example", Port: 5432, User: "postgres", Database: "postgres"}
129+
out := appendViewReloptionDiff(context.Background(), "", source, target, []string{"public"}, func(cc *pgx.ConnConfig) {
130+
if cc.Host == source.Host {
131+
sourceConn.Intercept(cc)
132+
} else {
133+
targetConn.Intercept(cc)
134+
}
135+
})
136+
assert.Equal(t, `ALTER VIEW "public"."user_details" SET (security_invoker=false);
137+
`, out)
138+
}

0 commit comments

Comments
 (0)