Skip to main content

core_rules/
views.rs

1//! Materialized property views — incremental per-node derived properties.
2//!
3//! ## Storage choice
4//!
5//! View values are stored as regular entries in the `ColumnStore` under their
6//! `view_prop` name, updated in place on each triggering event.
7//!
8//! **Why ColumnStore in-place**:
9//! - Zero query-layer changes: every read path (scan, filter, project, group)
10//!   already reads from `ColumnStore` — view props appear automatically.
11//! - `ColumnStore::set` / `remove` handle cleanup; `remove_all` clears a
12//!   deleted node's view values as part of the normal tombstone sweep.
13//! - Rebuild-on-open recomputes values from persisted graph state (topo + props)
14//!   without touching the snapshot format.
15//! - No virtual-column overlay, no side-table, no special query path.
16//!
17//! **Trade-off**: view props appear in `node_info()` alongside real props.
18//! That is intentional — "they're column values" per the brief.
19//!
20//! ## MIN/MAX retraction cost
21//!
22//! Retracting an edge whose endpoint held the current MIN or MAX requires
23//! rescanning all remaining neighbors (O(degree)) because there is no
24//! sorted structure tracking the second-best value.  This is documented
25//! as the v1 cost; no auxiliary structures are maintained.
26//!
27//! ## Subscriptions interplay (v1)
28//!
29//! View-value updates do **not** emit subscription events.  Only user-write
30//! WAL records and rule fire/retract deltas generate `DbEvent`s.  View
31//! maintenance writes to `ColumnStore` directly and is invisible to the
32//! subscription layer.  This preserves T1's `pending_deltas` discipline:
33//! the `debug_assert!(pending_delta_count == 0)` at `log_then_apply_with`
34//! entry remains green through view-heavy workloads because view updates
35//! bypass the engine's delta path entirely.
36
37use core_storage::v8::seam::{BaseColumns, ColumnsView, TopologyView};
38use core_storage::{ColumnStore, Direction, IdMap, Interner, Value};
39use serde::{Deserialize, Serialize};
40use std::collections::BTreeMap;
41
42// ---------------------------------------------------------------------------
43// Public types
44// ---------------------------------------------------------------------------
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub enum AggFn {
48    Sum,
49    Avg,
50    Min,
51    Max,
52    Count,
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub enum ViewSource {
57    Degree {
58        edge_type: String,
59        direction: Direction,
60    },
61    NeighborAgg {
62        edge_type: String,
63        direction: Direction,
64        agg: AggFn,
65        /// Neighbor property to aggregate.
66        prop: String,
67    },
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct ViewDef {
72    pub name: String,
73    /// Node label this view applies to.
74    pub label: String,
75    /// The synthetic property name written into `ColumnStore`.
76    pub view_prop: String,
77    pub source: ViewSource,
78}
79
80impl ViewDef {
81    pub fn validate(&self) -> Result<(), String> {
82        if self.name.is_empty() {
83            return Err("view name must not be empty".into());
84        }
85        if self.label.is_empty() {
86            return Err("view label must not be empty".into());
87        }
88        if self.view_prop.is_empty() {
89            return Err("view_prop must not be empty".into());
90        }
91        // `ns` is the reserved namespace property (v0.6.6 §7.2). A view owns its
92        // `view_prop` column and rewrites it on every relevant change, so a view
93        // over `ns` would move nodes between namespaces behind the immutability
94        // rule — and `set_prop` refusing the same write would read as a
95        // contradiction.
96        if self.view_prop == core_storage::NS_PROP {
97            return Err(format!(
98                "view_prop must not be {:?}: it is the reserved namespace property, \
99                 which is set at insert and never written again",
100                core_storage::NS_PROP
101            ));
102        }
103        match &self.source {
104            ViewSource::Degree { edge_type, .. } => {
105                if edge_type.is_empty() {
106                    return Err("Degree edge_type must not be empty".into());
107                }
108            }
109            ViewSource::NeighborAgg {
110                edge_type, prop, ..
111            } => {
112                if edge_type.is_empty() {
113                    return Err("NeighborAgg edge_type must not be empty".into());
114                }
115                if prop.is_empty() {
116                    return Err("NeighborAgg prop must not be empty".into());
117                }
118            }
119        }
120        Ok(())
121    }
122
123    fn edge_type(&self) -> &str {
124        match &self.source {
125            ViewSource::Degree { edge_type, .. } => edge_type,
126            ViewSource::NeighborAgg { edge_type, .. } => edge_type,
127        }
128    }
129
130    fn direction(&self) -> Direction {
131        match &self.source {
132            ViewSource::Degree { direction, .. } => *direction,
133            ViewSource::NeighborAgg { direction, .. } => *direction,
134        }
135    }
136}
137
138// ---------------------------------------------------------------------------
139// ViewStore
140// ---------------------------------------------------------------------------
141
142#[derive(Debug, Default, Clone)]
143pub struct ViewStore {
144    /// Ordered by name.
145    views: BTreeMap<String, ViewDef>,
146    /// Nodes whose view values incremental maintenance may have written
147    /// since [`Self::take_touched`] was last called. A view's value is in no
148    /// WAL record, so this is the only account of which ones a commit moved;
149    /// the lock-free reader is told from it. May repeat a node, and may name
150    /// one no view applies to.
151    touched: Vec<u32>,
152}
153
154impl ViewStore {
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    pub fn views(&self) -> impl Iterator<Item = &ViewDef> {
160        self.views.values()
161    }
162
163    pub fn is_empty(&self) -> bool {
164        self.views.is_empty()
165    }
166
167    /// The nodes incremental maintenance has touched since the last call,
168    /// leaving none recorded.
169    pub fn take_touched(&mut self) -> Vec<u32> {
170        std::mem::take(&mut self.touched)
171    }
172
173    /// Forget the touched nodes without reading them.
174    pub fn clear_touched(&mut self) {
175        self.touched.clear();
176    }
177
178    pub fn has_view(&self, name: &str) -> bool {
179        self.views.contains_key(name)
180    }
181
182    /// If `prop_name` is managed by any view, return that view's name.
183    pub fn view_for_prop(&self, prop_name: &str) -> Option<&str> {
184        self.views
185            .values()
186            .find(|v| v.view_prop == prop_name)
187            .map(|v| v.name.as_str())
188    }
189
190    // -----------------------------------------------------------------------
191    // DDL
192    // -----------------------------------------------------------------------
193
194    /// Register a view and backfill values for all existing nodes.
195    ///
196    /// # Errors
197    /// - Duplicate view name.
198    /// - `view_prop` already claimed by another view.
199    /// - `view_prop` already present as a real node property in `ColumnStore`.
200    pub fn create_view(
201        &mut self,
202        def: ViewDef,
203        props: &mut ColumnStore,
204        topo: &TopologyView<'_>,
205        ids: &IdMap,
206        syms: &Interner,
207        labels: &[u32],
208    ) -> Result<(), String> {
209        self.create_view_over(def, props, topo, ids, syms, labels, None)
210    }
211
212    /// [`Self::create_view`] on a store opened from a snapshot: `base_cols`
213    /// is the snapshot's columns, which the first values of an aggregate view
214    /// have to read. `props` alone holds only what was written since, and a
215    /// view created over it summed nothing for neighbours the snapshot held.
216    #[allow(clippy::too_many_arguments)]
217    pub fn create_view_over(
218        &mut self,
219        def: ViewDef,
220        props: &mut ColumnStore,
221        topo: &TopologyView<'_>,
222        ids: &IdMap,
223        syms: &Interner,
224        labels: &[u32],
225        base_cols: Option<BaseColumns<'_>>,
226    ) -> Result<(), String> {
227        def.validate()?;
228
229        if self.views.contains_key(&def.name) {
230            return Err(format!("view {:?} already exists", def.name));
231        }
232        if let Some(existing) = self.views.values().find(|v| v.view_prop == def.view_prop) {
233            return Err(format!(
234                "view_prop {:?} is already used by view {:?}",
235                def.view_prop, existing.name
236            ));
237        }
238        // Real-prop collision: any ColumnStore field not owned by a view.
239        let view_props: std::collections::HashSet<&str> =
240            self.views.values().map(|v| v.view_prop.as_str()).collect();
241        // Of what was written since the snapshot only. The whole check —
242        // the snapshot's columns included — is made by the caller before the
243        // record is logged. This is the replay's side of it and stays as
244        // narrow as it was, so a `CreateView` an earlier release logged over
245        // a property its snapshot held does not stop the store opening.
246        let taken = props
247            .fields()
248            .any(|f| f == def.view_prop && !view_props.contains(f));
249        if taken {
250            return Err(format!(
251                "view_prop {:?} conflicts with an existing node property",
252                def.view_prop
253            ));
254        }
255
256        // Backfill: compute initial values for all existing nodes.
257        backfill_view(&def, props, topo, ids, syms, labels, base_cols);
258
259        self.views.insert(def.name.clone(), def);
260        Ok(())
261    }
262
263    /// Restore a view definition from a snapshot without collision checking or
264    /// backfilling.  The snapshot's `ColumnStore` already contains view values;
265    /// this method simply registers the definition so the store is aware of it.
266    ///
267    /// Called only from `open_with` during snapshot restore.  Callers must then
268    /// call `rebuild_all` to ensure values are correct after WAL replay.
269    pub fn restore_view(&mut self, def: ViewDef) -> Result<(), String> {
270        def.validate()?;
271        if self.views.contains_key(&def.name) {
272            return Ok(()); // idempotent during snapshot restore
273        }
274        self.views.insert(def.name.clone(), def);
275        Ok(())
276    }
277
278    /// Remove a view and delete its values from every node.
279    ///
280    /// # Errors
281    /// - View not found.
282    pub fn delete_view(
283        &mut self,
284        name: &str,
285        props: &mut ColumnStore,
286        ids: &IdMap,
287        labels: &[u32],
288        syms: &Interner,
289    ) -> Result<(), String> {
290        let def = self
291            .views
292            .remove(name)
293            .ok_or_else(|| format!("view {:?} not found", name))?;
294
295        // Remove the view-prop value from every matching node.
296        if let Some(label_sym) = syms.get(&def.label) {
297            for id in 0..ids.len() as u32 {
298                if labels.get(id as usize).copied() == Some(label_sym) {
299                    clear_view_value(props, id, &def.view_prop);
300                }
301            }
302        }
303        Ok(())
304    }
305
306    // -----------------------------------------------------------------------
307    // Incremental maintenance
308    // -----------------------------------------------------------------------
309
310    /// Called when an edge of type `etype` (symbol) between `src` and `dst` is
311    /// inserted (`inserted=true`) or deleted (`inserted=false`).
312    ///
313    /// Covers both manual user edges and derived engine edges.  The topo must
314    /// already reflect the new state (edge added before this call on insert;
315    /// edge removed before this call on delete) so that full-recompute paths
316    /// (Avg, Min, Max) see correct neighbor sets.
317    #[allow(clippy::too_many_arguments)]
318    pub fn on_edge_changed(
319        &mut self,
320        etype: u32,
321        src: u32,
322        dst: u32,
323        inserted: bool,
324        props: &mut ColumnStore,
325        topo: &TopologyView<'_>,
326        ids: &IdMap,
327        syms: &Interner,
328        labels: &[u32],
329        base_cols: Option<BaseColumns<'_>>,
330    ) {
331        self.on_edge_changed_except(
332            etype, src, dst, inserted, props, topo, ids, syms, labels, base_cols, None,
333        );
334    }
335
336    /// [`Self::on_edge_changed`], leaving the views of `gone` alone. For the
337    /// edges swept with a node that is being deleted: its own view values go
338    /// with it, and recomputing them once for each of its edges made deleting
339    /// a hub quadratic in its degree.
340    #[allow(clippy::too_many_arguments)]
341    pub fn on_edge_changed_except(
342        &mut self,
343        etype: u32,
344        src: u32,
345        dst: u32,
346        inserted: bool,
347        props: &mut ColumnStore,
348        topo: &TopologyView<'_>,
349        ids: &IdMap,
350        syms: &Interner,
351        labels: &[u32],
352        base_cols: Option<BaseColumns<'_>>,
353        gone: Option<u32>,
354    ) {
355        for def in self.views.values() {
356            let Some(et_sym) = syms.get(def.edge_type()) else {
357                continue;
358            };
359            if et_sym != etype {
360                continue;
361            }
362            let direction = def.direction();
363            // Determine which node is the "subject" whose view value changes.
364            // direction=Out: subject is src (counts/aggregates its out-neighbors)
365            // direction=In:  subject is dst (counts/aggregates its in-neighbors)
366            let subject = match direction {
367                Direction::Out => src,
368                Direction::In => dst,
369            };
370            // The "neighbor" whose property is read for NeighborAgg.
371            let neighbor = match direction {
372                Direction::Out => dst,
373                Direction::In => src,
374            };
375            if gone == Some(subject) {
376                continue;
377            }
378            self.touched.push(subject);
379            update_node_view(
380                def, subject, neighbor, inserted, props, topo, ids, syms, labels, base_cols,
381            );
382        }
383    }
384
385    /// Called after `SetProp` / `RemoveProp` — finds NeighborAgg views that
386    /// read `field` from neighbors and recomputes values for all subject nodes.
387    #[allow(clippy::too_many_arguments)]
388    pub fn on_prop_changed(
389        &mut self,
390        changed_node: u32,
391        field: &str,
392        props: &mut ColumnStore,
393        topo: &TopologyView<'_>,
394        ids: &IdMap,
395        syms: &Interner,
396        labels: &[u32],
397        base_cols: Option<BaseColumns<'_>>,
398    ) {
399        for def in self.views.values() {
400            let ViewSource::NeighborAgg {
401                edge_type,
402                direction,
403                prop,
404                ..
405            } = &def.source
406            else {
407                continue;
408            };
409            if prop != field {
410                continue;
411            }
412            let Some(et_sym) = syms.get(edge_type) else {
413                continue;
414            };
415            // Find subject nodes that have changed_node as a neighbor via et_sym/direction.
416            // direction=Out: subjects X where edge X→changed_node exists → In-neighbors of changed_node
417            // direction=In:  subjects X where edge changed_node→X exists → Out-neighbors of changed_node
418            let reverse_dir = match direction {
419                Direction::Out => Direction::In,
420                Direction::In => Direction::Out,
421            };
422            let subjects: Vec<u32> = topo.neighbors(et_sym, reverse_dir, changed_node).to_vec();
423            self.touched.extend_from_slice(&subjects);
424            for subject in subjects {
425                // Full recompute using the full base+overlay props view.
426                // Build the ColumnsView in a block so the immutable borrow of `props`
427                // ends before we write back.
428                let val = {
429                    let pv = build_cols_view(props, base_cols);
430                    compute_view_value(def, subject, pv, topo, ids, syms, labels)
431                };
432                match val {
433                    Some(v) => props.set(subject, &def.view_prop, v),
434                    None => {
435                        clear_view_value(props, subject, &def.view_prop);
436                    }
437                }
438            }
439        }
440    }
441
442    /// Initialize view values for a newly inserted node.
443    ///
444    /// Sets Degree, Count, and Sum views to their zero values (0 / 0.0).
445    /// Avg / Min / Max are left absent — there is no sensible neutral value
446    /// when no neighbors have been observed yet.
447    ///
448    /// Call this BEFORE the rule engine's `on_node_changed` for the new node so
449    /// that subsequent `on_edge_changed` calls (from derived-edge deltas) can
450    /// increment correctly from a known baseline.
451    pub fn init_node_views(
452        &mut self,
453        node: u32,
454        props: &mut ColumnStore,
455        syms: &Interner,
456        labels: &[u32],
457    ) {
458        for def in self.views.values() {
459            let Some(label_sym) = syms.get(&def.label) else {
460                continue;
461            };
462            if labels.get(node as usize).copied() != Some(label_sym) {
463                continue;
464            }
465            self.touched.push(node);
466            match &def.source {
467                ViewSource::Degree { .. } => {
468                    if props.get(node, &def.view_prop).is_none() {
469                        props.set(node, &def.view_prop, Value::Int(0));
470                    }
471                }
472                ViewSource::NeighborAgg {
473                    agg: AggFn::Count, ..
474                } => {
475                    if props.get(node, &def.view_prop).is_none() {
476                        props.set(node, &def.view_prop, Value::Int(0));
477                    }
478                }
479                ViewSource::NeighborAgg {
480                    agg: AggFn::Sum, ..
481                } => {
482                    // Always set to 0.0 — init is only called for freshly-inserted
483                    // nodes whose view_prop does not yet exist.
484                    props.set(node, &def.view_prop, Value::Float(0.0));
485                }
486                _ => {} // Avg / Min / Max: absent until neighbors exist
487            }
488        }
489    }
490
491    // -----------------------------------------------------------------------
492    // Rebuild
493    // -----------------------------------------------------------------------
494
495    /// Recompute all view values from scratch.  Called on open after WAL
496    /// replay so values are consistent with persisted graph state.
497    pub fn rebuild_all(
498        &self,
499        props: &mut ColumnStore,
500        topo: &TopologyView<'_>,
501        ids: &IdMap,
502        syms: &Interner,
503        labels: &[u32],
504    ) {
505        for def in self.views.values() {
506            backfill_view(def, props, topo, ids, syms, labels, None);
507        }
508    }
509}
510
511// ---------------------------------------------------------------------------
512// Private helpers
513// ---------------------------------------------------------------------------
514
515/// Clear a view's value on `node`: from what was written since the snapshot,
516/// and with a tombstone over whatever the snapshot holds. Without the
517/// tombstone an Avg, Min or Max that has become absent goes on reading as the
518/// snapshot's number. A tombstone over nothing is harmless, and a later
519/// `set` is read before it.
520fn clear_view_value(props: &mut ColumnStore, node: u32, view_prop: &str) {
521    props.remove(node, view_prop);
522    props.record_prop_tombstone(node, view_prop);
523}
524
525/// Build a `ColumnsView` that reads from both overlay props and (when present)
526/// the archived base columns.  Used in update/recompute paths so view values
527/// that live in the base after a V8 snapshot open are visible alongside
528/// overlay-only props added during WAL replay or live mutations.
529fn build_cols_view<'a>(
530    overlay: &'a ColumnStore,
531    base_cols: Option<BaseColumns<'a>>,
532) -> ColumnsView<'a> {
533    match base_cols {
534        None => ColumnsView::owned(overlay),
535        // The shared table travels with the columns: without it a V9 base's
536        // string columns, whose own tables are empty, read back as absent.
537        Some(b) => ColumnsView::with_base(overlay, b.cols).with_shared_strings(b.strings),
538    }
539}
540
541/// Compute and store the view value for every node matching `def.label`.
542fn backfill_view(
543    def: &ViewDef,
544    props: &mut ColumnStore,
545    topo: &TopologyView<'_>,
546    ids: &IdMap,
547    syms: &Interner,
548    labels: &[u32],
549    base_cols: Option<BaseColumns<'_>>,
550) {
551    let Some(label_sym) = syms.get(&def.label) else {
552        return;
553    };
554    let Some(et_sym) = syms.get(def.edge_type()) else {
555        // Edge type not yet interned → no such edges exist.
556        // Set zero for Degree / Count / Sum; leave absent for Avg / Min / Max.
557        for id in 0..ids.len() as u32 {
558            if labels.get(id as usize).copied() == Some(label_sym) {
559                match &def.source {
560                    ViewSource::Degree { .. } => {
561                        props.set(id, &def.view_prop, Value::Int(0));
562                    }
563                    ViewSource::NeighborAgg {
564                        agg: AggFn::Count, ..
565                    } => {
566                        props.set(id, &def.view_prop, Value::Int(0));
567                    }
568                    ViewSource::NeighborAgg {
569                        agg: AggFn::Sum, ..
570                    } => {
571                        props.set(id, &def.view_prop, Value::Float(0.0));
572                    }
573                    _ => {}
574                }
575            }
576        }
577        return;
578    };
579    for id in 0..ids.len() as u32 {
580        if labels.get(id as usize).copied() != Some(label_sym) {
581            continue;
582        }
583        // In a block, so the borrow of `props` ends before the write below.
584        let value = {
585            let pv = build_cols_view(props, base_cols);
586            compute_view_value(def, id, pv, topo, ids, syms, labels)
587        };
588        match value {
589            Some(val) => props.set(id, &def.view_prop, val),
590            None => {
591                clear_view_value(props, id, &def.view_prop);
592                // Degree and Count always yield Some; only Avg/Min/Max
593                // return None when there are no qualifying neighbors.
594            }
595        }
596        // Ensure Degree and Count always have a value (even 0).
597        // compute_view_value already returns Some(Int(0)) for empty neighbor sets
598        // in Degree and Count, so no extra step needed here.
599        let _ = et_sym;
600    }
601}
602
603/// Compute the view value for a single node.  Returns `None` when there are
604/// no qualifying neighbors for Avg/Min/Max (no sensible neutral value).
605pub fn compute_view_value(
606    def: &ViewDef,
607    node: u32,
608    props: ColumnsView<'_>,
609    topo: &TopologyView<'_>,
610    _ids: &IdMap,
611    syms: &Interner,
612    labels: &[u32],
613) -> Option<Value> {
614    // Label check.
615    let label_sym = syms.get(&def.label)?;
616    if labels.get(node as usize).copied() != Some(label_sym) {
617        return None;
618    }
619
620    // If the edge_type has never been used it is not in the symbol table.
621    // There are zero such edges, so Degree/Count return 0, Sum returns 0.0,
622    // and Avg/Min/Max are absent.
623    let et_sym = match syms.get(def.edge_type()) {
624        Some(s) => s,
625        None => {
626            return match &def.source {
627                ViewSource::Degree { .. } => Some(Value::Int(0)),
628                ViewSource::NeighborAgg {
629                    agg: AggFn::Count, ..
630                } => Some(Value::Int(0)),
631                ViewSource::NeighborAgg {
632                    agg: AggFn::Sum, ..
633                } => Some(Value::Float(0.0)),
634                ViewSource::NeighborAgg { .. } => None,
635            };
636        }
637    };
638    let direction = def.direction();
639    let neighbors = topo.neighbors(et_sym, direction, node);
640
641    match &def.source {
642        ViewSource::Degree { .. } => Some(Value::Int(neighbors.len() as i64)),
643        ViewSource::NeighborAgg { agg, prop, .. } => match agg {
644            AggFn::Count => Some(Value::Int(
645                neighbors
646                    .iter()
647                    .filter(|&&n| props.get(n, prop).is_some())
648                    .count() as i64,
649            )),
650            AggFn::Sum => {
651                let mut sum = 0.0f64;
652                for &nbr in neighbors.as_ref() {
653                    if let Some(vr) = props.get(nbr, prop) {
654                        if let Some(n) = as_float(vr.as_value()) {
655                            sum += n;
656                        }
657                    }
658                }
659                Some(Value::Float(sum))
660            }
661            AggFn::Avg => {
662                let mut sum = 0.0f64;
663                let mut count = 0usize;
664                for &nbr in neighbors.as_ref() {
665                    if let Some(vr) = props.get(nbr, prop) {
666                        if let Some(n) = as_float(vr.as_value()) {
667                            sum += n;
668                            count += 1;
669                        }
670                    }
671                }
672                if count == 0 {
673                    None
674                } else {
675                    Some(Value::Float(sum / count as f64))
676                }
677            }
678            AggFn::Min => {
679                let mut best: Option<f64> = None;
680                for &nbr in neighbors.as_ref() {
681                    if let Some(vr) = props.get(nbr, prop) {
682                        if let Some(n) = as_float(vr.as_value()) {
683                            best = Some(best.map_or(n, |m: f64| m.min(n)));
684                        }
685                    }
686                }
687                best.map(Value::Float)
688            }
689            AggFn::Max => {
690                let mut best: Option<f64> = None;
691                for &nbr in neighbors.as_ref() {
692                    if let Some(vr) = props.get(nbr, prop) {
693                        if let Some(n) = as_float(vr.as_value()) {
694                            best = Some(best.map_or(n, |m: f64| m.max(n)));
695                        }
696                    }
697                }
698                best.map(Value::Float)
699            }
700        },
701    }
702}
703
704/// Update `subject`'s view value after one edge change.
705/// `neighbor` is the endpoint whose property is read for NeighborAgg.
706/// `inserted`: true = edge was added, false = edge was removed.
707///
708/// The topo must already reflect the new state before this is called.
709/// Uses full recompute from `topo` (base+overlay) and `base_cols` (for reading
710/// neighbor props from base when the overlay is empty after a V8 snapshot open).
711#[allow(clippy::too_many_arguments)]
712fn update_node_view(
713    def: &ViewDef,
714    subject: u32,
715    neighbor: u32,
716    inserted: bool,
717    props: &mut ColumnStore,
718    topo: &TopologyView<'_>,
719    ids: &IdMap,
720    syms: &Interner,
721    labels: &[u32],
722    base_cols: Option<BaseColumns<'_>>,
723) {
724    // Label check: only subjects with the matching label get this view.
725    let Some(label_sym) = syms.get(&def.label) else {
726        return;
727    };
728    if labels.get(subject as usize).copied() != Some(label_sym) {
729        return;
730    }
731
732    match &def.source {
733        ViewSource::Degree { direction, .. } => {
734            // Degree: full recompute from topo so the count is always correct
735            // even when the current value lives in the base (V8 snapshot open).
736            let et_sym = match syms.get(def.edge_type()) {
737                Some(s) => s,
738                None => {
739                    props.set(subject, &def.view_prop, Value::Int(0));
740                    return;
741                }
742            };
743            let count = topo.neighbors(et_sym, *direction, subject).len() as i64;
744            props.set(subject, &def.view_prop, Value::Int(count));
745        }
746        ViewSource::NeighborAgg { .. } => {
747            // Full recompute using the merged base+overlay props view.
748            // Build the ColumnsView in a block so the immutable borrow of `props`
749            // ends before the write, satisfying the borrow checker under NLL.
750            let val = {
751                let pv = build_cols_view(props, base_cols);
752                compute_view_value(def, subject, pv, topo, ids, syms, labels)
753            };
754            match val {
755                Some(v) => props.set(subject, &def.view_prop, v),
756                None => {
757                    clear_view_value(props, subject, &def.view_prop);
758                }
759            }
760            // Ensure Degree/Count/Sum always have a value even when the
761            // agg result is absent (e.g. Sum=0.0 when no neighbors yet).
762            // compute_view_value already returns Some for those cases.
763        }
764    }
765    let _ = (neighbor, inserted); // consumed by full-recompute path above
766}
767
768fn as_float(v: &Value) -> Option<f64> {
769    match v {
770        Value::Int(i) => Some(*i as f64),
771        Value::Float(f) if f.is_finite() => Some(*f),
772        _ => None,
773    }
774}
775
776// ---------------------------------------------------------------------------
777// Tests
778// ---------------------------------------------------------------------------
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    use core_storage::{IdMap, Interner, Topology};
784
785    fn make_setup() -> (ViewStore, ColumnStore, Topology, IdMap, Interner, Vec<u32>) {
786        let mut ids = IdMap::new();
787        let mut syms = Interner::new();
788        let mut labels = Vec::new();
789        let mut topo = Topology::new();
790        let mut props = ColumnStore::new();
791
792        // Insert nodes: p0, p1, p2 (label Person), c0 (label City)
793        let person_sym = syms.intern("Person");
794        let city_sym = syms.intern("City");
795        let edge_sym = syms.intern("LIVES_IN");
796
797        for key in &["p0", "p1", "p2"] {
798            let id = ids.get_or_insert(key);
799            if labels.len() <= id as usize {
800                labels.resize(id as usize + 1, u32::MAX);
801            }
802            labels[id as usize] = person_sym;
803        }
804        let c0 = ids.get_or_insert("c0");
805        if labels.len() <= c0 as usize {
806            labels.resize(c0 as usize + 1, u32::MAX);
807        }
808        labels[c0 as usize] = city_sym;
809
810        // p0 and p1 LIVES_IN c0
811        let p0 = ids.get("p0").unwrap();
812        let p1 = ids.get("p1").unwrap();
813        topo.add_edge(edge_sym, p0, c0);
814        topo.add_edge(edge_sym, p1, c0);
815
816        // Set a numeric prop on p0 and p1
817        props.set(p0, "score", Value::Float(3.0));
818        props.set(p1, "score", Value::Float(7.0));
819
820        (ViewStore::new(), props, topo, ids, syms, labels)
821    }
822
823    #[test]
824    fn degree_view_basic() {
825        let (mut vs, mut props, topo, ids, syms, labels) = make_setup();
826        let def = ViewDef {
827            name: "city_pop".into(),
828            label: "City".into(),
829            view_prop: "in_deg".into(),
830            source: ViewSource::Degree {
831                edge_type: "LIVES_IN".into(),
832                direction: Direction::In,
833            },
834        };
835        vs.create_view(
836            def,
837            &mut props,
838            &TopologyView::owned(&topo),
839            &ids,
840            &syms,
841            &labels,
842        )
843        .unwrap();
844        let c0 = ids.get("c0").unwrap();
845        assert_eq!(props.get(c0, "in_deg"), Some(&Value::Int(2)));
846    }
847
848    #[test]
849    fn neighbor_agg_sum() {
850        let (mut vs, mut props, topo, ids, syms, labels) = make_setup();
851        let def = ViewDef {
852            name: "city_score_sum".into(),
853            label: "City".into(),
854            view_prop: "score_sum".into(),
855            source: ViewSource::NeighborAgg {
856                edge_type: "LIVES_IN".into(),
857                direction: Direction::In,
858                agg: AggFn::Sum,
859                prop: "score".into(),
860            },
861        };
862        vs.create_view(
863            def,
864            &mut props,
865            &TopologyView::owned(&topo),
866            &ids,
867            &syms,
868            &labels,
869        )
870        .unwrap();
871        let c0 = ids.get("c0").unwrap();
872        // p0=3.0 + p1=7.0
873        assert_eq!(props.get(c0, "score_sum"), Some(&Value::Float(10.0)));
874    }
875
876    #[test]
877    fn neighbor_agg_count_skips_missing_prop() {
878        let (mut vs, mut props, topo, ids, syms, labels) = make_setup();
879        let p1 = ids.get("p1").unwrap();
880        props.remove(p1, "score");
881        let def = ViewDef {
882            name: "city_score_n".into(),
883            label: "City".into(),
884            view_prop: "score_n".into(),
885            source: ViewSource::NeighborAgg {
886                edge_type: "LIVES_IN".into(),
887                direction: Direction::In,
888                agg: AggFn::Count,
889                prop: "score".into(),
890            },
891        };
892        vs.create_view(
893            def,
894            &mut props,
895            &TopologyView::owned(&topo),
896            &ids,
897            &syms,
898            &labels,
899        )
900        .unwrap();
901        let c0 = ids.get("c0").unwrap();
902        assert_eq!(props.get(c0, "score_n"), Some(&Value::Int(1)));
903    }
904
905    #[test]
906    fn view_prop_collision_rejected() {
907        let (mut vs, mut props, topo, ids, syms, labels) = make_setup();
908        // Insert a real prop with the same name
909        let p0 = ids.get("p0").unwrap();
910        props.set(p0, "collision_prop", Value::Int(1));
911        let def = ViewDef {
912            name: "test_view".into(),
913            label: "Person".into(),
914            view_prop: "collision_prop".into(),
915            source: ViewSource::Degree {
916                edge_type: "LIVES_IN".into(),
917                direction: Direction::Out,
918            },
919        };
920        let err = vs
921            .create_view(
922                def,
923                &mut props,
924                &TopologyView::owned(&topo),
925                &ids,
926                &syms,
927                &labels,
928            )
929            .unwrap_err();
930        assert!(
931            err.contains("conflicts with an existing node property"),
932            "{err}"
933        );
934    }
935
936    #[test]
937    fn delete_view_removes_values() {
938        let (mut vs, mut props, topo, ids, syms, labels) = make_setup();
939        let def = ViewDef {
940            name: "city_pop".into(),
941            label: "City".into(),
942            view_prop: "in_deg".into(),
943            source: ViewSource::Degree {
944                edge_type: "LIVES_IN".into(),
945                direction: Direction::In,
946            },
947        };
948        vs.create_view(
949            def,
950            &mut props,
951            &TopologyView::owned(&topo),
952            &ids,
953            &syms,
954            &labels,
955        )
956        .unwrap();
957        let c0 = ids.get("c0").unwrap();
958        assert!(props.get(c0, "in_deg").is_some());
959        vs.delete_view("city_pop", &mut props, &ids, &labels, &syms)
960            .unwrap();
961        assert!(props.get(c0, "in_deg").is_none());
962    }
963}