use super::dbt::passthrough_arg_to_string;
use super::error::TemplateError;
use minijinja::{Environment, Value};
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
use regex::Regex;
use std::sync::LazyLock;
#[cfg(not(target_arch = "wasm32"))]
use std::time::{Duration, Instant};
const RECURSION_LIMIT: usize = 100;
const MAX_PREPROCESS_SIZE: usize = 10_000_000;
#[cfg(not(target_arch = "wasm32"))]
const RENDER_TIMEOUT: Duration = Duration::from_secs(5);
static TEST_BLOCK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)\{%-?\s*test\b[^%]*-?%\}.*?\{%-?\s*endtest\s*-?%\}").unwrap()
});
static SNAPSHOT_BLOCK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)\{%-?\s*snapshot\b[^%]*-?%\}(.*?)\{%-?\s*endsnapshot\s*-?%\}").unwrap()
});
fn preprocess_dbt_tags(template: &str) -> Cow<'_, str> {
if template.len() > MAX_PREPROCESS_SIZE {
return Cow::Borrowed(template);
}
if !template.contains("{%") {
return Cow::Borrowed(template);
}
let after_test = TEST_BLOCK_RE.replace_all(template, "");
let after_snapshot = SNAPSHOT_BLOCK_RE.replace_all(&after_test, "$1");
match after_snapshot {
Cow::Borrowed(_) if matches!(after_test, Cow::Borrowed(_)) => Cow::Borrowed(template),
_ => Cow::Owned(after_snapshot.into_owned()),
}
}
pub(crate) fn render_jinja(
template: &str,
context: &HashMap<String, serde_json::Value>,
) -> Result<String, TemplateError> {
let mut env = Environment::new();
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
env.set_recursion_limit(RECURSION_LIMIT);
env.add_template("sql", template)?;
let ctx = json_context_to_minijinja(context);
let tmpl = env.get_template("sql")?;
let rendered = tmpl.render(ctx)?;
Ok(rendered)
}
pub(crate) fn render_dbt(
template: &str,
context: &HashMap<String, serde_json::Value>,
) -> Result<String, TemplateError> {
let preprocessed = preprocess_dbt_tags(template);
let mut stubbed_functions: HashSet<String> = HashSet::new();
let mut env = Environment::new();
env.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
env.set_recursion_limit(RECURSION_LIMIT);
super::dbt::register_dbt_builtins(&mut env, context);
env.add_template("sql", &preprocessed)?;
let ctx = json_context_to_minijinja(context);
const MAX_RETRIES: usize = 50;
#[cfg(not(target_arch = "wasm32"))]
let start_time = Instant::now();
for _ in 0..MAX_RETRIES {
#[cfg(not(target_arch = "wasm32"))]
if start_time.elapsed() > RENDER_TIMEOUT {
return Err(TemplateError::RenderError(format!(
"Template rendering timed out after {:?}. Stubbed functions: {}",
RENDER_TIMEOUT,
format_stubbed_list(&stubbed_functions)
)));
}
let tmpl = env.get_template("sql")?;
match tmpl.render(ctx.clone()) {
Ok(rendered) => {
#[cfg(feature = "tracing")]
if !stubbed_functions.is_empty() {
let stubbed_list: Vec<_> = stubbed_functions.iter().cloned().collect();
tracing::debug!(
stubbed_functions = ?stubbed_list,
"Template rendered with stubbed unknown macros"
);
}
return Ok(rendered);
}
Err(e) => {
if let Some(func_name) = extract_unknown_function(&e) {
if stubbed_functions.contains(&func_name) {
return Err(TemplateError::RenderError(e.to_string()));
}
#[cfg(feature = "tracing")]
tracing::debug!(
function = %func_name,
stubbed_count = stubbed_functions.len() + 1,
"Stubbing unknown dbt macro"
);
register_passthrough_function(&mut env, &func_name);
stubbed_functions.insert(func_name);
continue;
}
return Err(TemplateError::RenderError(e.to_string()));
}
}
}
Err(TemplateError::RenderError(format!(
"Too many unknown functions in template (limit: {MAX_RETRIES}). Stubbed: {}",
format_stubbed_list(&stubbed_functions)
)))
}
fn register_passthrough_function(env: &mut Environment<'_>, name: &str) {
let name_owned = name.to_string();
env.add_function(name_owned.clone(), move |args: &[Value]| -> Value {
if let Some(first) = args.first() {
if let Some(rendered) = passthrough_arg_to_string(first) {
return Value::from(rendered);
}
}
Value::from(format!("__{name_owned}__"))
});
}
fn extract_unknown_function(err: &minijinja::Error) -> Option<String> {
use minijinja::ErrorKind;
if err.kind() != ErrorKind::UnknownFunction {
return None;
}
const PREFIX: &str = "unknown function: ";
const SUFFIX: &str = " is unknown";
let msg = err.to_string();
let start = msg.find(PREFIX)? + PREFIX.len();
let remaining = &msg[start..];
let end = remaining.find(SUFFIX)?;
let func_name = &remaining[..end];
if func_name.is_empty() || func_name.len() > 100 {
return None;
}
if !func_name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '.')
{
return None;
}
Some(func_name.to_string())
}
fn format_stubbed_list(stubbed: &HashSet<String>) -> String {
if stubbed.is_empty() {
"(none)".to_string()
} else {
let mut list: Vec<_> = stubbed.iter().cloned().collect();
list.sort();
list.join(", ")
}
}
fn json_context_to_minijinja(context: &HashMap<String, serde_json::Value>) -> Value {
Value::from_serialize(context)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_simple_variable() {
let mut ctx = HashMap::new();
ctx.insert("table_name".to_string(), serde_json::json!("users"));
let result = render_jinja("SELECT * FROM {{ table_name }}", &ctx).unwrap();
assert_eq!(result, "SELECT * FROM users");
}
#[test]
fn renders_conditional() {
let mut ctx = HashMap::new();
ctx.insert("include_deleted".to_string(), serde_json::json!(true));
let template =
r#"SELECT * FROM users{% if include_deleted %} WHERE deleted = false{% endif %}"#;
let result = render_jinja(template, &ctx).unwrap();
assert_eq!(result, "SELECT * FROM users WHERE deleted = false");
}
#[test]
fn renders_loop() {
let mut ctx = HashMap::new();
ctx.insert(
"columns".to_string(),
serde_json::json!(["id", "name", "email"]),
);
let template = r#"SELECT {% for col in columns %}{{ col }}{% if not loop.last %}, {% endif %}{% endfor %} FROM users"#;
let result = render_jinja(template, &ctx).unwrap();
assert_eq!(result, "SELECT id, name, email FROM users");
}
#[test]
fn errors_on_undefined_variable_in_strict_mode() {
let ctx = HashMap::new();
let result = render_jinja("SELECT * FROM {{ undefined_table }}", &ctx);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
TemplateError::UndefinedVariable(_)
));
}
#[test]
fn errors_on_syntax_error() {
let ctx = HashMap::new();
let result = render_jinja("SELECT * FROM {{ unclosed", &ctx);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), TemplateError::SyntaxError(_)));
}
#[test]
fn preprocess_removes_test_blocks() {
let template = r#"{% test my_test(model) %}
SELECT * FROM {{ model }} WHERE id IS NULL
{% endtest %}
SELECT * FROM users"#;
let result = preprocess_dbt_tags(template);
assert!(!result.contains("test my_test"));
assert!(!result.contains("endtest"));
assert!(result.contains("SELECT * FROM users"));
}
#[test]
fn preprocess_removes_test_blocks_with_whitespace_control() {
let template = r#"{%- test not_null(model, column_name) -%}
SELECT * FROM {{ model }} WHERE {{ column_name }} IS NULL
{%- endtest -%}
SELECT 1"#;
let result = preprocess_dbt_tags(template);
assert!(!result.contains("test not_null"));
assert!(result.contains("SELECT 1"));
}
#[test]
fn preprocess_keeps_snapshot_content() {
let template = r#"{% snapshot orders_snapshot %}
SELECT * FROM orders
{% endsnapshot %}"#;
let result = preprocess_dbt_tags(template);
assert!(!result.contains("snapshot orders_snapshot"));
assert!(!result.contains("endsnapshot"));
assert!(result.contains("SELECT * FROM orders"));
}
#[test]
fn preprocess_handles_multiple_blocks() {
let template = r#"{% test test1() %}test sql{% endtest %}
{% snapshot snap1 %}SELECT 1{% endsnapshot %}
{% test test2() %}more test sql{% endtest %}
SELECT * FROM final"#;
let result = preprocess_dbt_tags(template);
assert!(!result.contains("test1"));
assert!(!result.contains("test2"));
assert!(result.contains("SELECT 1")); assert!(result.contains("SELECT * FROM final"));
}
#[test]
fn dbt_render_with_test_block() {
let ctx = HashMap::new();
let template = r#"{% test my_test(model) %}
SELECT * FROM {{ ref('test_model') }}
{% endtest %}
SELECT * FROM {{ ref('users') }}"#;
let result = render_dbt(template, &ctx).unwrap();
assert!(!result.contains("test_model"));
assert!(result.contains("users"));
}
#[test]
fn dbt_render_with_snapshot_block() {
let ctx = HashMap::new();
let template = r#"{% snapshot my_snapshot %}
{{ config(unique_key='id') }}
SELECT * FROM {{ ref('source_table') }}
{% endsnapshot %}"#;
let result = render_dbt(template, &ctx).unwrap();
assert!(result.contains("SELECT * FROM source_table"));
assert!(!result.contains("snapshot"));
}
}