Skip to content

Commit a69d454

Browse files
fix(#1512): make FK-index coverage complete via a post-parse pass
The inline check only compared against the primary key (and couldn't see indexes declared after the FK line). Move the decision to a post-parse pass in prepare_declare, where the full primary key and index list are known: emit an FK support index unless its columns are a left-prefix of the primary key, of a declared index, or of a longer FK-support index already emitted (longest-first). compile_foreign_key now only records candidates. Never under-indexes; no longer creates redundant indexes. Adds a test for the declared-index coverage case.
1 parent 2ec984c commit a69d454

2 files changed

Lines changed: 77 additions & 16 deletions

File tree

‎src/datajoint/declare.py‎

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ def compile_foreign_key(
190190
index_sql: list[str],
191191
adapter,
192192
fk_attribute_map: dict[str, tuple[str, str]] | None = None,
193+
fk_index_candidates: list[list[str]] | None = None,
193194
) -> None:
194195
"""
195196
Parse a foreign key line and update declaration components.
@@ -215,6 +216,11 @@ def compile_foreign_key(
215216
Database adapter for backend-specific SQL generation.
216217
fk_attribute_map : dict, optional
217218
Mapping of ``child_attr -> (parent_table, parent_attr)``. Updated in place.
219+
fk_index_candidates : list, optional
220+
PostgreSQL only. Collects each non-unique foreign key's columns as a
221+
candidate supporting index; the redundancy/coverage decision is made
222+
post-parse (once the full primary key and index list are known).
223+
Updated in place.
218224
219225
Raises
220226
------
@@ -301,27 +307,22 @@ def compile_foreign_key(
301307
f"FOREIGN KEY ({fk_cols}) REFERENCES {ref_table_name} ({pk_cols}) ON UPDATE CASCADE ON DELETE RESTRICT"
302308
)
303309

304-
# Declare a supporting index on the foreign-key columns.
310+
# Supporting index on the foreign-key columns.
305311
#
306-
# MySQL/InnoDB creates one implicitly for every foreign key, so we emit an
307-
# explicit index only on the PostgreSQL path — Postgres never auto-indexes
308-
# the referencing (child) columns and offers no server setting to make it
309-
# (see #1512). The `unique` case keeps its UNIQUE INDEX on every backend.
310-
#
311-
# Coverage-aware: skip when the foreign-key columns are already a left-prefix
312-
# of the child's primary key, since the primary-key index then already serves
313-
# foreign-key lookups and cascades. That holds exactly for a *leading*
314-
# primary foreign key; a secondary foreign key (`primary_key is None`) or a
315-
# non-leading primary foreign key is not covered and needs its own index.
312+
# The `unique` option is a uniqueness constraint, always emitted, on every
313+
# backend. For the non-unique case: MySQL/InnoDB indexes every foreign key
314+
# implicitly, but PostgreSQL never indexes the referencing (child) columns
315+
# and offers no server setting to make it (see #1512), so DataJoint must.
316+
# Whether that index is *redundant* (the columns are already a left-prefix of
317+
# the primary key or of another declared index) can only be decided once the
318+
# whole definition is parsed, so here we merely record the candidate; the
319+
# coverage decision happens post-parse in `prepare_declare`.
316320
fk_attrs = list(ref.primary_key)
317321
if is_unique:
318322
index_cols = ", ".join(adapter.quote_identifier(attr) for attr in fk_attrs)
319323
index_sql.append(f"UNIQUE INDEX ({index_cols})")
320-
elif adapter.backend == "postgresql":
321-
covered_by_pk = primary_key is not None and list(primary_key[: len(fk_attrs)]) == fk_attrs
322-
if not covered_by_pk:
323-
index_cols = ", ".join(adapter.quote_identifier(attr) for attr in fk_attrs)
324-
index_sql.append(f"INDEX ({index_cols})")
324+
elif adapter.backend == "postgresql" and fk_index_candidates is not None:
325+
fk_index_candidates.append(fk_attrs)
325326

326327

327328
def prepare_declare(
@@ -368,6 +369,7 @@ def prepare_declare(
368369
external_stores = []
369370
fk_attribute_map = {} # child_attr -> (parent_table, parent_attr)
370371
column_comments = {} # column_name -> comment (for PostgreSQL COMMENT ON)
372+
fk_index_candidates = [] # PostgreSQL: FK column-lists that may need a support index (#1512)
371373

372374
for line in definition:
373375
if not line or line.startswith("#"): # ignore additional comments
@@ -385,6 +387,7 @@ def prepare_declare(
385387
index_sql,
386388
adapter,
387389
fk_attribute_map,
390+
fk_index_candidates,
388391
)
389392
elif re.match(r"^(unique\s+)?index\s*\(.*\)\s*(#.*)?$", line, re.I): # index
390393
compile_index(re.sub(r"\s*#.*$", "", line), index_sql, adapter)
@@ -400,6 +403,33 @@ def prepare_declare(
400403
if comment:
401404
column_comments[name] = comment
402405

406+
# Foreign-key support indexes (PostgreSQL; #1512). Now that the whole
407+
# definition is parsed, emit an index on each candidate foreign key's columns
408+
# UNLESS they are already a left-prefix of the primary key, of a declared
409+
# index, or of a longer FK-support index already emitted — in which case that
410+
# existing index already serves foreign-key lookups and cascades. Candidates
411+
# are only collected on PostgreSQL (MySQL/InnoDB indexes FKs implicitly), so
412+
# this loop is a no-op elsewhere.
413+
if fk_index_candidates:
414+
415+
def _index_columns(index_def: str) -> list[str]:
416+
match = re.match(r"(?:unique\s+)?index\s*\(([^)]+)\)", index_def, re.I)
417+
return [col.strip().strip('`"') for col in match.group(1).split(",")] if match else []
418+
419+
# Existing prefixes an FK index could be redundant against: the primary
420+
# key and every already-declared index. Grows as we accept FK indexes.
421+
existing_prefixes = [list(primary_key)] + [_index_columns(s) for s in index_sql]
422+
423+
def _covered(candidate: list[str]) -> bool:
424+
return any(prefix[: len(candidate)] == candidate for prefix in existing_prefixes)
425+
426+
# Longest first, so a shorter FK index left-covered by a longer one is skipped.
427+
for candidate in sorted(fk_index_candidates, key=len, reverse=True):
428+
if not _covered(candidate):
429+
index_cols = ", ".join(adapter.quote_identifier(attr) for attr in candidate)
430+
index_sql.append(f"INDEX ({index_cols})")
431+
existing_prefixes.append(candidate)
432+
403433
return (
404434
table_comment,
405435
primary_key,

‎tests/integration/test_fk_index.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,34 @@ class Q(dj.Manual):
137137
assert not _fk_support_indexes(
138138
connection_by_backend, schema_by_backend.database, Q.table_name
139139
), "a leading primary FK is covered by the PK index; no redundant FK-support index should be created"
140+
141+
142+
def test_secondary_fk_covered_by_declared_index(schema_by_backend, db_creds_by_backend, connection_by_backend):
143+
"""A secondary FK whose columns are a left-prefix of a user-declared index
144+
needs no separate FK-support index (post-parse coverage vs declared indexes)."""
145+
if db_creds_by_backend["backend"] != "postgresql":
146+
pytest.skip("PostgreSQL-specific")
147+
148+
@schema_by_backend
149+
class Master(dj.Manual):
150+
definition = """
151+
master_id : int32
152+
"""
153+
154+
@schema_by_backend
155+
class Child(dj.Manual):
156+
definition = """
157+
child_id : int32
158+
---
159+
-> Master
160+
note : varchar(16)
161+
index (master_id, note)
162+
"""
163+
164+
# The declared index (master_id, note) already left-covers master_id, so no
165+
# standalone FK-support index on master_id should be created.
166+
names = _fk_support_indexes(connection_by_backend, schema_by_backend.database, Child.table_name)
167+
assert (
168+
f"idx_{Child.table_name}_master_id" not in names
169+
), f"a secondary FK covered by a declared index must not get a redundant FK-support index; got {names}"
170+
assert f"idx_{Child.table_name}_master_id_note" in names, "the declared composite index should still exist"

0 commit comments

Comments
 (0)