hamelin_lib 0.12.0

Core library for Hamelin query language
Documentation
//! Template parameter substitution and table-path expansion.
//!
//! This module owns everything for `${name}` template parameters:
//! - [`substitute_query`] replaces every parameter hole with a concrete value to produce a
//!   non-templated query (used before translation/execution).
//! - [`expand_templated_table_path`] enumerates all concrete identifiers a templated table path can
//!   refer to under its declared `IdentifierFragment` domains (used by dataset listing and
//!   templated-schema type-checking).

use std::collections::{HashMap, HashSet};
use std::sync::Arc;

use serde::Deserialize;

use crate::err::{TemplateParameterSpecError, TemplateSubstitutionError};
use crate::tree::ast::clause::{TableSegmentPart, TemplatedTablePath};
use crate::tree::ast::identifier::{CompoundIdentifier, Identifier, SimpleIdentifier};
use crate::types::{Type, STRING};

mod expand;
mod substitute;

pub use expand::expand_templated_table_path;
pub use substitute::substitute_query;

// ---------------------------------------------------------------------------
// Template parameter model types
// ---------------------------------------------------------------------------

/// Declared kind for a `${name}` dashboard template parameter.
#[derive(Clone, Debug, PartialEq)]
pub enum TemplateParameterKind {
    /// Hamelin type used when the parameter appears in expressions.
    Primitive(Type),
    /// Finite set of allowed identifier fragments for parameters in table paths; typed as `string`
    /// in expressions.
    IdentifierFragment(Vec<String>),
}

/// Allowed primitive types for dashboard template parameters.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TemplateParameterType {
    Boolean,
    Int,
    Double,
    String,
}

impl From<TemplateParameterType> for Type {
    fn from(value: TemplateParameterType) -> Self {
        match value {
            TemplateParameterType::Boolean => Type::Boolean,
            TemplateParameterType::Int => Type::Int,
            TemplateParameterType::Double => Type::Double,
            TemplateParameterType::String => Type::String,
        }
    }
}

/// Concrete value supplied when substituting template parameters before translation.
#[derive(Clone, Debug, PartialEq)]
pub enum TemplateParameterValue {
    Boolean(bool),
    Int(i64),
    Double(f64),
    String(String),
}

/// JSON shape for loading [`TemplateParameterKind`] (CLI `--template-parameters`, etc.).
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TemplateParameterSpecSerde {
    Primitive {
        #[serde(rename = "type")]
        typ: TemplateParameterType,
    },
    IdentifierFragment {
        values: Vec<String>,
    },
}

impl TryFrom<TemplateParameterSpecSerde> for TemplateParameterKind {
    type Error = TemplateParameterSpecError;

    fn try_from(value: TemplateParameterSpecSerde) -> Result<Self, Self::Error> {
        match value {
            TemplateParameterSpecSerde::Primitive { typ } => {
                Ok(TemplateParameterKind::Primitive(typ.into()))
            }
            TemplateParameterSpecSerde::IdentifierFragment { values } => {
                if values.is_empty() {
                    return Err(TemplateParameterSpecError::EmptyIdentifierFragmentValues);
                }
                let mut seen = HashSet::new();
                for value in &values {
                    if !seen.insert(value) {
                        return Err(TemplateParameterSpecError::DuplicateIdentifierFragmentValue);
                    }
                    if let Err(e) = SimpleIdentifier::parse(value.as_str()) {
                        return Err(TemplateParameterSpecError::InvalidIdentifierFragmentValue {
                            value: value.clone(),
                            detail: e.to_string(),
                        });
                    }
                }
                Ok(TemplateParameterKind::IdentifierFragment(values))
            }
        }
    }
}

