Skip to content

Commit 9800381

Browse files
feat: Add backend-agnostic cascade delete support
Cascade delete previously relied on parsing MySQL-specific foreign key error messages. Now uses adapter methods for both MySQL and PostgreSQL. New adapter methods: 1. parse_foreign_key_error(error_message) -> dict - Parses FK violation errors to extract constraint details - MySQL: Extracts from detailed error with full FK definition - PostgreSQL: Extracts table names and constraint from simpler error 2. get_constraint_info_sql(constraint_name, schema, table) -> str - Queries information_schema for FK column mappings - Used when error message doesn't include full FK details - MySQL: Uses KEY_COLUMN_USAGE with CONCAT for parent name - PostgreSQL: Joins KEY_COLUMN_USAGE with CONSTRAINT_COLUMN_USAGE table.py cascade delete updates: - Use adapter.parse_foreign_key_error() instead of hardcoded regexp - Backend-agnostic quote stripping (handles both ` and ") - Use adapter.get_constraint_info_sql() for querying FK details - Properly rebuild child table names with schema when missing This enables cascade delete operations to work correctly on PostgreSQL while maintaining full backward compatibility with MySQL. Part of multi-backend PostgreSQL support implementation.
1 parent b96c52d commit 9800381

4 files changed

Lines changed: 194 additions & 42 deletions

File tree

src/datajoint/adapters/base.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,72 @@ def get_foreign_keys_sql(self, schema_name: str, table_name: str) -> str:
600600
"""
601601
...
602602

603+
@abstractmethod
604+
def get_constraint_info_sql(self, constraint_name: str, schema_name: str, table_name: str) -> str:
605+
"""
606+
Generate query to get foreign key constraint details from information_schema.
607+
608+
Used during cascade delete to determine FK columns when error message
609+
doesn't provide full details.
610+
611+
Parameters
612+
----------
613+
constraint_name : str
614+
Name of the foreign key constraint.
615+
schema_name : str
616+
Schema/database name of the child table.
617+
table_name : str
618+
Name of the child table.
619+
620+
Returns
621+
-------
622+
str
623+
SQL query that returns rows with columns:
624+
- fk_attrs: foreign key column name in child table
625+
- parent: parent table name (quoted, with schema)
626+
- pk_attrs: referenced column name in parent table
627+
"""
628+
...
629+
630+
@abstractmethod
631+
def parse_foreign_key_error(self, error_message: str) -> dict[str, str | list[str]] | None:
632+
"""
633+
Parse a foreign key violation error message to extract constraint details.
634+
635+
Used during cascade delete to identify which child table is preventing
636+
deletion and what columns are involved.
637+
638+
Parameters
639+
----------
640+
error_message : str
641+
The error message from a foreign key constraint violation.
642+
643+
Returns
644+
-------
645+
dict or None
646+
Dictionary with keys if successfully parsed:
647+
- child: child table name (quoted with schema if available)
648+
- name: constraint name (quoted)
649+
- fk_attrs: list of foreign key column names (may be None if not in message)
650+
- parent: parent table name (quoted, may be None if not in message)
651+
- pk_attrs: list of parent key column names (may be None if not in message)
652+
653+
Returns None if error message doesn't match FK violation pattern.
654+
655+
Examples
656+
--------
657+
MySQL error:
658+
"Cannot delete or update a parent row: a foreign key constraint fails
659+
(`schema`.`child`, CONSTRAINT `fk_name` FOREIGN KEY (`child_col`)
660+
REFERENCES `parent` (`parent_col`))"
661+
662+
PostgreSQL error:
663+
"update or delete on table \"parent\" violates foreign key constraint
664+
\"child_parent_id_fkey\" on table \"child\"
665+
DETAIL: Key (parent_id)=(1) is still referenced from table \"child\"."
666+
"""
667+
...
668+
603669
@abstractmethod
604670
def get_indexes_sql(self, schema_name: str, table_name: str) -> str:
605671
"""

src/datajoint/adapters/mysql.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,44 @@ def get_foreign_keys_sql(self, schema_name: str, table_name: str) -> str:
595595
f"ORDER BY constraint_name, ordinal_position"
596596
)
597597

