rulemorph 0.3.1

YAML-based declarative data transformation engine for CSV/JSON to JSON
Documentation
use serde_json::Value as JsonValue;

use super::DtoError;
use crate::model::{Expr, RuleFile};
use crate::path::{PathToken, parse_path};

#[derive(Clone)]
pub(super) struct SchemaNode {
    pub(super) fields: Vec<Field>,
}

#[derive(Clone)]
pub(super) struct Field {
    pub(super) key: String,
    pub(super) field_type: FieldType,
    pub(super) optional: bool,
}

#[derive(Clone)]
pub(super) enum FieldType {
    Primitive(PrimitiveType),
    Object(Box<SchemaNode>),
    JsonValue,
}

#[derive(Clone, Copy)]
pub(super) enum PrimitiveType {
    String,
    Int,
    Float,
    Bool,
}

pub(super) fn build_schema(rule: &RuleFile) -> Result<SchemaNode, DtoError> {
    let mut root = SchemaNode { fields: Vec::new() };

    let step_mappings = rule
        .steps
        .iter()
        .flat_map(|steps| steps.iter())
        .flat_map(|step| step.mappings.iter())
        .flat_map(|mappings| mappings.iter());

    for mapping in rule.mappings.iter().chain(step_mappings) {
        let tokens =
            parse_path(&mapping.target).map_err(|_| DtoError::new("target path is invalid"))?;
        if tokens
            .iter()
            .any(|token| matches!(token, PathToken::Index(_)))
        {
            return Err(DtoError::new("target path must not include indexes"));
        }

        let mut keys = Vec::new();
        for token in tokens {
            match token {
                PathToken::Key(key) => keys.push(key),
                PathToken::Index(_) => {}
            }
        }

        if keys.is_empty() {
            return Err(DtoError::new("target path is invalid"));
        }

        let field_type = match mapping.value_type.as_deref() {
            Some("string") => FieldType::Primitive(PrimitiveType::String),
            Some("int") => FieldType::Primitive(PrimitiveType::Int),
            Some("float") => FieldType::Primitive(PrimitiveType::Float),
            Some("bool") => FieldType::Primitive(PrimitiveType::Bool),
            Some(_) => return Err(DtoError::new("unsupported type in mapping")),
            None => FieldType::JsonValue,
        };
        let conditional = match &mapping.when {
            None => false,
            Some(Expr::Literal(JsonValue::Bool(true))) => false,
            _ => true,
        };
        let optional = conditional
            || !(mapping.required || mapping.value.is_some() || mapping.default.is_some());

        insert_field(&mut root, &keys, field_type, optional)?;
    }

    Ok(root)
}

fn insert_field(
    node: &mut SchemaNode,
    keys: &[String],
    field_type: FieldType,
    optional: bool,
) -> Result<(), DtoError> {
    if keys.is_empty() {
        return Err(DtoError::new("target path is invalid"));
    }

    let key = &keys[0];
    if keys.len() == 1 {
        if node.fields.iter().any(|field| field.key == *key) {
            return Err(DtoError::new("duplicate target in dto"));
        }
        node.fields.push(Field {
            key: key.clone(),
            field_type,
            optional,
        });
        return Ok(());
    }

    if let Some(field) = node.fields.iter_mut().find(|field| field.key == *key) {
        match &mut field.field_type {
            FieldType::Object(child) => {
                return insert_field(child, &keys[1..], field_type, optional);
            }
            _ => return Err(DtoError::new("target conflicts with non-object")),
        }
    }

    let mut child = SchemaNode { fields: Vec::new() };
    insert_field(&mut child, &keys[1..], field_type, optional)?;
    node.fields.push(Field {
        key: key.clone(),
        field_type: FieldType::Object(Box::new(child)),
        optional: false,
    });
    Ok(())
}

pub(super) fn node_has_required(node: &SchemaNode) -> bool {
    for field in &node.fields {
        match &field.field_type {
            FieldType::Object(child) => {
                if node_has_required(child) {
                    return true;
                }
            }
            _ => {
                if !field.optional {
                    return true;
                }
            }
        }
    }
    false
}

pub(super) fn node_uses_json(node: &SchemaNode) -> bool {
    for field in &node.fields {
        match &field.field_type {
            FieldType::JsonValue => return true,
            FieldType::Object(child) => {
                if node_uses_json(child) {
                    return true;
                }
            }
            _ => {}
        }
    }
    false
}