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}