hayson-engine 0.9.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
use serde_json::{Map, Value};

use crate::catalog::{TriggerEffect, TriggerEntry, TriggerEvent, TriggerValueSource};
use crate::schema::HaysonRecord;

/// What a live write did to a key, as seen by the trigger dispatcher — `old`/`new` are the same
/// values `Table::patch` already reads to merge a partial update, just handed to the trigger
/// instead of discarded.
pub struct TriggerFiring<'a> {
    #[allow(dead_code)]
    pub event: TriggerEvent,
    pub old: Option<&'a HaysonRecord>,
    pub new: Option<&'a HaysonRecord>,
}

pub fn matching<'a>(triggers: &'a [TriggerEntry], table: &str, event: TriggerEvent) -> Vec<&'a TriggerEntry> {
    triggers
        .iter()
        .filter(|t| t.table == table && t.event == event)
        .collect()
}

/// Builds the document a trigger's effect writes, from the firing operation's `old`/`new` record.
/// A `NewField`/`OldField` naming a field absent from that side (including a `Delete`'s `new`,
/// always `None`) is skipped, same as the field never having been set.
pub fn effect_document(effect: &TriggerEffect, firing: &TriggerFiring) -> Value {
    let mut document = Map::new();
    for mapping in &effect.fields {
        let value = match &mapping.source {
            TriggerValueSource::NewField(field) => firing.new.and_then(|record| record.get(field).cloned()),
            TriggerValueSource::OldField(field) => firing.old.and_then(|record| record.get(field).cloned()),
            TriggerValueSource::Constant(value) => Some(value.clone()),
            TriggerValueSource::Now => Some(Value::from(chrono::Utc::now().timestamp_millis())),
        };
        if let Some(value) = value {
            document.insert(mapping.target.clone(), value);
        }
    }
    Value::Object(document)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::catalog::TriggerFieldMapping;

    fn record(pairs: &[(&str, Value)]) -> HaysonRecord {
        pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
    }

    #[test]
    fn copies_new_and_old_fields_and_fills_constants() {
        let effect = TriggerEffect {
            table: "audit".into(),
            fields: vec![
                TriggerFieldMapping {
                    target: "afterName".into(),
                    source: TriggerValueSource::NewField("name".into()),
                },
                TriggerFieldMapping {
                    target: "beforeName".into(),
                    source: TriggerValueSource::OldField("name".into()),
                },
                TriggerFieldMapping {
                    target: "kind".into(),
                    source: TriggerValueSource::Constant(Value::from("rename")),
                },
            ],
        };
        let old = record(&[("name", Value::from("Ania"))]);
        let new = record(&[("name", Value::from("Anna"))]);
        let firing = TriggerFiring {
            event: TriggerEvent::Update,
            old: Some(&old),
            new: Some(&new),
        };

        let document = effect_document(&effect, &firing);
        assert_eq!(document.get("afterName"), Some(&Value::from("Anna")));
        assert_eq!(document.get("beforeName"), Some(&Value::from("Ania")));
        assert_eq!(document.get("kind"), Some(&Value::from("rename")));
    }

    #[test]
    fn a_field_missing_on_the_named_side_is_skipped() {
        let effect = TriggerEffect {
            table: "audit".into(),
            fields: vec![TriggerFieldMapping {
                target: "afterName".into(),
                source: TriggerValueSource::NewField("name".into()),
            }],
        };
        let firing = TriggerFiring {
            event: TriggerEvent::Delete,
            old: Some(&record(&[("name", Value::from("Ania"))])),
            new: None,
        };

        let document = effect_document(&effect, &firing);
        assert!(document.get("afterName").is_none());
    }
}