Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions internal/diff/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,15 +251,16 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d
// Check if only the comment changed and definition is identical
// Both IRs come from pg_get_viewdef() at the same PostgreSQL version, so string comparison is sufficient
definitionsEqual := diff.Old.Definition == diff.New.Definition
commentOnlyChange := diff.CommentChanged && definitionsEqual && diff.Old.Materialized == diff.New.Materialized
optionsEqual := viewOptionsEqual(diff.Old.Options, diff.New.Options)
commentOnlyChange := diff.CommentChanged && definitionsEqual && optionsEqual && diff.Old.Materialized == diff.New.Materialized

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

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

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

// Add WITH clause for view options (e.g., security_invoker, security_barrier)
var withClause string
if len(view.Options) > 0 {
withClause = " WITH (" + strings.Join(view.Options, ", ") + ")"
}

// Use the view definition as-is - it has already been normalized
return fmt.Sprintf("%s %s AS\n%s;", createClause, viewName, view.Definition)
return fmt.Sprintf("%s %s%s AS\n%s;", createClause, viewName, withClause, view.Definition)
}

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

// Compare view options (e.g., security_invoker, security_barrier)
if !viewOptionsEqual(old.Options, new.Options) {
return false
}

// Both definitions come from pg_get_viewdef(), so they are already normalized
return old.Definition == new.Definition
}
Expand Down Expand Up @@ -820,3 +832,16 @@ func sortModifiedViewsForProcessing(views []*viewDiff) {
return false
})
}

// viewOptionsEqual compares two view option slices for equality
func viewOptionsEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i, opt := range a {
if opt != b[i] {
return false
}
}
return true
}
5 changes: 5 additions & 0 deletions ir/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -1363,11 +1363,16 @@ func (i *Inspector) buildViews(ctx context.Context, schema *IR, targetSchema str
return fmt.Errorf("failed to get columns for view %s.%s: %w", schemaName, viewName, err)
}

// Copy and sort reloptions for deterministic comparison and output
options := append([]string(nil), view.Reloptions...)
sort.Strings(options)

v := &View{
Schema: schemaName,
Name: viewName,
Definition: definition,
Columns: columns,
Options: options,
Comment: comment,
Materialized: view.IsMaterialized.Valid && view.IsMaterialized.Bool,
}
Expand Down
1 change: 1 addition & 0 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ type View struct {
Name string `json:"name"`
Definition string `json:"definition"`
Columns []string `json:"columns,omitempty"` // Ordered list of output column names
Options []string `json:"options,omitempty"` // View options (e.g., "security_invoker=true", "security_barrier=true")
Comment string `json:"comment,omitempty"`
Materialized bool `json:"materialized,omitempty"`
Indexes map[string]*Index `json:"indexes,omitempty"` // For materialized views only
Expand Down
6 changes: 4 additions & 2 deletions ir/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1073,7 +1073,8 @@ WITH view_definitions AS (
c.oid AS view_oid,
COALESCE(d.description, '') AS view_comment,
(c.relkind = 'm') AS is_materialized,
n.nspname AS view_schema
n.nspname AS view_schema,
c.reloptions AS reloptions
FROM pg_class c
JOIN pg_namespace n ON c.relnamespace = n.oid
LEFT JOIN pg_description d ON d.objoid = c.oid AND d.classoid = 'pg_class'::regclass AND d.objsubid = 0
Expand All @@ -1090,7 +1091,8 @@ SELECT
-- This ensures cross-schema table references are qualified with schema names
sp.view_def AS view_definition,
vd.view_comment,
vd.is_materialized
vd.is_materialized,
vd.reloptions
FROM view_definitions vd
CROSS JOIN LATERAL (
SELECT
Expand Down
8 changes: 6 additions & 2 deletions ir/queries/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions testdata/diff/create_view/add_view/diff.sql
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ CREATE OR REPLACE VIEW nullif_functions_view AS
FROM employees e
JOIN departments d USING (id)
WHERE e.priority > 0;
CREATE OR REPLACE VIEW secure_employee_view WITH (security_invoker=true) AS
SELECT id,
name,
email,
status
FROM employees
WHERE status::text = 'active'::text;
CREATE OR REPLACE VIEW text_search_view AS
SELECT id,
COALESCE((first_name::text || ' '::text) || last_name::text, 'Anonymous'::text) AS display_name,
Expand Down
10 changes: 10 additions & 0 deletions testdata/diff/create_view/add_view/new.sql
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,13 @@ FROM (
) AS combined_data
WHERE id IS NOT NULL
ORDER BY source_type, id;

-- View with security_invoker option (PG 15+, issue #343)
CREATE VIEW public.secure_employee_view WITH (security_invoker = true) AS
SELECT
id,
name,
email,
status
FROM employees
WHERE status = 'active';
6 changes: 6 additions & 0 deletions testdata/diff/create_view/add_view/plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
"operation": "create",
"path": "public.nullif_functions_view"
},
{
"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;",
"type": "view",
"operation": "create",
"path": "public.secure_employee_view"
},
{
"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;",
"type": "view",
Expand Down
8 changes: 8 additions & 0 deletions testdata/diff/create_view/add_view/plan.sql
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ CREATE OR REPLACE VIEW nullif_functions_view AS
JOIN departments d USING (id)
WHERE e.priority > 0;

CREATE OR REPLACE VIEW secure_employee_view WITH (security_invoker=true) AS
SELECT id,
name,
email,
status
FROM employees
WHERE status::text = 'active'::text;

CREATE OR REPLACE VIEW text_search_view AS
SELECT id,
COALESCE((first_name::text || ' '::text) || last_name::text, 'Anonymous'::text) AS display_name,
Expand Down
13 changes: 11 additions & 2 deletions testdata/diff/create_view/add_view/plan.txt
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
Plan: 5 to add.
Plan: 6 to add.

Summary by type:
views: 5 to add
views: 6 to add

Views:
+ array_operators_view
+ cte_with_case_view
+ nullif_functions_view
+ secure_employee_view
+ text_search_view
+ union_subquery_view

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

CREATE OR REPLACE VIEW secure_employee_view WITH (security_invoker=true) AS
SELECT id,
name,
email,
status
FROM employees
WHERE status::text = 'active'::text;

CREATE OR REPLACE VIEW text_search_view AS
SELECT id,
COALESCE((first_name::text || ' '::text) || last_name::text, 'Anonymous'::text) AS display_name,
Expand Down