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
45 changes: 43 additions & 2 deletions internal/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -1528,10 +1528,19 @@ func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollecto
key := fmt.Sprintf("%s.%s", tableDiff.Table.Schema, tableDiff.Table.Name)
existingTables[key] = true
}
// Build lookup of all new table names (qualified) for policy deferral (#373).
// Policies that reference other new tables must be deferred until all tables exist.
newTableLookup := make(map[string]struct{}, len(d.addedTables))
for _, table := range d.addedTables {
newTableLookup[fmt.Sprintf("%s.%s", strings.ToLower(table.Schema), strings.ToLower(table.Name))] = struct{}{}
}
Comment on lines +1533 to +1536
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

newTableLookup stores an unqualified key (table.Name) for every added table. This collides when the migration adds tables with the same name in different schemas, which can lead to incorrect deferral decisions (e.g., the unqualified key may be treated as the policy’s “own table” and skipped even when the referenced table is actually in another schema). Consider tracking tables by fully-qualified name only, or keeping a mapping from unqualified name → set of qualified names so cross-schema cases can’t be lost.

Copilot uses AI. Check for mistakes.
var shouldDeferPolicy func(*ir.RLSPolicy) bool
if len(newFunctionLookup) > 0 {
if len(newFunctionLookup) > 0 || len(newTableLookup) > 0 {
shouldDeferPolicy = func(policy *ir.RLSPolicy) bool {
return policyReferencesNewFunction(policy, newFunctionLookup)
if policyReferencesNewFunction(policy, newFunctionLookup) {
return true
}
return policyReferencesOtherNewTable(policy, newTableLookup)
}
}

Expand Down Expand Up @@ -2028,6 +2037,38 @@ func policyReferencesNewFunction(policy *ir.RLSPolicy, newFunctions map[string]s
return false
}

// policyReferencesOtherNewTable determines if a policy's USING or WITH CHECK expressions
// reference any newly added table other than the policy's own table (#373).
// newTables keys are fully qualified (schema.table) to avoid cross-schema collisions.
func policyReferencesOtherNewTable(policy *ir.RLSPolicy, newTables map[string]struct{}) bool {
if len(newTables) == 0 || policy == nil {
return false
}

ownQualified := fmt.Sprintf("%s.%s", strings.ToLower(policy.Schema), strings.ToLower(policy.Table))

for _, expr := range []string{policy.Using, policy.WithCheck} {
if expr == "" {
continue
}
exprLower := strings.ToLower(expr)
for qualifiedName := range newTables {
// Skip the policy's own table
if qualifiedName == ownQualified {
continue
}
// Extract the unqualified table name for substring matching.
// Policy expressions may use unqualified or qualified references.
parts := strings.SplitN(qualifiedName, ".", 2)
tableName := parts[len(parts)-1]
if strings.Contains(exprLower, tableName) {
return true
}
}
}
Comment on lines +2040 to +2068
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

policyReferencesOtherNewTable does an O(policies × newTables) scan and uses strings.Contains against raw SQL text. If the intended fix is to guarantee policies are created only after all tables exist, it would be more robust (and simpler/faster) to defer policy creation unconditionally for newly created tables instead of trying to detect table references via substring matching.

Suggested change
// policyReferencesOtherNewTable determines if a policy's USING or WITH CHECK expressions
// reference any newly added table other than the policy's own table (#373).
func policyReferencesOtherNewTable(policy *ir.RLSPolicy, newTables map[string]struct{}) bool {
if len(newTables) == 0 || policy == nil {
return false
}
for _, expr := range []string{policy.Using, policy.WithCheck} {
if expr == "" {
continue
}
exprLower := strings.ToLower(expr)
for tableName := range newTables {
// Skip the policy's own table
if strings.EqualFold(tableName, policy.Table) ||
strings.EqualFold(tableName, fmt.Sprintf("%s.%s", policy.Schema, policy.Table)) {
continue
}
// Check if the expression contains a reference to this table name
if strings.Contains(exprLower, strings.ToLower(tableName)) {
return true
}
}
}
// policyReferencesOtherNewTable determines if a policy belongs to a newly added table
// that should have its policy creation deferred until all tables exist (#373).
func policyReferencesOtherNewTable(policy *ir.RLSPolicy, newTables map[string]struct{}) bool {
if len(newTables) == 0 || policy == nil {
return false
}
// Normalize table name for lookup.
tableName := strings.ToLower(policy.Table)
// Check unqualified table name.
if _, ok := newTables[tableName]; ok {
return true
}
// Also check schema-qualified form if a schema is present.
if policy.Schema != "" {
qualified := fmt.Sprintf("%s.%s", strings.ToLower(policy.Schema), tableName)
if _, ok := newTables[qualified]; ok {
return true
}
}

Copilot uses AI. Check for mistakes.
return false
}

// tableUsesDeferredDomain determines if a table uses any deferred domain types in its columns.
func tableUsesDeferredDomain(table *ir.Table, deferredDomains map[string]struct{}) bool {
if len(deferredDomains) == 0 || table == nil {
Expand Down
10 changes: 9 additions & 1 deletion internal/diff/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@ import (

// generateCreatePoliciesSQL generates CREATE POLICY statements
func generateCreatePoliciesSQL(policies []*ir.RLSPolicy, targetSchema string, collector *diffCollector) {
// Sort policies by name for consistent ordering
// Sort policies by (schema, table, name) for deterministic ordering across tables.
// Policy names are only unique per table, so sorting by name alone is insufficient
// when policies from multiple tables are collected together (#373).
sortedPolicies := make([]*ir.RLSPolicy, len(policies))
copy(sortedPolicies, policies)
sort.Slice(sortedPolicies, func(i, j int) bool {
if sortedPolicies[i].Schema != sortedPolicies[j].Schema {
return sortedPolicies[i].Schema < sortedPolicies[j].Schema
}
if sortedPolicies[i].Table != sortedPolicies[j].Table {
return sortedPolicies[i].Table < sortedPolicies[j].Table
}
return sortedPolicies[i].Name < sortedPolicies[j].Name
})

Expand Down
8 changes: 5 additions & 3 deletions internal/diff/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,9 @@ type deferredConstraint struct {
}

// generateCreateTablesSQL generates CREATE TABLE statements with co-located indexes, policies, and RLS.
// Policies that reference newly added helper functions are collected for deferred creation after
// dependent functions/procedures have been created, while all other policies are emitted inline.
// Policies that reference other new tables in the same migration or newly added functions
// (via USING/WITH CHECK expressions) are deferred for creation after all tables and functions
// exist (#373). All other policies are emitted inline.
// It returns deferred policies and foreign key constraints that should be applied after dependent objects exist.
// Tables are assumed to be pre-sorted in topological order for dependency-aware creation.
func generateCreateTablesSQL(
Expand Down Expand Up @@ -474,7 +475,8 @@ func generateCreateTablesSQL(
generateRLSChangesSQL(rlsChanges, targetSchema, collector)
}

// Collect policies that can run immediately; defer only those that depend on new helper functions
// Collect policies: defer those that reference other new tables or new functions (#373),
// emit the rest inline with their parent table.
if len(table.Policies) > 0 {
var inlinePolicies []*ir.RLSPolicy
policyNames := sortedKeys(table.Policies)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS manager (
id SERIAL,
user_id uuid NOT NULL
);

ALTER TABLE manager ENABLE ROW LEVEL SECURITY;

CREATE TABLE IF NOT EXISTS project_manager (
id SERIAL,
project_id integer NOT NULL,
manager_id integer NOT NULL,
is_deleted boolean DEFAULT false NOT NULL
);

CREATE POLICY employee_manager_select ON manager FOR SELECT TO PUBLIC USING (id IN ( SELECT pam.manager_id FROM project_manager pam WHERE ((pam.project_id IN ( SELECT unnest(ARRAY[1, 2, 3]) AS unnest)) AND (pam.is_deleted = false))));
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
CREATE TABLE project_manager (
id SERIAL,
project_id int NOT NULL,
manager_id int NOT NULL,
is_deleted boolean NOT NULL DEFAULT false
);

CREATE TABLE manager (
id SERIAL,
user_id uuid NOT NULL
);

ALTER TABLE manager ENABLE ROW LEVEL SECURITY;

CREATE POLICY employee_manager_select ON manager
FOR SELECT
TO PUBLIC
USING (
id IN (
SELECT pam.manager_id
FROM project_manager pam
WHERE pam.project_id IN (
SELECT unnest(ARRAY[1, 2, 3])
)
AND pam.is_deleted = false
)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- Empty schema (no tables)
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"version": "1.0.0",
"pgschema_version": "1.7.4",
"created_at": "1970-01-01T00:00:00Z",
"source_fingerprint": {
"hash": "965b1131737c955e24c7f827c55bd78e4cb49a75adfd04229e0ba297376f5085"
},
"groups": [
{
"steps": [
{
"sql": "CREATE TABLE IF NOT EXISTS manager (\n id SERIAL,\n user_id uuid NOT NULL\n);",
"type": "table",
"operation": "create",
"path": "public.manager"
},
{
"sql": "ALTER TABLE manager ENABLE ROW LEVEL SECURITY;",
"type": "table.rls",
"operation": "alter",
"path": "public.manager"
},
{
"sql": "CREATE TABLE IF NOT EXISTS project_manager (\n id SERIAL,\n project_id integer NOT NULL,\n manager_id integer NOT NULL,\n is_deleted boolean DEFAULT false NOT NULL\n);",
"type": "table",
"operation": "create",
"path": "public.project_manager"
},
{
"sql": "CREATE POLICY employee_manager_select ON manager FOR SELECT TO PUBLIC USING (id IN ( SELECT pam.manager_id FROM project_manager pam WHERE ((pam.project_id IN ( SELECT unnest(ARRAY[1, 2, 3]) AS unnest)) AND (pam.is_deleted = false))));",
"type": "table.policy",
"operation": "create",
"path": "public.manager.employee_manager_select"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS manager (
id SERIAL,
user_id uuid NOT NULL
);

ALTER TABLE manager ENABLE ROW LEVEL SECURITY;

CREATE TABLE IF NOT EXISTS project_manager (
id SERIAL,
project_id integer NOT NULL,
manager_id integer NOT NULL,
is_deleted boolean DEFAULT false NOT NULL
);

CREATE POLICY employee_manager_select ON manager FOR SELECT TO PUBLIC USING (id IN ( SELECT pam.manager_id FROM project_manager pam WHERE ((pam.project_id IN ( SELECT unnest(ARRAY[1, 2, 3]) AS unnest)) AND (pam.is_deleted = false))));
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
Plan: 2 to add.

Summary by type:
tables: 2 to add

Tables:
+ manager
+ employee_manager_select (policy)
~ manager (rls)
+ project_manager

DDL to be executed:
--------------------------------------------------

CREATE TABLE IF NOT EXISTS manager (
id SERIAL,
user_id uuid NOT NULL
);

ALTER TABLE manager ENABLE ROW LEVEL SECURITY;

CREATE TABLE IF NOT EXISTS project_manager (
id SERIAL,
project_id integer NOT NULL,
manager_id integer NOT NULL,
is_deleted boolean DEFAULT false NOT NULL
);

CREATE POLICY employee_manager_select ON manager FOR SELECT TO PUBLIC USING (id IN ( SELECT pam.manager_id FROM project_manager pam WHERE ((pam.project_id IN ( SELECT unnest(ARRAY[1, 2, 3]) AS unnest)) AND (pam.is_deleted = false))));
Loading