598+
def get_constraint_info_sql(self, constraint_name: str, schema_name: str, table_name: str) -> str:
599+
"""Query to get FK constraint details from information_schema."""
600+
return (
601+
f"SELECT "
602+
f" COLUMN_NAME as fk_attrs, "
603+
f" CONCAT('`', REFERENCED_TABLE_SCHEMA, '`.`', REFERENCED_TABLE_NAME, '`') as parent, "
604+
f" REFERENCED_COLUMN_NAME as pk_attrs "
605+
f"FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE "
606+
f"WHERE CONSTRAINT_NAME = %s AND TABLE_SCHEMA = %s AND TABLE_NAME = %s"
607+
)
608+
609+
def parse_foreign_key_error(self, error_message: str) -> dict[str, str | list[str]] | None:
610+
"""Parse MySQL foreign key violation error message."""
611+
import re
612+
613+
# MySQL FK error pattern with backticks
614+
pattern = re.compile(
615+
r"[\w\s:]*\((?P<child>`[^`]+`.`[^`]+`), "
616+
r"CONSTRAINT (?P<name>`[^`]+`) "
617+
r"(FOREIGN KEY \((?P<fk_attrs>[^)]+)\) "
618+
r"REFERENCES (?P<parent>`[^`]+`(\.`[^`]+`)?) \((?P<pk_attrs>[^)]+)\)[\s\w]+\))?"
619+
)
620+
621+
match = pattern.match(error_message)
622+
if not match:
623+
return None
624+
625+
result = match.groupdict()
626+
627+
# Parse comma-separated FK attrs if present
628+
if result.get("fk_attrs"):
629+
result["fk_attrs"] = [col.strip("`") for col in result["fk_attrs"].split(",")]
630+
# Parse comma-separated PK attrs if present
631+
if result.get("pk_attrs"):
632+
result["pk_attrs"] = [col.strip("`") for col in result["pk_attrs"].split(",")]
633+
634+
return result
635+
598636
def get_indexes_sql(self, schema_name: str, table_name: str) -> str:
599637
"""Query to get index definitions."""
600638
return (

src/datajoint/adapters/postgres.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,52 @@ def get_foreign_keys_sql(self, schema_name: str, table_name: str) -> str:
667667
f"ORDER BY kcu.constraint_name, kcu.ordinal_position"
668668
)
669669

