Skip to content

Commit 23c2f56

Browse files
tianzhouclaude
andauthored
feat: add support for view reloptions (security_invoker, security_barrier) (#343) (#347)
* feat: add support for view reloptions (security_invoker, security_barrier) (#343) Track pg_class.reloptions for views and include WITH clause in generated DDL. This preserves security_invoker=true and other view options during plan/apply. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: sort view reloptions for deterministic comparison and output Address review feedback: - Sort reloptions at ingestion (inspector) so comparison and DDL rendering are order-insensitive and deterministic - Deduplicate: viewsEqual now delegates to viewOptionsEqual Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: copy reloptions slice before sorting to avoid mutating shared state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8618f59 commit 23c2f56

10 files changed

Lines changed: 87 additions & 10 deletions

File tree

internal/diff/view.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,15 +251,16 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d
251251
// Check if only the comment changed and definition is identical
252252
// Both IRs come from pg_get_viewdef() at the same PostgreSQL version, so string comparison is sufficient
253253
definitionsEqual := diff.Old.Definition == diff.New.Definition
254-
commentOnlyChange := diff.CommentChanged && definitionsEqual && diff.Old.Materialized == diff.New.Materialized
254+
optionsEqual := viewOptionsEqual(diff.Old.Options, diff.New.Options)
255+
commentOnlyChange := diff.CommentChanged && definitionsEqual && optionsEqual && diff.Old.Materialized == diff.New.Materialized
255256

256257
// Check if only indexes changed (for materialized views)
257258
hasIndexChanges := len(diff.AddedIndexes) > 0 || len(diff.DroppedIndexes) > 0 || len(diff.ModifiedIndexes) > 0
258-
indexOnlyChange := diff.New.Materialized && hasIndexChanges && definitionsEqual && !diff.CommentChanged
259+
indexOnlyChange := diff.New.Materialized && hasIndexChanges && definitionsEqual && optionsEqual && !diff.CommentChanged
259260

260261
// Check if only triggers changed (for INSTEAD OF triggers on views)
261262
hasTriggerChanges := len(diff.AddedTriggers) > 0 || len(diff.DroppedTriggers) > 0 || len(diff.ModifiedTriggers) > 0
262-
triggerOnlyChange := hasTriggerChanges && definitionsEqual && !diff.CommentChanged && !hasIndexChanges
263+
triggerOnlyChange := hasTriggerChanges && definitionsEqual && optionsEqual && !diff.CommentChanged && !hasIndexChanges
263264

264265
// Handle non-structural changes (comment-only, index-only, or trigger-only)
265266
if commentOnlyChange || indexOnlyChange || triggerOnlyChange {
@@ -503,8 +504,14 @@ func generateViewSQL(view *ir.View, targetSchema string) string {
503504
createClause = "CREATE OR REPLACE VIEW"
504505
}
505506

507+
// Add WITH clause for view options (e.g., security_invoker, security_barrier)
508+
var withClause string
509+
if len(view.Options) > 0 {
510+
withClause = " WITH (" + strings.Join(view.Options, ", ") + ")"
511+
}
512+
506513
// Use the view definition as-is - it has already been normalized
507-
return fmt.Sprintf("%s %s AS\n%s;", createClause, viewName, view.Definition)
514+
return fmt.Sprintf("%s %s%s AS\n%s;", createClause, viewName, withClause, view.Definition)
508515
}
509516

510517
// diffViewTriggers computes added, dropped, and modified triggers between two views
@@ -572,6 +579,11 @@ func viewsEqual(old, new *ir.View) bool {
572579
return false
573580
}
574581

582+
// Compare view options (e.g., security_invoker, security_barrier)
583+
if !viewOptionsEqual(old.Options, new.Options) {
584+
return false
585+
}
586+
575587
// Both definitions come from pg_get_viewdef(), so they are already normalized
576588
return old.Definition == new.Definition
577589
}
@@ -820,3 +832,16 @@ func sortModifiedViewsForProcessing(views []*viewDiff) {
820832
return false
821833
})
822834
}
835+
836+
// viewOptionsEqual compares two view option slices for equality
837+
func viewOptionsEqual(a, b []string) bool {
838+
if len(a) != len(b) {
839+
return false
840+
}
841+
for i, opt := range a {
842+
if opt != b[i] {
843+
return false
844+
}
845+
}
846+
return true
847+
}

ir/inspector.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,11 +1363,16 @@ func (i *Inspector) buildViews(ctx context.Context, schema *IR, targetSchema str
13631363
return fmt.Errorf("failed to get columns for view %s.%s: %w", schemaName, viewName, err)
13641364
}
13651365

1366+
// Copy and sort reloptions for deterministic comparison and output
1367+
options := append([]string(nil), view.Reloptions...)
1368+
sort.Strings(options)
1369+
13661370
v := &View{
13671371
Schema: schemaName,
13681372
Name: viewName,
13691373
Definition: definition,
13701374
Columns: columns,
1375+
Options: options,
13711376
Comment: comment,
13721377
Materialized: view.IsMaterialized.Valid && view.IsMaterialized.Bool,
13731378
}

