Skip to main content

arete_server/
materialized_view.rs

1//! Materialized view evaluation for view pipelines.
2//!
3//! This module handles the runtime evaluation of ViewDef pipelines,
4//! maintaining materialized results that update as source data changes.
5
6use crate::cache::EntityCache;
7use serde::Serialize;
8use serde_json::Value;
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11use tokio::sync::RwLock;
12
13/// Result of evaluating whether an update affects a materialized view
14#[derive(Debug, Clone, PartialEq)]
15pub enum ViewEffect {
16    /// Update does not affect the view result
17    NoEffect,
18    /// Entity should be added to the view result
19    Add { key: String },
20    /// Entity should be removed from the view result
21    Remove { key: String },
22    /// Entity in view was updated
23    Update { key: String },
24    /// Entity replaces another in the view (for single-result views)
25    Replace { old_key: String, new_key: String },
26}
27
28/// Sort order for view evaluation
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30pub enum SortOrder {
31    Asc,
32    Desc,
33}
34
35/// Comparison operators
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
37pub enum CompareOp {
38    Eq,
39    Ne,
40    Gt,
41    Gte,
42    Lt,
43    Lte,
44}
45
46/// A materialized view that tracks a subset of entities based on a pipeline
47#[derive(Debug)]
48pub struct MaterializedView {
49    /// View identifier
50    pub id: String,
51    /// Source view/entity this derives from
52    pub source_id: String,
53    /// Current set of entity keys in this view's result
54    current_keys: Arc<RwLock<HashSet<String>>>,
55    /// Pipeline configuration (simplified for now)
56    pipeline: ViewPipeline,
57}
58
59#[derive(Debug, Clone, Default, Serialize)]
60pub struct ViewPipeline {
61    /// Filter predicate (field path, op, value)
62    pub filter: Option<FilterConfig>,
63    /// Sort configuration
64    pub sort: Option<SortConfig>,
65    /// Limit (take N) - if Some(1), treated as single-result view for Replace effects
66    pub limit: Option<usize>,
67}
68
69#[derive(Debug, Clone, Serialize)]
70pub struct FilterConfig {
71    pub field_path: Vec<String>,
72    pub op: CompareOp,
73    pub value: Value,
74}
75
76impl FilterConfig {
77    /// Whether `entity` passes the filter. A missing field reads as null,
78    /// which passes only `Ne` against a non-null value.
79    pub fn matches(&self, entity: &Value) -> bool {
80        static NULL: Value = Value::Null;
81        let mut field = entity;
82        for segment in &self.field_path {
83            match field.get(segment) {
84                Some(value) => field = value,
85                None => {
86                    field = &NULL;
87                    break;
88                }
89            }
90        }
91        match self.op {
92            CompareOp::Eq => *field == self.value,
93            CompareOp::Ne => *field != self.value,
94            CompareOp::Gt => compare_values(field, &self.value) == std::cmp::Ordering::Greater,
95            CompareOp::Gte => compare_values(field, &self.value) != std::cmp::Ordering::Less,
96            CompareOp::Lt => compare_values(field, &self.value) == std::cmp::Ordering::Less,
97            CompareOp::Lte => compare_values(field, &self.value) != std::cmp::Ordering::Greater,
98        }
99    }
100}
101
102#[derive(Debug, Clone, Serialize)]
103pub struct SortConfig {
104    pub field_path: Vec<String>,
105    pub order: SortOrder,
106}
107
108impl MaterializedView {
109    /// Create a new materialized view
110    pub fn new(id: String, source_id: String, pipeline: ViewPipeline) -> Self {
111        Self {
112            id,
113            source_id,
114            current_keys: Arc::new(RwLock::new(HashSet::new())),
115            pipeline,
116        }
117    }
118
119    /// Get current keys in the view
120    pub async fn get_keys(&self) -> HashSet<String> {
121        self.current_keys.read().await.clone()
122    }
123
124    /// Evaluate initial state from cache
125    pub async fn evaluate_initial(&self, cache: &EntityCache) -> Vec<(String, Value)> {
126        let entities = cache.get_all(&self.source_id).await;
127        self.evaluate_pipeline(entities).await
128    }
129
130    /// Evaluate pipeline on a set of entities
131    async fn evaluate_pipeline(&self, mut entities: Vec<(String, Value)>) -> Vec<(String, Value)> {
132        // Apply filter
133        if let Some(ref filter) = self.pipeline.filter {
134            entities.retain(|(_, v)| self.matches_filter(v, filter));
135        }
136
137        // Apply sort
138        if let Some(ref sort) = self.pipeline.sort {
139            entities.sort_by(|(_, a), (_, b)| {
140                let a_val = extract_field(a, &sort.field_path);
141                let b_val = extract_field(b, &sort.field_path);
142                let cmp = compare_values(&a_val, &b_val);
143                match sort.order {
144                    SortOrder::Asc => cmp,
145                    SortOrder::Desc => cmp.reverse(),
146                }
147            });
148        }
149
150        // Apply limit
151        if let Some(limit) = self.pipeline.limit {
152            entities.truncate(limit);
153        }
154
155        // Update current keys
156        let keys: HashSet<String> = entities.iter().map(|(k, _)| k.clone()).collect();
157        *self.current_keys.write().await = keys;
158
159        entities
160    }
161
162    /// Check if an entity matches the filter
163    fn matches_filter(&self, entity: &Value, filter: &FilterConfig) -> bool {
164        filter.matches(entity)
165    }
166
167    /// Determine the effect of an entity update on this view
168    pub async fn compute_effect(
169        &self,
170        key: &str,
171        new_value: Option<&Value>,
172        _cache: &EntityCache,
173    ) -> ViewEffect {
174        let current_keys = self.current_keys.read().await;
175        let was_in_view = current_keys.contains(key);
176        drop(current_keys);
177
178        // Check if entity now matches filter
179        let matches_now = match new_value {
180            Some(v) => {
181                if let Some(ref filter) = self.pipeline.filter {
182                    self.matches_filter(v, filter)
183                } else {
184                    true
185                }
186            }
187            None => false, // Deleted
188        };
189
190        match (was_in_view, matches_now) {
191            (false, true) => {
192                if self.pipeline.limit == Some(1) {
193                    let current_keys = self.current_keys.read().await;
194                    if let Some(current_key) = current_keys.iter().next() {
195                        if current_key != key {
196                            return ViewEffect::Replace {
197                                old_key: current_key.clone(),
198                                new_key: key.to_string(),
199                            };
200                        }
201                    }
202                }
203                ViewEffect::Add {
204                    key: key.to_string(),
205                }
206            }
207            (true, false) => ViewEffect::Remove {
208                key: key.to_string(),
209            },
210            (true, true) => ViewEffect::Update {
211                key: key.to_string(),
212            },
213            (false, false) => ViewEffect::NoEffect,
214        }
215    }
216
217    /// Apply an effect to update the current keys
218    pub async fn apply_effect(&self, effect: &ViewEffect) {
219        let mut keys = self.current_keys.write().await;
220        match effect {
221            ViewEffect::Add { key } => {
222                keys.insert(key.clone());
223            }
224            ViewEffect::Remove { key } => {
225                keys.remove(key);
226            }
227            ViewEffect::Replace { old_key, new_key } => {
228                keys.remove(old_key);
229                keys.insert(new_key.clone());
230            }
231            ViewEffect::Update { .. } | ViewEffect::NoEffect => {}
232        }
233    }
234}
235
236/// Extract a field value from a JSON object using a path
237fn extract_field(value: &Value, path: &[String]) -> Value {
238    let mut current = value;
239    for segment in path {
240        match current.get(segment) {
241            Some(v) => current = v,
242            None => return Value::Null,
243        }
244    }
245    current.clone()
246}
247
248/// Compare two JSON values
249fn compare_values(a: &Value, b: &Value) -> std::cmp::Ordering {
250    match (a, b) {
251        (Value::Number(a), Value::Number(b)) => {
252            let a_f = a.as_f64().unwrap_or(0.0);
253            let b_f = b.as_f64().unwrap_or(0.0);
254            a_f.partial_cmp(&b_f).unwrap_or(std::cmp::Ordering::Equal)
255        }
256        (Value::String(a), Value::String(b)) => a.cmp(b),
257        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
258        _ => std::cmp::Ordering::Equal,
259    }
260}
261
262/// Registry of materialized views
263#[derive(Default)]
264pub struct MaterializedViewRegistry {
265    views: HashMap<String, Arc<MaterializedView>>,
266    /// Map from source view ID to dependent materialized views
267    dependencies: HashMap<String, Vec<String>>,
268}
269
270impl MaterializedViewRegistry {
271    pub fn new() -> Self {
272        Self::default()
273    }
274
275    /// Register a materialized view
276    pub fn register(&mut self, view: MaterializedView) {
277        let view_id = view.id.clone();
278        let source_id = view.source_id.clone();
279
280        self.dependencies
281            .entry(source_id)
282            .or_default()
283            .push(view_id.clone());
284
285        self.views.insert(view_id, Arc::new(view));
286    }
287
288    /// Get a materialized view by ID
289    pub fn get(&self, id: &str) -> Option<Arc<MaterializedView>> {
290        self.views.get(id).cloned()
291    }
292
293    /// Get all views that depend on a source
294    pub fn get_dependents(&self, source_id: &str) -> Vec<Arc<MaterializedView>> {
295        self.dependencies
296            .get(source_id)
297            .map(|ids| {
298                ids.iter()
299                    .filter_map(|id| self.views.get(id).cloned())
300                    .collect()
301            })
302            .unwrap_or_default()
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use serde_json::json;
310
311    #[tokio::test]
312    async fn test_filter_evaluation() {
313        let pipeline = ViewPipeline {
314            filter: Some(FilterConfig {
315                field_path: vec!["status".to_string()],
316                op: CompareOp::Eq,
317                value: json!("active"),
318            }),
319            sort: None,
320            limit: None,
321        };
322
323        let view =
324            MaterializedView::new("test/active".to_string(), "test/list".to_string(), pipeline);
325
326        let entities = vec![
327            ("1".to_string(), json!({"status": "active", "value": 10})),
328            ("2".to_string(), json!({"status": "inactive", "value": 20})),
329            ("3".to_string(), json!({"status": "active", "value": 30})),
330        ];
331
332        let result = view.evaluate_pipeline(entities).await;
333        assert_eq!(result.len(), 2);
334        assert_eq!(result[0].0, "1");
335        assert_eq!(result[1].0, "3");
336    }
337
338    #[tokio::test]
339    async fn test_sort_and_limit() {
340        let pipeline = ViewPipeline {
341            filter: None,
342            sort: Some(SortConfig {
343                field_path: vec!["value".to_string()],
344                order: SortOrder::Desc,
345            }),
346            limit: Some(2),
347        };
348
349        let view =
350            MaterializedView::new("test/top2".to_string(), "test/list".to_string(), pipeline);
351
352        let entities = vec![
353            ("1".to_string(), json!({"value": 10})),
354            ("2".to_string(), json!({"value": 30})),
355            ("3".to_string(), json!({"value": 20})),
356        ];
357
358        let result = view.evaluate_pipeline(entities).await;
359        assert_eq!(result.len(), 2);
360        assert_eq!(result[0].0, "2"); // value: 30
361        assert_eq!(result[1].0, "3"); // value: 20
362    }
363}