@@ -180,9 +180,18 @@ class Analysis(dj.Manual):
180180the related sub-diagram. Each time we cross a foreign key we must re-express the
181181restriction in the neighbor's attribute names. Renaming is the only thing that
182182changes names across an edge, so it is the only place this needs care. The shape
183- of ` r ` decides how.
183+ of ` r ` decides how. A restriction comes in one of three kinds, and they cross a
184+ renamed edge differently:
184185
185- ### Form 1 — ` A & key ` , where ` key ` is a dict ` {attr: value, ...} `
186+ - ** materialized** — a dict, or a sequence of dicts (literal ` attr: value ` rows);
187+ - ** subquery** — a query expression (another table, possibly restricted);
188+ - ** string** — a raw SQL predicate over attribute names, e.g. ` 'weight > 10' ` .
189+
190+ Only the materialized kind is * frozen literal values* ; the other two are * live*
191+ (evaluated against current data). This split is what decides whether a renamed
192+ edge can be crossed by simply relabelling, or must be crossed relationally.
193+
194+ ### Kind 1 — a materialized restriction: a dict, or a sequence of dicts
186195
187196A dict is a set of "attribute equals value" conditions. Crossing a renamed
188197foreign key, the neighbor's restriction is obtained by ** renaming the dict's keys
@@ -194,61 +203,87 @@ through the edge, values unchanged**:
194203- ** upstream** (` A ` is the child, neighbor is the parent): apply the pairs the
195204 other way — ` A & {'animal': 5} ` induces ` parent & {'subject_id': 5} ` .
196205
206+ In both directions the rule is the same: ** keep the referenced attributes
207+ (relabelled), and drop any key field that does not exist on the neighbor.** Going
208+ upstream this drops the child's own identity attributes (e.g. ` analysis_id ` ),
209+ which the parent does not have — leaving exactly the parent's key. Going
210+ downstream nothing is dropped; the child's own key attributes are simply left
211+ unconstrained (a partial key).
212+
197213Multi-hop composes: the renamings chain, so a key is relabelled edge by edge
198- (` subject_id: 5 ` → ` animal: 5 ` → ` creature: 5 ` ). This is exact because the
214+ (` subject_id: 5 ` , then ` animal: 5 ` , then ` creature: 5 ` ). This is exact because the
199215renaming is pure (values and types are preserved) and the attribute's identity
200216across the edge is fixed by the edge's pairing, not by any coincidental match of
201217names.
202218
203- ** When the shortcut is exact.** Only for key attributes the foreign key actually
204- carries across (the referenced attributes — typically the primary key). Two
205- caveats:
206-
207- 1 . A dict entry on an attribute the edge does ** not** carry (a secondary
208- attribute of ` A ` , or one the foreign key doesn't reference) has no name on the
209- neighbor, so it can't be relabelled. If that entry changes which ` A ` rows
210- exist, dropping it would over-select the neighbor. So Form 1 applies when
211- ` key ` 's attributes are among the edge's referenced attributes; otherwise the
212- non-carried part must be enforced as in Form 2.
219+ ** A sequence of dicts** — as returned by ` A.keys() ` — is the OR (union) of its
220+ member dicts; relabel each element the same way, and the result stays a sequence
221+ of relabelled dicts. Because the values are frozen literals, a materialized
222+ restriction is ** stable** : it cannot be invalidated by traversal order or by
223+ deletions elsewhere, so the delete-order hazard that forces ` cascade ` to
224+ materialize (see below) does not arise here. The whole walk stays symbolic — no
225+ subqueries, and the per-table keys stay human-legible.
226+
227+ ** When the relabelling is exact.** Only for key attributes the foreign key
228+ actually carries across (the referenced attributes — typically the primary key).
229+ Two caveats:
230+
231+ 1 . Dropping is exact when the removed field is an ** identity attribute the
232+ neighbor simply lacks** (the upstream case above — the child's own key). It is
233+ lossy only when the removed field was an extra ** constraint** — a secondary
234+ attribute that narrows which ` A ` rows exist (e.g. `{'subject_id': 5,
235+ 'weight': 10}` ): dropping ` weight` would keep neighbor rows for ` subject_id 5`
236+ even if that row's weight is not 10. Resolve this by ** materializing first** —
237+ ` (A & key).keys() ` reduces the seed to referenced-key values with the
238+ constraint already applied, after which relabel-and-drop is exact.
2132392 . If the foreign key carries only part of ` A ` 's identity, the relabelled dict is
214240 a partial-key restriction on the neighbor — still exact, just not a full key.
215241
216- This is the common, cheap case ("give me everything for this entity",
217- ` A & {'subject_id': 5} ` ): the per-table restriction stays a dict, and traversal
218- is a name-substitution walk — no subqueries, and the per-table keys stay
219- human-legible.
242+ This is the common, cheap case: "give me everything for this entity"
243+ (` A & {'subject_id': 5} ` , or a set of such rows via ` A.keys() ` ).
220244
221- ### Form 2 — ` A & cond ` , where ` cond ` is a general condition
245+ ### Kind 2 — a subquery restriction: a query expression
222246
223- A general condition — a SQL predicate (` 'weight > 10' ` ), a query expression, a
224- list — is not a set of equalities on the foreign-key attributes, so there are no
225- keys to relabel. Propagate it as a ** restriction by the renamed, projected
226- seed** : restrict ` A ` by ` cond ` , project it onto the referenced attributes under
227- the neighbor's names, and restrict the neighbor by that.
247+ ` A & subq ` , where ` subq ` is another table or a restricted expression, has named
248+ attributes but no literal values yet — it is * live* . There are no keys to
249+ relabel; cross the edge ** relationally** , by projecting the restricted seed onto
250+ the neighbor's referenced attributes (renamed) and restricting the neighbor:
228251
229- - ** downstream:** ` child & (A & cond ).proj(animal='subject_id', sess='session_id') `
252+ - ** downstream:** ` child & (A & subq ).proj(animal='subject_id', sess='session_id') `
230253 — project restricted ` A ` to the referenced attributes under the child's names,
231- then restrict the child by it .
232- - ** upstream:** ` parent & (A & cond ).proj(subject_id='animal', session_id='sess') `
254+ then restrict the child.
255+ - ** upstream:** ` parent & (A & subq ).proj(subject_id='animal', session_id='sess') `
233256 — project under the parent's names (the renaming reversed).
234257
235- This is always correct, including when ` cond ` touches attributes the foreign key
236- doesn't carry: those simply constrain which ` A ` rows the projection sees.
237-
238- ### How the two relate
239-
240- Form 1 is the special case of Form 2 where ` cond ` is a dict of equalities on the
241- carried attributes: there, restricting the neighbor by
242- ` (A & key).proj(...renamed...) ` selects exactly the neighbor rows the relabelled
243- dict does — so we skip building the projection and just rename keys. Form 2 is
244- the fallback whenever that equivalence doesn't hold.
245-
246- ** Consequence for ` expand ` .** Per reached table, ` expand ` can carry either a
247- relabelled dict (Form 1 — when the seed is a qualifying dict and every edge on
248- the path is a rename over carried attributes) or a relational restriction
249- (Form 2). Prefer the dict path when available: it is symbolic, composes by
250- chaining the edge renamings, and yields legible per-table keys — this is the
251- "update the key names as we traverse" behavior.
258+ Because it is live, a subquery restriction is delete-order-sensitive: when it
259+ feeds a ` delete ` , it must be materialized first (see the unifying note), which
260+ also turns it into Kind 1.
261+
262+ ### Kind 3 — a string query: a raw SQL predicate
263+
264+ ` A & 'weight > 10' ` is opaque text naming attributes in ` A ` 's namespace. It
265+ ** cannot cross the edge as text** : it may name attributes the foreign key does
266+ not carry (` weight ` here), and rewriting arbitrary SQL to the neighbor's names is
267+ not reliable. So restrict ` A ` by the string first, then project the referenced
268+ attributes (renamed) onto the neighbor and restrict — exactly as in Kind 2. The
269+ string's * effect* crosses only through the referenced-attribute values of the
270+ surviving ` A ` rows; the string itself never crosses.
271+
272+ ### The unifying note
273+
274+ Kinds 2 and 3 are * live* ; Kind 1 is * materialized* . Any live restriction can be
275+ made materialized by fetching the seed's keys — ` (A & r).keys() ` — at which point
276+ relabelling (Kind 1) becomes available and the result is delete-safe. That is
277+ precisely what ` cascade ` does at plan time. So there are really two strategies —
278+ ** relabel** (materialized) or ** restrict-then-project** (live) — and a live
279+ restriction becomes relabel-able the moment it is materialized.
280+
281+ ** Consequence for ` expand ` .** Per reached table, ` expand ` carries either a
282+ relabelled materialized restriction or a relational one. Prefer the materialized
283+ path when available: it is symbolic, composes by chaining the edge renamings,
284+ yields legible per-table keys, and is delete-safe — this is the "update the key
285+ names as we traverse" behavior. A live seed can be materialized up front to get
286+ all of that.
252287
253288## Summary
254289
0 commit comments