flowscope_core/templater/mod.rs
1//! SQL template preprocessing for Jinja2 and dbt-style templates.
2//!
3//! This module provides preprocessing support for templated SQL, allowing FlowScope
4//! to analyze SQL files that use Jinja2 syntax or dbt macros.
5//!
6//! # Architecture
7//!
8//! Templating is a preprocessing step that runs before SQL parsing:
9//!
10//! ```text
11//! Templated SQL → [templater] → Raw SQL → [parser] → AST → [analyzer] → Lineage
12//! ```
13//!
14//! # Modes
15//!
16//! - **Raw**: No templating, SQL is passed through unchanged (default)
17//! - **Jinja**: Standard Jinja2 template rendering with strict variable checking
18//! - **Dbt**: Jinja2 with dbt builtin macros (`ref`, `source`, `config`, `var`, etc.)
19//!
20//! # Example
21//!
22//! ```
23//! use flowscope_core::templater::{template_sql, TemplateConfig, TemplateMode};
24//! use std::collections::HashMap;
25//!
26//! // dbt-style template
27//! let template = r#"
28//! {{ config(materialized='table') }}
29//! SELECT * FROM {{ ref('users') }}
30//! WHERE created_at > '{{ var("start_date", "2024-01-01") }}'
31//! "#;
32//!
33//! let config = TemplateConfig {
34//! mode: TemplateMode::Dbt,
35//! context: HashMap::new(),
36//! };
37//!
38//! let rendered = template_sql(template, &config).unwrap();
39//! assert!(rendered.contains("FROM users"));
40//! ```
41
42mod dbt;
43mod error;
44mod jinja;
45
46pub use error::TemplateError;
47
48use schemars::JsonSchema;
49use serde::{Deserialize, Serialize};
50use std::collections::HashMap;
51
52/// Configuration for SQL template preprocessing.
53#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
54#[serde(rename_all = "camelCase")]
55pub struct TemplateConfig {
56 /// The templating mode to use.
57 #[serde(default)]
58 pub mode: TemplateMode,
59
60 /// Context variables available to the template.
61 ///
62 /// For dbt mode, variables under the "vars" key are accessible via `var()`.
63 #[serde(default)]
64 pub context: HashMap<String, serde_json::Value>,
65}
66
67/// Templating mode for SQL preprocessing.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
69#[serde(rename_all = "lowercase")]
70pub enum TemplateMode {
71 /// No templating - SQL is passed through unchanged.
72 #[default]
73 Raw,
74
75 /// Standard Jinja2 template rendering.
76 ///
77 /// Uses strict mode: undefined variables cause an error.
78 Jinja,
79
80 /// dbt-style templating with builtin macros.
81 ///
82 /// Includes stub implementations of:
83 /// - `ref('model')` / `ref('project', 'model')` - model references
84 /// - `source('schema', 'table')` - source table references
85 /// - `config(...)` - model configuration (returns empty string)
86 /// - `var('name')` / `var('name', 'default')` - variable access
87 /// - `is_incremental()` - always returns false for lineage analysis
88 /// - `this` - undefined (incremental model self-reference)
89 Dbt,
90}
91
92/// Renders a SQL template according to the specified configuration.
93///
94/// This is the main entry point for template preprocessing. It dispatches
95/// to the appropriate renderer based on the configured mode.
96///
97/// # Arguments
98///
99/// * `sql` - The SQL template string to render
100/// * `config` - Configuration specifying the mode and context variables
101///
102/// # Returns
103///
104/// The rendered SQL string, or an error if rendering fails.
105///
106/// # Errors
107///
108/// - `TemplateError::SyntaxError` - Invalid template syntax
109/// - `TemplateError::UndefinedVariable` - Undefined variable in Jinja mode
110/// - `TemplateError::RenderError` - Other rendering failures
111pub fn template_sql(sql: &str, config: &TemplateConfig) -> Result<String, TemplateError> {
112 match config.mode {
113 TemplateMode::Raw => Ok(sql.to_string()),
114 TemplateMode::Jinja => jinja::render_jinja(sql, &config.context),
115 TemplateMode::Dbt => jinja::render_dbt(sql, &config.context),
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn raw_mode_passes_through() {
125 let sql = "SELECT * FROM {{ not_a_template }}";
126 let config = TemplateConfig::default();
127
128 let result = template_sql(sql, &config).unwrap();
129 assert_eq!(result, sql);
130 }
131
132 #[test]
133 fn jinja_mode_renders_variables() {
134 let sql = "SELECT * FROM {{ table }}";
135 let mut context = HashMap::new();
136 context.insert("table".to_string(), serde_json::json!("users"));
137
138 let config = TemplateConfig {
139 mode: TemplateMode::Jinja,
140 context,
141 };
142
143 let result = template_sql(sql, &config).unwrap();
144 assert_eq!(result, "SELECT * FROM users");
145 }
146
147 #[test]
148 fn dbt_mode_renders_ref() {
149 let sql = "SELECT * FROM {{ ref('users') }}";
150 let config = TemplateConfig {
151 mode: TemplateMode::Dbt,
152 context: HashMap::new(),
153 };
154
155 let result = template_sql(sql, &config).unwrap();
156 assert_eq!(result, "SELECT * FROM users");
157 }
158
159 #[test]
160 fn config_serialization() {
161 let config = TemplateConfig {
162 mode: TemplateMode::Dbt,
163 context: HashMap::new(),
164 };
165
166 let json = serde_json::to_string(&config).unwrap();
167 assert!(json.contains("\"mode\":\"dbt\""));
168
169 let parsed: TemplateConfig = serde_json::from_str(&json).unwrap();
170 assert_eq!(parsed.mode, TemplateMode::Dbt);
171 }
172
173 #[test]
174 fn config_deserialization_with_defaults() {
175 let json = r#"{ "mode": "jinja" }"#;
176 let config: TemplateConfig = serde_json::from_str(json).unwrap();
177
178 assert_eq!(config.mode, TemplateMode::Jinja);
179 assert!(config.context.is_empty());
180 }
181}