Skip to content

Commit 5fee84f

Browse files
committed
Fix when referring table has multiple FKs
Prior to this change, a part of the clean_up function that changes the name of foreign keys back to their original wasn't handling referring tables that had more than one FK to the original table. This commit changes that, so that no matter how many FKs a referring table has to the original table there won't be any problems.
1 parent a1e556d commit 5fee84f

File tree

2 files changed

+175
-15
lines changed

2 files changed

+175
-15
lines changed

src/psycopack/_repack.py

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -581,13 +581,41 @@ def clean_up(self) -> None:
581581
idx_data["idx_to"] = idx.name
582582

583583
# Rename foreign keys from other tables using a dict data structure to
584-
# hold names from/to.
585-
table_to_fk: dict[str, dict[str, str]] = {}
584+
# hold names from/to. Support multiple FKs from the same referring table.
585+
# Match FKs by their definition (columns), similar to how indexes are matched.
586+
fk_definitions: dict[str, list[dict[str, str]]] = defaultdict(list)
587+
586588
for fk in self.introspector.get_referring_fks(table=self.repacked_name):
587-
table_to_fk[fk.referring_table] = {"cons_from": fk.name}
589+
# Normalize the FK definition by replacing the old table name with the new one
590+
# This allows us to match FKs based on their structure.
591+
fk_def = fk.definition.replace(
592+
f"REFERENCES {self.schema}.{self.repacked_name}",
593+
f"REFERENCES {self.schema}.{self.table}",
594+
).replace(
595+
f"REFERENCES {self.repacked_name}",
596+
f"REFERENCES {self.table}",
597+
)
598+
fk_definitions[fk_def].append(
599+
{
600+
"cons_from": fk.name,
601+
"referring_table": fk.referring_table,
602+
}
603+
)
588604

589605
for fk in self.introspector.get_referring_fks(table=self.table):
590-
table_to_fk[fk.referring_table]["cons_to"] = fk.name
606+
# Use the FK definition as the key to match with the old FK.
607+
fk_def = fk.definition
608+
if fk_def in fk_definitions:
609+
# Find the first unmatched FK with this definition.
610+
fk_pair = next(
611+
(
612+
pair
613+
for pair in fk_definitions[fk_def]
614+
if "cons_to" not in pair
615+
),
616+
)
617+
if fk_pair:
618+
fk_pair["cons_to"] = fk.name
591619

592620
with (
593621
self.command.db_transaction(),
@@ -616,17 +644,17 @@ def clean_up(self) -> None:
616644
idx_to=index_data["idx_from"],
617645
)
618646

619-
for table in table_to_fk:
620-
fk_data = table_to_fk[table]
621-
self.command.drop_constraint(
622-
table=table,
623-
constraint=fk_data["cons_from"],
624-
)
625-
self.command.rename_constraint(
626-
table=table,
627-
cons_from=fk_data["cons_to"],
628-
cons_to=fk_data["cons_from"],
629-
)
647+
for fk_def in fk_definitions:
648+
for fk_data in fk_definitions[fk_def]:
649+
self.command.drop_constraint(
650+
table=fk_data["referring_table"],
651+
constraint=fk_data["cons_from"],
652+
)
653+
self.command.rename_constraint(
654+
table=fk_data["referring_table"],
655+
cons_from=fk_data["cons_to"],
656+
cons_to=fk_data["cons_from"],
657+
)
630658

631659
self.command.drop_table_if_exists(table=self.repacked_name)
632660
self.command.drop_table_if_exists(table=self.backfill_log)

