vantage-mongodb 1.1.1

MongoDB persistence backend for Vantage framework
Documentation
//! Rhai entry points for MongoDB vistas: the `rhai:` pipeline script, the
//! `modify:` script, and the host shared by `modify:` and scripted
//! references.

use std::sync::{Arc, LazyLock};

use bson::{Bson, Document};
use vantage_core::{Context, Result, error};
use vantage_rhai::rhai::{Array, Dynamic, Map};
use vantage_rhai::{Block, Env, Host, Limits};
use vantage_vista::{DataVocab, TableShell, TargetResolver, Vista, VistaFactory};

use crate::rhai_engine::{MongoVocab, dynamic_to_bson};
use crate::vista::factory::MongoVistaFactory;
use crate::vista::spec::pipeline::{check_stages, not_a_stage};

/// Constructors only: a pipeline script builds data, it reaches no tables.
fn pipeline_host() -> &'static Host {
    static HOST: LazyLock<Host> = LazyLock::new(|| {
        Host::builder(Limits::background())
            .vocab(MongoVocab)
            .build()
    });
    &HOST
}

/// Run a `rhai:` script and return the pipeline it evaluates to. `args` are
/// in scope as the `args` map. The stages are validated like YAML
/// `pipeline:` stages.
pub(crate) fn eval_pipeline(code: &str, args: &[(String, String)]) -> Result<Vec<Document>> {
    let mut map = Map::new();
    for (k, v) in args {
        map.insert(k.as_str().into(), v.clone().into());
    }
    let env = Env::new().var("args", Dynamic::from_map(map));
    let value = pipeline_host()
        .compile(&Block::from(code))
        .and_then(|script| script.eval(&env))
        .context(error!("rhai pipeline script failed"))?;
    let type_name = value.type_name();
    let items = value.try_cast::<Array>().ok_or_else(|| {
        error!(
            "rhai pipeline script must evaluate to an array of stages",
            got = type_name
        )
    })?;
    let stages = items
        .into_iter()
        .enumerate()
        .map(|(index, stage)| {
            match dynamic_to_bson(stage)
                .with_context(|| error!("pipeline stage is not BSON", stage = index))?
            {
                Bson::Document(doc) => Ok(doc),
                _ => Err(not_a_stage(index)),
            }
        })
        .collect::<Result<Vec<_>>>()?;
    check_stages(&stages)?;
    Ok(stages)
}

/// Run a `modify:` script against a built vista, exposed as `self`.
pub(crate) fn apply_modify(factory: &MongoVistaFactory, vista: Vista, code: &str) -> Result<Vista> {
    let host = script_host(&*vista.source, factory.clone());
    vantage_vista::eval_modify_script(&host, code, vista)
}

/// The shell's MongoDB vocabulary first, then vantage-vista's describe-only
/// data verbs, with `table(name)` building the named vista through
/// `factory`.
pub(crate) fn script_host(shell: &dyn TableShell, factory: MongoVistaFactory) -> Host {
    Host::builder(Limits::background())
        .vocab_fn(|engine| shell.register_rhai_extensions(engine))
        .vocab(DataVocab::describe(Some(target_resolver(factory))))
        .build()
}

/// `table(name)` → the vista the factory builds from
/// [`MongoVistaFactory::script_spec`].
fn target_resolver(factory: MongoVistaFactory) -> TargetResolver {
    Arc::new(move |name| factory.build_from_spec(factory.script_spec(name)?))
}

#[cfg(test)]
mod tests {
    use super::*;
    use bson::doc;

    #[test]
    fn pipeline_script_sees_args() {
        let code = r#"
            let stages = [];
            if args.cat != "" { stages.push(#{"$match": #{category: args.cat}}); }
            stages.push(#{"$sort": doc([["n", -1], ["_id", 1]])});
            stages
        "#;
        let stages = eval_pipeline(code, &[("cat".into(), "cake".into())]).unwrap();
        assert_eq!(
            stages,
            vec![
                doc! { "$match": { "category": "cake" } },
                doc! { "$sort": { "n": -1_i64, "_id": 1_i64 } },
            ]
        );
        let stages = eval_pipeline(code, &[("cat".into(), String::new())]).unwrap();
        assert_eq!(stages.len(), 1);
    }

    #[test]
    fn pipeline_script_must_return_stages() {
        let err = eval_pipeline(r#"#{"$match": #{}}"#, &[]).unwrap_err();
        assert!(err.to_string().contains("array of stages"), "{err}");
        let err = eval_pipeline("[]", &[]).unwrap_err();
        assert!(err.to_string().contains("at least one stage"), "{err}");
        let err = eval_pipeline(r#"[#{"$match": #{}}, 1]"#, &[]).unwrap_err();
        assert!(err.to_string().contains("stage: 1"), "{err}");
    }

    #[test]
    fn pipeline_script_is_bounded() {
        let err = eval_pipeline("loop {}", &[]).unwrap_err();
        assert!(format!("{err:?}").contains("limit"), "{err:?}");
    }
}