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