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