-
Notifications
You must be signed in to change notification settings - Fork 55
fix: render policy and constraint expressions with table schema search_path (#449) #467
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "context" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/pgplex/pgschema/testutil" | ||
| ) | ||
|
|
||
| // TestIssue449RepeatPlanIdempotency verifies that a plan generated immediately after a | ||
| // successful apply reports no changes (https://github.com/pgplex/pgschema/issues/449). | ||
| // | ||
| // Two distinct drift sources are covered: | ||
| // 1. CHECK constraints casting to a type in the managed (non-public) schema: the current | ||
| // state rendered the cast schema-qualified while the desired state stripped the | ||
| // qualifier, producing a perpetual DROP/ADD/VALIDATE cycle (also issue #445). | ||
| // 2. RLS policy expressions on a table whose name equals the managed schema name: the | ||
| // same-schema qualifier stripping mistook table-qualified column references | ||
| // (profiles.id) for schema qualifiers and removed them from the current state only. | ||
| func TestIssue449RepeatPlanIdempotency(t *testing.T) { | ||
| if testing.Short() { | ||
| t.Skip("Skipping integration test in short mode") | ||
| } | ||
|
|
||
| ctx := context.Background() | ||
|
|
||
| embeddedPG := testutil.SetupPostgres(t) | ||
| defer embeddedPG.Stop() | ||
| conn, host, port, dbname, user, password := testutil.ConnectToPostgres(t, embeddedPG) | ||
| defer conn.Close() | ||
|
|
||
| // applyThenReplan applies the desired state to the given schema and returns the SQL | ||
| // of a plan generated immediately afterwards. An idempotent plan returns "". | ||
| applyThenReplan := func(t *testing.T, schema, desiredSQL string) string { | ||
| t.Helper() | ||
|
|
||
| if _, err := conn.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema); err != nil { | ||
| t.Fatalf("Failed to create schema %s: %v", schema, err) | ||
| } | ||
|
|
||
| desiredStateFile := filepath.Join(t.TempDir(), "desired.sql") | ||
| if err := os.WriteFile(desiredStateFile, []byte(desiredSQL), 0644); err != nil { | ||
| t.Fatalf("Failed to write desired state file: %v", err) | ||
| } | ||
|
|
||
| if err := applySchemaChanges(host, port, dbname, user, password, schema, desiredStateFile); err != nil { | ||
| t.Fatalf("Failed to apply desired state: %v", err) | ||
| } | ||
|
|
||
| replanOutput, err := generatePlanSQLFormatted(host, port, dbname, user, password, schema, desiredStateFile) | ||
| if err != nil { | ||
| t.Fatalf("Failed to generate repeat plan: %v", err) | ||
| } | ||
| return replanOutput | ||
| } | ||
|
|
||
| t.Run("check_constraint_same_schema_type_cast", func(t *testing.T) { | ||
| replan := applyThenReplan(t, "app449", ` | ||
| CREATE TYPE item_status AS ENUM ('active', 'disabled'); | ||
|
|
||
| CREATE TABLE items ( | ||
| id integer PRIMARY KEY, | ||
| status item_status NOT NULL, | ||
| CONSTRAINT items_status_check CHECK (status <> 'disabled'::item_status) | ||
| ); | ||
| `) | ||
| if replan != "" { | ||
| t.Errorf("Expected no changes on repeat plan after apply, but got:\n%s", replan) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("policy_schema_name_equals_table_name", func(t *testing.T) { | ||
| replan := applyThenReplan(t, "profiles", ` | ||
| CREATE TABLE profiles ( | ||
| id uuid PRIMARY KEY, | ||
| user_id uuid NOT NULL | ||
| ); | ||
|
|
||
| CREATE TABLE profile_media ( | ||
| id uuid PRIMARY KEY, | ||
| profile_id uuid NOT NULL REFERENCES profiles (id) | ||
| ); | ||
|
|
||
| ALTER TABLE profile_media ENABLE ROW LEVEL SECURITY; | ||
|
|
||
| CREATE POLICY "Users can read their own media" ON profile_media | ||
| FOR SELECT | ||
| USING (profile_id IN (SELECT id FROM profiles)); | ||
| `) | ||
| if replan != "" { | ||
| t.Errorf("Expected no changes on repeat plan after apply, but got:\n%s", replan) | ||
| } | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -325,8 +325,8 @@ SELECT | |
| COALESCE(fcl.relname, '') AS foreign_table_name, | ||
| COALESCE(fa.attname, '') AS foreign_column_name, | ||
| COALESCE(fa.attnum, 0) AS foreign_ordinal_position, | ||
| CASE WHEN c.contype = 'c' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS check_clause, | ||
| CASE WHEN c.contype = 'x' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS exclusion_definition, | ||
| cd.check_clause, | ||
| cd.exclusion_definition, | ||
| CASE c.confdeltype | ||
| WHEN 'a' THEN 'NO ACTION' | ||
| WHEN 'r' THEN 'RESTRICT' | ||
|
|
@@ -355,6 +355,16 @@ LEFT JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) | |
| LEFT JOIN pg_class fcl ON c.confrelid = fcl.oid | ||
| LEFT JOIN pg_namespace fn ON fcl.relnamespace = fn.oid | ||
| LEFT JOIN pg_attribute fa ON fa.attrelid = c.confrelid AND fa.attnum = c.confkey[array_position(c.conkey, a.attnum)] | ||
| LEFT JOIN LATERAL ( | ||
| SELECT | ||
| -- Render with search_path set to the table's own schema so same-schema | ||
| -- type/function references come out unqualified while cross-schema | ||
| -- references stay qualified. This matches how the desired state renders | ||
| -- in its temporary schema, keeping both sides comparable (issue #449). | ||
| set_config('search_path', quote_ident(n.nspname), true) AS dummy, | ||
| CASE WHEN c.contype = 'c' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS check_clause, | ||
| CASE WHEN c.contype = 'x' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS exclusion_definition | ||
| ) cd ON true | ||
| WHERE n.nspname NOT IN ('information_schema', 'pg_catalog', 'pg_toast') | ||
| AND n.nspname NOT LIKE 'pg_temp_%' | ||
| AND n.nspname NOT LIKE 'pg_toast_temp_%' | ||
|
|
@@ -769,13 +779,12 @@ ORDER BY n.nspname, c.relname; | |
|
|
||
| -- GetRLSPolicies retrieves all row level security policies | ||
| -- This replicates the pg_policies system view but computes the qual/with_check | ||
| -- expressions with a forced search_path so cross-schema references stay qualified: | ||
| -- 1. set_config sets search_path to only pg_catalog | ||
| -- 2. pg_get_expr then uses that search_path and includes schema qualifiers for any | ||
| -- function or table reference outside pg_catalog. Same-schema qualifiers are stripped | ||
| -- later by normalizePolicyExpression. Without this, references to objects on the | ||
| -- session search_path (e.g. Supabase's auth.uid()) are emitted unqualified and the | ||
| -- generated CREATE POLICY fails on apply. (Issue #427) | ||
| -- expressions with a forced search_path so qualification is deterministic: | ||
| -- 1. set_config sets search_path to only the table's own schema | ||
| -- 2. pg_get_expr then renders same-schema references unqualified while cross-schema | ||
| -- references (e.g. Supabase's auth.uid(), issue #427) stay qualified. Without this, | ||
| -- rendering depends on the session search_path and the current/desired states | ||
| -- compare differently (issue #449). | ||
| -- name: GetRLSPolicies :many | ||
| SELECT | ||
| n.nspname AS schemaname, | ||
|
|
@@ -805,7 +814,7 @@ JOIN pg_catalog.pg_class c ON c.oid = pol.polrelid | |
| JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace | ||
| LEFT JOIN LATERAL ( | ||
| SELECT | ||
| set_config('search_path', 'pg_catalog', true) AS dummy, | ||
| set_config('search_path', quote_ident(n.nspname), true) AS dummy, | ||
| pg_get_expr(pol.polqual, pol.polrelid) AS qual, | ||
| pg_get_expr(pol.polwithcheck, pol.polrelid) AS with_check | ||
| ) e ON true | ||
|
|
@@ -862,7 +871,12 @@ JOIN pg_catalog.pg_class c ON c.oid = pol.polrelid | |
| JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace | ||
| LEFT JOIN LATERAL ( | ||
| SELECT | ||
| set_config('search_path', 'pg_catalog', true) AS dummy, | ||
| -- Render with search_path set to the table's own schema so same-schema | ||
| -- references come out unqualified while cross-schema references (e.g. | ||
| -- Supabase's auth.uid(), issue #427) stay qualified. This matches how the | ||
| -- desired state renders in its temporary schema, so both sides compare | ||
| -- equal without regex-stripping qualifiers afterwards (issue #449). | ||
| set_config('search_path', quote_ident(n.nspname), true) AS dummy, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The desired-state database is loaded with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The apply-time search_path ( |
||
| pg_get_expr(pol.polqual, pol.polrelid) AS qual, | ||
| pg_get_expr(pol.polwithcheck, pol.polrelid) AS with_check | ||
|
Comment on lines
+879
to
881
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This has the same ordering problem as the constraint query:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as the constraint-query thread: this is the pre-existing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair point. You're right that this is the pre-existing Given the red-to-green test coverage on |
||
| ) e ON true | ||
|
|
@@ -970,8 +984,8 @@ SELECT | |
| COALESCE(fcl.relname, '') AS foreign_table_name, | ||
| COALESCE(fa.attname, '') AS foreign_column_name, | ||
| COALESCE(fa.attnum, 0) AS foreign_ordinal_position, | ||
| CASE WHEN c.contype = 'c' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS check_clause, | ||
| CASE WHEN c.contype = 'x' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS exclusion_definition, | ||
| cd.check_clause, | ||
| cd.exclusion_definition, | ||
| CASE c.confdeltype | ||
| WHEN 'a' THEN 'NO ACTION' | ||
| WHEN 'r' THEN 'RESTRICT' | ||
|
|
@@ -1004,6 +1018,16 @@ LEFT JOIN pg_class fcl ON c.confrelid = fcl.oid | |
| LEFT JOIN pg_namespace fn ON fcl.relnamespace = fn.oid | ||
| LEFT JOIN pg_attribute fa ON fa.attrelid = c.confrelid AND fa.attnum = c.confkey[array_position(c.conkey, a.attnum)] | ||
| LEFT JOIN pg_index i ON i.indexrelid = c.conindid | ||
| LEFT JOIN LATERAL ( | ||
| SELECT | ||
| -- Render with search_path set to the table's own schema so same-schema | ||
| -- type/function references come out unqualified while cross-schema | ||
| -- references stay qualified. This matches how the desired state renders | ||
| -- in its temporary schema, keeping both sides comparable (issue #449). | ||
| set_config('search_path', quote_ident(n.nspname), true) AS dummy, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The desired-state path applies SQL with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as the policy-query thread: apply-time search_path only affects resolution, not rendering. Both sides are inspected through this same query, so a CHECK constraint referencing a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right β I was wrong. Since both the current state and desired state are read through the same query (with the same |
||
| CASE WHEN c.contype = 'c' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS check_clause, | ||
| CASE WHEN c.contype = 'x' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS exclusion_definition | ||
|
Comment on lines
+1027
to
+1029
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This lateral subquery relies on the unreferenced
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The ordering here is the established pattern in this file: the column-default and index-predicate queries have used the same There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right. My concern was overstated. PostgreSQL does guarantee that volatile functions in a target list are evaluated in order and not elided β the planner cannot skip I withdraw the comment. The implementation is sound. |
||
| ) cd ON true | ||
|
tianzhou marked this conversation as resolved.
|
||
| WHERE n.nspname = $1 | ||
| -- Skip internal per-partition FK rows (conparentid != 0) that PostgreSQL | ||
| -- creates when a FK references a partitioned table. pg_dump omits these; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keeping
ON truedeliberately: it matches the existingLATERAL set_configpattern used by the column-default and index queries in this file, which also execute for every row (columns are far more numerous than constraints). Inspection is a cold path that runs once per plan/dump, andset_configcosts microseconds per row, so filtering oncontypewould save negligible work while diverging from the established pattern.