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
28 changes: 25 additions & 3 deletions internal/diff/constraint.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package diff

import (
"fmt"
"slices"
"strings"

"github.com/pgplex/pgschema/ir"
Expand Down Expand Up @@ -59,9 +60,7 @@ func generateConstraintSQL(constraint *ir.Constraint, targetSchema string, quali
if constraint.UpdateRule != "" && constraint.UpdateRule != "NO ACTION" {
stmt += fmt.Sprintf(" ON UPDATE %s", constraint.UpdateRule)
}
if constraint.DeleteRule != "" && constraint.DeleteRule != "NO ACTION" {
stmt += fmt.Sprintf(" ON DELETE %s", constraint.DeleteRule)
}
stmt += onDeleteClause(constraint)
// Add deferrable clause
stmt += deferrableClause(constraint)
// Add NOT VALID if needed
Expand Down Expand Up @@ -182,6 +181,11 @@ func constraintsEqual(old, new *ir.Constraint) bool {
if old.DeleteRule != new.DeleteRule {
return false
}
// The SET NULL/SET DEFAULT column list is a set: PostgreSQL stores it in the
// order written, so compare order-independently to avoid a needless recreate.
if !slices.Equal(slices.Sorted(slices.Values(old.DeleteSetColumns)), slices.Sorted(slices.Values(new.DeleteSetColumns))) {
return false
}
if old.UpdateRule != new.UpdateRule {
return false
}
Expand Down Expand Up @@ -237,3 +241,21 @@ func constraintsEqual(old, new *ir.Constraint) bool {

return true
}

// onDeleteClause renders the ON DELETE action of a foreign key, including the
// optional column list of SET NULL / SET DEFAULT (PG15+, issue #589). Returns
// "" for the default NO ACTION.
func onDeleteClause(constraint *ir.Constraint) string {
if constraint.DeleteRule == "" || constraint.DeleteRule == "NO ACTION" {
return ""
}
clause := fmt.Sprintf(" ON DELETE %s", constraint.DeleteRule)
if len(constraint.DeleteSetColumns) > 0 {
cols := make([]string, len(constraint.DeleteSetColumns))
for i, col := range constraint.DeleteSetColumns {
cols[i] = ir.QuoteIdentifier(col)
}
clause += fmt.Sprintf(" (%s)", strings.Join(cols, ", "))
}
return clause
}
4 changes: 1 addition & 3 deletions internal/diff/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -2036,9 +2036,7 @@ func generateForeignKeyClauseMode(constraint *ir.Constraint, targetSchema string
if constraint.UpdateRule != "" && constraint.UpdateRule != "NO ACTION" {
clause += fmt.Sprintf(" ON UPDATE %s", constraint.UpdateRule)
}
if constraint.DeleteRule != "" && constraint.DeleteRule != "NO ACTION" {
clause += fmt.Sprintf(" ON DELETE %s", constraint.DeleteRule)
}
clause += onDeleteClause(constraint)

// Add deferrable clause
if constraint.Deferrable {
Expand Down
8 changes: 8 additions & 0 deletions internal/plan/rewrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,14 @@ func generateForeignKeyRewrite(constraint *ir.Constraint) []RewriteStep {
}
if constraint.DeleteRule != "" && constraint.DeleteRule != "NO ACTION" {
fkClause += fmt.Sprintf(" ON DELETE %s", constraint.DeleteRule)
// SET NULL / SET DEFAULT column list (PG15+, issue #589)
if len(constraint.DeleteSetColumns) > 0 {
var setCols []string
for _, col := range constraint.DeleteSetColumns {
setCols = append(setCols, ir.QuoteIdentifier(col))
}
fkClause += fmt.Sprintf(" (%s)", joinStrings(setCols, ", "))
}
}

// Add DEFERRABLE clauses if specified
Expand Down
10 changes: 10 additions & 0 deletions ir/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ir
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"sort"
"strconv"
Expand Down Expand Up @@ -557,6 +558,15 @@ func (i *Inspector) buildConstraints(ctx context.Context, schema *IR, targetSche
if deleteRule := i.safeInterfaceToString(constraint.DeleteRule); deleteRule != "" && deleteRule != "<nil>" {
c.DeleteRule = deleteRule
}
// ON DELETE SET NULL/SET DEFAULT (column list) arrives as a JSON array of
// column names (empty string when absent or on PG14). Issue #589.
if setCols := constraint.DeleteSetColumns.String; setCols != "" {
var cols []string
if err := json.Unmarshal([]byte(setCols), &cols); err != nil {
return fmt.Errorf("failed to parse delete set columns for constraint %s.%s.%s: %w", schemaName, tableName, constraintName, err)
}
c.DeleteSetColumns = cols
}
if updateRule := i.safeInterfaceToString(constraint.UpdateRule); updateRule != "" && updateRule != "<nil>" {
c.UpdateRule = updateRule
}
Expand Down
1 change: 1 addition & 0 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ type Constraint struct {
CheckClause string `json:"check_clause,omitempty"`
ExclusionDefinition string `json:"exclusion_definition,omitempty"` // Full EXCLUDE definition from pg_get_constraintdef()
DeleteRule string `json:"delete_rule,omitempty"`
DeleteSetColumns []string `json:"delete_set_columns,omitempty"` // PG15+: column list of ON DELETE SET NULL/SET DEFAULT (confdelsetcols)
UpdateRule string `json:"update_rule,omitempty"`
Deferrable bool `json:"deferrable,omitempty"`
InitiallyDeferred bool `json:"initially_deferred,omitempty"`
Expand Down
30 changes: 28 additions & 2 deletions ir/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -337,14 +337,27 @@ SELECT
c.condeferred AS initially_deferred,
c.convalidated AS is_valid,
COALESCE((to_jsonb(c) ->> 'conperiod')::boolean, false) AS is_period,
c.connoinherit AS no_inherit
c.connoinherit AS no_inherit,
-- ON DELETE SET NULL/SET DEFAULT (column list), PG15+ (pg_constraint.confdelsetcols).
-- Rendered as a JSON array of column names so it survives the per-column row fan-out
-- and stays a single scalar on PG14 (where the attribute does not exist). Issue #589.
COALESCE(ds.delete_set_columns, '') AS delete_set_columns
FROM pg_constraint c
JOIN pg_class cl ON c.conrelid = cl.oid
JOIN pg_namespace n ON cl.relnamespace = n.oid
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 jsonb_agg(da.attname ORDER BY e.ord)::text AS delete_set_columns
FROM jsonb_array_elements_text(
CASE WHEN jsonb_typeof(to_jsonb(c) -> 'confdelsetcols') = 'array'
THEN to_jsonb(c) -> 'confdelsetcols'
ELSE '[]'::jsonb END
) WITH ORDINALITY AS e(attnum, ord)
JOIN pg_attribute da ON da.attrelid = c.conrelid AND da.attnum = e.attnum::int
) ds ON true
LEFT JOIN LATERAL (
SELECT
-- Render with search_path set to the table's own schema so same-schema
Expand Down Expand Up @@ -1054,7 +1067,11 @@ SELECT
c.connoinherit AS no_inherit,
-- pg_index.indnullsnotdistinct is PG15+. Use to_jsonb so the column reference
-- doesn't fail to plan on PG14 (where the attribute does not exist on pg_index).
COALESCE((to_jsonb(i) ->> 'indnullsnotdistinct')::boolean, false) AS nulls_not_distinct
COALESCE((to_jsonb(i) ->> 'indnullsnotdistinct')::boolean, false) AS nulls_not_distinct,
-- ON DELETE SET NULL/SET DEFAULT (column list), PG15+ (pg_constraint.confdelsetcols).
-- Rendered as a JSON array of column names so it survives the per-column row fan-out
-- and stays a single scalar on PG14 (where the attribute does not exist). Issue #589.
COALESCE(ds.delete_set_columns, '') AS delete_set_columns
FROM pg_constraint c
JOIN pg_class cl ON c.conrelid = cl.oid
JOIN pg_namespace n ON cl.relnamespace = n.oid
Expand All @@ -1063,6 +1080,15 @@ 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 jsonb_agg(da.attname ORDER BY e.ord)::text AS delete_set_columns
FROM jsonb_array_elements_text(
CASE WHEN jsonb_typeof(to_jsonb(c) -> 'confdelsetcols') = 'array'
THEN to_jsonb(c) -> 'confdelsetcols'
ELSE '[]'::jsonb END
) WITH ORDINALITY AS e(attnum, ord)
JOIN pg_attribute da ON da.attrelid = c.conrelid AND da.attnum = e.attnum::int
) ds ON true
LEFT JOIN LATERAL (
SELECT
-- Render with search_path set to the table's own schema so same-schema
Expand Down
34 changes: 32 additions & 2 deletions ir/queries/queries.sql.go

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

11 changes: 11 additions & 0 deletions testdata/diff/create_table/add_fk/diff.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
ALTER TABLE audit_log
ADD CONSTRAINT audit_log_org_id_actor_member_id_fkey FOREIGN KEY (org_id, actor_member_id) REFERENCES members (org_id, id) ON DELETE SET NULL (actor_member_id);

ALTER TABLE books
ADD CONSTRAINT books_author_id_fkey FOREIGN KEY (author_id) REFERENCES authors (id) ON DELETE CASCADE;

Expand All @@ -7,6 +10,11 @@ ADD CONSTRAINT employees_department_id_fkey FOREIGN KEY (department_id) REFERENC
ALTER TABLE nodes
ADD CONSTRAINT nodes_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES nodes (id);

ALTER TABLE notes DROP CONSTRAINT notes_org_id_author_member_id_fkey;

ALTER TABLE notes
ADD CONSTRAINT notes_org_id_author_member_id_fkey FOREIGN KEY (org_id, author_member_id) REFERENCES members (org_id, id) ON DELETE SET NULL (author_member_id);

ALTER TABLE orders
ADD CONSTRAINT orders_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES customers (id);

Expand All @@ -25,6 +33,9 @@ ADD CONSTRAINT products_category_code_fkey FOREIGN KEY (category_code) REFERENCE
ALTER TABLE projects
ADD CONSTRAINT projects_tenant_id_org_id_fkey FOREIGN KEY (tenant_id, org_id) REFERENCES organizations (tenant_id, org_id);

ALTER TABLE tasks
ADD CONSTRAINT tasks_org_id_owner_member_id_fkey FOREIGN KEY (org_id, owner_member_id) REFERENCES members (org_id, id) ON DELETE SET DEFAULT (owner_member_id);

ALTER TABLE teams
ADD CONSTRAINT teams_manager_id_fkey FOREIGN KEY (manager_id) REFERENCES managers (id) ON DELETE SET NULL;

Expand Down
34 changes: 34 additions & 0 deletions testdata/diff/create_table/add_fk/new.sql
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,40 @@ CREATE TABLE public.orders (
CONSTRAINT orders_manager_id_fkey FOREIGN KEY (manager_id) REFERENCES public.managers(id) ON DELETE SET NULL
);

-- Composite FK with ON DELETE SET NULL / SET DEFAULT column list (PG15+, issue #589)
CREATE TABLE public.members (
id integer NOT NULL,
org_id integer NOT NULL,
CONSTRAINT members_pkey PRIMARY KEY (id),
CONSTRAINT members_org_id_id_key UNIQUE (org_id, id)
);

CREATE TABLE public.audit_log (
id integer NOT NULL,
org_id integer NOT NULL,
actor_member_id integer,
CONSTRAINT audit_log_pkey PRIMARY KEY (id),
CONSTRAINT audit_log_org_id_actor_member_id_fkey FOREIGN KEY (org_id, actor_member_id) REFERENCES public.members(org_id, id) ON DELETE SET NULL (actor_member_id)
);

CREATE TABLE public.tasks (
id integer NOT NULL,
org_id integer NOT NULL,
owner_member_id integer DEFAULT 0,
CONSTRAINT tasks_pkey PRIMARY KEY (id),
CONSTRAINT tasks_org_id_owner_member_id_fkey FOREIGN KEY (org_id, owner_member_id) REFERENCES public.members(org_id, id) ON DELETE SET DEFAULT (owner_member_id)
);

-- Existing composite FK that lacks the column list: the desired state adds it,
-- so the constraint must be recreated (the column list must be compared).
CREATE TABLE public.notes (
id integer NOT NULL,
org_id integer NOT NULL,
author_member_id integer,
CONSTRAINT notes_pkey PRIMARY KEY (id),
CONSTRAINT notes_org_id_author_member_id_fkey FOREIGN KEY (org_id, author_member_id) REFERENCES public.members(org_id, id) ON DELETE SET NULL (author_member_id)
);

-- Temporal FK case (PG18+)
CREATE TABLE public.price_history (
product_id integer NOT NULL,
Expand Down
32 changes: 32 additions & 0 deletions testdata/diff/create_table/add_fk/old.sql
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,38 @@ CREATE TABLE public.orders (
CONSTRAINT orders_pkey PRIMARY KEY (id)
);

-- Composite FK with ON DELETE SET NULL / SET DEFAULT column list (PG15+, issue #589)
CREATE TABLE public.members (
id integer NOT NULL,
org_id integer NOT NULL,
CONSTRAINT members_pkey PRIMARY KEY (id),
CONSTRAINT members_org_id_id_key UNIQUE (org_id, id)
);

CREATE TABLE public.audit_log (
id integer NOT NULL,
org_id integer NOT NULL,
actor_member_id integer,
CONSTRAINT audit_log_pkey PRIMARY KEY (id)
);

CREATE TABLE public.tasks (
id integer NOT NULL,
org_id integer NOT NULL,
owner_member_id integer DEFAULT 0,
CONSTRAINT tasks_pkey PRIMARY KEY (id)
);

-- Existing composite FK that lacks the column list: the desired state adds it,
-- so the constraint must be recreated (the column list must be compared).
CREATE TABLE public.notes (
id integer NOT NULL,
org_id integer NOT NULL,
author_member_id integer,
CONSTRAINT notes_pkey PRIMARY KEY (id),
CONSTRAINT notes_org_id_author_member_id_fkey FOREIGN KEY (org_id, author_member_id) REFERENCES public.members(org_id, id) ON DELETE SET NULL
);

-- Temporal FK case (PG18+)
CREATE TABLE public.price_history (
product_id integer NOT NULL,
Expand Down
Loading