/// Build expression-type and identifier-fragment maps for type-checking.
pub fn template_context_from_specs(
    spec: Option<&Arc<HashMap<String, TemplateParameterKind>>>,
) -> (
    Arc<HashMap<SimpleIdentifier, Type>>,
    Arc<HashMap<SimpleIdentifier, Arc<[String]>>>,
) {
    let Some(spec) = spec else {
        return (Arc::new(HashMap::new()), Arc::new(HashMap::new()));
    };
    let mut types: HashMap<SimpleIdentifier, Type> = HashMap::new();
    let mut enums: HashMap<SimpleIdentifier, Arc<[String]>> = HashMap::new();
    for (name, kind) in spec.iter() {
        let key = SimpleIdentifier::new(name.as_str());
        match kind {
            TemplateParameterKind::Primitive(t) => {
                types.insert(key.clone(), t.clone());
            }
            TemplateParameterKind::IdentifierFragment(vals) => {
                types.insert(key.clone(), STRING);
                enums.insert(key, Arc::from(vals.clone()));
            }
        }
    }
    (Arc::new(types), Arc::new(enums))
}

// ---------------------------------------------------------------------------
// Table-path expansion and substitution helpers
// ---------------------------------------------------------------------------

/// Hard cap on the number of concrete tables a templated path may expand to.
///
/// Acts as a guardrail against accidental exponential expansion when a dashboard wires up a
/// templated path against several large `IdentifierFragment` parameters.
const MAX_TABLE_PATH_EXPANSIONS: usize = 100;

/// Cartesian product of string template domains, in scan order.
fn cartesian_template_values(domains: &[Arc<[String]>]) -> Vec<Vec<String>> {
    let mut out: Vec<Vec<String>> = vec![vec![]];
    for d in domains {
        let mut next = Vec::with_capacity(out.len() * d.len());
        for prefix in &out {
            for v in d.iter() {
                let mut row = prefix.clone();
                row.push(v.clone());
                next.push(row);
            }
        }
        out = next;
    }
    out
}

/// Ordered unique parameter names appearing in a templated table path (scan order).
///
/// Returns an error if any parameter name fails to parse (e.g. `${ }` in a source with parse
/// errors), surfacing the real parse error instead of a misleading `MissingTemplateParameter`.
fn ordered_params_in_templated_path(
    path: &TemplatedTablePath,
) -> Result<Vec<SimpleIdentifier>, TemplateSubstitutionError> {
    let mut param_order = Vec::new();
    for seg in &path.segments {
        for part in &seg.parts {
            if let TableSegmentPart::Parameter(psi) = part {
                let name = psi
                    .clone()
                    .valid()
                    .map_err(TemplateSubstitutionError::ParameterIdentifierParseError)?;
                if !param_order.iter().any(|p| p == &name) {
                    param_order.push(name);
                }
            }
        }
    }
    Ok(param_order)
}

/// Instantiate a templated path to a concrete [`Identifier`] using per-parameter string fragments.
fn materialize_templated_table_path(
    path: &TemplatedTablePath,
    subst: &HashMap<SimpleIdentifier, String>,
) -> Result<Identifier, TemplateSubstitutionError> {
    let mut parts: Vec<SimpleIdentifier> = Vec::with_capacity(path.segments.len());
    for seg in &path.segments {
        let mut acc = String::new();
        for p in &seg.parts {
            match p {
                TableSegmentPart::Text(psi) => {
                    let si = psi
                        .clone()
                        .valid()
                        .map_err(TemplateSubstitutionError::ParameterIdentifierParseError)?;
                    acc.push_str(si.as_str());
                }
                TableSegmentPart::Parameter(psi) => {
                    let name = psi
                        .clone()
                        .valid()
                        .map_err(TemplateSubstitutionError::ParameterIdentifierParseError)?;
                    let v = subst.get(&name).ok_or_else(|| {
                        TemplateSubstitutionError::MissingTemplateParameter(
                            name.as_str().to_string(),
                        )
                    })?;
                    acc.push_str(v);
                }
            }
        }
        if acc.is_empty() {
            return Err(TemplateSubstitutionError::EmptyTablePathSegment);
        }
        parts.push(SimpleIdentifier::new(&acc));
    }
    match parts.as_slice() {
        // Grammar: `tablePath: tablePathSegment (DOT tablePathSegment)*` guarantees at least one
        // segment, so this arm is unreachable under normal parsing. Kept as a defensive error
        // rather than a panic to stay safe if the AST is constructed outside the parser.
        [] => Err(TemplateSubstitutionError::EmptyTablePath),
        [one] => Ok(one.clone().into()),
        [f, s, rest @ ..] => {
            Ok(CompoundIdentifier::new(f.clone(), s.clone(), rest.to_vec()).into())
        }
    }
}

