Skip to content

Commit cf77c1e

Browse files
Merge pull request #1471 from datajoint/feat/1423-diagram-trace
feat(#1423): Diagram.trace() for upstream restriction propagation
2 parents 7ceec5d + 7795854 commit cf77c1e

6 files changed

Lines changed: 716 additions & 3 deletions

File tree

src/datajoint/adapters/base.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,29 @@ def find_downstream_schemas_sql(self, schemas_list: str) -> str:
850850
raise NotImplementedError
851851
...
852852

853+
def find_upstream_schemas_sql(self, schemas_list: str) -> str:
854+
"""
855+
Generate query to find schemas that the given schemas reference via FK.
856+
857+
Used to discover unloaded schemas that the loaded ones depend on
858+
(the upstream / ancestor direction). Symmetric to
859+
:meth:`find_downstream_schemas_sql`.
860+
861+
Parameters
862+
----------
863+
schemas_list : str
864+
Comma-separated, quoted schema names for an IN clause.
865+
866+
Returns
867+
-------
868+
str
869+
SQL query returning rows with a single column ``schema_name``
870+
containing distinct schema names that are referenced by the
871+
given schemas.
872+
"""
873+
raise NotImplementedError
874+
...
875+
853876
@abstractmethod
854877
def get_constraint_info_sql(self, constraint_name: str, schema_name: str, table_name: str) -> str:
855878
"""

src/datajoint/adapters/mysql.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,16 @@ def find_downstream_schemas_sql(self, schemas_list: str) -> str:
696696
f"AND table_schema NOT IN ({schemas_list})"
697697
)
698698

699+
def find_upstream_schemas_sql(self, schemas_list: str) -> str:
700+
"""Find schemas that the given schemas reference via FK."""
701+
return (
702+
f"SELECT DISTINCT referenced_table_schema as schema_name "
703+
f"FROM information_schema.key_column_usage "
704+
f"WHERE table_schema IN ({schemas_list}) "
705+
f"AND referenced_table_schema IS NOT NULL "
706+
f"AND referenced_table_schema NOT IN ({schemas_list})"
707+
)
708+
699709
def get_constraint_info_sql(self, constraint_name: str, schema_name: str, table_name: str) -> str:
700710
"""Query to get FK constraint details from information_schema."""
701711
return (

src/datajoint/adapters/postgres.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,20 @@ def find_downstream_schemas_sql(self, schemas_list: str) -> str:
861861
f"AND ns1.nspname NOT IN ({schemas_list})"
862862
)
863863

864+
def find_upstream_schemas_sql(self, schemas_list: str) -> str:
865+
"""Find schemas that the given schemas reference via FK."""
866+
return (
867+
f"SELECT DISTINCT ns2.nspname as schema_name "
868+
f"FROM pg_constraint c "
869+
f"JOIN pg_class cl1 ON c.conrelid = cl1.oid "
870+
f"JOIN pg_namespace ns1 ON cl1.relnamespace = ns1.oid "
871+
f"JOIN pg_class cl2 ON c.confrelid = cl2.oid "
872+
f"JOIN pg_namespace ns2 ON cl2.relnamespace = ns2.oid "
873+
f"WHERE c.contype = 'f' "
874+
f"AND ns1.nspname IN ({schemas_list}) "
875+
f"AND ns2.nspname NOT IN ({schemas_list})"
876+
)
877+
864878
def get_constraint_info_sql(self, constraint_name: str, schema_name: str, table_name: str) -> str:
865879
"""
866880
Query to get FK constraint details from information_schema.

src/datajoint/dependencies.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,35 @@ def load_all_downstream(self) -> None:
259259

260260
self.load(force=True, schema_names=known_schemas)
261261

262+
def load_all_upstream(self) -> None:
263+
"""
264+
Load dependencies including all upstream schemas referenced via FK chains.
265+
266+
Iteratively discovers schemas that the currently loaded schemas
267+
reference, expanding the dependency graph until no new schemas
268+
are found. This ensures that upstream restriction propagation
269+
(``Diagram.trace()``) reaches all ancestor tables, including
270+
those in schemas the user has not explicitly activated.
271+
272+
Called automatically by ``Diagram.trace()``. Symmetric to
273+
:meth:`load_all_downstream`.
274+
"""
275+
adapter = self._conn.adapter
276+
known_schemas = set(self._conn.schemas)
277+
if not known_schemas:
278+
self.load()
279+
return
280+
281+
while True:
282+
schemas_list = ", ".join(adapter.quote_string(s) for s in known_schemas)
283+
result = self._conn.query(adapter.find_upstream_schemas_sql(schemas_list))
284+
new_schemas = {row[0] for row in result} - known_schemas
285+
if not new_schemas:
286+
break
287+
known_schemas |= new_schemas
288+
289+
self.load(force=True, schema_names=known_schemas)
290+
262291
def topo_sort(self) -> list[str]:
263292
"""
264293
Return table names in topological order.

src/datajoint/diagram.py

Lines changed: 249 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,245 @@ def cascade(cls, table_expr, part_integrity="enforce"):
387387
result._expanded_nodes &= keep
388388
return result
389389

390+
@classmethod
391+
def trace(cls, table_expr):
392+
"""
393+
Create an upstream-trace diagram for a (restricted) table expression.
394+
395+
The upstream mirror of :meth:`cascade`. Walks the FK graph upward
396+
from the seed, propagating the restriction to every ancestor with
397+
OR convergence — an ancestor entity is included if reachable through
398+
*any* FK path from the seed.
399+
400+
Reuses the upward propagation rules
401+
(``_apply_propagation_rule_upward``) defined alongside the cascade
402+
engine, applied here in a generalized form (any child → any parent,
403+
not just Part → Master).
404+
405+
Parameters
406+
----------
407+
table_expr : QueryExpression
408+
A (possibly restricted) table expression.
409+
(e.g., ``Spectrum & key``).
410+
411+
Returns
412+
-------
413+
Diagram
414+
New Diagram restricted to the seed and its ancestors, with
415+
per-ancestor restrictions accumulated through the FK graph.
416+
Use ``diagram[T]`` to obtain a pre-restricted
417+
``QueryExpression`` for an ancestor, or ``diagram.counts()``
418+
to preview row counts per ancestor.
419+
420+
Examples
421+
--------
422+
>>> trace = dj.Diagram.trace(Spectrum & {"recording_id": 5})
423+
>>> trace[Session].fetch1("session_date")
424+
>>> trace.counts() # entity counts per ancestor
425+
>>> trace["schema.Session"] # FreeTable, when class isn't in scope
426+
427+
See Also
428+
--------
429+
:meth:`cascade` — the downstream mirror.
430+
"""
431+
conn = table_expr.connection
432+
conn.dependencies.load_all_upstream()
433+
node = table_expr.full_table_name
434+
435+
result = cls.__new__(cls)
436+
nx.DiGraph.__init__(result, conn.dependencies)
437+
result._connection = conn
438+
result.context = {}
439+
result._cascade_restrictions = {} # trace uses cascade-shape storage (OR semantics)
440+
result._restrict_conditions = {}
441+
result._restriction_attrs = {}
442+
result._mode = "trace"
443+
444+
# Include seed + all ancestors
445+
ancestors = set(nx.ancestors(result, node)) | {node}
446+
result.nodes_to_show = ancestors
447+
result._expanded_nodes = set(ancestors)
448+
449+
# Seed restriction
450+
restriction = AndList(table_expr.restriction)
451+
result._cascade_restrictions[node] = [restriction] if restriction else []
452+
result._restriction_attrs[node] = set(table_expr.restriction_attributes)
453+
454+
# Propagate upstream
455+
result._propagate_restrictions_upstream(node)
456+
457+
# Trim graph to trace subgraph: only restricted tables (seed + ancestors)
458+
# plus alias nodes connecting them.
459+
keep = set(result._cascade_restrictions)
460+
for alias in (n for n in result.nodes() if n.isdigit()):
461+
if set(result.predecessors(alias)) & keep and set(result.successors(alias)) & keep:
462+
keep.add(alias)
463+
result.remove_nodes_from(set(result.nodes()) - keep)
464+
result.nodes_to_show &= keep
465+
result._expanded_nodes &= keep
466+
return result
467+
468+
def _propagate_restrictions_upstream(self, start_node):
469+
"""
470+
Propagate the seed's restriction upstream through the FK graph.
471+
472+
Symmetric to :meth:`_propagate_restrictions` but walks ``in_edges``
473+
instead of ``out_edges`` and applies the upward rules
474+
(``_apply_propagation_rule_upward``) at each real edge. Multiple
475+
passes until no new ancestor is restricted; termination is
476+
guaranteed because the dependency graph is a DAG.
477+
"""
478+
sorted_nodes = topo_sort(self)
479+
# Only propagate through ancestors of start_node
480+
allowed_nodes = {start_node} | set(nx.ancestors(self, start_node))
481+
propagated_edges = set()
482+
483+
restrictions = self._cascade_restrictions
484+
485+
any_new = True
486+
while any_new:
487+
any_new = False
488+
489+
# Walk in reverse topological order so children are processed
490+
# before their parents — when we reach a parent, its restriction
491+
# accumulates from all of its (already-processed) children.
492+
for node in reversed(sorted_nodes):
493+
if node not in restrictions or node not in allowed_nodes:
494+
continue
495+
496+
child_ft = self._restricted_table(node)
497+
child_attrs = self._restriction_attrs.get(node, set())
498+
499+
for parent, _, edge_props in self.in_edges(node, data=True):
500+
edge_key = (parent, node)
501+
if edge_key in propagated_edges:
502+
continue
503+
propagated_edges.add(edge_key)
504+
505+
if parent not in allowed_nodes:
506+
continue
507+
508+
if isinstance(parent, str) and parent.isdigit():
509+
# Alias node — find the real parent on the far side.
510+
# The alias has its own in_edges; the props on both
511+
# half-edges are identical, so we can use either.
512+
for real_parent, _, real_edge_props in self.in_edges(parent, data=True):
513+
real_edge_key = (real_parent, parent, node)
514+
if real_edge_key in propagated_edges:
515+
continue
516+
propagated_edges.add(real_edge_key)
517+
if real_parent not in allowed_nodes:
518+
continue
519+
attr_map = real_edge_props.get("attr_map", {})
520+
aliased = real_edge_props.get("aliased", False)
521+
was_new = real_parent not in restrictions
522+
self._apply_propagation_rule_upward(
523+
child_ft,
524+
child_attrs,
525+
real_parent,
526+
attr_map,
527+
aliased,
528+
"cascade", # OR semantics for trace
529+
restrictions,
530+
)
531+
if was_new and real_parent in restrictions:
532+
any_new = True
533+
else:
534+
attr_map = edge_props.get("attr_map", {})
535+
aliased = edge_props.get("aliased", False)
536+
was_new = parent not in restrictions
537+
self._apply_propagation_rule_upward(
538+
child_ft,
539+
child_attrs,
540+
parent,
541+
attr_map,
542+
aliased,
543+
"cascade",
544+
restrictions,
545+
)
546+
if was_new and parent in restrictions:
547+
any_new = True
548+
549+
def __getitem__(self, key):
550+
"""
551+
Return a pre-restricted query expression (or FreeTable) for an
552+
ancestor table in this trace.
553+
554+
Only meaningful for trace diagrams (constructed via
555+
:meth:`Diagram.trace`). For ordinary diagrams, defers to
556+
:class:`networkx.DiGraph`'s adjacency-dict lookup.
557+
558+
Parameters
559+
----------
560+
key : type or str
561+
A Table subclass (e.g. ``Session``) — returns a pre-restricted
562+
``QueryExpression``. Or a string giving the table's class name
563+
or fully-qualified SQL name — returns a pre-restricted
564+
``FreeTable``.
565+
566+
Returns
567+
-------
568+
QueryExpression or FreeTable
569+
The ancestor's table restricted to rows reachable via FK from
570+
the seed of this trace.
571+
572+
Raises
573+
------
574+
DataJointError
575+
If the requested table is not in the trace's subgraph (i.e.
576+
not an ancestor of the seed, and not the seed itself).
577+
578+
Examples
579+
--------
580+
>>> trace = dj.Diagram.trace(Spectrum & key)
581+
>>> trace[Session].fetch1("session_date") # class index
582+
>>> trace["my_schema.Session"].to_dicts() # string index → FreeTable
583+
"""
584+
# Non-trace diagrams: defer to networkx adjacency lookup so existing
585+
# `diagram[node_name]` patterns (used in diagram algebra, ERD tests)
586+
# keep working.
587+
if getattr(self, "_mode", None) != "trace":
588+
return super().__getitem__(key)
589+
590+
from .table import Table
591+
592+
# Resolve `key` to a full table name
593+
if isinstance(key, type) and issubclass(key, Table):
594+
full_name = key.full_table_name
595+
elif isinstance(key, Table):
596+
full_name = key.full_table_name
597+
elif isinstance(key, str):
598+
# Accept either a class name (resolve via context) or a full SQL name
599+
if "`" in key or '"' in key:
600+
full_name = key
601+
else:
602+
# Class name — search graph nodes for a matching tail
603+
candidates = [
604+
n
605+
for n in self.nodes()
606+
if not (isinstance(n, str) and n.isdigit()) and n.lower().rstrip('`"').endswith(key.lower())
607+
]
608+
if not candidates:
609+
raise DataJointError(f"Table {key!r} is not in this trace's subgraph " f"(not an ancestor of the seed).")
610+
if len(candidates) > 1:
611+
raise DataJointError(
612+
f"Ambiguous table reference {key!r}: matches " f"{', '.join(candidates)}. Use a fully-qualified name."
613+
)
614+
full_name = candidates[0]
615+
else:
616+
raise DataJointError(f"trace[...] expects a Table class, Table instance, or string; " f"got {type(key).__name__}.")
617+
618+
if full_name not in self._cascade_restrictions:
619+
raise DataJointError(f"Table {full_name} is not in this trace's subgraph " f"(not an ancestor of the seed).")
620+
621+
# For class-typed key, return a restricted class instance; for string,
622+
# return a FreeTable.
623+
if isinstance(key, (type, Table)):
624+
ft = self._restricted_table(full_name)
625+
return ft
626+
else:
627+
return self._restricted_table(full_name)
628+
390629
def _restricted_table(self, node):
391630
"""
392631
Return a FreeTable for ``node`` with this diagram's restrictions applied.
@@ -624,7 +863,9 @@ def _apply_propagation_rule_upward(self, child_ft, child_attrs, parent_node, att
624863
``child.proj(**{parent: child for child, parent in attr_map.items()})``
625864
— reverses the renaming so the result has parent's column names.
626865
3. Non-aliased AND child restriction attrs ⊄ parent PK:
627-
``child.proj()`` — project child to parent's PK columns.
866+
``child.proj(*attr_map.keys())`` — project child onto its FK columns
867+
(which, being non-aliased, share names with parent's PK columns) so
868+
the subsequent restriction on the parent joins on the right columns.
628869
"""
629870
parent_pk = self.nodes[parent_node].get("primary_key", set())
630871

@@ -648,8 +889,13 @@ def _apply_propagation_rule_upward(self, child_ft, child_attrs, parent_node, att
648889
restrictions.setdefault(parent_node, AndList()).append(parent_item)
649890
parent_attrs = set(attr_map.values()) # parent's PK column names
650891
else:
651-
# Backward Rule 3: project child to parent PK
652-
parent_item = child_ft.proj()
892+
# Backward Rule 3: project child to its FK columns (which by name
893+
# match parent's PK columns in the non-aliased case). For primary
894+
# FKs (attr_map.keys() ⊆ child_pk) this is a no-op since
895+
# ``proj()`` already returns the PK. For non-primary FKs this
896+
# explicitly carries the FK columns into the projection so the
897+
# subsequent restriction on the parent joins on the right columns.
898+
parent_item = child_ft.proj(*attr_map.keys())
653899
if mode == "cascade":
654900
restrictions.setdefault(parent_node, []).append(parent_item)
655901
else:

0 commit comments

Comments
 (0)