Skip to main content

dre_core/
render.rs

1//! Runtime Jinja rendering: one environment per Binding, shared by SQL, output paths and
2//! template values. Provides `run.*`, `var()`, `env_var()`, `run_query()`, `columns()`,
3//! `ref()`, `target`, `profile()`, the calendar functions in [`crate::dates`] and every macro in
4//! `macros/`.
5
6use std::collections::BTreeMap;
7use std::fmt;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, Mutex};
10
11use chrono::{NaiveDate, Utc};
12
13use crate::dates::{Calendar, Date, DateTime};
14
15use crate::lookups::{self, Cell, Load, Lookup, Table};
16use crate::packages::{DispatchOrder, Package};
17use minijinja::value::{Enumerator, Kwargs, Object, ObjectRepr, Value};
18use minijinja::{Environment, Error, ErrorKind, UndefinedBehavior};
19use serde_json::{Map as JsonMap, Value as Json};
20
21const MACROS: &str = "__dre_macros__";
22
23/// Ambient `run.*` context.
24#[derive(Debug, Clone)]
25pub struct RunContext {
26    pub report: String,
27    pub set: Option<String>,
28    pub target: String,
29    pub profile: String,
30    /// The source plugin type (`duckdb`, `databricks`, ...), for SQL that differs per database.
31    pub source_type: String,
32    /// The schedule's name under `dre run --schedule`, else `None`.
33    pub schedule: Option<String>,
34    pub date: NaiveDate,
35    /// When the run started: `run.now`.
36    pub now: chrono::DateTime<Utc>,
37    /// The run's timezone and week settings.
38    pub calendar: Calendar,
39}
40
41/// Rows returned to templates by `run_query()`.
42#[derive(Debug, Clone, Default)]
43pub struct QueryRows {
44    pub columns: Vec<String>,
45    pub rows: Vec<Vec<Value>>,
46}
47
48/// One column of a relation, as `columns()` returns it.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Column {
51    pub name: String,
52    /// The Arrow type name (`Int64`, `Utf8`, `Date32`, ...).
53    pub data_type: String,
54}
55
56/// Executes `run_query()` SQL on the Binding's own session.
57pub trait QueryRunner: Send + Sync {
58    /// Run `sql`, failing if it returns more than `max_rows` rows.
59    fn run_query(&self, sql: &str, max_rows: u64) -> Result<QueryRows, String>;
60    /// The columns `sql` returns, without fetching rows.
61    fn columns(&self, sql: &str) -> Result<Vec<Column>, String>;
62    /// Load a lookup into a temp table on the session: `Some((relation, plugin warning))`, or
63    /// `None` when the source can't load rows.
64    fn load(&self, _name: &str, _table: &Table) -> Result<Option<(String, Option<String>)>, String> {
65        Ok(None)
66    }
67}
68
69/// One profile target as templates see it: `target.*` and `profile('name').*`.
70#[derive(Debug, Clone, Default)]
71pub struct Connection {
72    /// The profile's name.
73    pub profile: String,
74    /// The target's name (`dev`, `prod`).
75    pub target: String,
76    /// The plugin type.
77    pub kind: String,
78    /// Every field, with `env_var()` already rendered.
79    pub fields: JsonMap<String, Json>,
80    /// Fields that hold secrets: reading one is an error.
81    pub secrets: Vec<String>,
82}
83
84/// Where `target` and `profile()` get their values. `role` is `source` or `destination`.
85pub trait Connections: Send + Sync {
86    /// The Binding's source connection.
87    fn source(&self) -> Result<Connection, String>;
88    fn profile(&self, name: &str, role: Option<&str>) -> Result<Connection, String>;
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct RenderError {
93    pub file: PathBuf,
94    pub line: Option<usize>,
95    pub message: String,
96}
97
98impl fmt::Display for RenderError {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        match self.line {
101            Some(l) => write!(f, "{}:{l}: {}", self.file.display(), self.message),
102            None => write!(f, "{}: {}", self.file.display(), self.message),
103        }
104    }
105}
106
107pub struct Renderer {
108    env: Environment<'static>,
109    /// What `columns()` found, by relation, for the file being rendered.
110    columns_cache: Arc<Mutex<BTreeMap<String, Value>>>,
111    /// Warnings raised while rendering (e.g. a large lookup inlined), drained by the caller.
112    warnings: Arc<Mutex<Vec<String>>>,
113    import: String,
114    /// Per combined macro template: `(first line, file, line count)` of each macro file in it.
115    macro_files: BTreeMap<String, Vec<(usize, PathBuf, usize)>>,
116}
117
118pub struct RendererConfig<'a> {
119    pub root: &'a Path,
120    pub macros: &'a [PathBuf],
121    pub context: RunContext,
122    /// The Binding's merged vars.
123    pub vars: JsonMap<String, Json>,
124    /// `--var` overrides, highest precedence.
125    pub cli_vars: BTreeMap<String, String>,
126    pub runner: Option<Arc<dyn QueryRunner>>,
127    /// Profiles for `target` and `profile()`; `None` where no profile resolves (offline checks).
128    pub connections: Option<Arc<dyn Connections>>,
129    pub run_query_max_rows: u64,
130    /// Every `.sql` file `ref()` can name, by basename, relative to `root`.
131    pub sql: BTreeMap<String, PathBuf>,
132    /// Lookups `ref()` and `lookup()` can name.
133    pub lookups: BTreeMap<String, Lookup>,
134    pub lookup_inline_max_rows: u64,
135    /// Macro packages, imported under their names.
136    pub packages: Vec<Package>,
137    /// The project's name: the root namespace in `dispatch()` search orders.
138    pub project_name: String,
139    pub dispatch: DispatchOrder,
140}
141
142impl Renderer {
143    pub fn new(cfg: RendererConfig<'_>) -> Result<Renderer, RenderError> {
144        let mut env = Environment::new();
145        env.set_undefined_behavior(UndefinedBehavior::Strict);
146        env.set_keep_trailing_newline(true);
147
148        let vars = cfg.vars;
149        let cli = cfg.cli_vars;
150        env.add_function(
151            "var",
152            move |name: String, default: Option<Value>| -> Result<Value, Error> {
153                if let Some(v) = cli.get(&name) {
154                    return Ok(Value::from(v.clone()));
155                }
156                if let Some(v) = vars.get(&name) {
157                    return Ok(Value::from_serialize(v));
158                }
159                default.ok_or_else(|| {
160                    Error::new(
161                        ErrorKind::UndefinedError,
162                        format!("`var('{name}')` has no value and no default"),
163                    )
164                })
165            },
166        );
167        env.add_function("env_var", |name: String, default: Option<Value>| -> Result<Value, Error> {
168            match std::env::var(&name) {
169                Ok(v) => Ok(Value::from(v)),
170                Err(_) => default.ok_or_else(|| {
171                    Error::new(
172                        ErrorKind::UndefinedError,
173                        format!("`env_var('{name}')`: environment variable `{name}` is not set and no default is given"),
174                    )
175                }),
176            }
177        });
178        env.add_function("raise_error", |message: String| -> Result<Value, Error> {
179            Err(Error::new(ErrorKind::InvalidOperation, message))
180        });
181        let source_type = cfg.context.source_type.clone();
182        crate::dates::register(&mut env, cfg.context.calendar, cfg.context.date);
183        env.add_global("run", Value::from_object(Run(cfg.context)));
184        if let Some(c) = cfg.connections {
185            // Resolved on first use: a report that never reads `target` never asks for it.
186            env.add_global(
187                "target",
188                Value::from_object(LazyTarget {
189                    connections: c.clone(),
190                    resolved: std::sync::OnceLock::new(),
191                }),
192            );
193            env.add_function(
194                "profile",
195                move |name: String, kwargs: Kwargs| -> Result<Value, Error> {
196                    let role: Option<String> = kwargs.get("role")?;
197                    kwargs.assert_all_used()?;
198                    if let Some(r) = &role
199                        && r != "source"
200                        && r != "destination"
201                    {
202                        return Err(Error::new(
203                            ErrorKind::InvalidOperation,
204                            format!(
205                                "`profile('{name}', role='{r}')`: role must be 'source' or 'destination'"
206                            ),
207                        ));
208                    }
209                    c.profile(&name, role.as_deref())
210                        .map(|t| Value::from_object(ConnectionValue(t)))
211                        .map_err(|e| {
212                            Error::new(ErrorKind::InvalidOperation, format!("`profile('{name}')`: {e}"))
213                        })
214                },
215            );
216        }
217        let runner = cfg.runner;
218        let warnings: Arc<Mutex<Vec<String>>> = Arc::default();
219        let lookups = Arc::new(Lookups {
220            root: cfg.root.to_path_buf(),
221            defs: cfg.lookups,
222            inline_max: cfg.lookup_inline_max_rows,
223            runner: runner.clone(),
224            tables: Mutex::default(),
225            loaded: Mutex::default(),
226            warnings: warnings.clone(),
227        });
228        let l = lookups.clone();
229        env.add_function("lookup", move |name: String| -> Result<Value, Error> {
230            let t = l.table(&name)?;
231            let rows = QueryRows {
232                columns: t.columns.clone(),
233                rows: t
234                    .rows
235                    .iter()
236                    .map(|r| r.iter().map(cell_value).collect())
237                    .collect(),
238            };
239            Ok(Value::from_object(QueryResult::new(rows)))
240        });
241        let default_max = cfg.run_query_max_rows;
242        let runner_c = runner.clone();
243        env.add_function(
244            "run_query",
245            move |sql: String, kwargs: Kwargs| -> Result<Value, Error> {
246                let max_rows: Option<u64> = kwargs.get("max_rows")?;
247                kwargs.assert_all_used()?;
248                let Some(runner) = &runner else {
249                    return Err(Error::new(
250                        ErrorKind::InvalidOperation,
251                        "`run_query()` has no connection here",
252                    ));
253                };
254                let rows = runner
255                    .run_query(&sql, max_rows.unwrap_or(default_max))
256                    .map_err(|e| Error::new(ErrorKind::InvalidOperation, e))?;
257                Ok(Value::from_object(QueryResult::new(rows)))
258            },
259        );
260        // Per rendered file: a later query may have recreated the relation.
261        let columns_cache: Arc<Mutex<BTreeMap<String, Value>>> = Arc::default();
262        let cache = columns_cache.clone();
263        env.add_function("columns", move |rel: String| -> Result<Value, Error> {
264            if let Some(v) = cache.lock().unwrap().get(&rel) {
265                return Ok(v.clone());
266            }
267            let Some(runner) = &runner_c else {
268                return Err(Error::new(
269                    ErrorKind::InvalidOperation,
270                    format!("`columns('{rel}')` has no connection here"),
271                ));
272            };
273            let sql = format!("select * from {rel} as _dre_cols where 1=0");
274            let cols = runner.columns(&sql).map_err(|e| {
275                Error::new(
276                    ErrorKind::InvalidOperation,
277                    format!("`columns('{rel}')` failed: {e}"),
278                )
279            })?;
280            let v = Value::from(
281                cols.into_iter()
282                    .map(|c| {
283                        Value::from_iter([("name", Value::from(c.name)), ("type", Value::from(c.data_type))])
284                    })
285                    .collect::<Vec<_>>(),
286            );
287            cache.lock().unwrap().insert(rel, v.clone());
288            Ok(v)
289        });
290
291        // Every macro file is combined into one template, imported (on the first line, so line
292        // numbers don't move) into everything rendered. Each package gets its own template,
293        // imported under the package's name.
294        let root = cfg.root.to_path_buf();
295        let display = |p: &Path| {
296            p.strip_prefix(&root)
297                .map(Path::to_path_buf)
298                .unwrap_or_else(|_| p.to_path_buf())
299        };
300        let own: Vec<(PathBuf, PathBuf)> = cfg.macros.iter().map(|m| (cfg.root.join(m), m.clone())).collect();
301        let (combined, names, spans) = combine(&own)?;
302        let mut import = if names.is_empty() {
303            String::new()
304        } else {
305            format!("{{% from \"{MACROS}\" import {} %}}", names.join(", "))
306        };
307        let mut templates = vec![(MACROS.to_string(), combined, spans, !names.is_empty())];
308        for p in &cfg.packages {
309            let files: Vec<(PathBuf, PathBuf)> = p.macros.iter().map(|m| (m.clone(), display(m))).collect();
310            let (src, _, spans) = combine(&files)?;
311            let t = package_template(&p.name);
312            import.push_str(&format!("{{% import \"{t}\" as {} %}}", p.name));
313            templates.push((t, src, spans, true));
314        }
315        add_ref(&mut env, cfg.root, cfg.sql, lookups, &import);
316        add_dispatch(
317            &mut env,
318            &cfg.project_name,
319            !names.is_empty(),
320            &cfg.packages,
321            cfg.dispatch,
322            source_type,
323        );
324        let mut r = Renderer {
325            env,
326            columns_cache,
327            warnings,
328            import,
329            macro_files: BTreeMap::new(),
330        };
331        for (name, src, spans, add) in templates {
332            r.macro_files.insert(name.clone(), spans);
333            if add {
334                r.env
335                    .add_template_owned(name.clone(), src)
336                    .map_err(|e| r.error(Path::new(&name), &e))?;
337            }
338        }
339        Ok(r)
340    }
341
342    /// Warnings raised since the last call.
343    pub fn take_warnings(&self) -> Vec<String> {
344        std::mem::take(&mut *self.warnings.lock().unwrap())
345    }
346
347    /// Render `src`, reporting errors against `file`.
348    pub fn render(&self, file: &Path, src: &str) -> Result<String, RenderError> {
349        self.columns_cache.lock().unwrap().clear();
350        let full = format!("{}{src}", self.import);
351        let name = file.to_string_lossy().to_string();
352        let tmpl = self
353            .env
354            .template_from_named_str(&name, &full)
355            .map_err(|e| self.error(file, &e))?;
356        tmpl.render(()).map_err(|e| self.error(file, &e))
357    }
358
359    fn error(&self, file: &Path, e: &Error) -> RenderError {
360        // Report the innermost location: a macro file if the error happened inside a macro.
361        let mut deepest: &Error = e;
362        while let Some(src) = std::error::Error::source(deepest).and_then(|s| s.downcast_ref::<Error>()) {
363            deepest = src;
364        }
365        let (name, line) = match (deepest.name(), deepest.line()) {
366            (Some(n), l) => (n.to_string(), l),
367            _ => (e.name().unwrap_or_default().to_string(), e.line()),
368        };
369        let (file, line) = if let Some(spans) = self.macro_files.get(&name) {
370            match line.and_then(|l| {
371                spans
372                    .iter()
373                    .find(|(s, _, n)| l >= *s && l < s + n)
374                    .map(|(s, f, _)| (f, l - s + 1))
375            }) {
376                Some((f, l)) => (f.clone(), Some(l)),
377                None => (file.to_path_buf(), line),
378            }
379        } else {
380            (file.to_path_buf(), line)
381        };
382        let message = deepest
383            .detail()
384            .map(str::to_string)
385            .unwrap_or_else(|| deepest.kind().to_string());
386        RenderError { file, line, message }
387    }
388}
389
390fn package_template(name: &str) -> String {
391    format!("__dre_package_{name}__")
392}
393
394/// Concatenate macro files `(path to read, path to report)` into one template source, with
395/// every macro name defined and where each file starts.
396#[allow(clippy::type_complexity)]
397fn combine(
398    files: &[(PathBuf, PathBuf)],
399) -> Result<(String, Vec<String>, Vec<(usize, PathBuf, usize)>), RenderError> {
400    let re = regex::Regex::new(r"\{%-?\s*macro\s+([A-Za-z_]\w*)").unwrap();
401    let mut combined = String::new();
402    let mut names = Vec::new();
403    let mut spans = Vec::new();
404    for (read, shown) in files {
405        let src = std::fs::read_to_string(read).map_err(|e| RenderError {
406            file: shown.clone(),
407            line: None,
408            message: format!("can't read macro file: {e}"),
409        })?;
410        let start = combined.matches('\n').count() + 1;
411        names.extend(re.captures_iter(&src).map(|c| c[1].to_string()));
412        combined.push_str(&src);
413        if !combined.ends_with('\n') {
414            combined.push('\n');
415        }
416        spans.push((start, shown.clone(), src.matches('\n').count() + 1));
417    }
418    Ok((combined, names, spans))
419}
420
421/// `dispatch('name', 'namespace')` returns the macro to call for the active source: in each
422/// namespace of the search order, `<source type>__name`, then `default__name`. The search order
423/// is `dispatch:` in dre_project.yml, else the root project then the namespace, so a project can
424/// override a package's macro by defining, say, `databricks__name` in its own macros/.
425fn add_dispatch(
426    env: &mut Environment<'static>,
427    project: &str,
428    project_has_macros: bool,
429    packages: &[Package],
430    order: DispatchOrder,
431    source_type: String,
432) {
433    let project = project.to_string();
434    let templates: BTreeMap<String, String> = packages
435        .iter()
436        .map(|p| (p.name.clone(), package_template(&p.name)))
437        .collect();
438    env.add_function(
439        "dispatch",
440        move |state: &minijinja::State<'_, '_>,
441              name: String,
442              namespace: Option<String>|
443              -> Result<Value, Error> {
444            let ns = namespace.unwrap_or_else(|| project.clone());
445            let search = order.get(&ns).cloned().unwrap_or_else(|| {
446                if ns == project {
447                    vec![ns.clone()]
448                } else {
449                    vec![project.clone(), ns.clone()]
450                }
451            });
452            let candidates = [format!("{source_type}__{name}"), format!("default__{name}")];
453            for n in &search {
454                let template = if n == &project {
455                    if !project_has_macros {
456                        continue;
457                    }
458                    MACROS.to_string()
459                } else {
460                    match templates.get(n) {
461                        Some(t) => t.clone(),
462                        None => continue,
463                    }
464                };
465                let tmpl = state.env().get_template(&template)?;
466                let captured = tmpl.render_captured(())?;
467                let st = captured.state();
468                for c in &candidates {
469                    if st.lookup(c).is_some_and(|v| !v.is_undefined()) {
470                        return Ok(Value::from_object(Dispatched {
471                            template,
472                            name: c.clone(),
473                        }));
474                    }
475                }
476            }
477            Err(Error::new(
478                ErrorKind::InvalidOperation,
479                format!(
480                    "`dispatch('{name}', '{ns}')`: no `{}` or `{}` in {}",
481                    candidates[0],
482                    candidates[1],
483                    search.join(", ")
484                ),
485            ))
486        },
487    );
488}
489
490/// A macro chosen by `dispatch()`, called by name in its own template.
491#[derive(Debug)]
492struct Dispatched {
493    template: String,
494    name: String,
495}
496
497impl Object for Dispatched {
498    fn call(self: &Arc<Self>, state: &minijinja::State<'_, '_>, args: &[Value]) -> Result<Value, Error> {
499        let tmpl = state.env().get_template(&self.template)?;
500        let out = tmpl.render_captured(())?.state().call_macro(&self.name, args)?;
501        Ok(Value::from(out))
502    }
503}
504
505/// `ref('name')`: another `.sql` file, rendered in the same context (vars, `run.*`, macros, the
506/// Binding's connection) and returned in parentheses, ready to use as a subquery or CTE body.
507/// DRE builds no tables, so a ref inlines SQL rather than pointing at a materialised model.
508fn add_ref(
509    env: &mut Environment<'static>,
510    root: &Path,
511    sql: BTreeMap<String, PathBuf>,
512    lookups: Arc<Lookups>,
513    import: &str,
514) {
515    let root = root.to_path_buf();
516    let import = import.to_string();
517    // The chain of refs being rendered, to report cycles instead of recursing forever.
518    let stack: Arc<Mutex<Vec<String>>> = Arc::default();
519    env.add_function(
520        "ref",
521        move |state: &minijinja::State<'_, '_>, name: String| -> Result<Value, Error> {
522            if lookups.defs.contains_key(&name) {
523                return lookups.relation(&name).map(Value::from);
524            }
525            let Some(rel) = sql.get(&name) else {
526                return Err(Error::new(
527                    ErrorKind::InvalidOperation,
528                    format!("`ref('{name}')`: no `{name}.sql` and no lookup `{name}` in the project"),
529                ));
530            };
531            {
532                let mut chain = stack.lock().unwrap();
533                if chain.contains(&name) {
534                    chain.push(name.clone());
535                    let cycle = chain.join(" → ");
536                    chain.clear();
537                    return Err(Error::new(
538                        ErrorKind::InvalidOperation,
539                        format!("`ref()` cycle: {cycle}"),
540                    ));
541                }
542                chain.push(name.clone());
543            }
544            let result = (|| {
545                let src = std::fs::read_to_string(root.join(rel)).map_err(|e| {
546                    Error::new(
547                        ErrorKind::InvalidOperation,
548                        format!("can't read {}: {e}", rel.display()),
549                    )
550                })?;
551                let file = rel.to_string_lossy().to_string();
552                let rendered = state
553                    .env()
554                    .template_from_named_str(&file, &format!("{import}{src}"))?
555                    .render(())?;
556                let statements = crate::sqlsplit::split(&rendered);
557                match statements.as_slice() {
558                    [one] => Ok(Value::from(format!("(\n{}\n)", one.text))),
559                    _ => Err(Error::new(
560                        ErrorKind::InvalidOperation,
561                        format!(
562                            "`ref('{name}')` needs {} to hold exactly one statement, but it has {}",
563                            rel.display(),
564                            statements.len()
565                        ),
566                    )),
567                }
568            })();
569            let mut chain = stack.lock().unwrap();
570            if chain.last() == Some(&name) {
571                chain.pop();
572            }
573            result
574        },
575    );
576}
577
578/// Lookups for one Binding: read once, inlined or loaded once.
579struct Lookups {
580    root: PathBuf,
581    defs: BTreeMap<String, Lookup>,
582    inline_max: u64,
583    runner: Option<Arc<dyn QueryRunner>>,
584    tables: Mutex<BTreeMap<String, Arc<Table>>>,
585    /// What `ref()` returns for each lookup already used: inline SQL or a temp table's name.
586    loaded: Mutex<BTreeMap<String, String>>,
587    warnings: Arc<Mutex<Vec<String>>>,
588}
589
590impl Lookups {
591    fn table(&self, name: &str) -> Result<Arc<Table>, Error> {
592        let def = self.defs.get(name).ok_or_else(|| {
593            Error::new(
594                ErrorKind::InvalidOperation,
595                format!("no lookup `{name}` under lookups/"),
596            )
597        })?;
598        if let Some(t) = self.tables.lock().unwrap().get(name) {
599            return Ok(t.clone());
600        }
601        let t = Arc::new(lookups::read(&self.root, def).map_err(|e| {
602            Error::new(
603                ErrorKind::InvalidOperation,
604                format!("{}: {e}", def.file.display()),
605            )
606        })?);
607        self.tables.lock().unwrap().insert(name.to_string(), t.clone());
608        Ok(t)
609    }
610
611    fn relation(&self, name: &str) -> Result<String, Error> {
612        if let Some(r) = self.loaded.lock().unwrap().get(name) {
613            return Ok(r.clone());
614        }
615        let t = self.table(name)?;
616        let rows = t.rows.len() as u64;
617        let load = match self.defs[name].load {
618            Load::Inline => false,
619            Load::TempTable => true,
620            Load::Auto => rows > self.inline_max,
621        };
622        let loaded = match (&self.runner, load) {
623            (Some(runner), true) => runner.load(name, &t).map_err(|e| {
624                Error::new(
625                    ErrorKind::InvalidOperation,
626                    format!("loading lookup `{name}`: {e}"),
627                )
628            })?,
629            _ => None,
630        };
631        let relation = match loaded {
632            Some((relation, warning)) => {
633                if let Some(w) = warning {
634                    self.warn(format!("lookup `{name}`: {w}"));
635                }
636                relation
637            }
638            None => {
639                if load && self.runner.is_some() {
640                    self.warn(format!(
641                        "lookup `{name}` has {rows} rows; this source can't load it into a temp table, so it's inlined in the SQL. Data this size probably belongs in a table in the database"
642                    ));
643                }
644                t.inline_sql()
645            }
646        };
647        self.loaded
648            .lock()
649            .unwrap()
650            .insert(name.to_string(), relation.clone());
651        Ok(relation)
652    }
653
654    fn warn(&self, w: String) {
655        self.warnings.lock().unwrap().push(w);
656    }
657}
658
659fn cell_value(c: &Cell) -> Value {
660    match c {
661        Cell::Null => Value::from(()),
662        Cell::Text(s) => Value::from(s.clone()),
663        Cell::Int(n) => Value::from(*n),
664        Cell::Num(n) => Value::from(*n),
665        Cell::Bool(b) => Value::from(*b),
666        Cell::Date(d) => Value::from(d.to_string()),
667    }
668}
669
670#[derive(Debug)]
671struct Run(RunContext);
672
673impl Object for Run {
674    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
675        let c = &self.0;
676        Some(match key.as_str()? {
677            "report" => Value::from(c.report.clone()),
678            "set" => c.set.clone().map(Value::from).unwrap_or(Value::from(())),
679            "target" => Value::from(c.target.clone()),
680            "profile" => Value::from(c.profile.clone()),
681            "source_type" => Value::from(c.source_type.clone()),
682            "schedule" => c.schedule.clone().map(Value::from).unwrap_or(Value::from(())),
683            "date" => Date::value(c.date, c.calendar),
684            "now" => DateTime::now(c.now, c.calendar),
685            "timezone" => Value::from(c.calendar.tz.name()),
686            _ => return None,
687        })
688    }
689
690    fn call_method(
691        self: &Arc<Self>,
692        _: &minijinja::State<'_, '_>,
693        method: &str,
694        args: &[Value],
695    ) -> Result<Value, Error> {
696        match method {
697            "date_format" => {
698                let fmt = args.first().and_then(|a| a.as_str()).ok_or_else(|| {
699                    Error::new(
700                        ErrorKind::InvalidOperation,
701                        "`run.date_format()` takes a strftime format string",
702                    )
703                })?;
704                let mut out = String::new();
705                use std::fmt::Write;
706                write!(out, "{}", self.0.date.format(fmt)).map_err(|_| {
707                    Error::new(
708                        ErrorKind::InvalidOperation,
709                        format!("invalid date format `{fmt}`"),
710                    )
711                })?;
712                Ok(Value::from(out))
713            }
714            _ => Err(Error::from(ErrorKind::UnknownMethod)),
715        }
716    }
717}
718
719/// `target` and `profile('name')`: a profile target's fields, refusing secret ones.
720#[derive(Debug)]
721struct ConnectionValue(Connection);
722
723impl Object for ConnectionValue {
724    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
725        connection_field(&self.0, key)
726    }
727
728    fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
729        write!(f, "{}", self.0.target)
730    }
731}
732
733/// `target`: the Binding's source connection, looked up the first time a template reads it.
734struct LazyTarget {
735    connections: Arc<dyn Connections>,
736    resolved: std::sync::OnceLock<Result<Connection, String>>,
737}
738
739impl fmt::Debug for LazyTarget {
740    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
741        f.write_str("target")
742    }
743}
744
745impl LazyTarget {
746    fn get(&self) -> &Result<Connection, String> {
747        self.resolved.get_or_init(|| self.connections.source())
748    }
749}
750
751impl Object for LazyTarget {
752    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
753        match self.get() {
754            Ok(c) => connection_field(c, key),
755            Err(e) => Some(Value::from(Error::new(
756                ErrorKind::InvalidOperation,
757                format!("`target`: {e}"),
758            ))),
759        }
760    }
761
762    fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
763        match self.get() {
764            Ok(c) => write!(f, "{}", c.target),
765            Err(_) => Err(fmt::Error),
766        }
767    }
768}
769
770/// One field of a profile target for templates; a secret or missing one is an error value.
771fn connection_field(c: &Connection, key: &Value) -> Option<Value> {
772    {
773        let k = key.as_str()?;
774        Some(match k {
775            "name" => Value::from(c.target.clone()),
776            "type" => Value::from(c.kind.clone()),
777            "profile" => Value::from(c.profile.clone()),
778            _ if c.secrets.iter().any(|s| s == k) => Value::from(Error::new(
779                ErrorKind::InvalidOperation,
780                format!(
781                    "`{k}` of profile `{}` holds a secret, so templates can't read it (it would end up in compiled SQL and logs)",
782                    c.profile
783                ),
784            )),
785            _ => match c.fields.get(k) {
786                Some(v) => Value::from_serialize(v),
787                None => Value::from(Error::new(
788                    ErrorKind::UndefinedError,
789                    format!(
790                        "profile `{}` (target `{}`) has no field `{k}`; it has: {}",
791                        c.profile,
792                        c.target,
793                        c.fields
794                            .keys()
795                            .filter(|f| !c.secrets.contains(f))
796                            .cloned()
797                            .collect::<Vec<_>>()
798                            .join(", ")
799                    ),
800                )),
801            },
802        })
803    }
804}
805
806#[derive(Debug)]
807struct QueryResult {
808    columns: Vec<String>,
809    rows: Vec<Value>,
810}
811
812impl QueryResult {
813    fn new(q: QueryRows) -> QueryResult {
814        let cols: Arc<Vec<String>> = Arc::new(q.columns.clone());
815        let rows = q
816            .rows
817            .into_iter()
818            .map(|values| {
819                Value::from_object(Row {
820                    columns: cols.clone(),
821                    values,
822                })
823            })
824            .collect();
825        QueryResult {
826            columns: q.columns,
827            rows,
828        }
829    }
830}
831
832impl Object for QueryResult {
833    fn repr(self: &Arc<Self>) -> ObjectRepr {
834        ObjectRepr::Seq
835    }
836
837    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
838        match key.as_str() {
839            Some("rows") => Some(Value::from(self.rows.clone())),
840            Some("columns") => Some(Value::from(self.columns.clone())),
841            _ => self.rows.get(key.as_usize()?).cloned(),
842        }
843    }
844
845    fn enumerate(self: &Arc<Self>) -> Enumerator {
846        Enumerator::Seq(self.rows.len())
847    }
848
849    /// `result.column('name')` (or an index): that column's values, one per row.
850    fn call_method(
851        self: &Arc<Self>,
852        _: &minijinja::State<'_, '_>,
853        method: &str,
854        args: &[Value],
855    ) -> Result<Value, Error> {
856        if method != "column" {
857            return Err(Error::from(ErrorKind::UnknownMethod));
858        }
859        let [key] = args else {
860            return Err(Error::new(
861                ErrorKind::InvalidOperation,
862                "`column()` takes one column name or index",
863            ));
864        };
865        let i = match key.as_str() {
866            Some(n) => self.columns.iter().position(|c| c == n).ok_or_else(|| {
867                Error::new(
868                    ErrorKind::InvalidOperation,
869                    format!("no column `{n}` (columns: {})", self.columns.join(", ")),
870                )
871            })?,
872            None => key
873                .as_usize()
874                .filter(|i| *i < self.columns.len())
875                .ok_or_else(|| Error::new(ErrorKind::InvalidOperation, format!("no column {key}")))?,
876        };
877        Ok(Value::from(
878            self.rows
879                .iter()
880                .map(|r| r.get_item_by_index(i).unwrap_or_default())
881                .collect::<Vec<_>>(),
882        ))
883    }
884}
885
886/// One `run_query()` row: accessible by column name (`row.region`, `row['region']`) or index.
887#[derive(Debug)]
888struct Row {
889    columns: Arc<Vec<String>>,
890    values: Vec<Value>,
891}
892
893impl Object for Row {
894    fn repr(self: &Arc<Self>) -> ObjectRepr {
895        ObjectRepr::Seq
896    }
897
898    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
899        if let Some(name) = key.as_str() {
900            let i = self.columns.iter().position(|c| c == name)?;
901            return self.values.get(i).cloned();
902        }
903        self.values.get(key.as_usize()?).cloned()
904    }
905
906    fn enumerate(self: &Arc<Self>) -> Enumerator {
907        Enumerator::Seq(self.values.len())
908    }
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    fn renderer(vars: Json, cli: &[(&str, &str)], macros: &[(&str, &str)]) -> (tempfile::TempDir, Renderer) {
916        let dir = tempfile::tempdir().unwrap();
917        let mut files = Vec::new();
918        for (name, src) in macros {
919            let p = PathBuf::from("macros").join(name);
920            std::fs::create_dir_all(dir.path().join("macros")).unwrap();
921            std::fs::write(dir.path().join(&p), src).unwrap();
922            files.push(p);
923        }
924        let r = Renderer::new(RendererConfig {
925            root: dir.path(),
926            macros: &files,
927            context: RunContext {
928                report: "monthly".into(),
929                set: Some("client_a".into()),
930                target: "prod".into(),
931                profile: "warehouse".into(),
932                source_type: "duckdb".into(),
933                schedule: None,
934                date: NaiveDate::from_ymd_opt(2026, 1, 25).unwrap(),
935                now: Utc::now(),
936                calendar: Calendar::default(),
937            },
938            vars: vars.as_object().cloned().unwrap_or_default(),
939            cli_vars: cli.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
940            runner: None,
941            connections: None,
942            run_query_max_rows: 10_000,
943            sql: BTreeMap::new(),
944            lookups: BTreeMap::new(),
945            lookup_inline_max_rows: 200,
946            packages: Vec::new(),
947            project_name: "acme".into(),
948            dispatch: DispatchOrder::new(),
949        })
950        .unwrap();
951        (dir, r)
952    }
953
954    fn render(r: &Renderer, src: &str) -> Result<String, RenderError> {
955        r.render(Path::new("reports/q.sql"), src)
956    }
957
958    #[test]
959    fn run_context_and_date_formats() {
960        let (_d, r) = renderer(Json::Null, &[], &[]);
961        let out = render(
962            &r,
963            "{{ run.report }}/{{ run.set }}/{{ run.target }}/{{ run.profile }} {{ run.date }} {{ run.date.yyyymmdd }} \
964             {{ run.date.ddmmyyyy }} {{ run.date.yyyy }}-{{ run.date.mm }}-{{ run.date.dd }} {{ run.date_format('%Y-W%V') }}",
965        )
966        .unwrap();
967        assert_eq!(
968            out,
969            "monthly/client_a/prod/warehouse 2026-01-25 20260125 25012026 2026-01-25 2026-W04"
970        );
971    }
972
973    #[test]
974    fn var_precedence_is_cli_then_binding_then_default() {
975        let (_d, r) = renderer(
976            serde_json::json!({"a": "binding", "b": "binding", "n": 5}),
977            &[("a", "cli")],
978            &[],
979        );
980        assert_eq!(
981            render(
982                &r,
983                "{{ var('a') }} {{ var('b') }} {{ var('c', 'dflt') }} {{ var('n') + 1 }}"
984            )
985            .unwrap(),
986            "cli binding dflt 6"
987        );
988        let e = render(&r, "select\n{{ var('missing') }}").unwrap_err();
989        assert_eq!(
990            (e.line, e.message.contains("`var('missing')` has no value")),
991            (Some(2), true),
992            "{e}"
993        );
994    }
995
996    #[test]
997    fn env_var_reads_the_environment_or_errors() {
998        let (_d, r) = renderer(Json::Null, &[], &[]);
999        assert_eq!(
1000            render(&r, "{{ env_var('DRE_TEST_UNSET_VAR', 'x') }}").unwrap(),
1001            "x"
1002        );
1003        let home = std::env::var("PATH").unwrap();
1004        assert_eq!(render(&r, "{{ env_var('PATH') }}").unwrap(), home);
1005        assert!(
1006            render(&r, "{{ env_var('DRE_TEST_UNSET_VAR') }}")
1007                .unwrap_err()
1008                .message
1009                .contains("is not set")
1010        );
1011    }
1012
1013    #[test]
1014    fn macros_from_every_file_are_callable_and_keep_line_numbers() {
1015        let (_d, r) = renderer(
1016            Json::Null,
1017            &[],
1018            &[
1019                ("a.sql", "{% macro double(x) %}{{ x * 2 }}{% endmacro %}\n"),
1020                (
1021                    "b.sql",
1022                    "{% macro broken() %}\n{{ var('nope') }}\n{% endmacro %}\n{% macro quad(x) %}{{ x * 4 }}{% endmacro %}\n",
1023                ),
1024            ],
1025        );
1026        assert_eq!(
1027            render(&r, "select {{ double(2) }}, {{ quad(1) }}\n").unwrap(),
1028            "select 4, 4\n"
1029        );
1030        let e = render(&r, "line one\nselect {{ broken() }}").unwrap_err();
1031        assert_eq!(e.file, PathBuf::from("macros/b.sql"), "{e}");
1032        assert_eq!(e.line, Some(2), "{e}");
1033        let e = render(&r, "one\ntwo\n{{ nope }}").unwrap_err();
1034        assert_eq!(
1035            (e.file.clone(), e.line),
1036            (PathBuf::from("reports/q.sql"), Some(3)),
1037            "{e}"
1038        );
1039    }
1040
1041    struct Fake;
1042    impl QueryRunner for Fake {
1043        fn run_query(&self, sql: &str, max_rows: u64) -> Result<QueryRows, String> {
1044            if max_rows < 2 {
1045                return Err(format!("`{sql}` returned more than {max_rows} rows"));
1046            }
1047            Ok(QueryRows {
1048                columns: vec!["region".into(), "n".into()],
1049                rows: vec![
1050                    vec![Value::from("apac"), Value::from(1)],
1051                    vec![Value::from("emea"), Value::from(2)],
1052                ],
1053            })
1054        }
1055        fn columns(&self, _: &str) -> Result<Vec<Column>, String> {
1056            Ok(Vec::new())
1057        }
1058    }
1059
1060    #[test]
1061    fn run_query_rows_are_accessible_by_name_and_index() {
1062        let dir = tempfile::tempdir().unwrap();
1063        let r = Renderer::new(RendererConfig {
1064            root: dir.path(),
1065            macros: &[],
1066            context: RunContext {
1067                report: "r".into(),
1068                set: None,
1069                target: "t".into(),
1070                profile: "p".into(),
1071                source_type: "duckdb".into(),
1072                schedule: None,
1073                date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
1074                now: Utc::now(),
1075                calendar: Calendar::default(),
1076            },
1077            vars: JsonMap::new(),
1078            cli_vars: BTreeMap::new(),
1079            runner: Some(Arc::new(Fake)),
1080            connections: None,
1081            run_query_max_rows: 10_000,
1082            sql: BTreeMap::new(),
1083            lookups: BTreeMap::new(),
1084            lookup_inline_max_rows: 200,
1085            packages: Vec::new(),
1086            project_name: "acme".into(),
1087            dispatch: DispatchOrder::new(),
1088        })
1089        .unwrap();
1090        let src = "{% set res = run_query('select') %}{{ res.columns | join(',') }}|\
1091                   {% for row in res.rows %}{{ row.region }}={{ row[1] }}{{ ',' if not loop.last }}{% endfor %}|{{ res | length }}|{{ res[1]['region'] }}";
1092        assert_eq!(render(&r, src).unwrap(), "region,n|apac=1,emea=2|2|emea");
1093        let e = render(&r, "{{ run_query('select', max_rows=1) }}").unwrap_err();
1094        assert!(e.message.contains("more than 1 rows"), "{e}");
1095        assert!(render(&r, "{% if run.set is none %}none{% endif %}").unwrap() == "none");
1096    }
1097}