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