Skip to content

fix: defer cross-table policy creation until all tables exist (#373)#374

Merged
tianzhou merged 5 commits intomainfrom
fix/issue-373-policy-ordering
Mar 26, 2026
Merged

fix: defer cross-table policy creation until all tables exist (#373)#374
tianzhou merged 5 commits intomainfrom
fix/issue-373-policy-ordering

Conversation

@tianzhou
Copy link
Contributor

@tianzhou tianzhou commented Mar 26, 2026

Summary

  • RLS policies with USING/WITH CHECK expressions that reference other tables (e.g., subqueries) were being created immediately after their parent table, before the referenced tables existed
  • Fixed by selectively deferring policy creation: policies whose USING/WITH CHECK expressions reference other new tables in the same migration are deferred until all tables exist; policies that only reference their own table or existing tables remain inline
  • Added policyReferencesOtherNewTable which uses substring matching on policy expressions — false positives are safe (just defer unnecessarily), and this matches the existing referencesNewFunction pattern
  • Added test case dependency/issue_373_policy_references_other_table reproducing the exact scenario from the issue

Fixes #373

Test plan

  • PGSCHEMA_TEST_FILTER="dependency/issue_373" go test -v ./internal/diff -run TestDiffFromFiles — new test passes
  • PGSCHEMA_TEST_FILTER="dependency/" go test -v ./internal/diff -run TestDiffFromFiles — all 14 dependency tests pass
  • PGSCHEMA_TEST_FILTER="create_policy/" go test -v ./internal/diff -run TestDiffFromFiles — all 10 policy tests pass
  • PGSCHEMA_TEST_FILTER="create_policy/" go test -v ./cmd -run TestPlanAndApply — all 10 policy integration tests pass
  • go test -v ./cmd/dump -run TestDumpCommand_Employee — dump output ordering unchanged for tables without cross-table policy references

🤖 Generated with Claude Code

Policies may reference other tables in USING/WITH CHECK expressions
(e.g., subqueries), and those tables may not exist yet when the policy's
parent table is created. Always defer policy creation to after all tables
exist, rather than only deferring policies that reference new functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 26, 2026 02:18
@greptile-apps
Copy link

greptile-apps bot commented Mar 26, 2026

Greptile Summary

This PR fixes a DDL ordering bug where RLS policies containing cross-table subqueries in their USING/WITH CHECK expressions were being emitted immediately after their parent table, before the referenced tables had been created. The fix simplifies the policy-deferral logic: instead of selectively deferring only policies that reference newly added helper functions, all policies are now unconditionally deferred until after all tables in both batches have been created.\n\nKey changes:\n- Removed the shouldDeferPolicy closure and the policyReferencesNewFunction conditional wiring from generateCreateSQL in diff.go\n- Simplified generateCreateTablesSQL in table.go to always append policies to deferredPolicies (no inline emission)\n- Added regression test case dependency/issue_373_policy_references_other_table with a manager table whose policy subqueries project_manager\n- The function doc comment for generateCreateTablesSQL was not updated and still describes the old "emit inline unless referencing new functions" behavior\n- policyReferencesNewFunction in diff.go is now dead production code — it is only exercised by policy_dependency_test.go, whose test comments also reflect the now-obsolete inline-vs-deferred distinction

Confidence Score: 5/5

Safe to merge — the fix is a clean simplification, the regression test validates the exact bug scenario, and the only remaining items are non-blocking doc/dead-code cleanup.

The logic change is minimal and correct: unconditionally deferring all policies is strictly safer than the previous selective deferral, which only caught function-reference dependencies but missed cross-table references. Existing tests (10 create_policy + 13 dependency cases) plus the new regression test all pass. The two P2 issues (stale doc comment and dead policyReferencesNewFunction function/tests) are cosmetic and carry no runtime risk.

internal/diff/table.go (stale function comment) and internal/diff/diff.go (dead policyReferencesNewFunction + its tests in policy_dependency_test.go)

Important Files Changed

Filename Overview
internal/diff/diff.go Removes shouldDeferPolicy closure and its conditional wiring; policyReferencesNewFunction is now dead production code (still referenced only in tests).
internal/diff/table.go Simplifies generateCreateTablesSQL to unconditionally defer all policies; stale function doc comment not updated.
testdata/diff/dependency/issue_373_policy_references_other_table/new.sql New test fixture: manager table with a policy whose USING expression subqueries project_manager, which didn't exist when the policy was created under the old ordering.
testdata/diff/dependency/issue_373_policy_references_other_table/plan.json Expected plan confirms policy step is emitted last, after both tables are created.
testdata/diff/dependency/issue_373_policy_references_other_table/diff.sql Golden diff SQL: CREATE TABLE manager → ALTER TABLE (RLS) → CREATE TABLE project_manager → CREATE POLICY. Correct ordering.

Sequence Diagram

sequenceDiagram
    participant G as generateCreateSQL
    participant B1 as tablesWithoutDeps batch
    participant B2 as tablesWithDeps batch
    participant C as diffCollector

    Note over G,C: Before fix — policy emitted inline (wrong order)
    G->>B1: generateCreateTablesSQL(tablesWithoutDeps, shouldDeferPolicy)
    B1->>C: CREATE TABLE manager
    B1->>C: ALTER TABLE manager ENABLE ROW LEVEL SECURITY
    B1->>C: ❌ CREATE POLICY (project_manager doesn't exist yet)
    B1->>C: CREATE TABLE project_manager

    Note over G,C: After fix — all policies deferred
    G->>B1: generateCreateTablesSQL(tablesWithoutDeps)
    B1->>C: CREATE TABLE manager
    B1->>C: ALTER TABLE manager ENABLE ROW LEVEL SECURITY
    B1-->>G: deferredPolicies1 = [employee_manager_select]
    G->>B2: generateCreateTablesSQL(tablesWithDeps)
    B2->>C: CREATE TABLE project_manager
    B2-->>G: deferredPolicies2 = []
    G->>C: ✅ CREATE POLICY employee_manager_select (both tables exist)
Loading

Comments Outside Diff (2)

  1. internal/diff/table.go, line 386-390 (link)

    P2 Stale function doc comment

    The comment still describes the old conditional-defer behavior ("while all other policies are emitted inline"), but all policies are now unconditionally deferred. This will mislead the next reader.

  2. internal/diff/diff.go, line 2010-2022 (link)

    P2 policyReferencesNewFunction is now dead production code

    After removing shouldDeferPolicy, this function is no longer called from any production code path — only from policy_dependency_test.go. If the intent is to keep it as a utility, it should be documented as such; otherwise it and its tests can be removed to avoid confusion. The test comment "expected policy referencing only built-in functions to remain inline" in policy_dependency_test.go:58 is also misleading now that all policies are deferred unconditionally.

Reviews (1): Last reviewed commit: "fix: defer all policies to after all tab..." | Re-trigger Greptile

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes statement ordering for PostgreSQL RLS policy creation so policies are created only after all tables exist, preventing failures when policy USING/WITH CHECK expressions reference other tables (issue #373).

Changes:

  • Defer all CREATE POLICY statements until after both table-creation batches complete.
  • Simplify create-order logic by removing the “defer only policies referencing new functions” pathway.
  • Add a new dependency regression fixture dependency/issue_373_policy_references_other_table.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

File Description
internal/diff/table.go Stops emitting policies inline during table creation; collects all policies for a later phase.
internal/diff/diff.go Removes selective policy deferral logic and always applies deferred policies after all tables are created.
testdata/diff/dependency/issue_373_policy_references_other_table/* Adds a repro/expected-plan fixture validating correct ordering for cross-table policy references.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 479 to 483
if len(table.Policies) > 0 {
var inlinePolicies []*ir.RLSPolicy
policyNames := sortedKeys(table.Policies)
for _, name := range policyNames {
policy := table.Policies[name]
if shouldDeferPolicy != nil && shouldDeferPolicy(policy) {
deferredPolicies = append(deferredPolicies, policy)
} else {
inlinePolicies = append(inlinePolicies, policy)
}
}

if len(inlinePolicies) > 0 {
generateCreatePoliciesSQL(inlinePolicies, targetSchema, collector)
deferredPolicies = append(deferredPolicies, table.Policies[name])
}
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.

With all policies now deferred, ordering across tables becomes more important for deterministic plans. generateCreatePoliciesSQL currently sorts only by policy.Name (policy names are only unique per table in Postgres), so identical policy names on different tables can lead to nondeterministic ordering. Consider sorting by (schema, table, name) or by the full path instead.

Copilot uses AI. Check for mistakes.
Comment on lines +476 to +478
// Defer all policies to after all tables are created.
// Policies may reference other tables in USING/WITH CHECK expressions (e.g., subqueries),
// and those tables may not exist yet at this point. (#373)
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.

The function-level doc comment for generateCreateTablesSQL (above this block) still describes “co-located” policy creation and deferring only policies referencing new helper functions, but the implementation now defers all policies. Please update the doc comment to match the new behavior to avoid misleading future changes/debugging.

Copilot uses AI. Check for mistakes.
…omment

Address review feedback:
- Sort policies by (schema, table, name) for deterministic ordering when
  policies from multiple tables are collected together
- Update generateCreateTablesSQL doc comment to reflect that all policies
  are now deferred

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

internal/diff/diff.go:1553

  • Policies are now deferred until after all tables exist, but they are still emitted before views and before the “functions with view dependencies” batch. Since policy USING/WITH CHECK expressions can reference views and/or functions that aren’t created until after generateCreateViewsSQL / generateCreateFunctionsSQL(functionsWithViewDeps), this ordering can still produce invalid plans for schemas where policies depend on newly-created views or view-dependent functions. Consider moving policy creation to after view creation (and after functionsWithViewDeps) or adding dependency-aware ordering for policy expressions.
	// Create tables WITHOUT function/domain dependencies first (functions may reference these)
	deferredPolicies1, deferredConstraints1 := generateCreateTablesSQL(tablesWithoutDeps, targetSchema, collector, existingTables)

	// Build view lookup - needed for detecting functions that depend on views
	newViewLookup := buildViewLookup(d.addedViews)

	// Separate functions into those with/without view dependencies

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 1525 to 1533
// Build map of existing tables (tables being modified, so they already exist)
existingTables := make(map[string]bool, len(d.modifiedTables))
for _, tableDiff := range d.modifiedTables {
key := fmt.Sprintf("%s.%s", tableDiff.Table.Schema, tableDiff.Table.Name)
existingTables[key] = true
}
var shouldDeferPolicy func(*ir.RLSPolicy) bool
if len(newFunctionLookup) > 0 {
shouldDeferPolicy = func(policy *ir.RLSPolicy) bool {
return policyReferencesNewFunction(policy, newFunctionLookup)
}
}

// Create default privileges BEFORE tables so auto-grants apply to new tables
generateCreateDefaultPrivilegesSQL(d.addedDefaultPrivileges, targetSchema, collector)

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.

shouldDeferPolicy was removed, but policyReferencesNewFunction (and its unit test policy_dependency_test.go) now appears to be unused outside tests. Consider deleting the helper/tests or repurposing them for the new “defer all policies” behavior to avoid carrying dead dependency logic.

Copilot uses AI. Check for mistakes.
Instead of deferring all policies (which changed dump output ordering),
selectively defer only policies whose USING/WITH CHECK expressions
reference another new table being created in the same batch. Policies
that only reference their own table or existing tables remain inline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

internal/diff/table.go:493

  • The updated docstring/inline comment says only policies that reference other new tables are deferred, and the implementation still emits inlinePolicies during table creation for policies that don’t match shouldDeferPolicy. This doesn’t match the PR title/description (“defer all policies to after all tables are created”) and still relies on heuristic detection for correctness. Consider deferring all CREATE POLICY statements for newly created tables (collect all policies in deferredPolicies and emit them in one pass after all tables are created), or adjust the PR description/comments to match the selective behavior.
		// 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)
			for _, name := range policyNames {
				policy := table.Policies[name]
				if shouldDeferPolicy != nil && shouldDeferPolicy(policy) {
					deferredPolicies = append(deferredPolicies, policy)
				} else {
					inlinePolicies = append(inlinePolicies, policy)
				}
			}

			if len(inlinePolicies) > 0 {
				generateCreatePoliciesSQL(inlinePolicies, targetSchema, collector)
			}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// 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 batch (via USING/WITH CHECK expressions)
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.

Comment says policies referencing other new tables “in the same batch” are deferred, but shouldDeferPolicy is built from all d.addedTables and generateCreateTablesSQL is invoked in two batches (tablesWithoutDeps and tablesWithDeps). Please update the comment to reflect that this defers policies referencing other new tables in the same migration, not just within a single batch.

Suggested change
// Policies that reference other new tables in the same batch (via USING/WITH CHECK expressions)
// Policies that reference other new tables in the same migration (via USING/WITH CHECK expressions)

Copilot uses AI. Check for mistakes.
Comment on lines +2041 to +2064
// 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
}
}
}
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.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@tianzhou tianzhou changed the title fix: defer all policies to after all tables are created (#373) fix: defer cross-table policy creation until all tables exist (#373) Mar 26, 2026
@tianzhou tianzhou requested a review from Copilot March 26, 2026 04:00
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 387 to 390
// Policies that reference other new tables in the same migration (via USING/WITH CHECK expressions)
// are deferred for creation after all tables 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.
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.

The doc comment for generateCreateTablesSQL says only policies referencing other new tables are deferred, but the implementation also defers policies that reference newly added functions (via shouldDeferPolicy calling policyReferencesNewFunction). Please update this comment to reflect both deferral conditions so future readers don’t assume function-dependency deferral was removed.

Suggested change
// Policies that reference other new tables in the same migration (via USING/WITH CHECK expressions)
// are deferred for creation after all tables 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.
// 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.

Copilot uses AI. Check for mistakes.
Comment on lines +1533 to +1537
newTableLookup := make(map[string]struct{}, len(d.addedTables))
for _, table := range d.addedTables {
newTableLookup[table.Name] = struct{}{}
newTableLookup[fmt.Sprintf("%s.%s", table.Schema, table.Name)] = struct{}{}
}
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.
- Store only fully-qualified (schema.table) keys in newTableLookup to
  avoid cross-schema name collisions
- Update generateCreateTablesSQL doc comment to mention both table and
  function deferral conditions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@tianzhou tianzhou merged commit 0e9d685 into main Mar 26, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: RLS Policies Generated Before Referenced Tables Exist

2 participants