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