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
13 changes: 11 additions & 2 deletions cmd/dump/dump_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"context"
"fmt"
"os"
"regexp"
"strings"
"testing"

Expand Down Expand Up @@ -651,8 +652,13 @@ func runTenantSchemaTest(t *testing.T, testDataDir string) {
}
}

// normalizeSchemaOutput removes version-specific lines for comparison.
// This allows comparing dumps across different PostgreSQL versions.
// tzOffsetRe matches timezone offsets (e.g. -08, +00, +05:30) at the end of
// quoted timestamp literals inside partition bound expressions. pg_get_expr
// returns machine-local offsets, so we normalize them for cross-platform comparison.
var tzOffsetRe = regexp.MustCompile(`(\d{2}:\d{2}:\d{2})[-+]\d{2}(:\d{2})?`)

// normalizeSchemaOutput removes version-specific lines and normalizes
// timezone offsets in partition bounds for cross-platform comparison.
func normalizeSchemaOutput(output string) string {
lines := strings.Split(output, "\n")
var normalizedLines []string
Expand All @@ -663,6 +669,9 @@ func normalizeSchemaOutput(output string) string {
strings.Contains(line, "-- Dumped from database version") {
continue
}
if strings.Contains(line, "PARTITION OF") || strings.Contains(line, "FOR VALUES") {
line = tzOffsetRe.ReplaceAllString(line, "${1}+00")
}
normalizedLines = append(normalizedLines, line)
}

Expand Down
8 changes: 8 additions & 0 deletions cmd/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,14 @@ func normalizeSchemaNames(irData *ir.IR, fromSchema, toSchema string) {
}
}

// Normalize partition parent schema reference and bound expression
if table.PartitionOfSchema == fromSchema {
table.PartitionOfSchema = toSchema
}
if table.PartitionBound != "" {
table.PartitionBound = stripQualifiers(replaceString(table.PartitionBound))
}