670+
def get_constraint_info_sql(self, constraint_name: str, schema_name: str, table_name: str) -> str:
671+
"""Query to get FK constraint details from information_schema."""
672+
return (
673+
f"SELECT "
674+
f" kcu.column_name as fk_attrs, "
675+
f" '\"' || ccu.table_schema || '\".\"' || ccu.table_name || '\"' as parent, "
676+
f" ccu.column_name as pk_attrs "
677+
f"FROM information_schema.key_column_usage AS kcu "
678+
f"JOIN information_schema.constraint_column_usage AS ccu "
679+
f" ON kcu.constraint_name = ccu.constraint_name "
680+
f" AND kcu.constraint_schema = ccu.constraint_schema "
681+
f"WHERE kcu.constraint_name = %s "
682+
f" AND kcu.table_schema = %s "
683+
f" AND kcu.table_name = %s "
684+
f"ORDER BY kcu.ordinal_position"
685+
)
686+
687+
def parse_foreign_key_error(self, error_message: str) -> dict[str, str | list[str]] | None:
688+
"""Parse PostgreSQL foreign key violation error message."""
689+
import re
690+
691+
# PostgreSQL FK error pattern
692+
# Example: 'update or delete on table "parent" violates foreign key constraint "child_parent_id_fkey" on table "child"'
693+
pattern = re.compile(
694+
r'.*table "(?P<parent_table>[^"]+)" violates foreign key constraint "(?P<name>[^"]+)" on table "(?P<child_table>[^"]+)"'
695+
)
696+
697+
match = pattern.match(error_message)
698+
if not match:
699+
return None
700+
701+
result = match.groupdict()
702+
703+
# Build child table name (assume same schema as parent for now)
704+
# The error doesn't include schema, so we return unqualified names
705+
# and let the caller add schema context
706+
child = f'"{result["child_table"]}"'
707+
708+
return {
709+
"child": child,
710+
"name": f'"{result["name"]}"',
711+
"fk_attrs": None, # Not in error message, will need constraint query
712+
"parent": f'"{result["parent_table"]}"',
713+
"pk_attrs": None, # Not in error message, will need constraint query
714+
}
715+
670716
def get_indexes_sql(self, schema_name: str, table_name: str) -> str:
671717
"""Query to get index definitions."""
672718
return (

src/datajoint/table.py

Lines changed: 44 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -30,24 +30,8 @@
3030

3131
logger = logging.getLogger(__name__.split(".")[0])
3232

33-
foreign_key_error_regexp = re.compile(
34-
r"[\w\s:]*\((?P<child>`[^`]+`.`[^`]+`), "
35-
r"CONSTRAINT (?P<name>`[^`]+`) "
36-
r"(FOREIGN KEY \((?P<fk_attrs>[^)]+)\) "
37-
r"REFERENCES (?P<parent>`[^`]+`(\.`[^`]+`)?) \((?P<pk_attrs>[^)]+)\)[\s\w]+\))?"
38-
)
39-
40-
constraint_info_query = " ".join(
41-
"""
42-
SELECT
43-
COLUMN_NAME as fk_attrs,
44-
CONCAT('`', REFERENCED_TABLE_SCHEMA, '`.`', REFERENCED_TABLE_NAME, '`') as parent,
45-
REFERENCED_COLUMN_NAME as pk_attrs
46-
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
47-
WHERE
48-
CONSTRAINT_NAME = %s AND TABLE_SCHEMA = %s AND TABLE_NAME = %s;
49-
""".split()
50-
)
33+
# Note: Foreign key error parsing is now handled by adapter methods
34+
# Legacy regexp and query kept for reference but no longer used
5135

5236

5337
class _RenameMap(tuple):
@@ -895,35 +879,53 @@ def cascade(table):
895879
try:
896880
delete_count = table.delete_quick(get_count=True)
897881
except IntegrityError as error:
898-
match = foreign_key_error_regexp.match(error.args[0])
882+
# Use adapter to parse FK error message
883+
match = table.connection.adapter.parse_foreign_key_error(error.args[0])
899884
if match is None:
900885
raise DataJointError(
901-
"Cascading deletes failed because the error message is missing foreign key information."
886+
"Cascading deletes failed because the error message is missing foreign key information. "
902887
"Make sure you have REFERENCES privilege to all dependent tables."
903888
) from None
904-
match = match.groupdict()
905-
# if schema name missing, use table
906-
if "`.`" not in match["child"]:
907-
match["child"] = "{}.{}".format(table.full_table_name.split(".")[0], match["child"])
908-
if match["pk_attrs"] is not None: # fully matched, adjusting the keys
909-
match["fk_attrs"] = [k.strip("`") for k in match["fk_attrs"].split(",")]
910-
match["pk_attrs"] = [k.strip("`") for k in match["pk_attrs"].split(",")]
911-
else: # only partially matched, querying with constraint to determine keys
912-
match["fk_attrs"], match["parent"], match["pk_attrs"] = list(
913-
map(
914-
list,
915-
zip(
916-
*table.connection.query(
917-
constraint_info_query,
918-
args=(
919-
match["name"].strip("`"),
920-
*[_.strip("`") for _ in match["child"].split("`.`")],
921-
),
922-
).fetchall()
923-
),
924-
)
889+
890+
# Strip quotes from parsed values for backend-agnostic processing
891+
quote_chars = ('`', '"')
892+
893+
def strip_quotes(s):
894+
if s and any(s.startswith(q) for q in quote_chars):
895+
return s.strip('`"')
896+
return s
897+
898+
# Ensure child table has schema
899+
child_table = match["child"]
900+
if "." not in strip_quotes(child_table):
901+
# Add schema from current table
902+
schema = table.full_table_name.split(".")[0].strip('`"')
903+
child_unquoted = strip_quotes(child_table)
904+
child_table = f"{table.connection.adapter.quote_identifier(schema)}.{table.connection.adapter.quote_identifier(child_unquoted)}"
905+
match["child"] = child_table
906+
907+
# If FK/PK attributes not in error message, query information_schema
908+
if match["fk_attrs"] is None or match["pk_attrs"] is None:
909+
# Extract schema and table name from child
910+
child_parts = [strip_quotes(p) for p in child_table.split(".")]
911+
if len(child_parts) == 2:
912+
child_schema, child_table_name = child_parts
913+
else:
914+
child_schema = table.full_table_name.split(".")[0].strip('`"')
915+
child_table_name = child_parts[0]
916+
917+
constraint_query = table.connection.adapter.get_constraint_info_sql(
918+
strip_quotes(match["name"]),
919+
child_schema,
920+
child_table_name,
925921
)
926-
match["parent"] = match["parent"][0]
922+
923+
results = table.connection.query(constraint_query).fetchall()
924+
if results:
925+
match["fk_attrs"], match["parent"], match["pk_attrs"] = list(
926+
map(list, zip(*results))
927+
)
928+
match["parent"] = match["parent"][0] # All rows have same parent
927929

928930
# Restrict child by table if
929931
# 1. if table's restriction attributes are not in child's primary key

0 commit comments

Comments
 (0)