fix: stub cross-schema partition parents in plan database - #555
Conversation
When a partition child is in a different schema from its partitioned parent, the plan database fails because it tries to create the partition on the real parent table. Fix by detecting PARTITION OF references to cross-schema parents and prepending stub CREATE TABLE ... PARTITION BY statements, mirroring the existing FK stub pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThe PR detects desired-state cross-schema
Confidence Score: 3/5This PR is not safe to merge until the malformed partition DDL and external plan-database cleanup boundary are fixed. Every cross-schema partition stub currently receives an invalid duplicated partition strategy, while external providers also leave real-schema stub objects behind after validation. Files Needing Attention: ir/stub.go, internal/postgres/external.go Important Files Changed
Sequence DiagramsequenceDiagram
participant Plan as GeneratePlan
participant Target as Target database
participant Stub as Stub builder
participant Provider as Desired-state provider
Plan->>Target: Inspect cross-schema parent
Target-->>Stub: Columns, constraints, partition definition
Stub-->>Plan: CREATE SCHEMA/TABLE stub
Plan->>Provider: Apply stub + desired SQL
Provider-->>Plan: Syntax failure or desired IR
Note over Provider: External provider only cleans its temporary schema
Reviews (1): Last reviewed commit: "fix: stub cross-schema partition parents..." | Re-trigger Greptile |
There was a problem hiding this comment.
Pull request overview
This PR addresses #552 by ensuring pgschema plan can apply desired SQL that includes PARTITION OF references to a partitioned parent table in a different schema, by detecting those references and prepending stub DDL for the missing parent(s) before applying the desired state into the plan database.
Changes:
- Added partition-parent stub generation in
ir(BuildPartitionedTableStubSQL) includingPARTITION BYmetadata. - Added SQL scanning for
PARTITION OFtargets (ExtractPartitionOfTargets) with unit tests. - Wired new stub-prepending into
cmd/planbeforeApplySchema, mirroring the existing FK ignored-table stubbing flow.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
ir/stub.go |
Adds partitioned-parent stub DDL builder and a catalog query for partition strategy/key. |
internal/postgres/fk_refs.go |
Adds ExtractPartitionOfTargets SQL scanner to find cross-schema partition parents. |
internal/postgres/fk_refs_test.go |
Adds unit tests covering ExtractPartitionOfTargets parsing behavior. |
cmd/plan/plan.go |
Integrates partition-parent stubbing into plan generation before applying desired SQL. |
cmd/plan/partition_stubs.go |
Implements prependPartitionParentStubs to discover and prepend required stubs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…rategy pg_get_partkeydef returns the full partition key definition including the strategy (e.g., "RANGE (id)"), so we must not prepend the strategy separately. Simplify queryPartitionInfo → queryPartitionKeyDef. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… DB with pre-existing partitions When the external plan DB already contains the parent table with its partitions, CREATE TABLE IF NOT EXISTS is a no-op and the desired SQL's PARTITION OF still conflicts with existing partitions. Fix by dropping the parent CASCADE before creating the stub, so the desired SQL can attach its partition children cleanly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…dle plan DB with pre-existing partitions" This reverts commit 27e7779.
…SCADE When the plan DB already has the cross-schema parent with attached partitions, use a PL/pgSQL DO block to DETACH PARTITION each child instead of DROP TABLE CASCADE. This is non-destructive: it only removes the parent-child relationship without dropping any tables. The DO block is a no-op when the parent does not exist (clean plan DB). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… DROP CASCADE" This reverts commit 4e46ffe.
Redesign the cross-schema partition stub approach to stay within the temp schema sandbox: - BuildPartitionedTableStubSQL now emits unqualified table names so the stub lands in the temp schema via search_path - PARTITION OF references to cross-schema parents are rewritten to strip the schema prefix, resolving to the stub via search_path - Return an error when a referenced partition parent is not found on the target database instead of silently skipping - Add unit tests for replacePartitionOfRef This avoids creating or mutating persistent objects (schemas, tables) outside the temp schema in the plan database. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tianzhou
left a comment
There was a problem hiding this comment.
All 5 Copilot review comments addressed. Key change: redesigned the partition stub approach in 7a7444e6 to keep everything inside the temp schema sandbox — stubs use unqualified names and PARTITION OF references are rewritten to strip cross-schema prefixes.
Partition parent stubs now use _pgschema_partstub_<schema>__<table> as the table name instead of the bare table name. This prevents collisions when: - the target schema has a table with the same name as the parent - two cross-schema parents share the same table name (e.g., public.events and archive.events) PARTITION OF references are rewritten to point to the unique stub name. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
cmd/plan/partition_stubs.go:75
- The returned stub is created unqualified in the same temporary schema that
plan.golater inspects. It therefore entersdesiredStateIR, is normalized to the target schema, and is treated as a newly desired table; the #552 no-op case will produce a spuriousCREATE TABLE annotations_data.annotation_shapes ...plan. Keep helper parents outside the inspected schema or explicitly remove them from the desired IR before diffing.
return "", fmt.Errorf("cross-schema partition parent %s.%s not found or not a partitioned table on the target database", ref.Schema, ref.Table)
cmd/plan/partition_stubs.go:94
- Quoted lowercase references are not handled: for schema
publicand tableparent,QuoteIdentifierreturns bare names, so the quoted patterns are never added andPARTITION OF "public"."parent"remains qualified. The stub is then unused, causing the original missing/conflicting-parent failure. Match source identifier tokens rather than inferring whether they were quoted from their values.
// Build the qualified reference patterns as they may appear in SQL.
patterns := []struct{ old, new string }{
// unquoted: schema.table → stubName
{
cmd/plan/partition_stubs.go:119
- This scans the entire SQL string, unlike extraction which skips literals, comments, and dollar-quoted bodies. Once a real reference triggers rewriting, matching text inside a table comment or dynamic SQL function body is also changed (for example,
'PARTITION OF public.parent'becomes'PARTITION OF parent'), silently altering desired schema content. Restrict replacements to executable SQL tokens.
for _, p := range patterns {
result = replacePartitionOfRef(result, p.old, p.new)
}
ir/stub.go:120
- This partition-parent stub copies only type and
NOT NULL, omitting parent column defaults.PARTITION OFinherits those defaults, so the temporary child is inspected without defaults that exist on the real child;columnsEqualthen reports changes and can emit erroneousDROP DEFAULToperations. Clone defaults needed for inherited partition-column state rather than reusing the FK-only column shape.
b.WriteString(QuoteIdentifier(col.name))
b.WriteString(" ")
b.WriteString(col.dataType)
if col.notNull {
b.WriteString(" NOT NULL")
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (5)
cmd/plan/partition_stubs.go:68
- The temporary stub is later inspected as part of the desired schema, and the rewritten child is recorded with this stub as its
PartitionOfparent. Nothing removes the_pgschema_partstub_*table or restores the original cross-schema parent beforeGenerateMigration, so even a no-op plan will propose creating the stub in the target schema; a newly added child will also generatePARTITION OFagainst that fake parent. Preserve the original-to-stub mapping and sanitize the desired IR after inspection.
stubName := partitionStubName(ref.Schema, ref.Table)
ddl, err := ir.BuildPartitionedTableStubSQL(ctx, conn, ref.Schema, ref.Table, stubName)
cmd/plan/partition_stubs.go:128
- This replacement scans the raw SQL, unlike extraction which skips literals, comments, and dollar-quoted bodies. Once a real partition reference selects a parent for rewriting, the same text inside a function body, string, or comment is also changed, potentially altering persisted function behavior or literal defaults. Restrict replacements to parsed SQL-code spans.
func replacePartitionOfRef(sql, oldRef, newRef string) string {
lower := strings.ToLower(sql)
lowerOld := strings.ToLower(oldRef)
cmd/plan/partition_stubs.go:155
- This match has no identifier boundary after
oldRef. If the SQL references bothext.eventsandext.events_archive, rewritingext.eventsfirst also consumes the prefix of the latter and produces an invalid target such as"stub"_archive. It also case-folds quoted identifiers, which PostgreSQL treats as case-sensitive. Match the parsed qualified identifier exactly.
if k+len(lowerOld) <= len(sql) && strings.EqualFold(sql[k:k+len(lowerOld)], lowerOld) {
b.WriteString(sql[pos:afterOf])
b.WriteString(sql[afterOf:k])
b.WriteString(newRef)
i = k + len(lowerOld)
ir/stub.go:120
- A partition child inherits column defaults from its parent, but this stub emits only type and
NOT NULL. For a parent column with a default, the child created against the stub is inspected without that default, while the real child has it; the diff then emits a spuriousDROP DEFAULT. Partition-parent stubs need to reproduce inherited column attributes rather than reuse the deliberately minimal FK-stub column model.
for i, col := range cols {
b.WriteString(" ")
b.WriteString(QuoteIdentifier(col.name))
b.WriteString(" ")
b.WriteString(col.dataType)
if col.notNull {
b.WriteString(" NOT NULL")
cmd/plan/partition_stubs.go:119
- The extractor accepts valid whitespace around the qualification dot, but the rewrite patterns do not. For example,
PARTITION OF public . parentis detected and a stub is created, yet this loop leaves the clause pointing atpublic.parent, so the original cross-schema failure remains. Rewrite the exact parsed target span instead of matching a few rendered spellings.
This issue also appears in the following locations of the same file:
- line 126
- line 151
result := sql
for _, p := range patterns {
result = replacePartitionOfRef(result, p.old, p.new)
}
Closes #552
Summary
ExtractPartitionOfTargetsto detectPARTITION OFreferences to cross-schema parents in desired SQLBuildPartitionedTableStubSQLto generate stubCREATE TABLE ... PARTITION BYDDL for partition parentsprependPartitionParentStubsincmd/plan/to wire up the stubbing beforeApplySchema, mirroring the existing FK stub pattern (prependIgnoredTableStubs)PARTITION OFclause resolves correctlyTest plan
ExtractPartitionOfTargets(5 cases: simple, qualified, dedup, partition-by-not-matched, quoted)go build ./...passesgo vet ./...passesinternal/postgrestests pass🤖 Generated with Claude Code