diff --git a/internal/diff/view.go b/internal/diff/view.go index becf49a0..c3755daf 100644 --- a/internal/diff/view.go +++ b/internal/diff/view.go @@ -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 { @@ -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 @@ -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 } @@ -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 +} diff --git a/ir/inspector.go b/ir/inspector.go index 36d6aa86..6d707e73 100644 --- a/ir/inspector.go +++ b/ir/inspector.go @@ -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, } diff --git a/ir/ir.go b/ir/ir.go index ebe18013..0909a215 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -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 diff --git a/ir/queries/queries.sql b/ir/queries/queries.sql index 6b06e2c9..e4ab8188 100644 --- a/ir/queries/queries.sql +++ b/ir/queries/queries.sql @@ -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 @@ -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 diff --git a/ir/queries/queries.sql.go b/ir/queries/queries.sql.go index b7c12dc1..27addacd 100644 --- a/ir/queries/queries.sql.go +++ b/ir/queries/queries.sql.go @@ -3185,7 +3185,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 @@ -3202,7 +3203,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 @@ -3218,6 +3220,7 @@ type GetViewsForSchemaRow struct { ViewDefinition sql.NullString `db:"view_definition" json:"view_definition"` ViewComment sql.NullString `db:"view_comment" json:"view_comment"` IsMaterialized sql.NullBool `db:"is_materialized" json:"is_materialized"` + Reloptions []string `db:"reloptions" json:"reloptions"` } // GetViewsForSchema retrieves all views and materialized views for a specific schema @@ -3239,6 +3242,7 @@ func (q *Queries) GetViewsForSchema(ctx context.Context, dollar_1 sql.NullString &i.ViewDefinition, &i.ViewComment, &i.IsMaterialized, + pq.Array(&i.Reloptions), ); err != nil { return nil, err } diff --git a/testdata/diff/create_view/add_view/diff.sql b/testdata/diff/create_view/add_view/diff.sql index f0287391..368d8e03 100644 --- a/testdata/diff/create_view/add_view/diff.sql +++ b/testdata/diff/create_view/add_view/diff.sql @@ -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, diff --git a/testdata/diff/create_view/add_view/new.sql b/testdata/diff/create_view/add_view/new.sql index 8434246d..987c2dc7 100644 --- a/testdata/diff/create_view/add_view/new.sql +++ b/testdata/diff/create_view/add_view/new.sql @@ -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'; diff --git a/testdata/diff/create_view/add_view/plan.json b/testdata/diff/create_view/add_view/plan.json index fe6c7bf4..551dba0d 100644 --- a/testdata/diff/create_view/add_view/plan.json +++ b/testdata/diff/create_view/add_view/plan.json @@ -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", diff --git a/testdata/diff/create_view/add_view/plan.sql b/testdata/diff/create_view/add_view/plan.sql index f11a9ba3..a64854e2 100644 --- a/testdata/diff/create_view/add_view/plan.sql +++ b/testdata/diff/create_view/add_view/plan.sql @@ -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, diff --git a/testdata/diff/create_view/add_view/plan.txt b/testdata/diff/create_view/add_view/plan.txt index d34a2aae..29df6c9d 100644 --- a/testdata/diff/create_view/add_view/plan.txt +++ b/testdata/diff/create_view/add_view/plan.txt @@ -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 @@ -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,