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}