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