Rindle docs and package mapSkip to main content

rindle/op/
union.rs

1//! The **union OR fan** (`union-fan-out.ts` / `union-fan-in.ts` / `push-accumulated.ts`,
2//! spec `06` §3.6) — the node-level OR fan built **over the source** (unlike the
3//! relationship-free [`FanOut`](crate::graph)/[`FanIn`](crate::graph) filter fan).
4//!
5//! The builder uses it when an `OR` branch contains a flipped subquery: a
6//! [`UnionFanOut`] broadcasts the source change to each branch (a filter pipeline
7//! and/or a [`FlippedJoin`](crate::op::FlippedJoin)), and the shared [`UnionFanIn`]
8//! collapses the branches' outputs back to **one** change (the OR dedup).
9//!
10//! ## The owned-accumulation model (the load-bearing decision)
11//!
12//! `UnionFanIn` must accumulate each branch's output across the broadcast, but a
13//! [`Change<'g>`] borrows the graph (its relationship thunks are `+ 'g`) so it
14//! cannot live in a `'static` operator field. Instead, when a branch pushes during
15//! the broadcast, `UnionFanIn` **materializes** the change's relationships into an
16//! owned ([`OwnedChange`]) tree — exactly what the `View`/`Catch` already do under
17//! the re-materialize model — and accumulates *that* in a plain
18//! `RefCell<Vec<OwnedChange>>` field. On drain it collapses the owned changes
19//! ([`push_accumulated_changes`]) and rebuilds a `Change<'g>` to forward. No
20//! `unsafe`, no `'static`-`Change` field.
21
22use std::cell::{Cell, RefCell};
23
24use crate::change::{
25    constraints_are_compatible, materialize_change_preserving_node, merge_constraints,
26    rebuild_change, Change, ChangeType, Constraint, FetchRequest, NodeStream, OutEdge, OwnedChange,
27    OwnedNode,
28};
29use crate::graph::{Graph, NodeId};
30use crate::value::{RelId, Schema};
31
32// The owned (no-`'g`) change model ([`OwnedChange`]/[`OwnedNode`] +
33// `materialize_change_preserving_node`/`rebuild_change`) lives in [`crate::change`] — it
34// is shared with the join child-push overlay. The union fan accumulates `OwnedChange`s
35// across a broadcast (a `Change<'g>` can't live in a `'static` field), collapses them,
36// and rebuilds one `Change<'g>` to forward. A `Child` **can** reach the union fan: the
37// fan-out broadcasts it (rels intact) to the branches and the CHILD collapse
38// ([`push_accumulated_changes`]) gives a preserved Child precedence over a converted
39// add/remove (the non-flipped EXISTS spine `Join` lives below the fan-out).
40
41// ---------------------------------------------------------------------------
42// push_accumulated_changes (the OR-correctness collapse) — owned
43// ---------------------------------------------------------------------------
44
45/// Union the relationship slots of `right` into `left`, **left wins** on a slot
46/// conflict (the JS `{...right, ...left}`, `push-accumulated.ts:275`).
47fn merge_owned_rels(
48    mut left: Vec<(RelId, Vec<OwnedNode>)>,
49    right: Vec<(RelId, Vec<OwnedNode>)>,
50) -> Vec<(RelId, Vec<OwnedNode>)> {
51    for (slot, children) in right {
52        if !left.iter().any(|(s, _)| *s == slot) {
53            left.push((slot, children));
54        }
55    }
56    left
57}
58
59/// Phase-1 collapse helper: keep the first node of a type (its row is the same
60/// across branches), merging in any later node's relationships (left/existing wins).
61fn merge_node_into(slot: &mut Option<OwnedNode>, incoming: OwnedNode) {
62    match slot {
63        Some(existing) => {
64            existing.rels = merge_owned_rels(std::mem::take(&mut existing.rels), incoming.rels);
65        }
66        None => *slot = Some(incoming),
67    }
68}
69
70fn merge_edit_into(slot: &mut Option<(OwnedNode, OwnedNode)>, node: OwnedNode, old: OwnedNode) {
71    match slot {
72        Some((en, eo)) => {
73            en.rels = merge_owned_rels(std::mem::take(&mut en.rels), node.rels);
74            eo.rels = merge_owned_rels(std::mem::take(&mut eo.rels), old.rels);
75        }
76        None => *slot = Some((node, old)),
77    }
78}
79
80/// Phase-1 collapse for a preserved `Child` (`mergeRelationships` CHILD-CHILD arm,
81/// `push-accumulated.ts:313-332`): keep the **first** branch's `Child` — its
82/// relationship slot AND its sub-change — unioning in only later branches' **top-node**
83/// relationships (left/first wins). Never concatenate or replace the sub-change:
84/// multiple branches preserving the same `Child` carry the identical sub-change, so
85/// keeping the first (and merging top-node rels) is the whole job; replacing it would
86/// double-apply or drop the grandchild.
87fn merge_child_into(slot: &mut Option<OwnedChange>, incoming: OwnedChange) {
88    debug_assert!(
89        matches!(incoming, OwnedChange::Child { .. }),
90        "merge_child_into on a non-Child"
91    );
92    match slot {
93        Some(OwnedChange::Child { node: existing, .. }) => {
94            if let OwnedChange::Child { node: inc, .. } = incoming {
95                existing.rels = merge_owned_rels(std::mem::take(&mut existing.rels), inc.rels);
96            }
97        }
98        Some(_) => unreachable!("merge_child_into on a non-Child slot"),
99        None => *slot = Some(incoming),
100    }
101}
102
103/// `makeAddEmptyRelationships` (`push-accumulated.ts:369`): for every schema
104/// relationship slot the change's node(s) lack, append an **empty** relationship, so
105/// the downstream always sees a complete relationship set. No-op for a
106/// relationship-free schema.
107fn add_empty_relationships(change: OwnedChange, schema: &Schema) -> OwnedChange {
108    if schema.relationships.is_empty() {
109        return change;
110    }
111    let fill = |mut node: OwnedNode| -> OwnedNode {
112        for i in 0..schema.relationships.len() {
113            let slot = RelId(i as u32);
114            if !node.rels.iter().any(|(s, _)| *s == slot) {
115                node.rels.push((slot, Vec::new()));
116            }
117        }
118        node
119    };
120    match change {
121        OwnedChange::Add(n) => OwnedChange::Add(fill(n)),
122        OwnedChange::Remove(n) => OwnedChange::Remove(fill(n)),
123        OwnedChange::Edit { node, old } => OwnedChange::Edit {
124            node: fill(node),
125            old: fill(old),
126        },
127        // A `Child` is a **strict no-op** (`makeAddEmptyRelationships`' CHILD arm,
128        // `push-accumulated.ts:409-410`: "children only have relationships along the path
129        // to the change"). The CHILD-survivor precedence emits the Child RAW anyway (it
130        // never reaches this wrap), but keep the arm a no-op for safety.
131        c @ OwnedChange::Child { .. } => c,
132    }
133}
134
135/// `pushAccumulatedChanges` (`push-accumulated.ts:87`) over **owned** changes — the
136/// OR-correctness collapse. Given the branch outputs and the **fan-out's** original
137/// change type, collapse to **exactly one** (or zero) output change, unioning
138/// relationships across branches that kept the same row and reconstructing an `Edit`
139/// from a branch-split add+remove. Always the relationship-**merge** strategy
140/// (UnionFanIn); the filter `FanIn`'s identity path stays in `graph.rs`
141/// `collapse_accumulated`.
142///
143/// A **`Child`** reaches here when the fan-out broadcast a `Child` (a non-flipped EXISTS
144/// child change, the spine `Join` sitting below the fan-out): each branch either
145/// **preserves** the `Child` (a leaf `Filter` passes it through) or **converts** it to
146/// an Add/Remove (an `Exists` gate when the relationship change flips membership 0↔1).
147/// The CHILD collapse (`push-accumulated.ts:221-256`) gives a preserved Child
148/// **precedence** — emitted RAW (no `add_empty_relationships`), siblings discarded —
149/// else the single Add/Remove survivor is emitted through `add_empty_relationships`.
150pub fn push_accumulated_changes(
151    acc: Vec<OwnedChange>,
152    fan_out_type: ChangeType,
153    schema: &Schema,
154) -> Option<OwnedChange> {
155    if acc.is_empty() {
156        // It is possible for no fork to pass the push along (every branch dropped it).
157        return None;
158    }
159
160    // Phase 1: collapse to one change per type, merging relationships on collision.
161    let mut add: Option<OwnedNode> = None;
162    let mut remove: Option<OwnedNode> = None;
163    let mut edit: Option<(OwnedNode, OwnedNode)> = None;
164    let mut child: Option<OwnedChange> = None;
165    for c in acc {
166        match c {
167            OwnedChange::Add(n) => {
168                // Under a CHILD fan-out a child flips exactly one gate, so at most one
169                // branch converts to Add (`push-accumulated.ts:104-113`).
170                debug_assert!(
171                    !(fan_out_type == ChangeType::Child && add.is_some()),
172                    "Fan-in:child expected at most one add when fan-out is of type child"
173                );
174                merge_node_into(&mut add, n);
175            }
176            OwnedChange::Remove(n) => {
177                debug_assert!(
178                    !(fan_out_type == ChangeType::Child && remove.is_some()),
179                    "Fan-in:child expected at most one remove when fan-out is of type child"
180                );
181                merge_node_into(&mut remove, n);
182            }
183            OwnedChange::Edit { node, old } => merge_edit_into(&mut edit, node, old),
184            child_change @ OwnedChange::Child { .. } => merge_child_into(&mut child, child_change),
185        }
186    }
187
188    // Phase 2: emit by the fan-out's change type. Most arms wrap the survivor in
189    // `add_empty_relationships`; the CHILD-survivor precedence emits RAW.
190    match fan_out_type {
191        ChangeType::Remove => {
192            debug_assert!(
193                add.is_none() && edit.is_none() && child.is_none(),
194                "Fan-in:remove expected all removes"
195            );
196            remove.map(|r| add_empty_relationships(OwnedChange::Remove(r), schema))
197        }
198        ChangeType::Add => {
199            debug_assert!(
200                remove.is_none() && edit.is_none() && child.is_none(),
201                "Fan-in:add expected all adds"
202            );
203            add.map(|a| add_empty_relationships(OwnedChange::Add(a), schema))
204        }
205        ChangeType::Edit => {
206            debug_assert!(
207                child.is_none(),
208                "Fan-in:edit produced a Child branch change"
209            );
210            let result = if let Some((mut enode, mut eold)) = edit {
211                // An Edit survived → it supersedes; merge any add into its new node
212                // and any remove into its old node (`push-accumulated.ts:174-183`).
213                if let Some(a) = add {
214                    enode.rels = merge_owned_rels(enode.rels, a.rels);
215                }
216                if let Some(r) = remove {
217                    eold.rels = merge_owned_rels(eold.rels, r.rels);
218                }
219                Some(OwnedChange::Edit {
220                    node: enode,
221                    old: eold,
222                })
223            } else {
224                // No edit survived: both add+remove ⇒ reconstruct the edit; else the
225                // single survivor (`push-accumulated.ts:202-218`).
226                match (add, remove) {
227                    (Some(a), Some(r)) => Some(OwnedChange::Edit { node: a, old: r }),
228                    (Some(a), None) => Some(OwnedChange::Add(a)),
229                    (None, Some(r)) => Some(OwnedChange::Remove(r)),
230                    (None, None) => None,
231                }
232            };
233            result.map(|c| add_empty_relationships(c, schema))
234        }
235        // CHILD collapse (`push-accumulated.ts:221-256`). Among {add, remove, child} at
236        // most 2 types appear (a child flips exactly one gate), and `edit` is impossible.
237        ChangeType::Child => {
238            debug_assert!(
239                edit.is_none(),
240                "Fan-in:child produced an Edit branch change"
241            );
242            debug_assert!(
243                [add.is_some(), remove.is_some(), child.is_some()]
244                    .iter()
245                    .filter(|b| **b)
246                    .count()
247                    <= 2,
248                "Fan-in:child expected at most 2 types on a child change from fan-out"
249            );
250            // A preserved `Child` takes precedence over a converted Add/Remove: emit it
251            // **RAW** (no `add_empty_relationships` — children only carry path rels) and
252            // **discard** any sibling add/remove (no rel merge across the precedence
253            // boundary, `push-accumulated.ts:237-241`).
254            if let Some(c) = child {
255                return Some(c);
256            }
257            // Else exactly one of add/remove survived (the relationship is unique to one
258            // exists check, so the converters can't disagree).
259            debug_assert!(
260                !(add.is_some() && remove.is_some()),
261                "Fan-in:child expected either add or remove, not both"
262            );
263            let survivor = match (add, remove) {
264                (Some(a), _) => OwnedChange::Add(a),
265                (None, Some(r)) => OwnedChange::Remove(r),
266                (None, None) => return None,
267            };
268            Some(add_empty_relationships(survivor, schema))
269        }
270    }
271}
272
273// ---------------------------------------------------------------------------
274// UnionFanOut / UnionFanIn operators
275// ---------------------------------------------------------------------------
276
277/// `UnionFanOut` (`union-fan-out.ts`): one input, **N branches**, built over the
278/// source. `fetch` delegates to the input; `push` broadcasts the change to every
279/// branch (each a filter pipeline and/or a [`FlippedJoin`](crate::op::FlippedJoin))
280/// then drives the paired [`UnionFanIn`]'s collapse. Unlike the filter
281/// [`FanOut`](crate::graph) it operates on full nodes via side-effecting
282/// `Graph::push`, not the return-based filter `chain_push`.
283pub struct UnionFanOut {
284    pub input: NodeId,
285    /// Branch heads + the port to push each on (a flipped branch receives on
286    /// `JoinParent`, a filter branch on `Single`). Wired two-phase via `set_fan`.
287    outputs: RefCell<Vec<OutEdge>>,
288    fan_in: Cell<Option<NodeId>>,
289}
290
291impl UnionFanOut {
292    pub fn new(input: NodeId) -> UnionFanOut {
293        UnionFanOut {
294            input,
295            outputs: RefCell::new(Vec::new()),
296            fan_in: Cell::new(None),
297        }
298    }
299
300    /// Wire the branch broadcast edges + the paired fan-in (the `set_fan` analogue).
301    pub(crate) fn set_fan(&self, branches: Vec<OutEdge>, fan_in: NodeId) {
302        *self.outputs.borrow_mut() = branches;
303        self.fan_in.set(Some(fan_in));
304    }
305
306    /// `fetch` delegates straight to the single input (`union-fan-out.ts:43`).
307    pub fn fetch<'g>(&'g self, g: &'g Graph, req: &FetchRequest) -> NodeStream<'g> {
308        g.fetch(self.input, req)
309    }
310
311    /// `push` (`union-fan-out.ts:27`): signal the fan-in, broadcast the **same change**
312    /// (relationships intact, any type including `Child`) to each branch, then drain the
313    /// fan-in's accumulation. The branches side-effect-push their outputs to the fan-in.
314    ///
315    /// The broadcast **preserves relationships for every change type** (the
316    /// `materialize_change_preserving_node` contract, mirroring `graph.rs::fan_out_push`):
317    /// a non-flipped EXISTS `Join` sits on the spine BELOW the fan-out, so the branches'
318    /// `Exists` gates count their gated relationship off the broadcast change's node. The
319    /// materialize runs **now**, under the active source overlay (post-change membership —
320    /// see `source_common::gen_push`), then a fresh `Change<'g>` is rebuilt per branch (a
321    /// `Change<'g>` is neither `Clone` nor `'static`).
322    pub fn push<'g>(&'g self, g: &'g Graph, change: Change<'g>) {
323        let fan_in = self.fan_in.get().expect("UnionFanOut fan_in not wired");
324        // Clone the branch list out before broadcasting — never hold the RefCell
325        // borrow across the reentrant branch pushes (the cardinal rule).
326        let branches = self.outputs.borrow().clone();
327        let fan_out_type = change.change_type();
328
329        g.union_fan_in_op(fan_in).fan_out_started();
330        let owned = materialize_change_preserving_node(change);
331        for edge in &branches {
332            g.push(edge.node, rebuild_change(owned.clone()), edge.port);
333        }
334        g.union_fan_in_op(fan_in).fan_out_done(g, fan_out_type);
335    }
336}
337
338/// `UnionFanIn` (`union-fan-in.ts`): **N branch inputs**, one output. `fetch`
339/// k-way-merges the branch fetches with PK dedup (`merge_node_streams`).
340/// `push` either **accumulates** (during a fan-out broadcast — the owned model) or
341/// does a direct cross-branch dedup (a flipped child pushed while the fan-out is
342/// idle). On drain it collapses the accumulation via `push_accumulated_changes`
343/// and forwards the single result.
344pub struct UnionFanIn {
345    pub fan_out: NodeId,
346    /// Branch tails — the nodes whose `fetch` is merged, and (for the
347    /// internal-change dedup) the branches to cross-check.
348    inputs: Vec<NodeId>,
349    /// Per-branch **pushable constraint**, parallel to `inputs`: the necessary leaf
350    /// equalities of each branch's `where` condition, derived at build time
351    /// ([`pushable_constraint`](crate::builder)). Empty for a branch with no pushable
352    /// equality. At `fetch` each branch's constraint is merged into the incoming
353    /// request so a branch like `eq(pk)` **seeks** the shared source connection instead
354    /// of full-scanning it — without this, every OR branch fetches the shared source
355    /// with the *same* request, so a `eq(pk) OR exists` query full-scans the table on
356    /// the eq branch even though it resolves to a single PK. The branch's own filter
357    /// chain still applies the full predicate, so this only narrows the rows scanned;
358    /// the result set is identical (the constraint is a *necessary* condition for the
359    /// branch to keep a row).
360    branch_constraints: Vec<Constraint>,
361    /// The merged branch schema (carries the `primary_key` + the asserted-`Some`
362    /// `sort` the merge needs, plus the unioned relationship slots).
363    schema: Schema,
364    /// The post-fan downstream **edge** — port-bearing (the `FilterEnd`/`Skip`/`Take`
365    /// template), so the union-fan tail can feed a relationship join's `JoinParent`
366    /// port (a `related` over a flipped `where`) as well as a plain `Single` sink.
367    output: Cell<Option<OutEdge>>,
368    /// The `#fanOutPushStarted` flag: `true` between `fan_out_started` and
369    /// `fan_out_done` (accumulate); `false` ⇒ a direct internal change.
370    fan_out_push_started: Cell<bool>,
371    /// The owned accumulation (no `'g` — materialized at push time). See the module
372    /// docs for why this is owned rather than `Vec<Change<'g>>`.
373    accumulated: RefCell<Vec<OwnedChange>>,
374}
375
376impl UnionFanIn {
377    pub fn new(
378        fan_out: NodeId,
379        inputs: Vec<NodeId>,
380        branch_constraints: Vec<Constraint>,
381        schema: Schema,
382    ) -> UnionFanIn {
383        assert!(
384            !schema.sort.is_empty(),
385            "UnionFanIn requires a defined sort (the branch-fetch merge needs sorted inputs)"
386        );
387        assert_eq!(
388            inputs.len(),
389            branch_constraints.len(),
390            "UnionFanIn needs exactly one pushable constraint per branch input"
391        );
392        UnionFanIn {
393            fan_out,
394            inputs,
395            branch_constraints,
396            schema,
397            output: Cell::new(None),
398            fan_out_push_started: Cell::new(false),
399            accumulated: RefCell::new(Vec::new()),
400        }
401    }
402
403    pub(crate) fn schema(&self) -> &Schema {
404        &self.schema
405    }
406
407    pub(crate) fn set_output(&self, edge: OutEdge) {
408        self.output.set(Some(edge));
409    }
410
411    /// The wired post-fan output edge, if any (the read side of `set_output`).
412    pub(crate) fn output_edge(&self) -> Option<OutEdge> {
413        self.output.get()
414    }
415
416    /// `fetch` (`union-fan-in.ts:103`): k-way merge of the branch fetches in
417    /// `compare_rows` order (reverse-aware), deduping consecutive PK-equal rows (a
418    /// row matched by two branches is yielded once). `Drop`-clean.
419    ///
420    /// Each branch is fetched through `fetch_branch`, which merges
421    /// the branch's build-time pushable constraint into the request — so an `eq(pk)`
422    /// branch seeks the shared source instead of full-scanning it (an OR-branch
423    /// constraint accumulation, not in the JS, which re-scans per branch).
424    pub fn fetch<'g>(&'g self, g: &'g Graph, req: &FetchRequest) -> NodeStream<'g> {
425        let streams: Vec<NodeStream<'g>> = self
426            .inputs
427            .iter()
428            .zip(&self.branch_constraints)
429            .map(|(&i, bc)| self.fetch_branch(g, i, bc, req))
430            .collect();
431        crate::op::merge_node_streams(
432            streams,
433            self.schema.sort.clone(),
434            req.reverse,
435            Some(self.schema.primary_key.clone()),
436        )
437    }
438
439    /// Fetch one OR branch, merging its build-time **pushable constraint** into the
440    /// request so the branch seeks the shared source rather than full-scanning it
441    /// (the OR-branch constraint accumulation — see [`branch_constraints`]).
442    ///
443    /// - an **empty** branch constraint passes `req` through unchanged;
444    /// - a constraint that **contradicts** `req.constraint` (no row can satisfy both,
445    ///   e.g. a `id = 5` branch under an incoming `id = 7` join key) yields an empty
446    ///   stream — the branch matches nothing;
447    /// - otherwise the branch fetches with `req.constraint ∧ branch_constraint`.
448    ///
449    /// This never changes results: the branch constraint is a *necessary* condition for
450    /// the branch to keep a row, and the branch's filter chain still re-applies the full
451    /// predicate downstream. The merged constraint keeps the row stream in connection
452    /// sort order, so the fan-in's k-way merge + PK dedup are unaffected.
453    ///
454    /// [`branch_constraints`]: Self::branch_constraints
455    fn fetch_branch<'g>(
456        &'g self,
457        g: &'g Graph,
458        input: NodeId,
459        branch_constraint: &Constraint,
460        req: &FetchRequest,
461    ) -> NodeStream<'g> {
462        if branch_constraint.is_empty() {
463            return g.fetch(input, req);
464        }
465        if let Some(c) = &req.constraint {
466            if !constraints_are_compatible(c, branch_constraint) {
467                return Box::new(std::iter::empty());
468            }
469        }
470        let branch_req = FetchRequest {
471            constraint: Some(merge_constraints(
472                req.constraint.as_ref(),
473                branch_constraint,
474            )),
475            multi_constraints: req.multi_constraints.clone(),
476            start: req.start.clone(),
477            reverse: req.reverse,
478        };
479        g.fetch(input, &branch_req)
480    }
481
482    /// `fanOutStartedPushing` (`union-fan-out.ts:29`): begin accumulating. Assert we
483    /// are not already mid-broadcast (no nested fan-out into the *same* fan-in).
484    pub(crate) fn fan_out_started(&self) {
485        assert!(
486            !self.fan_out_push_started.get(),
487            "UnionFanIn already in a fan-out push"
488        );
489        debug_assert!(self.accumulated.borrow().is_empty());
490        self.fan_out_push_started.set(true);
491    }
492
493    /// `push` (`union-fan-in.ts:116`): accumulate during a broadcast (materialize to
494    /// owned **before** taking the borrow, so the reentrant relationship fetch in
495    /// `materialize_change_preserving_node` never runs while the accumulation is
496    /// borrowed), else a direct internal change.
497    pub fn push<'g>(&'g self, g: &'g Graph, change: Change<'g>) {
498        if self.fan_out_push_started.get() {
499            // Preserve the top node's relationships across the accumulation (the
500            // broadcast preserves them; the `Child` arm must keep them for the CHILD
501            // collapse + `merge_owned_rels`). Behavior-identical to `materialize_change`
502            // for Add/Remove/Edit — it only overrides the `Child` arm.
503            let owned = materialize_change_preserving_node(change);
504            self.accumulated.borrow_mut().push(owned);
505        } else {
506            self.push_internal_change(g, change);
507        }
508    }
509
510    /// `fanOutDonePushing` (`union-fan-in.ts:193`): drain the accumulation, collapse
511    /// it to one change, forward it. Take the accumulation out (dropping the borrow)
512    /// **before** the collapse + downstream push re-enter the graph.
513    pub(crate) fn fan_out_done<'g>(&'g self, g: &'g Graph, fan_out_type: ChangeType) {
514        self.fan_out_push_started.set(false);
515        let acc = std::mem::take(&mut *self.accumulated.borrow_mut());
516        if self.inputs.is_empty() {
517            return; // degenerate union (no branches)
518        }
519        if let Some(collapsed) = push_accumulated_changes(acc, fan_out_type, &self.schema) {
520            let out = self.output.get().expect("UnionFanIn output not wired");
521            g.push(out.node, rebuild_change(collapsed), out.port);
522        }
523    }
524
525    /// `#pushInternalChange` (`union-fan-in.ts:145`): a branch's source pushed
526    /// **directly** into the fan-in while the fan-out is idle — now ONLY a **flipped**
527    /// EXISTS *child* change (a comment add/remove) that the branch's `FlippedJoin` turned
528    /// into a parent Add/Remove. (Non-flipped EXISTS Joins sit on the spine BELOW the
529    /// fan-out, so their child changes arrive through the broadcast + CHILD collapse, not
530    /// here — matching JS `union-fan-in.ts:131-133`: normal exists joins are before the
531    /// fan-out, related/take after.) It must be deduped across branches so an OR doesn't
532    /// double-emit a row another branch already keeps.
533    ///
534    /// - **CHILD / EDIT** → forward unconditionally: a child's grandchild change (or a
535    ///   non-key edit) keeps the row in exactly the same branches, so there is nothing
536    ///   to dedup (each branch's child relationship is its own — `union-fan-in.ts:148`).
537    /// - **ADD / REMOVE** → cross-branch existence check (`union-fan-in.ts:158`). The
538    ///   JS skips the *pushing* branch and forwards iff no **other** branch still has
539    ///   the row. We don't thread the pusher: instead we **count** the branches whose
540    ///   fetch (the row's PK constraint, the source overlay active so the count is the
541    ///   post-change membership) still yields the row. The pushing branch contributes
542    ///   1 on an Add (its row is present post-add) and 0 on a Remove (its row is gone
543    ///   post-remove), so "no other branch has it" is exactly `count == 1` for an Add
544    ///   and `count == 0` for a Remove. (Equivalent to the pusher-skipping check, with
545    ///   the overlay doing the accounting.)
546    fn push_internal_change<'g>(&'g self, g: &'g Graph, change: Change<'g>) {
547        debug_assert!(
548            !self.inputs.is_empty(),
549            "internal change into an empty union"
550        );
551        let out = self.output.get().expect("UnionFanIn output not wired");
552        let (out_node, out_port) = (out.node, out.port);
553        let forward = match &change {
554            Change::Child { .. } | Change::Edit { .. } => true,
555            Change::Add(_) | Change::Remove(_) => {
556                let row = change.primary_row();
557                let constraint: Constraint = self
558                    .schema
559                    .primary_key
560                    .iter()
561                    .map(|&c| (c, row.col(c).to_owned()))
562                    .collect();
563                // Count branches that still yield the row. Never holds a borrow across
564                // the reentrant fetch (the cardinal rule): each stream is consumed by
565                // `.next()` and dropped inside the closure.
566                let count = self
567                    .inputs
568                    .iter()
569                    .filter(|&&inp| {
570                        g.fetch(inp, &FetchRequest::with_constraint(constraint.clone()))
571                            .next()
572                            .is_some()
573                    })
574                    .count();
575                match &change {
576                    Change::Add(_) => count == 1,    // only the pusher has it
577                    Change::Remove(_) => count == 0, // no branch has it (pusher gone too)
578                    _ => unreachable!(),
579                }
580            }
581        };
582        if forward {
583            g.push(out_node, change, out_port);
584        }
585    }
586}
587
588#[cfg(test)]
589mod accumulate_tests {
590    use super::*;
591    use crate::value::{owned_row, OwnedValue as V, RelDef};
592
593    fn onode(id: i64, rels: Vec<(u32, Vec<OwnedNode>)>) -> OwnedNode {
594        OwnedNode {
595            row: owned_row(vec![V::Int(id)]),
596            rels: rels.into_iter().map(|(s, c)| (RelId(s), c)).collect(),
597        }
598    }
599    fn leaf(id: i64) -> OwnedNode {
600        onode(id, vec![])
601    }
602    fn schema_with_rels(n: usize) -> Schema {
603        let names = ["a", "b", "c"];
604        Schema::new(vec!["id"], vec![0], vec![(0, true)])
605            .with_relationships(names[..n].iter().map(|name| RelDef::new(name)).collect())
606    }
607    fn rels_of(c: &OwnedChange) -> Vec<u32> {
608        let n = match c {
609            OwnedChange::Add(n) | OwnedChange::Remove(n) => n,
610            OwnedChange::Edit { node, .. } => node,
611            OwnedChange::Child { .. } => unreachable!("no Child in the union collapse tests"),
612        };
613        let mut s: Vec<u32> = n.rels.iter().map(|(r, _)| r.0).collect();
614        s.sort_unstable();
615        s
616    }
617
618    #[test]
619    fn empty_accumulation_yields_nothing() {
620        assert!(push_accumulated_changes(vec![], ChangeType::Add, &schema_with_rels(0)).is_none());
621    }
622
623    #[test]
624    fn add_collapses_many_to_one_unioning_relationships() {
625        // Two branches keep the same Add row, each attaching a different rel slot →
626        // one Add carrying both (the relationship-merge), plus the empty 3rd slot.
627        let acc = vec![
628            OwnedChange::Add(onode(1, vec![(0, vec![leaf(10)])])),
629            OwnedChange::Add(onode(1, vec![(1, vec![leaf(20)])])),
630        ];
631        let out = push_accumulated_changes(acc, ChangeType::Add, &schema_with_rels(3)).unwrap();
632        assert!(matches!(out, OwnedChange::Add(_)));
633        assert_eq!(rels_of(&out), vec![0, 1, 2]); // 0,1 from branches + 2 empty-filled
634    }
635
636    #[test]
637    fn edit_reconstructed_from_branch_split_add_and_remove() {
638        // An edit fanned out; one branch turned it into a Remove(old), another into an
639        // Add(new) → reconstruct Edit{node: add, old: remove}.
640        let acc = vec![OwnedChange::Remove(leaf(1)), OwnedChange::Add(leaf(2))];
641        let out = push_accumulated_changes(acc, ChangeType::Edit, &schema_with_rels(0)).unwrap();
642        let int = |n: &OwnedNode| match n.row.col(0) {
643            crate::value::Value::Int(i) => i,
644            _ => panic!("expected Int"),
645        };
646        match out {
647            OwnedChange::Edit { node, old } => {
648                assert_eq!(int(&node), 2);
649                assert_eq!(int(&old), 1);
650            }
651            _ => panic!("expected reconstructed Edit"),
652        }
653    }
654
655    #[test]
656    fn edit_survivor_supersedes_and_absorbs_add_remove_rels() {
657        let acc = vec![
658            OwnedChange::Edit {
659                node: onode(2, vec![(0, vec![])]),
660                old: onode(1, vec![]),
661            },
662            OwnedChange::Add(onode(2, vec![(1, vec![leaf(9)])])),
663        ];
664        let out = push_accumulated_changes(acc, ChangeType::Edit, &schema_with_rels(2)).unwrap();
665        // Edit survives; its node absorbs the add's rel slot 1 (plus its own slot 0).
666        assert_eq!(rels_of(&out), vec![0, 1]);
667    }
668
669    #[test]
670    fn edit_with_only_one_survivor_emits_that() {
671        let acc = vec![OwnedChange::Remove(leaf(1))];
672        let out = push_accumulated_changes(acc, ChangeType::Edit, &schema_with_rels(0)).unwrap();
673        assert!(matches!(out, OwnedChange::Remove(_)));
674    }
675
676    // --- CHILD collapse (push-accumulated.ts:221-256) ---
677
678    fn child(node: OwnedNode, slot: u32, sub: OwnedChange) -> OwnedChange {
679        OwnedChange::Child {
680            node,
681            rel: RelId(slot),
682            sub: Box::new(sub),
683        }
684    }
685
686    #[test]
687    fn child_survivor_wins_and_discards_a_converted_add() {
688        // A child-add to relationship `a` (slot 0): the leaf-filter branch PRESERVES the
689        // Child; the `Exists` branch converts it to an Add. Child precedence → emit the
690        // Child RAW, discard the Add, and do NOT empty-fill (a Child keeps only path rels).
691        let acc = vec![
692            child(
693                onode(7, vec![(0, vec![leaf(70)])]),
694                0,
695                OwnedChange::Add(leaf(70)),
696            ),
697            OwnedChange::Add(onode(7, vec![(1, vec![leaf(71)])])),
698        ];
699        let out = push_accumulated_changes(acc, ChangeType::Child, &schema_with_rels(3)).unwrap();
700        match out {
701            OwnedChange::Child { node, rel, sub } => {
702                assert_eq!(rel, RelId(0));
703                // RAW: only slot 0 (its path rel) — NOT empty-filled to slots 1,2.
704                let slots: Vec<u32> = node.rels.iter().map(|(r, _)| r.0).collect();
705                assert_eq!(slots, vec![0]);
706                assert!(matches!(*sub, OwnedChange::Add(_)));
707            }
708            _ => panic!("expected the preserved Child to win"),
709        }
710    }
711
712    #[test]
713    fn child_converted_to_add_when_no_branch_preserves_it() {
714        // No branch preserved the Child; one `Exists` gate flipped 0→1 → Add. Empty-filled.
715        let acc = vec![OwnedChange::Add(onode(7, vec![(0, vec![leaf(70)])]))];
716        let out = push_accumulated_changes(acc, ChangeType::Child, &schema_with_rels(2)).unwrap();
717        assert!(matches!(out, OwnedChange::Add(_)));
718        assert_eq!(rels_of(&out), vec![0, 1]); // slot 0 + empty-filled slot 1
719    }
720
721    #[test]
722    fn child_converted_to_remove_when_no_branch_preserves_it() {
723        // One `Exists` gate flipped 1→0 → Remove. Empty-filled.
724        let acc = vec![OwnedChange::Remove(onode(7, vec![(0, vec![])]))];
725        let out = push_accumulated_changes(acc, ChangeType::Child, &schema_with_rels(2)).unwrap();
726        assert!(matches!(out, OwnedChange::Remove(_)));
727        assert_eq!(rels_of(&out), vec![0, 1]);
728    }
729
730    #[test]
731    fn two_preserved_children_merge_top_node_rels_keeping_first_subchange() {
732        // Two branches preserve the same Child, each attaching a different top-node rel
733        // slot. Merge unions the top-node rels (left/first wins) and keeps the FIRST
734        // branch's sub-change + slot — never concatenating the grandchild.
735        let acc = vec![
736            child(
737                onode(7, vec![(0, vec![leaf(70)])]),
738                0,
739                OwnedChange::Add(leaf(700)),
740            ),
741            child(
742                onode(7, vec![(1, vec![leaf(71)])]),
743                0,
744                OwnedChange::Add(leaf(999)),
745            ),
746        ];
747        let out = push_accumulated_changes(acc, ChangeType::Child, &schema_with_rels(2)).unwrap();
748        match out {
749            OwnedChange::Child { node, rel, sub } => {
750                assert_eq!(rel, RelId(0));
751                let mut slots: Vec<u32> = node.rels.iter().map(|(r, _)| r.0).collect();
752                slots.sort_unstable();
753                assert_eq!(slots, vec![0, 1]); // unioned top-node rels, NOT empty-filled
754                                               // First branch's sub-change kept (700, not 999).
755                match *sub {
756                    OwnedChange::Add(n) => {
757                        assert!(matches!(n.row.col(0), crate::value::Value::Int(700)))
758                    }
759                    _ => panic!("expected the first branch's Add sub-change"),
760                }
761            }
762            _ => panic!("expected a merged Child"),
763        }
764    }
765
766    #[test]
767    fn child_fanout_with_no_surviving_branch_yields_nothing() {
768        // Every branch dropped the child (a filter excluded it on all branches).
769        assert!(
770            push_accumulated_changes(vec![], ChangeType::Child, &schema_with_rels(2)).is_none()
771        );
772    }
773}