Skip to main content

aurora_db/
computed.rs

1// Computed Fields - Auto-calculated field values
2//
3// Supports dynamic field calculation based on other field values
4// Examples: full_name from first_name + last_name, age from birthdate, etc.
5//
6// ## Architectural Note: "Retrieval-Time Evaluation"
7//
8// We chose **Retrieval-Time Evaluation** over "Store-Time Calculation" for specific reasons:
9// 1.  **Storage Efficiency**: We don't store redundant data.
10// 2.  **Flexibility**: You can change the formula for "full_name" and it instantly
11//     applies to all old documents without a migration/backfill.
12// 3.  **Simplicity**: No complex dependency graph to track when fields change.
13//
14// Trade-off: Querying with filters on computed fields requires a full scan (filter after compute),
15// which is acceptable for the embedded/local use case but would scale poorly in a distributed DB.
16//
17// ## Scripting Engine
18// We use `Rhai` for the scripting engine because it is:
19// - **Safe**: No separate process, no memory access outside the scope.
20// - **Fast**: Optimized for repeated execution.
21// - **Rust-Native**: seamless integration with our `Value` types.
22//
23// IMPORTANT: Computed fields are evaluated at RETRIEVAL TIME ONLY.
24// They do NOT affect mutations or pub/sub events (unless explicitly enriched).
25
26use crate::error::Result;
27use crate::types::{Document, Value};
28use rhai::{Dynamic, Engine, Scope};
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31use std::sync::Arc;
32
33/// Computation expression for a field
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub enum ComputedExpression {
36    /// Concatenate string fields with space separator
37    Concat(Vec<String>),
38    /// Sum numeric fields
39    Sum(Vec<String>),
40    /// Multiply numeric fields
41    Product(Vec<String>),
42    /// Average numeric fields
43    Average(Vec<String>),
44    /// Template string with ${field} interpolation
45    /// Example: "${first_name} ${last_name}"
46    Template(String),
47    /// Rhai script expression
48    /// Example: "doc.price * doc.quantity"
49    Script(String),
50    /// Legacy custom expression (deprecated, use Script instead)
51    #[serde(rename = "custom")]
52    Custom(String),
53}
54
55impl ComputedExpression {
56    /// Evaluate the expression against a document
57    pub fn evaluate(&self, doc: &Document) -> Option<Value> {
58        match self {
59            ComputedExpression::Concat(fields) => {
60                let mut result = String::new();
61                for field in fields {
62                    if let Some(value) = doc.data.get(field)
63                        && let Some(s) = value.as_str()
64                    {
65                        if !result.is_empty() {
66                            result.push(' ');
67                        }
68                        result.push_str(s);
69                    }
70                }
71                Some(Value::String(result))
72            }
73
74            ComputedExpression::Sum(fields) => {
75                let mut sum = 0i64;
76                for field in fields {
77                    if let Some(value) = doc.data.get(field)
78                        && let Some(i) = value.as_i64()
79                    {
80                        sum += i;
81                    }
82                }
83                Some(Value::Int(sum))
84            }
85
86            ComputedExpression::Product(fields) => {
87                let mut product = 1i64;
88                for field in fields {
89                    if let Some(value) = doc.data.get(field)
90                        && let Some(i) = value.as_i64()
91                    {
92                        product *= i;
93                    }
94                }
95                Some(Value::Int(product))
96            }
97
98            ComputedExpression::Average(fields) => {
99                let mut sum = 0.0;
100                let mut count = 0;
101                for field in fields {
102                    if let Some(value) = doc.data.get(field) {
103                        if let Some(f) = value.as_f64() {
104                            sum += f;
105                            count += 1;
106                        } else if let Some(i) = value.as_i64() {
107                            sum += i as f64;
108                            count += 1;
109                        }
110                    }
111                }
112                if count > 0 {
113                    Some(Value::Float(sum / count as f64))
114                } else {
115                    None
116                }
117            }
118
119            ComputedExpression::Template(template) => {
120                Some(Value::String(interpolate_template(template, doc)))
121            }
122
123            ComputedExpression::Script(script) | ComputedExpression::Custom(script) => {
124                evaluate_rhai_script(script, doc)
125            }
126        }
127    }
128}
129
130/// Interpolate template string with document field values
131/// Replaces ${field_name} with the field's string value
132fn interpolate_template(template: &str, doc: &Document) -> String {
133    let mut result = template.to_string();
134
135    // Find all ${...} patterns and replace them
136    while let Some(start) = result.find("${") {
137        if let Some(end) = result[start..].find('}') {
138            let end = start + end;
139            let field_name = &result[start + 2..end];
140
141            let replacement = doc
142                .data
143                .get(field_name)
144                .and_then(|v| match v {
145                    Value::String(s) => Some(s.clone()),
146                    Value::Int(i) => Some(i.to_string()),
147                    Value::Float(f) => Some(f.to_string()),
148                    Value::Bool(b) => Some(b.to_string()),
149                    _ => None,
150                })
151                .unwrap_or_default();
152
153            result = format!("{}{}{}", &result[..start], replacement, &result[end + 1..]);
154        } else {
155            break;
156        }
157    }
158
159    result
160}
161
162/// Convert Aurora Value to Rhai Dynamic
163fn value_to_dynamic(value: &Value) -> Dynamic {
164    match value {
165        Value::Null => Dynamic::UNIT,
166        Value::Bool(b) => Dynamic::from(*b),
167        Value::Int(i) => Dynamic::from(*i),
168        Value::Float(f) => Dynamic::from(*f),
169        Value::String(s) => Dynamic::from(s.clone()),
170        Value::Uuid(u) => Dynamic::from(u.to_string()),
171        Value::Array(arr) => {
172            let vec: Vec<Dynamic> = arr.iter().map(value_to_dynamic).collect();
173            Dynamic::from(vec)
174        }
175        Value::Object(map) => {
176            let mut rhai_map = rhai::Map::new();
177            for (k, v) in map {
178                rhai_map.insert(k.clone().into(), value_to_dynamic(v));
179            }
180            Dynamic::from(rhai_map)
181        }
182    }
183}
184
185/// Convert Rhai Dynamic to Aurora Value
186fn dynamic_to_value(dyn_val: Dynamic) -> Option<Value> {
187    if dyn_val.is_unit() {
188        return Some(Value::Null);
189    }
190    if let Some(b) = dyn_val.clone().try_cast::<bool>() {
191        return Some(Value::Bool(b));
192    }
193    if let Some(i) = dyn_val.clone().try_cast::<i64>() {
194        return Some(Value::Int(i));
195    }
196    if let Some(f) = dyn_val.clone().try_cast::<f64>() {
197        return Some(Value::Float(f));
198    }
199    if let Some(s) = dyn_val.clone().try_cast::<String>() {
200        return Some(Value::String(s));
201    }
202    if let Some(arr) = dyn_val.clone().try_cast::<Vec<Dynamic>>() {
203        let converted: Vec<Value> = arr.into_iter().filter_map(dynamic_to_value).collect();
204        return Some(Value::Array(converted));
205    }
206    if let Some(map) = dyn_val.try_cast::<rhai::Map>() {
207        let mut obj = HashMap::new();
208        for (k, v) in map {
209            if let Some(val) = dynamic_to_value(v) {
210                obj.insert(k.to_string(), val);
211            }
212        }
213        return Some(Value::Object(obj));
214    }
215    None
216}
217
218/// Evaluate a Rhai script with document fields available as `doc`
219fn evaluate_rhai_script(script: &str, doc: &Document) -> Option<Value> {
220    let engine = Engine::new();
221    let mut scope = Scope::new();
222
223    // Create a map for the document fields
224    let mut doc_map = rhai::Map::new();
225    for (key, value) in &doc.data {
226        doc_map.insert(key.clone().into(), value_to_dynamic(value));
227    }
228
229    // Add doc to scope
230    scope.push("doc", doc_map);
231
232    // Evaluate the script
233    match engine.eval_with_scope::<Dynamic>(&mut scope, script) {
234        Ok(result) => dynamic_to_value(result),
235        Err(_) => None, // Graceful degradation on script errors
236    }
237}
238
239/// Rhai-powered computed field engine with caching
240pub struct ComputedEngine {
241    engine: Arc<Engine>,
242}
243
244impl ComputedEngine {
245    /// Create a new computed engine with built-in functions
246    pub fn new() -> Self {
247        let mut engine = Engine::new();
248
249        // Register built-in string functions
250        engine.register_fn("uppercase", |s: &str| s.to_uppercase());
251        engine.register_fn("lowercase", |s: &str| s.to_lowercase());
252        engine.register_fn("trim", |s: &str| s.trim().to_string());
253        engine.register_fn("len", |s: &str| s.len() as i64);
254
255        // Register math functions
256        engine.register_fn("abs", |x: i64| x.abs());
257        engine.register_fn("abs", |x: f64| x.abs());
258        engine.register_fn("round", |x: f64| x.round());
259        engine.register_fn("floor", |x: f64| x.floor());
260        engine.register_fn("ceil", |x: f64| x.ceil());
261        engine.register_fn("min", |a: i64, b: i64| a.min(b));
262        engine.register_fn("max", |a: i64, b: i64| a.max(b));
263
264        Self {
265            engine: Arc::new(engine),
266        }
267    }
268
269    /// Evaluate a Rhai script with document context
270    pub fn evaluate(&self, script: &str, doc: &Document) -> Option<Value> {
271        let mut scope = Scope::new();
272
273        // Create a map for the document fields
274        let mut doc_map = rhai::Map::new();
275        for (key, value) in &doc.data {
276            doc_map.insert(key.clone().into(), value_to_dynamic(value));
277        }
278
279        scope.push("doc", doc_map);
280
281        match self.engine.eval_with_scope::<Dynamic>(&mut scope, script) {
282            Ok(result) => dynamic_to_value(result),
283            Err(_) => None,
284        }
285    }
286}
287
288impl Default for ComputedEngine {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294/// Computed field registry
295pub struct ComputedFields {
296    // collection_name -> (field_name -> expression)
297    fields: HashMap<String, HashMap<String, ComputedExpression>>,
298    engine: ComputedEngine,
299}
300
301impl ComputedFields {
302    pub fn new() -> Self {
303        Self {
304            fields: HashMap::new(),
305            engine: ComputedEngine::new(),
306        }
307    }
308
309    /// Register a computed field
310    pub fn register(
311        &mut self,
312        collection: impl Into<String>,
313        field: impl Into<String>,
314        expression: ComputedExpression,
315    ) {
316        let collection = collection.into();
317        self.fields
318            .entry(collection)
319            .or_default()
320            .insert(field.into(), expression);
321    }
322
323    /// Apply computed fields to a document (retrieval time only)
324    pub fn apply(&self, collection: &str, doc: &mut Document) -> Result<()> {
325        if let Some(computed) = self.fields.get(collection) {
326            for (field_name, expression) in computed {
327                if let Some(value) = expression.evaluate(doc) {
328                    doc.data.insert(field_name.clone(), value);
329                }
330            }
331        }
332        Ok(())
333    }
334
335    /// Get computed fields for a collection
336    pub fn get_fields(&self, collection: &str) -> Option<&HashMap<String, ComputedExpression>> {
337        self.fields.get(collection)
338    }
339
340    /// Evaluate a script expression with document context
341    pub fn evaluate_script(&self, script: &str, doc: &Document) -> Option<Value> {
342        self.engine.evaluate(script, doc)
343    }
344}
345
346impl Default for ComputedFields {
347    fn default() -> Self {
348        Self::new()
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_concat_expression() {
358        let expr =
359            ComputedExpression::Concat(vec!["first_name".to_string(), "last_name".to_string()]);
360
361        let mut doc = Document::new();
362        doc.data
363            .insert("first_name".to_string(), Value::String("John".to_string()));
364        doc.data
365            .insert("last_name".to_string(), Value::String("Doe".to_string()));
366
367        let result = expr.evaluate(&doc);
368        assert_eq!(result, Some(Value::String("John Doe".to_string())));
369    }
370
371    #[test]
372    fn test_sum_expression() {
373        let expr = ComputedExpression::Sum(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
374
375        let mut doc = Document::new();
376        doc.data.insert("a".to_string(), Value::Int(10));
377        doc.data.insert("b".to_string(), Value::Int(20));
378        doc.data.insert("c".to_string(), Value::Int(30));
379
380        let result = expr.evaluate(&doc);
381        assert_eq!(result, Some(Value::Int(60)));
382    }
383
384    #[test]
385    fn test_average_expression() {
386        let expr = ComputedExpression::Average(vec!["score1".to_string(), "score2".to_string()]);
387
388        let mut doc = Document::new();
389        doc.data.insert("score1".to_string(), Value::Float(85.5));
390        doc.data.insert("score2".to_string(), Value::Float(92.5));
391
392        let result = expr.evaluate(&doc);
393        assert_eq!(result, Some(Value::Float(89.0)));
394    }
395
396    #[test]
397    fn test_computed_fields_registry() {
398        let mut registry = ComputedFields::new();
399
400        registry.register(
401            "users",
402            "full_name",
403            ComputedExpression::Concat(vec!["first_name".to_string(), "last_name".to_string()]),
404        );
405
406        let mut doc = Document::new();
407        doc.data
408            .insert("first_name".to_string(), Value::String("Jane".to_string()));
409        doc.data
410            .insert("last_name".to_string(), Value::String("Smith".to_string()));
411
412        registry.apply("users", &mut doc).unwrap();
413
414        assert_eq!(
415            doc.data.get("full_name"),
416            Some(&Value::String("Jane Smith".to_string()))
417        );
418    }
419
420    #[test]
421    fn test_template_expression() {
422        let expr =
423            ComputedExpression::Template("Hello, ${name}! You are ${age} years old.".to_string());
424
425        let mut doc = Document::new();
426        doc.data
427            .insert("name".to_string(), Value::String("Alice".to_string()));
428        doc.data.insert("age".to_string(), Value::Int(30));
429
430        let result = expr.evaluate(&doc);
431        assert_eq!(
432            result,
433            Some(Value::String(
434                "Hello, Alice! You are 30 years old.".to_string()
435            ))
436        );
437    }
438
439    #[test]
440    fn test_rhai_simple_expression() {
441        let expr = ComputedExpression::Script("doc.price * doc.quantity".to_string());
442
443        let mut doc = Document::new();
444        doc.data.insert("price".to_string(), Value::Int(100));
445        doc.data.insert("quantity".to_string(), Value::Int(5));
446
447        let result = expr.evaluate(&doc);
448        assert_eq!(result, Some(Value::Int(500)));
449    }
450
451    #[test]
452    fn test_rhai_string_concat() {
453        let expr = ComputedExpression::Script(r#"doc.first + " " + doc.last"#.to_string());
454
455        let mut doc = Document::new();
456        doc.data
457            .insert("first".to_string(), Value::String("John".to_string()));
458        doc.data
459            .insert("last".to_string(), Value::String("Doe".to_string()));
460
461        let result = expr.evaluate(&doc);
462        assert_eq!(result, Some(Value::String("John Doe".to_string())));
463    }
464
465    #[test]
466    fn test_rhai_conditional() {
467        let expr = ComputedExpression::Script(
468            r#"if doc.age >= 18 { "adult" } else { "minor" }"#.to_string(),
469        );
470
471        let mut doc = Document::new();
472        doc.data.insert("age".to_string(), Value::Int(25));
473
474        let result = expr.evaluate(&doc);
475        assert_eq!(result, Some(Value::String("adult".to_string())));
476
477        doc.data.insert("age".to_string(), Value::Int(15));
478        let result = expr.evaluate(&doc);
479        assert_eq!(result, Some(Value::String("minor".to_string())));
480    }
481
482    #[test]
483    fn test_rhai_null_handling() {
484        let expr = ComputedExpression::Script("doc.missing_field".to_string());
485
486        let doc = Document::new();
487
488        // Script accessing missing field returns Null (Rhai returns unit for missing keys)
489        let result = expr.evaluate(&doc);
490        assert_eq!(result, Some(Value::Null));
491    }
492
493    #[test]
494    fn test_computed_engine_builtin_functions() {
495        let engine = ComputedEngine::new();
496
497        let mut doc = Document::new();
498        doc.data
499            .insert("name".to_string(), Value::String("hello world".to_string()));
500        doc.data.insert("value".to_string(), Value::Float(3.7));
501
502        // Test uppercase
503        let result = engine.evaluate(r#"uppercase(doc.name)"#, &doc);
504        assert_eq!(result, Some(Value::String("HELLO WORLD".to_string())));
505
506        // Test round
507        let result = engine.evaluate("round(doc.value)", &doc);
508        assert_eq!(result, Some(Value::Float(4.0)));
509    }
510}