/// Validate supplied `values` against declared `specs`: every declared parameter must have a
/// value, every value key must be declared, each value kind must match the spec, and
/// `IdentifierFragment` values must be within the declared domain.
pub fn validate_template_values(
    specs: &HashMap<String, TemplateParameterKind>,
    values: &HashMap<String, TemplateParameterValue>,
) -> Result<(), TemplateSubstitutionError> {
    for name in specs.keys() {
        if !values.contains_key(name) {
            return Err(TemplateSubstitutionError::MissingTemplateParameter(
                name.clone(),
            ));
        }
    }

    for (name, value) in values {
        let spec = specs
            .get(name)
            .ok_or_else(|| TemplateSubstitutionError::UndeclaredTemplateParameter(name.clone()))?;

        match (spec, value) {
            (TemplateParameterKind::Primitive(ty), val) => {
                let matches = matches!(
                    (ty, val),
                    (Type::Boolean, TemplateParameterValue::Boolean(_))
                        | (Type::Int, TemplateParameterValue::Int(_))
                        | (Type::Double, TemplateParameterValue::Double(_))
                        | (Type::String, TemplateParameterValue::String(_))
                );
                if !matches {
                    let got = match val {
                        TemplateParameterValue::Boolean(_) => "boolean",
                        TemplateParameterValue::Int(_) => "int",
                        TemplateParameterValue::Double(_) => "double",
                        TemplateParameterValue::String(_) => "string",
                    };
                    return Err(TemplateSubstitutionError::WrongValueKind {
                        name: name.clone(),
                        expected: ty.to_string(),
                        got: got.to_string(),
                    });
                }
            }
            (
                TemplateParameterKind::IdentifierFragment(allowed),
                TemplateParameterValue::String(s),
            ) => {
                if !allowed.iter().any(|v| v == s) {
                    return Err(TemplateSubstitutionError::WrongValueKind {
                        name: name.clone(),
                        expected: format!(
                            "one of [{}]",
                            allowed
                                .iter()
                                .map(|v| format!("'{v}'"))
                                .collect::<Vec<_>>()
                                .join(", ")
                        ),
                        got: format!("'{s}'"),
                    });
                }
            }
            (TemplateParameterKind::IdentifierFragment(_), val) => {
                let got = match val {
                    TemplateParameterValue::Boolean(_) => "boolean",
                    TemplateParameterValue::Int(_) => "int",
                    TemplateParameterValue::Double(_) => "double",
                    TemplateParameterValue::String(_) => "string",
                };
                return Err(TemplateSubstitutionError::WrongValueKind {
                    name: name.clone(),
                    expected: "string (identifier_fragment)".to_string(),
                    got: got.to_string(),
                });
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod validate_tests {
    use std::collections::HashMap;

    use crate::err::TemplateSubstitutionError;
    use crate::tree::template::{
        validate_template_values, TemplateParameterKind, TemplateParameterValue,
    };
    use crate::types::STRING;

    #[test]
    fn rejects_missing_value_for_declared_spec() {
        let specs = HashMap::from([(
            "region".to_string(),
            TemplateParameterKind::Primitive(STRING),
        )]);
        let values = HashMap::new();

        let err = validate_template_values(&specs, &values).unwrap_err();
        assert_eq!(
            err,
            TemplateSubstitutionError::MissingTemplateParameter("region".to_string())
        );
    }

    #[test]
    fn accepts_declared_spec_with_matching_value() {
        let specs = HashMap::from([(
            "region".to_string(),
            TemplateParameterKind::Primitive(STRING),
        )]);
        let values = HashMap::from([(
            "region".to_string(),
            TemplateParameterValue::String("us".to_string()),
        )]);

        validate_template_values(&specs, &values).unwrap();
    }
}