vantage-mongodb 0.5.2

MongoDB persistence backend for Vantage framework
Documentation
//! YAML-facing types for the MongoDB Vista driver.

use serde::{Deserialize, Serialize};
use vantage_core::{Result, error};
use vantage_vista::{NoExtras, VistaSpec};

/// Table-level `mongo:` block in YAML. All fields optional — when the block
/// is omitted entirely, the spec name doubles as the collection name.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MongoTableExtras {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mongo: Option<MongoBlock>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MongoBlock {
    /// Override for the MongoDB collection name. Defaults to the spec name.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub collection: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MongoColumnExtras {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mongo: Option<MongoColumnBlock>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MongoColumnBlock {
    /// BSON field name when it differs from the spec column name.
    /// Single-level rename only — for nested documents use `nested_path`.
    /// Mutually exclusive with `nested_path`; `nested_path` wins if both set.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub field: Option<String>,
    /// Dotted BSON path into a nested document (e.g. `address.city`). The
    /// vista surfaces the value at this path under the spec column name. On
    /// write, intermediate sub-documents are reconstructed and merged across
    /// sibling columns. On filter, the dotted form is used directly so Mongo
    /// can index the lookup server-side.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub nested_path: Option<String>,
}

impl MongoColumnBlock {
    /// Resolve the BSON path for this column. `nested_path` (split on `.`)
    /// wins; otherwise `field`; otherwise `Ok(None)` so the caller falls back
    /// to the spec column name.
    ///
    /// Validates that neither `nested_path` nor `field` is empty, and that
    /// `nested_path` contains no empty segments (e.g. `""`, `".a"`, `"a..b"`).
    /// `column_name` is woven into the error so the caller can locate the bad
    /// entry in YAML without re-walking the spec.
    pub fn resolved_path(&self, column_name: &str) -> Result<Option<Vec<String>>> {
        if let Some(p) = &self.nested_path {
            let segments: Vec<String> = p.split('.').map(str::to_string).collect();
            if segments.iter().any(String::is_empty) {
                return Err(error!(
                    "nested_path must not be empty or contain empty segments",
                    column = column_name.to_string(),
                    nested_path = p.clone()
                ));
            }
            return Ok(Some(segments));
        }
        if let Some(f) = &self.field {
            if f.is_empty() {
                return Err(error!(
                    "field must not be empty",
                    column = column_name.to_string()
                ));
            }
            return Ok(Some(vec![f.clone()]));
        }
        Ok(None)
    }
}

pub type MongoVistaSpec = VistaSpec<MongoTableExtras, MongoColumnExtras, NoExtras>;

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

    #[test]
    fn yaml_parses_into_mongo_vista_spec() {
        let yaml = r#"
name: client
columns:
  _id:
    type: object_id
    flags: [id]
  name:
    type: string
    flags: [title, searchable]
  is_paying_client:
    type: bool
mongo:
  collection: clients
"#;
        let spec: MongoVistaSpec = serde_yaml_ng::from_str(yaml).unwrap();
        assert_eq!(spec.name, "client");
        assert_eq!(spec.columns.len(), 3);
        assert_eq!(
            spec.driver
                .mongo
                .as_ref()
                .and_then(|m| m.collection.as_deref()),
            Some("clients")
        );
        assert_eq!(spec.columns["_id"].col_type.as_deref(), Some("object_id"));
        assert!(spec.columns["name"].flags.contains(&"title".to_string()));
    }

    #[test]
    fn yaml_rejects_unknown_mongo_block_field() {
        let yaml = r#"
name: client
columns:
  _id: { type: object_id, flags: [id] }
mongo:
  collection: clients
  bogus: 1
"#;
        let err = serde_yaml_ng::from_str::<MongoVistaSpec>(yaml).unwrap_err();
        assert!(err.to_string().contains("bogus") || err.to_string().contains("unknown"));
    }

    #[test]
    fn yaml_collection_defaults_to_spec_name_when_block_omitted() {
        let yaml = r#"
name: clients
columns:
  _id: { type: object_id, flags: [id] }
"#;
        let spec: MongoVistaSpec = serde_yaml_ng::from_str(yaml).unwrap();
        assert!(spec.driver.mongo.is_none());
    }

    #[test]
    fn resolved_path_rejects_empty_nested_path() {
        let block = MongoColumnBlock {
            field: None,
            nested_path: Some(String::new()),
        };
        let err = block
            .resolved_path("city")
            .expect_err("empty nested_path must error");
        let msg = err.to_string();
        assert!(msg.contains("nested_path"), "error: {msg}");
        assert!(msg.contains("city"), "error: {msg}");
    }

    #[test]
    fn resolved_path_rejects_nested_path_with_empty_segments() {
        for bad in ["a..b", ".a", "a.", "."] {
            let block = MongoColumnBlock {
                field: None,
                nested_path: Some(bad.to_string()),
            };
            assert!(
                block.resolved_path("city").is_err(),
                "path `{bad}` should have errored"
            );
        }
    }

    #[test]
    fn resolved_path_rejects_empty_field() {
        let block = MongoColumnBlock {
            field: Some(String::new()),
            nested_path: None,
        };
        let err = block
            .resolved_path("name")
            .expect_err("empty field must error");
        let msg = err.to_string();
        assert!(msg.contains("field"), "error: {msg}");
        assert!(msg.contains("name"), "error: {msg}");
    }

    #[test]
    fn resolved_path_accepts_valid_nested_and_field() {
        let block = MongoColumnBlock {
            field: None,
            nested_path: Some("address.city".into()),
        };
        assert_eq!(
            block.resolved_path("city").unwrap(),
            Some(vec!["address".into(), "city".into()])
        );

        let block = MongoColumnBlock {
            field: Some("fullName".into()),
            nested_path: None,
        };
        assert_eq!(
            block.resolved_path("full_name").unwrap(),
            Some(vec!["fullName".into()])
        );

        let block = MongoColumnBlock::default();
        assert_eq!(block.resolved_path("anything").unwrap(), None);
    }
}