ir/ir.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ type View struct {
123123
Name string `json:"name"`
124124
Definition string `json:"definition"`
125125
Columns []string `json:"columns,omitempty"` // Ordered list of output column names
126+
Options []string `json:"options,omitempty"` // View options (e.g., "security_invoker=true", "security_barrier=true")
126127
Comment string `json:"comment,omitempty"`
127128
Materialized bool `json:"materialized,omitempty"`
128129
Indexes map[string]*Index `json:"indexes,omitempty"` // For materialized views only

ir/queries/queries.sql

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,7 +1073,8 @@ WITH view_definitions AS (
10731073
c.oid AS view_oid,
10741074
COALESCE(d.description, '') AS view_comment,
10751075
(c.relkind = 'm') AS is_materialized,
1076-
n.nspname AS view_schema
1076+
n.nspname AS view_schema,
1077+
c.reloptions AS reloptions
10771078
FROM pg_class c
10781079
JOIN pg_namespace n ON c.relnamespace = n.oid
10791080
LEFT JOIN pg_description d ON d.objoid = c.oid AND d.classoid = 'pg_class'::regclass AND d.objsubid = 0
@@ -1090,7 +1091,8 @@ SELECT
10901091
-- This ensures cross-schema table references are qualified with schema names
10911092
sp.view_def AS view_definition,
10921093
vd.view_comment,
1093-
vd.is_materialized
1094+
vd.is_materialized,
1095+
vd.reloptions
10941096
FROM view_definitions vd
10951097
CROSS JOIN LATERAL (
10961098
SELECT

ir/queries/queries.sql.go

Lines changed: 6 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

testdata/diff/create_view/add_view/diff.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ CREATE OR REPLACE VIEW nullif_functions_view AS
6767
FROM employees e
6868
JOIN departments d USING (id)
6969
WHERE e.priority > 0;
70+
CREATE OR REPLACE VIEW secure_employee_view WITH (security_invoker=true) AS
71+
SELECT id,
72+
name,
73+
email,
74+
status
75+
FROM employees
76+
WHERE status::text = 'active'::text;
7077
CREATE OR REPLACE VIEW text_search_view AS
7178
SELECT id,
7279
COALESCE((first_name::text || ' '::text) || last_name::text, 'Anonymous'::text) AS display_name,

testdata/diff/create_view/add_view/new.sql

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,13 @@ FROM (
141141
) AS combined_data
142142
WHERE id IS NOT NULL
143143
ORDER BY source_type, id;
144+
145+
-- View with security_invoker option (PG 15+, issue #343)
146+
CREATE VIEW public.secure_employee_view WITH (security_invoker = true) AS
147+
SELECT
148+
id,
149+
name,
150+
email,
151+
status
152+
FROM employees
153+
WHERE status = 'active';

testdata/diff/create_view/add_view/plan.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@
2626
"operation": "create",
2727
"path": "public.nullif_functions_view"
2828
},
29+
{
30+
"sql": "CREATE OR REPLACE VIEW secure_employee_view WITH (security_invoker=true) AS\n SELECT id,\n name,\n email,\n status\n FROM employees\n WHERE status::text = 'active'::text;",
31+
"type": "view",
32+
"operation": "create",
33+
"path": "public.secure_employee_view"
34+
},
2935
{
3036
"sql": "CREATE OR REPLACE VIEW text_search_view AS\n SELECT id,\n COALESCE((first_name::text || ' '::text) || last_name::text, 'Anonymous'::text) AS display_name,\n COALESCE(email, ''::character varying) AS email,\n COALESCE(bio, 'No description available'::text) AS description,\n to_tsvector('english'::regconfig, (((COALESCE(first_name, ''::character varying)::text || ' '::text) || COALESCE(last_name, ''::character varying)::text) || ' '::text) || COALESCE(bio, ''::text)) AS search_vector\n FROM employees\n WHERE status::text = 'active'::text;",
3137
"type": "view",

testdata/diff/create_view/add_view/plan.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ CREATE OR REPLACE VIEW nullif_functions_view AS
7070
JOIN departments d USING (id)
7171
WHERE e.priority > 0;
7272

73+
CREATE OR REPLACE VIEW secure_employee_view WITH (security_invoker=true) AS
74+
SELECT id,
75+
name,
76+
email,
77+
status
78+
FROM employees
79+
WHERE status::text = 'active'::text;
80+
7381
CREATE OR REPLACE VIEW text_search_view AS
7482
SELECT id,
7583
COALESCE((first_name::text || ' '::text) || last_name::text, 'Anonymous'::text) AS display_name,

testdata/diff/create_view/add_view/plan.txt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
Plan: 5 to add.
1+
Plan: 6 to add.
22

33
Summary by type:
4-
views: 5 to add
4+
views: 6 to add
55

66
Views:
77
+ array_operators_view
88
+ cte_with_case_view
99
+ nullif_functions_view
10+
+ secure_employee_view
1011
+ text_search_view
1112
+ union_subquery_view
1213

@@ -85,6 +86,14 @@ CREATE OR REPLACE VIEW nullif_functions_view AS
8586
JOIN departments d USING (id)
8687
WHERE e.priority > 0;
8788

89+
CREATE OR REPLACE VIEW secure_employee_view WITH (security_invoker=true) AS
90+
SELECT id,
91+
name,
92+
email,
93+
status
94+
FROM employees
95+
WHERE status::text = 'active'::text;
96+
8897
CREATE OR REPLACE VIEW text_search_view AS
8998
SELECT id,
9099
COALESCE((first_name::text || ' '::text) || last_name::text, 'Anonymous'::text) AS display_name,

0 commit comments

Comments
 (0)