tests/test_repack.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2603,3 +2603,135 @@ def test_repack_with_change_log_strategy(
26032603
repack=repack,
26042604
cur=cur,
26052605
)
2606+
2607+
2608+
@pytest.mark.parametrize(
2609+
"sync_strategy",
2610+
[SyncStrategy.DIRECT_TRIGGER, SyncStrategy.CHANGE_LOG],
2611+
)
2612+
def test_multiple_foreign_keys_from_same_referring_table(
2613+
connection: _psycopg.Connection,
2614+
sync_strategy: SyncStrategy,
2615+
) -> None:
2616+
"""
2617+
Test that multiple foreign keys from the same referring table are correctly
2618+
handled during clean_up(). This verifies the fix for the bug where only one
2619+
FK would be renamed correctly when multiple FKs from the same table
2620+
existed.
2621+
"""
2622+
with _cur.get_cursor(connection, logged=True) as cur:
2623+
# Create the table to be repacked (simulating a users table)
2624+
cur.execute(
2625+
dedent("""
2626+
CREATE TABLE person (
2627+
id SERIAL PRIMARY KEY,
2628+
name VARCHAR(100)
2629+
);
2630+
""")
2631+
)
2632+
cur.execute("INSERT INTO person (name) VALUES ('Anna'), ('Bob'), ('Carlos');")
2633+
2634+
# Create a referring table with TWO foreign keys to the same table
2635+
# (simulating created_by and updated_by columns)
2636+
cur.execute(
2637+
dedent("""
2638+
CREATE TABLE posts (
2639+
id SERIAL PRIMARY KEY,
2640+
title VARCHAR(200),
2641+
created_by_id INTEGER REFERENCES person(id),
2642+
updated_by_id INTEGER REFERENCES person(id)
2643+
);
2644+
""")
2645+
)
2646+
cur.execute(
2647+
dedent("""
2648+
INSERT INTO posts (title, created_by_id, updated_by_id)
2649+
VALUES
2650+
('Post 1', 1, 1),
2651+
('Post 2', 1, 2),
2652+
('Post 3', 2, 3);
2653+
""")
2654+
)
2655+
2656+
# Collect FK info before repacking
2657+
introspector = _introspect.Introspector(
2658+
conn=connection,
2659+
cur=cur,
2660+
schema="public",
2661+
)
2662+
fks_before = introspector.get_referring_fks(table="person")
2663+
assert len(fks_before) == 2, "Expected 2 foreign keys from posts table"
2664+
fk_names_before = {fk.name for fk in fks_before}
2665+
2666+
# Run the full repack
2667+
repack = Psycopack(
2668+
table="person",
2669+
batch_size=10,
2670+
conn=connection,
2671+
cur=cur,
2672+
sync_strategy=sync_strategy,
2673+
change_log_batch_size=100,
2674+
)
2675+
repack.full()
2676+
2677+
# Verify both foreign keys still exist and point to the person table
2678+
fks_after = introspector.get_referring_fks(table="person")
2679+
assert len(fks_after) == 2, "Expected 2 foreign keys after repack"
2680+
fk_names_after = {fk.name for fk in fks_after}
2681+
2682+
# FK names should be preserved
2683+
assert fk_names_before == fk_names_after, (
2684+
f"FK names changed: before={fk_names_before}, after={fk_names_after}"
2685+
)
2686+
2687+
# Verify both FKs are still valid by checking constraints
2688+
cur.execute(
2689+
dedent("""
2690+
SELECT
2691+
conname,
2692+
conrelid::regclass,
2693+
confrelid::regclass
2694+
FROM
2695+
pg_constraint
2696+
WHERE
2697+
confrelid = 'person'::regclass
2698+
AND contype = 'f'
2699+
ORDER BY
2700+
conname;
2701+
""")
2702+
)
2703+
constraints = cur.fetchall()
2704+
assert len(constraints) == 2
2705+
2706+
# Verify both FKs point to the person table (not the old repacked table)
2707+
for constraint in constraints:
2708+
constraint_name, referring_table, referenced_table = constraint
2709+
assert str(referenced_table) == "person"
2710+
assert str(referring_table) == "posts"
2711+
2712+
# Verify data integrity: FK constraints should still work
2713+
cur.execute("SELECT COUNT(*) FROM posts;")
2714+
row = cur.fetchone()
2715+
assert row is not None
2716+
assert row[0] == 3
2717+
2718+
# Verify we can still query using the foreign keys
2719+
cur.execute(
2720+
dedent("""
2721+
SELECT
2722+
p.title,
2723+
u1.name as creator,
2724+
u2.name as updater
2725+
FROM posts p
2726+
JOIN person u1
2727+
ON p.created_by_id = u1.id
2728+
JOIN person u2
2729+
ON p.updated_by_id = u2.id
2730+
ORDER BY p.id;
2731+
""")
2732+
)
2733+
rows = cur.fetchall()
2734+
assert len(rows) == 3
2735+
assert rows[0] == ("Post 1", "Anna", "Anna")
2736+
assert rows[1] == ("Post 2", "Anna", "Bob")
2737+
assert rows[2] == ("Post 3", "Bob", "Carlos")

0 commit comments

Comments
 (0)