// Normalize schema references in LIKE clauses
for i := range table.LikeClauses {
if table.LikeClauses[i].SourceSchema == fromSchema {
Expand Down
46 changes: 46 additions & 0 deletions internal/diff/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,52 @@ func generateTableSQL(table *ir.Table, targetSchema string, qualifySchema bool,
// forced qualification is on).
tableName := ir.QualifyEntityNameWithQuotesMode(table.Schema, table.Name, targetSchema, qualifySchema)

// Partition children: emit PARTITION OF instead of standalone CREATE TABLE.
// Columns and inherited constraints come from the parent automatically;
// only child-specific constraints (conparentid = 0) remain in the IR.
if table.PartitionOf != "" && table.PartitionBound != "" {
parentSchema := table.PartitionOfSchema
if parentSchema == "" {
parentSchema = table.Schema
}
parentName := ir.QualifyEntityNameWithQuotesMode(parentSchema, table.PartitionOf, targetSchema, qualifySchema)

// Include child-specific constraints in the PARTITION OF statement.
inlineConstraints := getInlineConstraintsForTable(table)
var constraintParts []string
var deferred []*deferredConstraint
currentKey := fmt.Sprintf("%s.%s", table.Schema, table.Name)
for _, constraint := range inlineConstraints {
if suppressedInlineFKs[constraintPathKey(constraint)] {
continue
}
if shouldDeferConstraint(table, constraint, currentKey, createdTables, existingTables) {
deferred = append(deferred, &deferredConstraint{
table: table,
constraint: constraint,
})
continue
}
if def := generateConstraintSQL(constraint, targetSchema, qualifySchema); def != "" {
constraintParts = append(constraintParts, fmt.Sprintf(" %s", def))
}
}

createPrefix := "CREATE TABLE IF NOT EXISTS"
if table.Unlogged {
createPrefix = "CREATE UNLOGGED TABLE IF NOT EXISTS"
}

var sql string
if len(constraintParts) > 0 {
sql = fmt.Sprintf("%s %s PARTITION OF %s (\n%s\n) %s;",
createPrefix, tableName, parentName, strings.Join(constraintParts, ",\n"), table.PartitionBound)
} else {
sql = fmt.Sprintf("%s %s PARTITION OF %s %s;", createPrefix, tableName, parentName, table.PartitionBound)
}
Comment thread
Copilot marked this conversation as resolved.
return sql, deferred
}
Comment thread
Copilot marked this conversation as resolved.

var parts []string
createPrefix := "CREATE TABLE IF NOT EXISTS"
if table.Unlogged {
Expand Down
14 changes: 14 additions & 0 deletions internal/diff/topological.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,21 @@ func topologicallySortTables(tables []*ir.Table) []*ir.Table {
}

// Build edges: if tableA has a foreign key to tableB, add edge tableB -> tableA
// Also: if tableA is a partition child of tableB, add edge tableB -> tableA
for keyA, tableA := range tableMap {
// Partition parent → child dependency
if tableA.PartitionOf != "" {
parentSchema := tableA.PartitionOfSchema
if parentSchema == "" {
parentSchema = tableA.Schema
}
keyB := parentSchema + "." + tableA.PartitionOf
if _, exists := tableMap[keyB]; exists && keyA != keyB {
adjList[keyB] = append(adjList[keyB], keyA)
inDegree[keyA]++
}
}

for _, constraint := range tableA.Constraints {
if constraint.Type == ir.ConstraintTypeForeignKey && constraint.ReferencedTable != "" {
// Build referenced table key
Expand Down
1 change: 1 addition & 0 deletions internal/postgres/embedded.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ func StartEmbeddedPostgres(config *EmbeddedPostgresConfig) (*EmbeddedPostgres, e
"log_statement": "none", // Don't log SQL statements
"log_min_duration_statement": "-1", // Don't log slow queries
"unix_socket_directories": runtimePath, // Use a directory that is guaranteed to exist
"timezone": "UTC", // Ensure platform-independent output for pg_get_expr
})

// Create and start PostgreSQL instance
Expand Down
13 changes: 12 additions & 1 deletion ir/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@ func requiresPositionSorting(constraintType ConstraintType) bool {
}

// buildPartitionMapping builds a mapping from partition table names to their parent's partition keys
// and populates partition child metadata (PartitionOf, PartitionOfSchema, PartitionBound) on child tables.
func (i *Inspector) buildPartitionMapping(ctx context.Context, schema *IR, targetSchema string) map[string]string {
partitionMapping := make(map[string]string)

Expand All @@ -683,6 +684,8 @@ func (i *Inspector) buildPartitionMapping(ctx context.Context, schema *IR, targe
return partitionMapping
}

dbSchema := schema.getOrCreateSchema(targetSchema)

for _, child := range partitionChildren {
// Only process children in the target schema
if child.ChildSchema != targetSchema {
Expand All @@ -692,8 +695,16 @@ func (i *Inspector) buildPartitionMapping(ctx context.Context, schema *IR, targe
childTable := child.ChildTable
parentTable := child.ParentTable

// Set partition child metadata on the child table
if childTableInfo, exists := dbSchema.Tables[childTable]; exists {
childTableInfo.PartitionOf = parentTable
childTableInfo.PartitionOfSchema = child.ParentSchema
if child.PartitionBound.Valid {
childTableInfo.PartitionBound = child.PartitionBound.String
}
}

// Find the parent table's partition key
dbSchema := schema.getOrCreateSchema(targetSchema)
if parentTableInfo, exists := dbSchema.Tables[parentTable]; exists && parentTableInfo.IsPartitioned {
partitionMapping[childTable] = parentTableInfo.PartitionKey
}
Expand Down
3 changes: 3 additions & 0 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ type Table struct {
IsPartitioned bool `json:"is_partitioned"`
PartitionStrategy string `json:"partition_strategy,omitempty"` // RANGE, LIST, HASH
PartitionKey string `json:"partition_key,omitempty"` // Column(s) used for partitioning
PartitionOf string `json:"partition_of,omitempty"` // Parent table name (partition children)
PartitionOfSchema string `json:"partition_of_schema,omitempty"` // Parent table schema (partition children)
PartitionBound string `json:"partition_bound,omitempty"` // Partition bound expression (e.g. "FOR VALUES IN (1, 2)" or "DEFAULT")
LikeClauses []LikeClause `json:"like_clauses,omitempty"` // LIKE clauses in CREATE TABLE
Unlogged bool `json:"unlogged,omitempty"` // True for UNLOGGED tables
}
Expand Down
18 changes: 10 additions & 8 deletions ir/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,11 @@ LEFT JOIN LATERAL (
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_%'
-- Skip internal per-partition FK rows (conparentid != 0) that PostgreSQL
-- creates when a FK references a partitioned table. pg_dump omits these;
-- only the top-level FK (conparentid = 0) is a real, dumpable constraint.
AND (c.contype <> 'f' OR c.conparentid = 0)
-- Skip inherited per-partition constraint copies (conparentid != 0) that
-- PostgreSQL auto-creates on partition children. pg_dump omits these;
-- only root constraints (conparentid = 0) and child-specific constraints
-- are dumpable. PARTITION OF auto-creates the inherited copies.
AND c.conparentid = 0
ORDER BY n.nspname, cl.relname, c.contype, c.conname, a.attnum;

-- GetIndexes retrieves all indexes including regular and unique indexes created with CREATE INDEX
Expand Down Expand Up @@ -1031,10 +1032,11 @@ LEFT JOIN LATERAL (
CASE WHEN c.contype = 'x' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS exclusion_definition
) cd ON true
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;
-- only the top-level FK (conparentid = 0) is a real, dumpable constraint.
AND (c.contype <> 'f' OR c.conparentid = 0)
-- Skip inherited per-partition constraint copies (conparentid != 0) that
-- PostgreSQL auto-creates on partition children. pg_dump omits these;
-- only root constraints (conparentid = 0) and child-specific constraints
-- are dumpable. PARTITION OF auto-creates the inherited copies.
AND c.conparentid = 0
ORDER BY n.nspname, cl.relname, c.contype, c.conname, a.attnum;

-- GetSequencesForSchema retrieves all sequences for a specific schema
Expand Down
18 changes: 10 additions & 8 deletions ir/queries/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"pgschema_version": "1.11.1",
"created_at": "1970-01-01T00:00:00Z",
"source_fingerprint": {
"hash": "b52a7d3e192793cd8af86dbf6b990056fa88b14303a29a10bb55048423a811c0"
"hash": "e4ddb6f43ac5b7a34cb423cb508c9912218b7d05931422dbbb0019d897c80892"
},
"groups": null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS events (
id bigint NOT NULL,
region text NOT NULL,
payload text
) PARTITION BY LIST (region);

CREATE TABLE IF NOT EXISTS events_eu PARTITION OF events FOR VALUES IN ('eu');

CREATE TABLE IF NOT EXISTS events_other PARTITION OF events DEFAULT;

CREATE TABLE IF NOT EXISTS events_us PARTITION OF events FOR VALUES IN ('us');
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE public.events (
id bigint NOT NULL,
region text NOT NULL,
payload text
) PARTITION BY LIST (region);

CREATE TABLE public.events_us PARTITION OF public.events FOR VALUES IN ('us');
CREATE TABLE public.events_eu PARTITION OF public.events FOR VALUES IN ('eu');
CREATE TABLE public.events_other PARTITION OF public.events DEFAULT;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- Empty schema
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"version": "1.0.0",
"pgschema_version": "1.11.1",
"created_at": "1970-01-01T00:00:00Z",
"source_fingerprint": {
"hash": "965b1131737c955e24c7f827c55bd78e4cb49a75adfd04229e0ba297376f5085"
},
"groups": [
{
"steps": [
{
"sql": "CREATE TABLE IF NOT EXISTS events (\n id bigint NOT NULL,\n region text NOT NULL,\n payload text\n) PARTITION BY LIST (region);",
"type": "table",
"operation": "create",
"path": "public.events"
},
{
"sql": "CREATE TABLE IF NOT EXISTS events_eu PARTITION OF events FOR VALUES IN ('eu');",
"type": "table",
"operation": "create",
"path": "public.events_eu"
},
{
"sql": "CREATE TABLE IF NOT EXISTS events_other PARTITION OF events DEFAULT;",
"type": "table",
"operation": "create",
"path": "public.events_other"
},
{
"sql": "CREATE TABLE IF NOT EXISTS events_us PARTITION OF events FOR VALUES IN ('us');",
"type": "table",
"operation": "create",
"path": "public.events_us"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS events (
id bigint NOT NULL,
region text NOT NULL,
payload text
) PARTITION BY LIST (region);

CREATE TABLE IF NOT EXISTS events_eu PARTITION OF events FOR VALUES IN ('eu');

CREATE TABLE IF NOT EXISTS events_other PARTITION OF events DEFAULT;

CREATE TABLE IF NOT EXISTS events_us PARTITION OF events FOR VALUES IN ('us');
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
Plan: 4 to add.

Summary by type:
tables: 4 to add

Tables:
+ events
+ events_eu
+ events_other
+ events_us

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

CREATE TABLE IF NOT EXISTS events (
id bigint NOT NULL,
region text NOT NULL,
payload text
) PARTITION BY LIST (region);

CREATE TABLE IF NOT EXISTS events_eu PARTITION OF events FOR VALUES IN ('eu');

CREATE TABLE IF NOT EXISTS events_other PARTITION OF events DEFAULT;

CREATE TABLE IF NOT EXISTS events_us PARTITION OF events FOR VALUES IN ('us');
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"pgschema_version": "1.11.1",
"created_at": "1970-01-01T00:00:00Z",
"source_fingerprint": {
"hash": "78af5157bfb48369c44c1028df6929fa7b3966b23a288d78ab8cd45a3953dd7d"
"hash": "758267339ede8175e5b0a3c63d4a525550fa89d5ba42ae71486edbe75dc5cab3"
},
"groups": [
{
Expand Down
12 changes: 2 additions & 10 deletions testdata/dump/issue_409_partitioned_fk/pgschema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,11 @@ CREATE TABLE IF NOT EXISTS event (
-- Name: session_2026_01; Type: TABLE; Schema: -; Owner: -
--

CREATE TABLE IF NOT EXISTS session_2026_01 (
id bigint,
started_at timestamptz,
CONSTRAINT session_2026_01_pkey PRIMARY KEY (started_at, id)
);
CREATE TABLE IF NOT EXISTS session_2026_01 PARTITION OF session FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00');

--
-- Name: session_2026_02; Type: TABLE; Schema: -; Owner: -
--

CREATE TABLE IF NOT EXISTS session_2026_02 (
id bigint,
started_at timestamptz,
CONSTRAINT session_2026_02_pkey PRIMARY KEY (started_at, id)
);
CREATE TABLE IF NOT EXISTS session_2026_02 PARTITION OF session FOR VALUES FROM ('2026-02-01 00:00:00+00') TO ('2026-03-01 00:00:00+00');

7 changes: 1 addition & 6 deletions testdata/dump/issue_472_partition_clone_trigger/pgschema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,7 @@ CREATE TABLE IF NOT EXISTS ledger (
-- Name: ledger_2026_06; Type: TABLE; Schema: -; Owner: -
--

CREATE TABLE IF NOT EXISTS ledger_2026_06 (
id uuid,
amount bigint NOT NULL,
ts timestamptz,
CONSTRAINT ledger_2026_06_pkey PRIMARY KEY (ts, id)
);
CREATE TABLE IF NOT EXISTS ledger_2026_06 PARTITION OF ledger FOR VALUES FROM ('2026-06-01 00:00:00+00') TO ('2026-07-01 00:00:00+00');

--
-- Name: tg_noop(); Type: FUNCTION; Schema: -; Owner: -
Expand Down
Loading