Skip to main content

pylon_plugin/builtin/
computed.rs

1use std::collections::HashMap;
2
3use crate::Plugin;
4use serde_json::Value;
5
6/// A computed field definition.
7pub type ComputeFn = Box<dyn Fn(&Value) -> Value + Send + Sync>;
8
9/// Computed fields plugin. Auto-derives fields on read based on other fields.
10/// Example: fullName = firstName + " " + lastName
11pub struct ComputedFieldsPlugin {
12    /// Map of entity -> field_name -> compute function.
13    fields: HashMap<String, Vec<(String, ComputeFn)>>,
14}
15
16impl ComputedFieldsPlugin {
17    pub fn new() -> Self {
18        Self {
19            fields: HashMap::new(),
20        }
21    }
22
23    /// Add a computed field.
24    pub fn add<F>(&mut self, entity: &str, field_name: &str, compute: F)
25    where
26        F: Fn(&Value) -> Value + Send + Sync + 'static,
27    {
28        self.fields
29            .entry(entity.to_string())
30            .or_default()
31            .push((field_name.to_string(), Box::new(compute)));
32    }
33
34    /// Apply computed fields to a row.
35    pub fn apply(&self, entity: &str, row: &mut Value) {
36        if let Some(fields) = self.fields.get(entity) {
37            if let Some(obj) = row.as_object_mut() {
38                for (name, compute) in fields {
39                    let value = compute(&Value::Object(obj.clone()));
40                    obj.insert(name.clone(), value);
41                }
42            }
43        }
44    }
45
46    /// Apply computed fields to a list of rows.
47    pub fn apply_all(&self, entity: &str, rows: &mut [Value]) {
48        for row in rows {
49            self.apply(entity, row);
50        }
51    }
52}
53
54impl Plugin for ComputedFieldsPlugin {
55    fn name(&self) -> &str {
56        "computed-fields"
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn basic_computed_field() {
66        let mut plugin = ComputedFieldsPlugin::new();
67        plugin.add("User", "fullName", |row| {
68            let first = row.get("firstName").and_then(|v| v.as_str()).unwrap_or("");
69            let last = row.get("lastName").and_then(|v| v.as_str()).unwrap_or("");
70            Value::String(format!("{first} {last}").trim().to_string())
71        });
72
73        let mut row = serde_json::json!({"firstName": "Alice", "lastName": "Smith"});
74        plugin.apply("User", &mut row);
75        assert_eq!(row["fullName"], "Alice Smith");
76    }
77
78    #[test]
79    fn computed_field_from_numeric() {
80        let mut plugin = ComputedFieldsPlugin::new();
81        plugin.add("Product", "priceFormatted", |row| {
82            let price = row.get("price").and_then(|v| v.as_f64()).unwrap_or(0.0);
83            Value::String(format!("${:.2}", price))
84        });
85
86        let mut row = serde_json::json!({"price": 29.99});
87        plugin.apply("Product", &mut row);
88        assert_eq!(row["priceFormatted"], "$29.99");
89    }
90
91    #[test]
92    fn no_config_no_change() {
93        let plugin = ComputedFieldsPlugin::new();
94        let mut row = serde_json::json!({"name": "Alice"});
95        plugin.apply("User", &mut row);
96        assert!(row.get("fullName").is_none());
97    }
98
99    #[test]
100    fn apply_all_rows() {
101        let mut plugin = ComputedFieldsPlugin::new();
102        plugin.add("User", "upper", |row| {
103            let name = row.get("name").and_then(|v| v.as_str()).unwrap_or("");
104            Value::String(name.to_uppercase())
105        });
106
107        let mut rows = vec![
108            serde_json::json!({"name": "alice"}),
109            serde_json::json!({"name": "bob"}),
110        ];
111        plugin.apply_all("User", &mut rows);
112        assert_eq!(rows[0]["upper"], "ALICE");
113        assert_eq!(rows[1]["upper"], "BOB");
114    }
115
116    #[test]
117    fn multiple_computed_fields() {
118        let mut plugin = ComputedFieldsPlugin::new();
119        plugin.add("User", "initials", |row| {
120            let first = row.get("firstName").and_then(|v| v.as_str()).unwrap_or("");
121            let last = row.get("lastName").and_then(|v| v.as_str()).unwrap_or("");
122            let i = format!(
123                "{}{}",
124                first.chars().next().unwrap_or(' '),
125                last.chars().next().unwrap_or(' ')
126            );
127            Value::String(i.trim().to_string())
128        });
129        plugin.add("User", "emailDomain", |row| {
130            let email = row.get("email").and_then(|v| v.as_str()).unwrap_or("");
131            let domain = email.split('@').nth(1).unwrap_or("");
132            Value::String(domain.to_string())
133        });
134
135        let mut row = serde_json::json!({"firstName": "Alice", "lastName": "Smith", "email": "alice@example.com"});
136        plugin.apply("User", &mut row);
137        assert_eq!(row["initials"], "AS");
138        assert_eq!(row["emailDomain"], "example.com");
139    }
140}