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