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::DateTime(dt) => Dynamic::from(dt.to_rfc3339()),
172        Value::Array(arr) => {
173            let vec: Vec<Dynamic> = arr.iter().map(value_to_dynamic).collect();
174            Dynamic::from(vec)
175        }
176        Value::Object(map) => {
177            let mut rhai_map = rhai::Map::new();
178            for (k, v) in map {
179                rhai_map.insert(k.clone().into(), value_to_dynamic(v));
180            }
181            Dynamic::from(rhai_map)
182        }
183    }
184}
185
186/// Convert Rhai Dynamic to Aurora Value
187fn dynamic_to_value(dyn_val: Dynamic) -> Option<Value> {
188    if dyn_val.is_unit() {
189        return Some(Value::Null);
190    }
191    if let Some(b) = dyn_val.clone().try_cast::<bool>() {
192        return Some(Value::Bool(b));
193    }
194    if let Some(i) = dyn_val.clone().try_cast::<i64>() {
195        return Some(Value::Int(i));
196    }
197    if let Some(f) = dyn_val.clone().try_cast::<f64>() {
198        return Some(Value::Float(f));
199    }
200    if let Some(s) = dyn_val.clone().try_cast::<String>() {
201        // Try to recover a DateTime that was encoded as RFC3339 by value_to_dynamic
202        if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&s) {
203            return Some(Value::DateTime(dt.with_timezone(&chrono::Utc)));
204        }
205        return Some(Value::String(s));
206    }
207    if let Some(arr) = dyn_val.clone().try_cast::<Vec<Dynamic>>() {
208        let converted: Vec<Value> = arr.into_iter().filter_map(dynamic_to_value).collect();
209        return Some(Value::Array(converted));
210    }
211    if let Some(map) = dyn_val.try_cast::<rhai::Map>() {
212        let mut obj = HashMap::new();
213        for (k, v) in map {
214            if let Some(val) = dynamic_to_value(v) {
215                obj.insert(k.to_string(), val);
216            }
217        }
218        return Some(Value::Object(obj));
219    }
220    None
221}
222
223/// Evaluate a Rhai script with document fields available as `doc`
224fn evaluate_rhai_script(script: &str, doc: &Document) -> Option<Value> {
225    let engine = Engine::new();
226    let mut scope = Scope::new();
227
228    // Create a map for the document fields
229    let mut doc_map = rhai::Map::new();
230    for (key, value) in &doc.data {
231        doc_map.insert(key.clone().into(), value_to_dynamic(value));
232    }
233
234    // Add doc to scope
235    scope.push("doc", doc_map);
236
237    // Evaluate the script
238    match engine.eval_with_scope::<Dynamic>(&mut scope, script) {
239        Ok(result) => dynamic_to_value(result),
240        Err(_) => None, // Graceful degradation on script errors
241    }
242}
243
244/// Rhai-powered computed field engine with caching
245pub struct ComputedEngine {
246    engine: Arc<Engine>,
247}
248
249impl ComputedEngine {
250    /// Create a new computed engine with built-in functions
251    pub fn new() -> Self {
252        let mut engine = Engine::new();
253
254        // Register built-in string functions
255        engine.register_fn("uppercase", |s: &str| s.to_uppercase());
256        engine.register_fn("lowercase", |s: &str| s.to_lowercase());
257        engine.register_fn("trim", |s: &str| s.trim().to_string());
258        engine.register_fn("len", |s: &str| s.len() as i64);
259
260        // Register math functions
261        engine.register_fn("abs", |x: i64| x.abs());
262        engine.register_fn("abs", |x: f64| x.abs());
263        engine.register_fn("round", |x: f64| x.round());
264        engine.register_fn("floor", |x: f64| x.floor());
265        engine.register_fn("ceil", |x: f64| x.ceil());
266        engine.register_fn("min", |a: i64, b: i64| a.min(b));
267        engine.register_fn("max", |a: i64, b: i64| a.max(b));
268
269        Self {
270            engine: Arc::new(engine),
271        }
272    }
273
274    /// Evaluate a Rhai script with document context
275    pub fn evaluate(&self, script: &str, doc: &Document) -> Option<Value> {
276        let mut scope = Scope::new();
277
278        // Create a map for the document fields
279        let mut doc_map = rhai::Map::new();
280        for (key, value) in &doc.data {
281            doc_map.insert(key.clone().into(), value_to_dynamic(value));
282        }
283
284        scope.push("doc", doc_map);
285
286        match self.engine.eval_with_scope::<Dynamic>(&mut scope, script) {
287            Ok(result) => dynamic_to_value(result),
288            Err(_) => None,
289        }
290    }
291}
292
293impl Default for ComputedEngine {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299/// Computed field registry
300pub struct ComputedFields {
301    // collection_name -> (field_name -> expression)
302    fields: HashMap<String, HashMap<String, ComputedExpression>>,
303    engine: ComputedEngine,
304}
305
306impl ComputedFields {
307    pub fn new() -> Self {
308        Self {
309            fields: HashMap::new(),
310            engine: ComputedEngine::new(),
311        }
312    }
313
314    /// Register a computed field
315    pub fn register(
316        &mut self,
317        collection: impl Into<String>,
318        field: impl Into<String>,
319        expression: ComputedExpression,
320    ) {
321        let collection = collection.into();
322        self.fields
323            .entry(collection)
324            .or_default()
325            .insert(field.into(), expression);
326    }
327
328    /// Apply computed fields to a document (retrieval time only)
329    pub fn apply(&self, collection: &str, doc: &mut Document) -> Result<()> {
330        if let Some(computed) = self.fields.get(collection) {
331            for (field_name, expression) in computed {
332                if let Some(value) = expression.evaluate(doc) {
333                    doc.data.insert(field_name.clone(), value);
334                }
335            }
336        }
337        Ok(())
338    }
339
340    /// Get computed fields for a collection
341    pub fn get_fields(&self, collection: &str) -> Option<&HashMap<String, ComputedExpression>> {
342        self.fields.get(collection)
343    }
344
345    /// Evaluate a script expression with document context
346    pub fn evaluate_script(&self, script: &str, doc: &Document) -> Option<Value> {
347        self.engine.evaluate(script, doc)
348    }
349}
350
351impl Default for ComputedFields {
352    fn default() -> Self {
353        Self::new()
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn test_concat_expression() {
363        let expr =
364            ComputedExpression::Concat(vec!["first_name".to_string(), "last_name".to_string()]);
365
366        let mut doc = Document::new();
367        doc.data
368            .insert("first_name".to_string(), Value::String("John".to_string()));
369        doc.data
370            .insert("last_name".to_string(), Value::String("Doe".to_string()));
371
372        let result = expr.evaluate(&doc);
373        assert_eq!(result, Some(Value::String("John Doe".to_string())));
374    }
375
376    #[test]
377    fn test_sum_expression() {
378        let expr = ComputedExpression::Sum(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
379
380        let mut doc = Document::new();
381        doc.data.insert("a".to_string(), Value::Int(10));
382        doc.data.insert("b".to_string(), Value::Int(20));
383        doc.data.insert("c".to_string(), Value::Int(30));
384
385        let result = expr.evaluate(&doc);
386        assert_eq!(result, Some(Value::Int(60)));
387    }
388
389    #[test]
390    fn test_average_expression() {
391        let expr = ComputedExpression::Average(vec!["score1".to_string(), "score2".to_string()]);
392
393        let mut doc = Document::new();
394        doc.data.insert("score1".to_string(), Value::Float(85.5));
395        doc.data.insert("score2".to_string(), Value::Float(92.5));
396
397        let result = expr.evaluate(&doc);
398        assert_eq!(result, Some(Value::Float(89.0)));
399    }
400
401    #[test]
402    fn test_computed_fields_registry() {
403        let mut registry = ComputedFields::new();
404
405        registry.register(
406            "users",
407            "full_name",
408            ComputedExpression::Concat(vec!["first_name".to_string(), "last_name".to_string()]),
409        );
410
411        let mut doc = Document::new();
412        doc.data
413            .insert("first_name".to_string(), Value::String("Jane".to_string()));
414        doc.data
415            .insert("last_name".to_string(), Value::String("Smith".to_string()));
416
417        registry.apply("users", &mut doc).unwrap();
418
419        assert_eq!(
420            doc.data.get("full_name"),
421            Some(&Value::String("Jane Smith".to_string()))
422        );
423    }
424
425    #[test]
426    fn test_template_expression() {
427        let expr =
428            ComputedExpression::Template("Hello, ${name}! You are ${age} years old.".to_string());
429
430        let mut doc = Document::new();
431        doc.data
432            .insert("name".to_string(), Value::String("Alice".to_string()));
433        doc.data.insert("age".to_string(), Value::Int(30));
434
435        let result = expr.evaluate(&doc);
436        assert_eq!(
437            result,
438            Some(Value::String(
439                "Hello, Alice! You are 30 years old.".to_string()
440            ))
441        );
442    }
443
444    #[test]
445    fn test_rhai_simple_expression() {
446        let expr = ComputedExpression::Script("doc.price * doc.quantity".to_string());
447
448        let mut doc = Document::new();
449        doc.data.insert("price".to_string(), Value::Int(100));
450        doc.data.insert("quantity".to_string(), Value::Int(5));
451
452        let result = expr.evaluate(&doc);
453        assert_eq!(result, Some(Value::Int(500)));
454    }
455
456    #[test]
457    fn test_rhai_string_concat() {
458        let expr = ComputedExpression::Script(r#"doc.first + " " + doc.last"#.to_string());
459
460        let mut doc = Document::new();
461        doc.data
462            .insert("first".to_string(), Value::String("John".to_string()));
463        doc.data
464            .insert("last".to_string(), Value::String("Doe".to_string()));
465
466        let result = expr.evaluate(&doc);
467        assert_eq!(result, Some(Value::String("John Doe".to_string())));
468    }
469
470    #[test]
471    fn test_rhai_conditional() {
472        let expr = ComputedExpression::Script(
473            r#"if doc.age >= 18 { "adult" } else { "minor" }"#.to_string(),
474        );
475
476        let mut doc = Document::new();
477        doc.data.insert("age".to_string(), Value::Int(25));
478
479        let result = expr.evaluate(&doc);
480        assert_eq!(result, Some(Value::String("adult".to_string())));
481
482        doc.data.insert("age".to_string(), Value::Int(15));
483        let result = expr.evaluate(&doc);
484        assert_eq!(result, Some(Value::String("minor".to_string())));
485    }
486
487    #[test]
488    fn test_rhai_null_handling() {
489        let expr = ComputedExpression::Script("doc.missing_field".to_string());
490
491        let doc = Document::new();
492
493        // Script accessing missing field returns Null (Rhai returns unit for missing keys)
494        let result = expr.evaluate(&doc);
495        assert_eq!(result, Some(Value::Null));
496    }
497
498    #[test]
499    fn test_computed_engine_builtin_functions() {
500        let engine = ComputedEngine::new();
501
502        let mut doc = Document::new();
503        doc.data
504            .insert("name".to_string(), Value::String("hello world".to_string()));
505        doc.data.insert("value".to_string(), Value::Float(3.7));
506
507        // Test uppercase
508        let result = engine.evaluate(r#"uppercase(doc.name)"#, &doc);
509        assert_eq!(result, Some(Value::String("HELLO WORLD".to_string())));
510
511        // Test round
512        let result = engine.evaluate("round(doc.value)", &doc);
513        assert_eq!(result, Some(Value::Float(4.0)));
514    }
515
516    #[test]
517    fn test_datetime_roundtrip_through_script() {
518        use chrono::TimeZone;
519        let ts = chrono::Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
520
521        let expr = ComputedExpression::Script("doc.created_at".to_string());
522
523        let mut doc = Document::new();
524        doc.data
525            .insert("created_at".to_string(), Value::DateTime(ts));
526
527        let result = expr.evaluate(&doc);
528        assert_eq!(
529            result,
530            Some(Value::DateTime(ts)),
531            "DateTime should survive a round-trip through value_to_dynamic / dynamic_to_value"
532        );
533    }
534}