@@ -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