Skip to main content

dre_core/
render.rs

1//! Runtime Jinja rendering: one environment per connection a Binding uses (its queries' SQL) plus
2//! one for output paths and template values. Provides `run.*`, `var()`, `env_var()`,
3//! `run_query()`, `columns()`, `ref()`, `source()`, `target.name`, `connection.*`,
4//! `destination.*`, `profile()`, the calendar functions in [`crate::dates`] and every macro in
5//! `macros/`.
6//!
7//! [`Mode::Parse`] renders without a database, as dbt's parse does, to find every `source()` a
8//! query calls: `run_query()` returns no rows, `columns()` none, `connection.*` nothing, and
9//! `raise_error()` doesn't fire. [`Limited`] renders the values that choose a connection or a
10//! source (every `profile:`, a source's `database`/`schema`/`identifier`), where only `var()`,
11//! `env_var()`, `run.*` and `target.name` exist.
12
13use std::collections::BTreeMap;
14use std::fmt;
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex};
17
18use chrono::{NaiveDate, Utc};
19
20use crate::dates::{Calendar, Date, DateTime};
21
22use crate::lookups::{self, Cell, Load, Lookup, Table};
23use crate::packages::{DispatchOrder, Package};
24use minijinja::value::{Enumerator, Kwargs, Object, ObjectRepr, Value};
25use minijinja::{Environment, Error, ErrorKind, UndefinedBehavior};
26use serde_json::{Map as JsonMap, Value as Json};
27
28const MACROS: &str = "__dre_macros__";
29
30/// How a [`Renderer`] treats the database.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum Mode {
33    /// Rendering SQL to run (or compile): `run_query()` and `columns()` reach the database.
34    #[default]
35    Run,
36    /// The parse pass: no database, to find `source()` calls.
37    Parse,
38}
39
40/// Ambient `run.*` context.
41#[derive(Debug, Clone)]
42pub struct RunContext {
43    pub report: String,
44    pub set: Option<String>,
45    /// The run's target (environment): `run.target` and `target.name`.
46    pub target: String,
47    /// The schedule's name under `dre run --schedule`, else `None`.
48    pub schedule: Option<String>,
49    pub date: NaiveDate,
50    /// When the run started, or the instant it was scheduled for: `run.now`.
51    pub now: chrono::DateTime<Utc>,
52    /// `DRE_RUN_AT`: `run.scheduled_at`.
53    pub scheduled_at: Option<chrono::DateTime<Utc>>,
54    /// The run's timezone and week settings.
55    pub calendar: Calendar,
56    /// The Binding's `locale:`, for the number filters.
57    pub locale: crate::numbers::Locale,
58}
59
60/// Appended to a message template's name: it turns on Markdown escaping.
61const MARKDOWN_SUFFIX: &str = "\u{0}md";
62
63/// Rows returned to templates by `run_query()`.
64#[derive(Debug, Clone, Default)]
65pub struct QueryRows {
66    pub columns: Vec<String>,
67    pub rows: Vec<Vec<Value>>,
68}
69
70/// One column of a relation, as `columns()` returns it.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Column {
73    pub name: String,
74    /// The Arrow type name (`Int64`, `Utf8`, `Date32`, ...).
75    pub data_type: String,
76}
77
78/// Executes `run_query()` SQL on the Binding's sessions: on `profile`'s connection, else on the
79/// renderer's own.
80pub trait QueryRunner: Send + Sync {
81    /// Run `sql`, failing if it returns more than `max_rows` rows.
82    fn run_query(&self, sql: &str, max_rows: u64, profile: Option<&str>) -> Result<QueryRows, String>;
83    /// The columns `sql` returns, without fetching rows.
84    fn columns(&self, sql: &str, profile: Option<&str>) -> Result<Vec<Column>, String>;
85    /// Load a lookup into a temp table on the renderer's own session: `Some((relation, plugin
86    /// warning))`, or `None` when the source can't load rows.
87    fn load(&self, _name: &str, _table: &Table) -> Result<Option<(String, Option<String>)>, String> {
88        Ok(None)
89    }
90}
91
92/// One profile target as templates see it: `connection.*`, `destination.*` and
93/// `profile('name').*`.
94#[derive(Debug, Clone, Default)]
95pub struct Connection {
96    /// The profile's name: `.name` and `.profile`.
97    pub profile: String,
98    /// The target's name (`dev`, `prod`).
99    pub target: String,
100    /// The plugin type.
101    pub kind: String,
102    /// Every field, with `env_var()` already rendered.
103    pub fields: JsonMap<String, Json>,
104    /// Fields that hold secrets: reading one is an error.
105    pub secrets: Vec<String>,
106}
107
108/// Where `connection`, `destination` and `profile()` get their values. `role` is `connection`
109/// or `destination`; `None` finds the profile in whichever section has it.
110pub trait Connections: Send + Sync {
111    fn profile(&self, name: &str, role: Option<&str>) -> Result<Connection, String>;
112    /// The identifier quote character a source plugin type reports (`None`: it doesn't say).
113    fn identifier_quote(&self, kind: &str) -> Result<Option<String>, String>;
114}
115
116/// Which parts of a source's name to quote.
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
118pub struct Quoting {
119    pub database: bool,
120    pub schema: bool,
121    pub identifier: bool,
122}
123
124impl Quoting {
125    pub fn any(&self) -> bool {
126        self.database || self.schema || self.identifier
127    }
128}
129
130/// A declared source table with its fields rendered for one Binding: what `source()` returns.
131#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
132pub struct ResolvedSource {
133    pub source: String,
134    pub table: String,
135    /// The connection the source names, rendered; `None` runs it wherever its query runs.
136    pub profile: Option<String>,
137    pub database: Option<String>,
138    pub schema: String,
139    pub identifier: String,
140    pub quoting: Quoting,
141}
142
143impl ResolvedSource {
144    /// `database.schema.identifier`, or `schema.identifier` without a database, with the parts
145    /// `quoting` asks for quoted by `quote` (doubled inside a name).
146    pub fn relation(&self, quote: Option<&str>) -> String {
147        let part = |s: &str, q: bool| match (q, quote) {
148            (true, Some(c)) => format!("{c}{}{c}", s.replace(c, &format!("{c}{c}"))),
149            _ => s.to_string(),
150        };
151        let mut parts = Vec::new();
152        if let Some(d) = &self.database {
153            parts.push(part(d, self.quoting.database));
154        }
155        parts.push(part(&self.schema, self.quoting.schema));
156        parts.push(part(&self.identifier, self.quoting.identifier));
157        parts.join(".")
158    }
159
160    /// `source.table`, as selectors and the manifest name it.
161    pub fn key(&self) -> String {
162        format!("{}.{}", self.source, self.table)
163    }
164}
165
166/// Resolves `source('name', 'table')` for one Binding.
167pub type SourceResolver = Arc<dyn Fn(&str, &str) -> Result<ResolvedSource, String> + Send + Sync>;
168
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct RenderError {
171    pub file: PathBuf,
172    pub line: Option<usize>,
173    pub message: String,
174}
175
176impl fmt::Display for RenderError {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        match self.line {
179            Some(l) => write!(f, "{}:{l}: {}", self.file.display(), self.message),
180            None => write!(f, "{}: {}", self.file.display(), self.message),
181        }
182    }
183}
184
185/// What `columns()` found, by relation and connection.
186type ColumnsCache = BTreeMap<(String, Option<String>), Value>;
187
188pub struct Renderer {
189    env: Environment<'static>,
190    /// The parse pass's first `raise_error()` message in the current render.
191    raised: Arc<Mutex<Option<String>>>,
192    /// What `columns()` found, by relation, for the file being rendered.
193    columns_cache: Arc<Mutex<ColumnsCache>>,
194    /// `(source, table)` of every `source()` call since the last [`Renderer::take_sources`].
195    source_calls: Arc<Mutex<Vec<(String, String)>>>,
196    /// The destination being rendered: `destination.*`.
197    destination: Arc<Mutex<Option<Connection>>>,
198    /// Warnings raised while rendering (e.g. a large lookup inlined), drained by the caller.
199    warnings: Arc<Mutex<Vec<String>>>,
200    import: String,
201    /// Per combined macro template: `(first line, file, line count)` of each macro file in it.
202    macro_files: BTreeMap<String, Vec<(usize, PathBuf, usize)>>,
203}
204
205pub struct RendererConfig<'a> {
206    pub root: &'a Path,
207    pub macros: &'a [PathBuf],
208    pub context: RunContext,
209    /// The Binding's merged vars.
210    pub vars: JsonMap<String, Json>,
211    /// `--var` overrides, highest precedence.
212    pub cli_vars: BTreeMap<String, String>,
213    pub runner: Option<Arc<dyn QueryRunner>>,
214    /// Profiles for `connection`, `destination` and `profile()`; `None` offline.
215    pub connections: Option<Arc<dyn Connections>>,
216    pub mode: Mode,
217    /// The connection `connection.*` reads and `run_query()` uses by default: the query's in
218    /// query SQL, the Binding's inherited one elsewhere.
219    pub connection: Option<String>,
220    /// That connection's plugin type, for `dispatch()` and source quoting; empty when unknown.
221    pub source_type: String,
222    /// `source()`; `None`: the project declares no sources.
223    pub sources: Option<SourceResolver>,
224    pub run_query_max_rows: u64,
225    /// Every `.sql` file `ref()` can name, by basename, relative to `root`.
226    pub sql: BTreeMap<String, PathBuf>,
227    /// Lookups `ref()` and `lookup()` can name.
228    pub lookups: BTreeMap<String, Lookup>,
229    pub lookup_inline_max_rows: u64,
230    /// Macro packages, imported under their names.
231    pub packages: Vec<Package>,
232    /// The project's name: the root namespace in `dispatch()` search orders.
233    pub project_name: String,
234    pub dispatch: DispatchOrder,
235}
236
237impl Renderer {
238    pub fn new(cfg: RendererConfig<'_>) -> Result<Renderer, RenderError> {
239        let mut env = Environment::new();
240        let parse = cfg.mode == Mode::Parse;
241        // The parse pass has no data: `run_query(...)[0].x` must render as nothing, not fail.
242        env.set_undefined_behavior(if parse {
243            UndefinedBehavior::Chainable
244        } else {
245            UndefinedBehavior::Strict
246        });
247        env.set_syntax(
248            minijinja::syntax::SyntaxConfig::builder()
249                .keep_trailing_newline(true)
250                .build()
251                .expect("default delimiters are valid"),
252        );
253        // Message text is portable Markdown: every value it prints is escaped, so a `*` or `_`
254        // in the data stays literal (`| safe` opts out).
255        env.set_auto_escape_callback(|name| {
256            if name.ends_with(MARKDOWN_SUFFIX) {
257                minijinja::AutoEscape::Custom("markdown".into())
258            } else {
259                minijinja::AutoEscape::None
260            }
261        });
262        env.set_formatter(|out, state, value| {
263            if *state.auto_escape() == minijinja::AutoEscape::Custom("markdown".into()) {
264                let text = value.to_string();
265                let text = if value.is_safe() {
266                    text
267                } else {
268                    dre_protocol::markdown::escape(&text)
269                };
270                return out.write_str(&text).map_err(Error::from);
271            }
272            minijinja::escape_formatter(out, state, value)
273        });
274        crate::mutable::register(&mut env);
275
276        add_vars(&mut env, cfg.vars, cfg.cli_vars);
277        // The parse pass has no data, so a `raise_error()` there may only mean `run_query()` came
278        // back empty: it doesn't stop the render, but if rendering fails anyway, its message is the
279        // one reported.
280        let raised: Arc<Mutex<Option<String>>> = Arc::default();
281        let raised_c = raised.clone();
282        env.add_function("raise_error", move |message: String| -> Result<Value, Error> {
283            if parse {
284                raised_c.lock().unwrap().get_or_insert(message);
285                return Ok(Value::from(""));
286            }
287            Err(Error::new(ErrorKind::InvalidOperation, message))
288        });
289        let source_type = cfg.source_type.clone();
290        crate::dates::register(&mut env, cfg.context.calendar, cfg.context.date);
291        crate::numbers::register(&mut env, cfg.context.locale);
292        env.add_global("target", Value::from_object(Target(cfg.context.target.clone())));
293        env.add_global("run", Value::from_object(Run(cfg.context)));
294        let destination: Arc<Mutex<Option<Connection>>> = Arc::default();
295        if parse {
296            env.add_global("connection", Value::from_object(Lenient));
297            env.add_global("destination", Value::from_object(Lenient));
298            env.add_function(
299                "profile",
300                |_name: String, kwargs: Kwargs| -> Result<Value, Error> {
301                    let role: Option<String> = kwargs.get("role")?;
302                    kwargs.assert_all_used()?;
303                    check_role(role.as_deref())?;
304                    Ok(Value::from_object(Lenient))
305                },
306            );
307        } else {
308            env.add_global(
309                "connection",
310                Value::from_object(LazyConnection {
311                    connections: cfg.connections.clone(),
312                    name: cfg.connection.clone(),
313                    resolved: std::sync::OnceLock::new(),
314                }),
315            );
316            env.add_global(
317                "destination",
318                Value::from_object(DestinationSlot(destination.clone())),
319            );
320            let c = cfg.connections.clone();
321            env.add_function(
322                "profile",
323                move |name: String, kwargs: Kwargs| -> Result<Value, Error> {
324                    let role: Option<String> = kwargs.get("role")?;
325                    kwargs.assert_all_used()?;
326                    check_role(role.as_deref())?;
327                    let Some(c) = &c else {
328                        return Err(Error::new(
329                            ErrorKind::InvalidOperation,
330                            format!("`profile('{name}')`: no profiles.yml is loaded here"),
331                        ));
332                    };
333                    c.profile(&name, role.as_deref())
334                        .map(|t| Value::from_object(ConnectionValue(t)))
335                        .map_err(|e| {
336                            Error::new(ErrorKind::InvalidOperation, format!("`profile('{name}')`: {e}"))
337                        })
338                },
339            );
340        }
341        // `source()`: every call is recorded; in a real render the relation is quoted with the
342        // connection's quote character where the source asks for it.
343        let source_calls: Arc<Mutex<Vec<(String, String)>>> = Arc::default();
344        let relations: Arc<Mutex<Vec<(String, ResolvedSource)>>> = Arc::default();
345        {
346            let calls = source_calls.clone();
347            let relations = relations.clone();
348            let resolver = cfg.sources.clone();
349            let connections = cfg.connections.clone();
350            let kind = cfg.source_type.clone();
351            let quote: Arc<std::sync::OnceLock<Result<Option<String>, String>>> = Arc::default();
352            env.add_function(
353                "source",
354                move |source: String, table: String| -> Result<Value, Error> {
355                    let fail = |e: String| {
356                        Error::new(
357                            ErrorKind::InvalidOperation,
358                            format!("`source('{source}', '{table}')`: {e}"),
359                        )
360                    };
361                    let Some(resolve) = &resolver else {
362                        return Err(fail("the project declares no `sources:`".into()));
363                    };
364                    let r = resolve(&source, &table).map_err(fail)?;
365                    calls.lock().unwrap().push((source.clone(), table.clone()));
366                    let q = if parse || !r.quoting.any() {
367                        None
368                    } else {
369                        let got = quote.get_or_init(|| match &connections {
370                            Some(c) if !kind.is_empty() => c.identifier_quote(&kind),
371                            _ => Err("no connection is known here to quote with".into()),
372                        });
373                        match got {
374                            Ok(Some(q)) => Some(q.clone()),
375                            Ok(None) => {
376                                return Err(fail(format!(
377                                    "`quoting` needs the identifier quote character, but the `{kind}` plugin doesn't report one; update it (`dre plugin update {kind}`)"
378                                )));
379                            }
380                            Err(e) => return Err(fail(format!("can't quote: {e}"))),
381                        }
382                    };
383                    let text = r.relation(q.as_deref());
384                    relations.lock().unwrap().push((text.clone(), r));
385                    Ok(Value::from(text))
386                },
387            );
388        }
389        let runner = cfg.runner;
390        let warnings: Arc<Mutex<Vec<String>>> = Arc::default();
391        let lookups = Arc::new(Lookups {
392            root: cfg.root.to_path_buf(),
393            defs: cfg.lookups,
394            inline_max: cfg.lookup_inline_max_rows,
395            runner: runner.clone(),
396            tables: Mutex::default(),
397            loaded: Mutex::default(),
398            warnings: warnings.clone(),
399        });
400        let l = lookups.clone();
401        env.add_function("lookup", move |name: String| -> Result<Value, Error> {
402            let t = l.table(&name)?;
403            let rows = QueryRows {
404                columns: t.columns.clone(),
405                rows: t
406                    .rows
407                    .iter()
408                    .map(|r| r.iter().map(cell_value).collect())
409                    .collect(),
410            };
411            Ok(Value::from_object(QueryResult::new(rows)))
412        });
413        let default_max = cfg.run_query_max_rows;
414        let runner_c = runner.clone();
415        let rels = relations.clone();
416        env.add_function(
417            "run_query",
418            move |sql: String, kwargs: Kwargs| -> Result<Value, Error> {
419                let max_rows: Option<u64> = kwargs.get("max_rows")?;
420                let profile: Option<String> = kwargs.get("profile")?;
421                kwargs.assert_all_used()?;
422                if parse {
423                    let mut empty = QueryResult::new(QueryRows::default());
424                    empty.lenient = true;
425                    return Ok(Value::from_object(empty));
426                }
427                let profile = choose_profile("run_query()", &sql, profile, &rels)?;
428                let Some(runner) = &runner else {
429                    return Err(Error::new(
430                        ErrorKind::InvalidOperation,
431                        "`run_query()` has no connection here",
432                    ));
433                };
434                let rows = runner
435                    .run_query(&sql, max_rows.unwrap_or(default_max), profile.as_deref())
436                    .map_err(|e| Error::new(ErrorKind::InvalidOperation, e))?;
437                Ok(Value::from_object(QueryResult::new(rows)))
438            },
439        );
440        // Per rendered file: a later query may have recreated the relation.
441        let columns_cache: Arc<Mutex<ColumnsCache>> = Arc::default();
442        let cache = columns_cache.clone();
443        let rels = relations.clone();
444        env.add_function(
445            "columns",
446            move |rel: String, kwargs: Kwargs| -> Result<Value, Error> {
447                let profile: Option<String> = kwargs.get("profile")?;
448                kwargs.assert_all_used()?;
449                if parse {
450                    return Ok(Value::from(Vec::<Value>::new()));
451                }
452                let what = format!("columns('{}')", crate::secrets::mask(&rel));
453                let profile = choose_profile(&what, &rel, profile, &rels)?;
454                let key = (rel.clone(), profile.clone());
455                if let Some(v) = cache.lock().unwrap().get(&key) {
456                    return Ok(v.clone());
457                }
458                let Some(runner) = &runner_c else {
459                    return Err(Error::new(
460                        ErrorKind::InvalidOperation,
461                        format!("`{what}` has no connection here"),
462                    ));
463                };
464                let sql = format!("select * from {rel} as _dre_cols where 1=0");
465                let cols = runner
466                    .columns(&sql, profile.as_deref())
467                    .map_err(|e| Error::new(ErrorKind::InvalidOperation, format!("`{what}` failed: {e}")))?;
468                let v = Value::from(
469                    cols.into_iter()
470                        .map(|c| {
471                            Value::from_pairs([
472                                ("name", Value::from(c.name)),
473                                ("type", Value::from(c.data_type)),
474                            ])
475                        })
476                        .collect::<Vec<_>>(),
477                );
478                cache.lock().unwrap().insert(key, v.clone());
479                Ok(v)
480            },
481        );
482
483        // Every macro file is combined into one template, imported (on the first line, so line
484        // numbers don't move) into everything rendered. Each package gets its own template,
485        // imported under the package's name.
486        let root = cfg.root.to_path_buf();
487        let display = |p: &Path| {
488            p.strip_prefix(&root)
489                .map(Path::to_path_buf)
490                .unwrap_or_else(|_| p.to_path_buf())
491        };
492        let own: Vec<(PathBuf, PathBuf)> = cfg.macros.iter().map(|m| (cfg.root.join(m), m.clone())).collect();
493        let (combined, names, spans) = combine(&own)?;
494        let mut import = if names.is_empty() {
495            String::new()
496        } else {
497            format!("{{% from \"{MACROS}\" import {} %}}", names.join(", "))
498        };
499        let mut templates = vec![(MACROS.to_string(), combined, spans, !names.is_empty())];
500        for p in &cfg.packages {
501            let files: Vec<(PathBuf, PathBuf)> = p.macros.iter().map(|m| (m.clone(), display(m))).collect();
502            let (src, _, spans) = combine(&files)?;
503            let t = package_template(&p.name);
504            import.push_str(&format!("{{% import \"{t}\" as {} %}}", p.name));
505            templates.push((t, src, spans, true));
506        }
507        add_ref(&mut env, cfg.root, cfg.sql, lookups, &import);
508        add_dispatch(
509            &mut env,
510            &cfg.project_name,
511            !names.is_empty(),
512            &cfg.packages,
513            cfg.dispatch,
514            source_type,
515            parse,
516        );
517        let mut r = Renderer {
518            env,
519            raised,
520            columns_cache,
521            source_calls,
522            destination,
523            warnings,
524            import,
525            macro_files: BTreeMap::new(),
526        };
527        for (name, src, spans, add) in templates {
528            r.macro_files.insert(name.clone(), spans);
529            if add {
530                r.env
531                    .add_template_owned(name.clone(), src)
532                    .map_err(|e| r.error(Path::new(&name), &e))?;
533            }
534        }
535        Ok(r)
536    }
537
538    /// Warnings raised since the last call.
539    pub fn take_warnings(&self) -> Vec<String> {
540        std::mem::take(&mut *self.warnings.lock().unwrap())
541    }
542
543    /// `(source, table)` of every `source()` called since the last call, in order, each once.
544    pub fn take_sources(&self) -> Vec<(String, String)> {
545        let mut out: Vec<(String, String)> = Vec::new();
546        for c in std::mem::take(&mut *self.source_calls.lock().unwrap()) {
547            if !out.contains(&c) {
548                out.push(c);
549            }
550        }
551        out
552    }
553
554    /// Set (or clear) the destination `destination.*` reads.
555    pub fn set_destination(&self, d: Option<Connection>) {
556        *self.destination.lock().unwrap() = d;
557    }
558
559    /// Render `src`, reporting errors against `file`.
560    pub fn render(&self, file: &Path, src: &str) -> Result<String, RenderError> {
561        self.render_with(file, src, Value::UNDEFINED, false)
562    }
563
564    /// Render `src` with `ctx`'s keys (a map) added to the context (`results`, `outputs`). With
565    /// `markdown`, it's message text: every printed value is escaped for portable Markdown.
566    pub fn render_with(
567        &self,
568        file: &Path,
569        src: &str,
570        ctx: Value,
571        markdown: bool,
572    ) -> Result<String, RenderError> {
573        self.columns_cache.lock().unwrap().clear();
574        *self.raised.lock().unwrap() = None;
575        let full = format!("{}{src}", self.import);
576        let mut name = file.to_string_lossy().to_string();
577        if markdown {
578            name.push_str(MARKDOWN_SUFFIX);
579        }
580        let tmpl = self
581            .env
582            .template_from_named_str(&name, &full)
583            .map_err(|e| self.error(file, &e))?;
584        let rendered = if ctx.is_undefined() {
585            tmpl.render(())
586        } else {
587            tmpl.render(ctx)
588        };
589        rendered.map_err(|e| {
590            let mut err = self.error(file, &e);
591            if let Some(m) = self.raised.lock().unwrap().take() {
592                err.message = m;
593            }
594            err
595        })
596    }
597
598    fn error(&self, file: &Path, e: &Error) -> RenderError {
599        // Report the innermost location: a macro file if the error happened inside a macro.
600        let mut deepest: &Error = e;
601        while let Some(src) = std::error::Error::source(deepest).and_then(|s| s.downcast_ref::<Error>()) {
602            deepest = src;
603        }
604        let (name, line) = match (deepest.name(), deepest.line()) {
605            (Some(n), l) => (n.to_string(), l),
606            _ => (e.name().unwrap_or_default().to_string(), e.line()),
607        };
608        let (file, line) = if let Some(spans) = self.macro_files.get(&name) {
609            match line.and_then(|l| {
610                spans
611                    .iter()
612                    .find(|(s, _, n)| l >= *s && l < s + n)
613                    .map(|(s, f, _)| (f, l - s + 1))
614            }) {
615                Some((f, l)) => (f.clone(), Some(l)),
616                None => (file.to_path_buf(), line),
617            }
618        } else {
619            (file.to_path_buf(), line)
620        };
621        let message = deepest
622            .detail()
623            .map(str::to_string)
624            .unwrap_or_else(|| deepest.kind().to_string());
625        RenderError { file, line, message }
626    }
627}
628
629/// `var()` (`--var` first, then the Binding's vars, then the default) and `env_var()`.
630/// A `--var` value as YAML 1.2's core rules read it: `true`/`false`, `null`/`~`, integers
631/// (no leading zeros), floats, and flow lists and maps (`[a, b]`, `{k: v}`) are typed; anything
632/// else stays a string (`yes`, `NO`, `2026-01-31`, `010`, `1.0.0`). Quoting forces a string
633/// (`'"false"'`), and an empty value is the empty string.
634pub fn cli_var_value(s: &str) -> Json {
635    let t = s.trim();
636    let int = regex_lite(t, |c, i| c.is_ascii_digit() || (i == 0 && (c == '-' || c == '+')));
637    match t {
638        "" => return Json::String(String::new()),
639        "true" | "True" | "TRUE" => return Json::Bool(true),
640        "false" | "False" | "FALSE" => return Json::Bool(false),
641        "null" | "Null" | "NULL" | "~" => return Json::Null,
642        _ => {}
643    }
644    if int {
645        let digits = t.trim_start_matches(['-', '+']);
646        if !digits.is_empty()
647            && !(digits.len() > 1 && digits.starts_with('0'))
648            && let Ok(n) = t.parse::<i64>()
649        {
650            return Json::from(n);
651        }
652        return Json::String(s.to_string());
653    }
654    let float = t.trim_start_matches(['-', '+']);
655    let is_float = !float.is_empty()
656        && float
657            .chars()
658            .all(|c| c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '-' | '+'))
659        && float
660            .chars()
661            .next()
662            .is_some_and(|c| c.is_ascii_digit() || c == '.')
663        && float.chars().filter(|c| *c == '.').count() <= 1
664        && !float.starts_with("00")
665        && !(float.starts_with('0') && float.len() > 1 && float.as_bytes()[1].is_ascii_digit());
666    if is_float
667        && (float.contains('.') || float.contains(['e', 'E']))
668        && let Ok(f) = t.parse::<f64>()
669        && let Some(n) = serde_json::Number::from_f64(f)
670    {
671        return Json::Number(n);
672    }
673    let quoted = (t.starts_with('"') && t.ends_with('"') && t.len() >= 2)
674        || (t.starts_with('\'') && t.ends_with('\'') && t.len() >= 2);
675    let flow = (t.starts_with('[') && t.ends_with(']')) || (t.starts_with('{') && t.ends_with('}'));
676    if (quoted || flow)
677        && let Ok(v) = serde_saphyr::from_str::<Json>(t)
678    {
679        return v;
680    }
681    Json::String(s.to_string())
682}
683
684/// Whether every character passes `ok(c, index)`.
685fn regex_lite(s: &str, ok: impl Fn(char, usize) -> bool) -> bool {
686    !s.is_empty() && s.chars().enumerate().all(|(i, c)| ok(c, i))
687}
688
689fn add_vars(env: &mut Environment<'static>, vars: JsonMap<String, Json>, cli: BTreeMap<String, String>) {
690    env.add_function(
691        "var",
692        move |name: String, default: Option<Value>| -> Result<Value, Error> {
693            if let Some(v) = cli.get(&name) {
694                return Ok(Value::from(minijinja::value::Serde(cli_var_value(v))));
695            }
696            if let Some(v) = vars.get(&name) {
697                return Ok(Value::from(minijinja::value::Serde(v)));
698            }
699            default.ok_or_else(|| {
700                Error::new(
701                    ErrorKind::UndefinedError,
702                    format!("`var('{name}')` has no value and no default"),
703                )
704            })
705        },
706    );
707    env.add_function("env_var", |name: String, default: Option<Value>| -> Result<Value, Error> {
708        match std::env::var(&name) {
709            Ok(v) => Ok(Value::from(v)),
710            Err(_) => default.ok_or_else(|| {
711                Error::new(
712                    ErrorKind::UndefinedError,
713                    format!("`env_var('{name}')`: environment variable `{name}` is not set and no default is given"),
714                )
715            }),
716        }
717    });
718}
719
720/// Renders the values that choose a connection or a source: every `profile:` value and a
721/// source's `profile`, `database`, `schema` and `identifier`. Only `var()`, `env_var()`, `run.*`,
722/// `target.name` and the calendar functions exist, because these are rendered before any
723/// connection is chosen: the parse pass, `dre ls` and the manifest need no database.
724pub struct Limited {
725    env: Environment<'static>,
726}
727
728/// What a [`Limited`] value can't use, and why.
729const LIMITED_FUNCTIONS: &[&str] = &[
730    "run_query",
731    "columns",
732    "source",
733    "ref",
734    "lookup",
735    "dispatch",
736    "profile",
737];
738const LIMITED_GLOBALS: &[&str] = &["connection", "destination"];
739
740impl Limited {
741    pub fn new(
742        context: RunContext,
743        vars: JsonMap<String, Json>,
744        cli_vars: BTreeMap<String, String>,
745    ) -> Limited {
746        let mut env = Environment::new();
747        env.set_undefined_behavior(UndefinedBehavior::Strict);
748        crate::mutable::register(&mut env);
749        add_vars(&mut env, vars, cli_vars);
750        env.add_function("raise_error", |message: String| -> Result<Value, Error> {
751            Err(Error::new(ErrorKind::InvalidOperation, message))
752        });
753        crate::dates::register(&mut env, context.calendar, context.date);
754        crate::numbers::register(&mut env, context.locale);
755        env.add_global("target", Value::from_object(Target(context.target.clone())));
756        env.add_global("run", Value::from_object(Run(context)));
757        for f in LIMITED_FUNCTIONS {
758            let name = *f;
759            env.add_function(name, move |_args: minijinja::value::Rest<Value>| -> Result<Value, Error> {
760                Err(Error::new(
761                    ErrorKind::InvalidOperation,
762                    format!("`{name}()` can't be used here: this value chooses a connection or a source, so it's rendered before any connection is open"),
763                ))
764            });
765        }
766        for g in LIMITED_GLOBALS {
767            env.add_global(*g, Value::from_object(Forbidden(g)));
768        }
769        Limited { env }
770    }
771
772    /// Render `src`, the value of `key` (named in errors, e.g. "`profile`").
773    pub fn render(&self, key: &str, src: &str) -> Result<String, String> {
774        if !crate::preflight::is_templated(src) {
775            return Ok(src.to_string());
776        }
777        self.env
778            .render_str(src, ())
779            .map(|v| v.trim().to_string())
780            .map_err(|e| {
781                let mut deepest: &Error = &e;
782                while let Some(s) = std::error::Error::source(deepest).and_then(|s| s.downcast_ref::<Error>())
783                {
784                    deepest = s;
785                }
786                let msg = deepest
787                    .detail()
788                    .map(str::to_string)
789                    .unwrap_or_else(|| deepest.kind().to_string());
790                format!("{key} `{src}`: {msg}")
791            })
792    }
793}
794
795/// `connection` or `destination` in a [`Limited`] value.
796#[derive(Debug)]
797struct Forbidden(&'static str);
798
799impl Object for Forbidden {
800    fn get_value(self: &Arc<Self>, _key: &Value) -> Option<Value> {
801        Some(Value::from(Error::new(
802            ErrorKind::InvalidOperation,
803            format!(
804                "`{}.*` can't be used here: this value chooses a connection or a source, so it's rendered before any connection is open",
805                self.0
806            ),
807        )))
808    }
809
810    fn render(self: &Arc<Self>, _f: &mut fmt::Formatter<'_>) -> fmt::Result {
811        Err(fmt::Error)
812    }
813}
814
815fn package_template(name: &str) -> String {
816    format!("__dre_package_{name}__")
817}
818
819/// Concatenate macro files `(path to read, path to report)` into one template source, with
820/// every macro name defined and where each file starts.
821#[allow(clippy::type_complexity)]
822fn combine(
823    files: &[(PathBuf, PathBuf)],
824) -> Result<(String, Vec<String>, Vec<(usize, PathBuf, usize)>), RenderError> {
825    let re = regex::Regex::new(r"\{%-?\s*macro\s+([A-Za-z_]\w*)").unwrap();
826    let mut combined = String::new();
827    let mut names = Vec::new();
828    let mut spans = Vec::new();
829    for (read, shown) in files {
830        let src = std::fs::read_to_string(read).map_err(|e| RenderError {
831            file: shown.clone(),
832            line: None,
833            message: format!("can't read macro file: {e}"),
834        })?;
835        let start = combined.matches('\n').count() + 1;
836        names.extend(re.captures_iter(&src).map(|c| c[1].to_string()));
837        combined.push_str(&src);
838        if !combined.ends_with('\n') {
839            combined.push('\n');
840        }
841        spans.push((start, shown.clone(), src.matches('\n').count() + 1));
842    }
843    Ok((combined, names, spans))
844}
845
846/// `dispatch('name', 'namespace')` returns the macro to call for the active source: in each
847/// namespace of the search order, `<source type>__name`, then `default__name`. The search order
848/// is `dispatch:` in dre_project.yml, else the root project then the namespace, so a project can
849/// override a package's macro by defining, say, `databricks__name` in its own macros/.
850fn add_dispatch(
851    env: &mut Environment<'static>,
852    project: &str,
853    project_has_macros: bool,
854    packages: &[Package],
855    order: DispatchOrder,
856    source_type: String,
857    parse: bool,
858) {
859    let project = project.to_string();
860    let templates: BTreeMap<String, String> = packages
861        .iter()
862        .map(|p| (p.name.clone(), package_template(&p.name)))
863        .collect();
864    env.add_function(
865        "dispatch",
866        move |state: &minijinja::State<'_, '_>,
867              name: String,
868              namespace: Option<String>|
869              -> Result<Value, Error> {
870            let ns = namespace.unwrap_or_else(|| project.clone());
871            let search = order.get(&ns).cloned().unwrap_or_else(|| {
872                if ns == project {
873                    vec![ns.clone()]
874                } else {
875                    vec![project.clone(), ns.clone()]
876                }
877            });
878            let candidates = [format!("{source_type}__{name}"), format!("default__{name}")];
879            for n in &search {
880                let template = if n == &project {
881                    if !project_has_macros {
882                        continue;
883                    }
884                    MACROS.to_string()
885                } else {
886                    match templates.get(n) {
887                        Some(t) => t.clone(),
888                        None => continue,
889                    }
890                };
891                let tmpl = state.env().get_template(&template)?;
892                let captured = tmpl.render_captured(())?;
893                let st = captured.state();
894                for c in &candidates {
895                    if st.lookup(c).is_some_and(|v| !v.is_undefined()) {
896                        return Ok(Value::from_object(Dispatched {
897                            template,
898                            name: c.clone(),
899                        }));
900                    }
901                }
902            }
903            if parse {
904                // The parse pass doesn't know the connection type: a macro only some types
905                // implement renders as nothing.
906                return Ok(Value::from_object(Dispatched {
907                    template: String::new(),
908                    name: String::new(),
909                }));
910            }
911            Err(Error::new(
912                ErrorKind::InvalidOperation,
913                format!(
914                    "`dispatch('{name}', '{ns}')`: no `{}` or `{}` in {}",
915                    candidates[0],
916                    candidates[1],
917                    search.join(", ")
918                ),
919            ))
920        },
921    );
922}
923
924/// A macro chosen by `dispatch()`, called by name in its own template.
925#[derive(Debug)]
926struct Dispatched {
927    template: String,
928    name: String,
929}
930
931impl Object for Dispatched {
932    fn call(self: &Arc<Self>, state: &mut minijinja::State<'_, '_>, args: &[Value]) -> Result<Value, Error> {
933        if self.template.is_empty() {
934            return Ok(Value::from(""));
935        }
936        let tmpl = state.env().get_template(&self.template)?;
937        let out = tmpl
938            .render_captured(())?
939            .with_state_mut(|s| s.call_macro(&self.name, args))?;
940        Ok(Value::from(out))
941    }
942}
943
944/// `ref('name')`: another `.sql` file, rendered in the same context (vars, `run.*`, macros, the
945/// Binding's connection) and returned in parentheses, ready to use as a subquery or CTE body.
946/// DRE builds no tables, so a ref inlines SQL rather than pointing at a materialised model.
947fn add_ref(
948    env: &mut Environment<'static>,
949    root: &Path,
950    sql: BTreeMap<String, PathBuf>,
951    lookups: Arc<Lookups>,
952    import: &str,
953) {
954    let root = root.to_path_buf();
955    let import = import.to_string();
956    // The chain of refs being rendered, to report cycles instead of recursing forever.
957    let stack: Arc<Mutex<Vec<String>>> = Arc::default();
958    env.add_function(
959        "ref",
960        move |state: &minijinja::State<'_, '_>, name: String| -> Result<Value, Error> {
961            if lookups.defs.contains_key(&name) {
962                return lookups.relation(&name).map(Value::from);
963            }
964            let Some(rel) = sql.get(&name) else {
965                return Err(Error::new(
966                    ErrorKind::InvalidOperation,
967                    format!("`ref('{name}')`: no `{name}.sql` and no lookup `{name}` in the project"),
968                ));
969            };
970            {
971                let mut chain = stack.lock().unwrap();
972                if chain.contains(&name) {
973                    chain.push(name.clone());
974                    let cycle = chain.join(" → ");
975                    chain.clear();
976                    return Err(Error::new(
977                        ErrorKind::InvalidOperation,
978                        format!("`ref()` cycle: {cycle}"),
979                    ));
980                }
981                chain.push(name.clone());
982            }
983            let result = (|| {
984                let src = std::fs::read_to_string(root.join(rel)).map_err(|e| {
985                    Error::new(
986                        ErrorKind::InvalidOperation,
987                        format!("can't read {}: {e}", rel.display()),
988                    )
989                })?;
990                let file = rel.to_string_lossy().to_string();
991                let rendered = state
992                    .env()
993                    .template_from_named_str(&file, &format!("{import}{src}"))?
994                    .render(())?;
995                let statements = crate::sqlsplit::split(&rendered);
996                match statements.as_slice() {
997                    [one] => Ok(Value::from(format!("(\n{}\n)", one.text))),
998                    _ => Err(Error::new(
999                        ErrorKind::InvalidOperation,
1000                        format!(
1001                            "`ref('{name}')` needs {} to hold exactly one statement, but it has {}",
1002                            rel.display(),
1003                            statements.len()
1004                        ),
1005                    )),
1006                }
1007            })();
1008            let mut chain = stack.lock().unwrap();
1009            if chain.last() == Some(&name) {
1010                chain.pop();
1011            }
1012            result
1013        },
1014    );
1015}
1016
1017/// Lookups for one Binding: read once, inlined or loaded once.
1018struct Lookups {
1019    root: PathBuf,
1020    defs: BTreeMap<String, Lookup>,
1021    inline_max: u64,
1022    runner: Option<Arc<dyn QueryRunner>>,
1023    tables: Mutex<BTreeMap<String, Arc<Table>>>,
1024    /// What `ref()` returns for each lookup already used: inline SQL or a temp table's name.
1025    loaded: Mutex<BTreeMap<String, String>>,
1026    warnings: Arc<Mutex<Vec<String>>>,
1027}
1028
1029impl Lookups {
1030    fn table(&self, name: &str) -> Result<Arc<Table>, Error> {
1031        let def = self.defs.get(name).ok_or_else(|| {
1032            Error::new(
1033                ErrorKind::InvalidOperation,
1034                format!("no lookup `{name}` under lookups/"),
1035            )
1036        })?;
1037        if let Some(t) = self.tables.lock().unwrap().get(name) {
1038            return Ok(t.clone());
1039        }
1040        let t = Arc::new(lookups::read(&self.root, def).map_err(|e| {
1041            Error::new(
1042                ErrorKind::InvalidOperation,
1043                format!("{}: {e}", def.file.display()),
1044            )
1045        })?);
1046        self.tables.lock().unwrap().insert(name.to_string(), t.clone());
1047        Ok(t)
1048    }
1049
1050    fn relation(&self, name: &str) -> Result<String, Error> {
1051        if let Some(r) = self.loaded.lock().unwrap().get(name) {
1052            return Ok(r.clone());
1053        }
1054        let t = self.table(name)?;
1055        let rows = t.rows.len() as u64;
1056        let load = match self.defs[name].load {
1057            Load::Inline => false,
1058            Load::TempTable => true,
1059            Load::Auto => rows > self.inline_max,
1060        };
1061        let loaded = match (&self.runner, load) {
1062            (Some(runner), true) => runner.load(name, &t).map_err(|e| {
1063                Error::new(
1064                    ErrorKind::InvalidOperation,
1065                    format!("loading lookup `{name}`: {e}"),
1066                )
1067            })?,
1068            _ => None,
1069        };
1070        let relation = match loaded {
1071            Some((relation, warning)) => {
1072                if let Some(w) = warning {
1073                    self.warn(format!("lookup `{name}`: {w}"));
1074                }
1075                relation
1076            }
1077            None => {
1078                if load && self.runner.is_some() {
1079                    self.warn(format!(
1080                        "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"
1081                    ));
1082                }
1083                t.inline_sql()
1084            }
1085        };
1086        self.loaded
1087            .lock()
1088            .unwrap()
1089            .insert(name.to_string(), relation.clone());
1090        Ok(relation)
1091    }
1092
1093    fn warn(&self, w: String) {
1094        self.warnings.lock().unwrap().push(w);
1095    }
1096}
1097
1098fn cell_value(c: &Cell) -> Value {
1099    match c {
1100        Cell::Null => Value::from(()),
1101        Cell::Text(s) => Value::from(s.clone()),
1102        Cell::Int(n) => Value::from(*n),
1103        Cell::Num(n) => Value::from(*n),
1104        Cell::Bool(b) => Value::from(*b),
1105        Cell::Date(d) => Value::from(d.to_string()),
1106    }
1107}
1108
1109#[derive(Debug)]
1110struct Run(RunContext);
1111
1112impl Object for Run {
1113    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1114        let c = &self.0;
1115        Some(match key.as_str()? {
1116            "report" => Value::from(c.report.clone()),
1117            "set" => c.set.clone().map(Value::from).unwrap_or(Value::from(())),
1118            "target" => Value::from(c.target.clone()),
1119            "profile" => removed("run.profile", "`connection.name` (the query's connection)"),
1120            "source_type" => removed("run.source_type", "`connection.type` (the query's connection)"),
1121            "schedule" => c.schedule.clone().map(Value::from).unwrap_or(Value::from(())),
1122            "date" => Date::value(c.date, c.calendar),
1123            "now" => DateTime::now(c.now, c.calendar),
1124            "scheduled_at" => c
1125                .scheduled_at
1126                .map(|t| DateTime::now(t, c.calendar))
1127                .unwrap_or(Value::from(())),
1128            "timezone" => Value::from(c.calendar.tz.name()),
1129            _ => return None,
1130        })
1131    }
1132
1133    fn call_method(
1134        self: &Arc<Self>,
1135        _: &mut minijinja::State<'_, '_>,
1136        method: &str,
1137        args: &[Value],
1138    ) -> Result<Value, Error> {
1139        match method {
1140            "date_format" => {
1141                let fmt = args.first().and_then(|a| a.as_str()).ok_or_else(|| {
1142                    Error::new(
1143                        ErrorKind::InvalidOperation,
1144                        "`run.date_format()` takes a strftime format string",
1145                    )
1146                })?;
1147                let mut out = String::new();
1148                use std::fmt::Write;
1149                write!(out, "{}", self.0.date.format(fmt)).map_err(|_| {
1150                    Error::new(
1151                        ErrorKind::InvalidOperation,
1152                        format!("invalid date format `{fmt}`"),
1153                    )
1154                })?;
1155                Ok(Value::from(out))
1156            }
1157            _ => Err(Error::from(ErrorKind::UnknownMethod)),
1158        }
1159    }
1160}
1161
1162/// An error value naming a removed template name and what to write instead.
1163fn removed(name: &str, instead: &str) -> Value {
1164    Value::from(Error::new(
1165        ErrorKind::InvalidOperation,
1166        format!("`{name}` was removed in DRE 0.2: use {instead}"),
1167    ))
1168}
1169
1170/// `profile(..., role=)` takes `connection` or `destination`.
1171fn check_role(role: Option<&str>) -> Result<(), Error> {
1172    match role {
1173        None | Some("connection") | Some("destination") => Ok(()),
1174        Some("source") => Err(Error::new(
1175            ErrorKind::InvalidOperation,
1176            "`role='source'` was renamed in DRE 0.2: use `role='connection'`",
1177        )),
1178        Some(r) => Err(Error::new(
1179            ErrorKind::InvalidOperation,
1180            format!("`role='{r}'`: role must be 'connection' or 'destination'"),
1181        )),
1182    }
1183}
1184
1185/// The connection `run_query()`/`columns()` uses: `profile=` when given (it must agree with
1186/// every source `sql` reads), else the connection of the sources `sql` reads, else `None` (the
1187/// renderer's own).
1188fn choose_profile(
1189    what: &str,
1190    sql: &str,
1191    explicit: Option<String>,
1192    relations: &Mutex<Vec<(String, ResolvedSource)>>,
1193) -> Result<Option<String>, Error> {
1194    let mut used: Vec<(String, String)> = Vec::new();
1195    for (text, r) in relations.lock().unwrap().iter() {
1196        if let Some(p) = &r.profile
1197            && mentions(sql, text)
1198            && !used.iter().any(|(k, _)| *k == r.key())
1199        {
1200            used.push((r.key(), p.clone()));
1201        }
1202    }
1203    let fail = |m: String| Error::new(ErrorKind::InvalidOperation, format!("`{what}`: {m}"));
1204    match explicit {
1205        Some(p) => {
1206            if let Some((k, q)) = used.iter().find(|(_, q)| *q != p) {
1207                return Err(fail(format!(
1208                    "`profile='{p}'` disagrees with source `{k}`, which is on connection `{q}`"
1209                )));
1210            }
1211            Ok(Some(p))
1212        }
1213        None => match used.as_slice() {
1214            [] => Ok(None),
1215            [(_, p), rest @ ..] => {
1216                if let Some((k, q)) = rest.iter().find(|(_, q)| q != p) {
1217                    return Err(fail(format!(
1218                        "reads source `{}` on connection `{p}` and source `{k}` on `{q}`; one statement runs on one connection",
1219                        used[0].0
1220                    )));
1221                }
1222                Ok(Some(p.clone()))
1223            }
1224        },
1225    }
1226}
1227
1228/// Whether `text` (a rendered relation) appears in `sql` as a whole name.
1229fn mentions(sql: &str, text: &str) -> bool {
1230    let ident = |c: char| c.is_alphanumeric() || c == '_';
1231    sql.match_indices(text).any(|(i, _)| {
1232        let before = sql[..i].chars().next_back();
1233        let after = sql[i + text.len()..].chars().next();
1234        !before.is_some_and(|c| ident(c) || c == '.') && !after.is_some_and(|c| ident(c) || c == '.')
1235    })
1236}
1237
1238/// `target`: the run's environment. Only `target.name` remains; every other field moved to
1239/// `connection.*` in DRE 0.2.
1240#[derive(Debug)]
1241struct Target(String);
1242
1243impl Object for Target {
1244    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1245        let k = key.as_str()?;
1246        Some(match k {
1247            "name" => Value::from(self.0.clone()),
1248            "type" => removed("target.type", "`connection.type` (the query's connection)"),
1249            "profile" => removed("target.profile", "`connection.name` (the query's connection)"),
1250            _ => removed(
1251                &format!("target.{k}"),
1252                &format!(
1253                    "`connection.{k}` (the query's connection) or `profile('<name>').{k}`; `target` is now only the environment, `target.name`"
1254                ),
1255            ),
1256        })
1257    }
1258
1259    fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1260        f.write_str(&self.0)
1261    }
1262}
1263
1264/// The parse pass's stand-in for `connection`, `destination` and `profile()`: every field is
1265/// undefined, which renders as nothing.
1266#[derive(Debug)]
1267struct Lenient;
1268
1269impl Object for Lenient {
1270    fn get_value(self: &Arc<Self>, _key: &Value) -> Option<Value> {
1271        None
1272    }
1273
1274    fn render(self: &Arc<Self>, _f: &mut fmt::Formatter<'_>) -> fmt::Result {
1275        Ok(())
1276    }
1277}
1278
1279/// `profile('name')`: a profile target's fields, refusing secret ones.
1280#[derive(Debug)]
1281struct ConnectionValue(Connection);
1282
1283impl Object for ConnectionValue {
1284    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1285        connection_field(&self.0, key)
1286    }
1287
1288    fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1289        write!(f, "{}", self.0.profile)
1290    }
1291}
1292
1293/// `connection`: the query's connection (or the Binding's inherited one outside query SQL),
1294/// looked up the first time a template reads it.
1295struct LazyConnection {
1296    connections: Option<Arc<dyn Connections>>,
1297    name: Option<String>,
1298    resolved: std::sync::OnceLock<Result<Connection, String>>,
1299}
1300
1301impl fmt::Debug for LazyConnection {
1302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1303        f.write_str("connection")
1304    }
1305}
1306
1307impl LazyConnection {
1308    fn get(&self) -> &Result<Connection, String> {
1309        self.resolved.get_or_init(|| {
1310            let name = self.name.as_deref().ok_or(
1311                "no connection here: this isn't a query's SQL and the Binding inherits no `profile`",
1312            )?;
1313            match &self.connections {
1314                Some(c) => c.profile(name, Some("connection")),
1315                None => Err("no profiles.yml is loaded here".into()),
1316            }
1317        })
1318    }
1319}
1320
1321impl Object for LazyConnection {
1322    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1323        match self.get() {
1324            Ok(c) => connection_field(c, key),
1325            Err(e) => Some(Value::from(Error::new(
1326                ErrorKind::InvalidOperation,
1327                format!("`connection`: {e}"),
1328            ))),
1329        }
1330    }
1331
1332    fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1333        match &self.name {
1334            Some(n) => f.write_str(n),
1335            None => Err(fmt::Error),
1336        }
1337    }
1338}
1339
1340/// `destination`: the destination whose `path` and options are being rendered.
1341#[derive(Debug)]
1342struct DestinationSlot(Arc<Mutex<Option<Connection>>>);
1343
1344impl Object for DestinationSlot {
1345    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1346        match &*self.0.lock().unwrap() {
1347            Some(c) => connection_field(c, key),
1348            None => Some(Value::from(Error::new(
1349                ErrorKind::InvalidOperation,
1350                "`destination` only exists while rendering a destination's `path` and options",
1351            ))),
1352        }
1353    }
1354
1355    fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1356        match &*self.0.lock().unwrap() {
1357            Some(c) => f.write_str(&c.profile),
1358            None => Err(fmt::Error),
1359        }
1360    }
1361}
1362
1363/// One field of a profile target for templates; a secret or missing one is an error value.
1364fn connection_field(c: &Connection, key: &Value) -> Option<Value> {
1365    {
1366        let k = key.as_str()?;
1367        Some(match k {
1368            "name" | "profile" => Value::from(c.profile.clone()),
1369            "target" => Value::from(c.target.clone()),
1370            "type" => Value::from(c.kind.clone()),
1371            _ if c.secrets.iter().any(|s| s == k) => Value::from(Error::new(
1372                ErrorKind::InvalidOperation,
1373                format!(
1374                    "`{k}` of profile `{}` holds a secret, so templates can't read it (it would end up in compiled SQL and logs)",
1375                    c.profile
1376                ),
1377            )),
1378            _ => match c.fields.get(k) {
1379                Some(v) => Value::from(minijinja::value::Serde(v)),
1380                None => Value::from(Error::new(
1381                    ErrorKind::UndefinedError,
1382                    format!(
1383                        "profile `{}` (target `{}`) has no field `{k}`; it has: {}",
1384                        c.profile,
1385                        c.target,
1386                        c.fields
1387                            .keys()
1388                            .filter(|f| !c.secrets.contains(f))
1389                            .cloned()
1390                            .collect::<Vec<_>>()
1391                            .join(", ")
1392                    ),
1393                )),
1394            },
1395        })
1396    }
1397}
1398
1399#[derive(Debug)]
1400struct QueryResult {
1401    columns: Vec<String>,
1402    rows: Vec<Value>,
1403    /// The parse pass's empty result: any `column()` is an empty list.
1404    lenient: bool,
1405}
1406
1407impl QueryResult {
1408    fn new(q: QueryRows) -> QueryResult {
1409        let cols: Arc<Vec<String>> = Arc::new(q.columns.clone());
1410        let rows = q
1411            .rows
1412            .into_iter()
1413            .map(|values| {
1414                Value::from_object(Row {
1415                    columns: cols.clone(),
1416                    values,
1417                })
1418            })
1419            .collect();
1420        QueryResult {
1421            columns: q.columns,
1422            rows,
1423            lenient: false,
1424        }
1425    }
1426}
1427
1428impl Object for QueryResult {
1429    fn repr(self: &Arc<Self>) -> ObjectRepr {
1430        ObjectRepr::Seq
1431    }
1432
1433    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1434        match key.as_str() {
1435            Some("rows") => Some(Value::from(self.rows.clone())),
1436            Some("columns") => Some(Value::from(self.columns.clone())),
1437            _ => self.rows.get(key.as_usize()?).cloned(),
1438        }
1439    }
1440
1441    fn enumerate(self: &Arc<Self>) -> Enumerator {
1442        Enumerator::Seq(self.rows.len())
1443    }
1444
1445    /// `result.column('name')` (or an index): that column's values, one per row.
1446    fn call_method(
1447        self: &Arc<Self>,
1448        _: &mut minijinja::State<'_, '_>,
1449        method: &str,
1450        args: &[Value],
1451    ) -> Result<Value, Error> {
1452        if method != "column" {
1453            return Err(Error::from(ErrorKind::UnknownMethod));
1454        }
1455        if self.lenient {
1456            return Ok(Value::from(Vec::<Value>::new()));
1457        }
1458        let [key] = args else {
1459            return Err(Error::new(
1460                ErrorKind::InvalidOperation,
1461                "`column()` takes one column name or index",
1462            ));
1463        };
1464        let i = match key.as_str() {
1465            Some(n) => self.columns.iter().position(|c| c == n).ok_or_else(|| {
1466                Error::new(
1467                    ErrorKind::InvalidOperation,
1468                    format!("no column `{n}` (columns: {})", self.columns.join(", ")),
1469                )
1470            })?,
1471            None => key
1472                .as_usize()
1473                .filter(|i| *i < self.columns.len())
1474                .ok_or_else(|| Error::new(ErrorKind::InvalidOperation, format!("no column {key}")))?,
1475        };
1476        Ok(Value::from(
1477            self.rows
1478                .iter()
1479                .map(|r| r.get_item_by_index(i).unwrap_or_default())
1480                .collect::<Vec<_>>(),
1481        ))
1482    }
1483}
1484
1485/// One `run_query()` row: accessible by column name (`row.region`, `row['region']`) or index.
1486#[derive(Debug)]
1487pub(crate) struct Row {
1488    columns: Arc<Vec<String>>,
1489    values: Vec<Value>,
1490}
1491
1492impl Row {
1493    pub(crate) fn value(columns: Arc<Vec<String>>, values: Vec<Value>) -> Value {
1494        Value::from_object(Row { columns, values })
1495    }
1496}
1497
1498impl Object for Row {
1499    fn repr(self: &Arc<Self>) -> ObjectRepr {
1500        ObjectRepr::Seq
1501    }
1502
1503    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1504        if let Some(name) = key.as_str() {
1505            let i = self.columns.iter().position(|c| c == name)?;
1506            return self.values.get(i).cloned();
1507        }
1508        self.values.get(key.as_usize()?).cloned()
1509    }
1510
1511    fn enumerate(self: &Arc<Self>) -> Enumerator {
1512        Enumerator::Seq(self.values.len())
1513    }
1514}
1515
1516#[cfg(test)]
1517mod tests {
1518    use super::*;
1519
1520    fn renderer(vars: Json, cli: &[(&str, &str)], macros: &[(&str, &str)]) -> (tempfile::TempDir, Renderer) {
1521        let dir = tempfile::tempdir().unwrap();
1522        let mut files = Vec::new();
1523        for (name, src) in macros {
1524            let p = PathBuf::from("macros").join(name);
1525            std::fs::create_dir_all(dir.path().join("macros")).unwrap();
1526            std::fs::write(dir.path().join(&p), src).unwrap();
1527            files.push(p);
1528        }
1529        let r = Renderer::new(RendererConfig {
1530            root: dir.path(),
1531            macros: &files,
1532            context: RunContext {
1533                report: "monthly".into(),
1534                set: Some("client_a".into()),
1535                target: "prod".into(),
1536                schedule: None,
1537                date: NaiveDate::from_ymd_opt(2026, 1, 25).unwrap(),
1538                now: Utc::now(),
1539                scheduled_at: None,
1540                calendar: Calendar::default(),
1541                locale: Default::default(),
1542            },
1543            vars: vars.as_object().cloned().unwrap_or_default(),
1544            cli_vars: cli.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
1545            runner: None,
1546            connections: None,
1547            mode: Mode::Run,
1548            connection: None,
1549            source_type: "duckdb".into(),
1550            sources: None,
1551            run_query_max_rows: 10_000,
1552            sql: BTreeMap::new(),
1553            lookups: BTreeMap::new(),
1554            lookup_inline_max_rows: 200,
1555            packages: Vec::new(),
1556            project_name: "acme".into(),
1557            dispatch: DispatchOrder::new(),
1558        })
1559        .unwrap();
1560        (dir, r)
1561    }
1562
1563    fn render(r: &Renderer, src: &str) -> Result<String, RenderError> {
1564        r.render(Path::new("reports/q.sql"), src)
1565    }
1566
1567    fn ok(src: &str) -> String {
1568        let (_d, r) = renderer(serde_json::json!({"fixed": [3, 1, 2], "m": {"a": 1}}), &[], &[]);
1569        render(&r, src).unwrap_or_else(|e| panic!("{src}: {e}"))
1570    }
1571
1572    fn err(src: &str) -> String {
1573        let (_d, r) = renderer(serde_json::json!({"fixed": [3, 1, 2], "m": {"a": 1}}), &[], &[]);
1574        render(&r, src).unwrap_err().to_string()
1575    }
1576
1577    #[test]
1578    fn python_string_and_dict_methods_work() {
1579        assert_eq!(ok("{{ 'a,b,c'.split(',') | join('-') }}"), "a-b-c");
1580        assert_eq!(
1581            ok("{{ 'y' if 'abc'.startswith('a') }}{{ 'y' if 'abc'.endswith('x') }}"),
1582            "y"
1583        );
1584        assert_eq!(
1585            ok("{{ ' x '.strip() }}|{{ 'abc'.upper() }}|{{ 'a-b'.replace('-', '_') }}"),
1586            "x|ABC|a_b"
1587        );
1588        assert_eq!(
1589            ok("{{ var('m').keys() | list }} {{ var('m').items() | list }}"),
1590            "['a'] [('a', 1)]"
1591        );
1592        assert_eq!(
1593            ok("{{ var('m').get('a') }} {{ var('m').get('z', 'none') }}"),
1594            "1 none"
1595        );
1596    }
1597
1598    #[test]
1599    fn list_appends_in_a_loop_and_joins() {
1600        assert_eq!(
1601            ok(
1602                "{% set sq = list() %}{% for i in range(1, 5) %}{% set _ = sq.append(i * i) %}{% endfor %}{{ sq | join('+') }}"
1603            ),
1604            "1+4+9+16"
1605        );
1606    }
1607
1608    #[test]
1609    fn list_methods_follow_python() {
1610        assert_eq!(
1611            ok("{% set l = list([3, 1, 2]) %}{% set _ = l.sort() %}{{ l }}"),
1612            "[1, 2, 3]"
1613        );
1614        assert_eq!(
1615            ok("{% set l = list([3, 1, 2]) %}{% set _ = l.sort(reverse=true) %}{{ l }}"),
1616            "[3, 2, 1]"
1617        );
1618        assert_eq!(
1619            ok("{% set l = list([1, 2]) %}{% set _ = l.extend([3, 4]) %}{{ l }} {{ l | length }}"),
1620            "[1, 2, 3, 4] 4"
1621        );
1622        assert_eq!(
1623            ok("{% set l = list([1, 2, 3]) %}{{ l.pop() }} {{ l.pop(0) }} {{ l }}"),
1624            "3 1 [2]"
1625        );
1626        assert_eq!(
1627            ok("{% set l = list([1, 2]) %}{% set _ = l.insert(0, 9) %}{% set _ = l.insert(99, 7) %}{{ l }}"),
1628            "[9, 1, 2, 7]"
1629        );
1630        assert_eq!(
1631            ok(
1632                "{% set l = list([1, 2, 1]) %}{% set _ = l.remove(1) %}{{ l }} {{ l.index(1) }} {{ l.count(1) }}"
1633            ),
1634            "[2, 1] 1 1"
1635        );
1636        assert_eq!(
1637            ok("{% set l = list([1, 2]) %}{% set _ = l.reverse() %}{{ l }}"),
1638            "[2, 1]"
1639        );
1640        assert_eq!(
1641            ok("{% set l = list([1, 2]) %}{% set _ = l.clear() %}{{ l }} {{ 'empty' if not l }}"),
1642            "[] empty"
1643        );
1644        assert_eq!(
1645            ok("{% set l = list([1, 2, 3]) %}{{ l[0] }} {{ l[-1] }} {{ l[1:] }}"),
1646            "1 3 [2, 3]"
1647        );
1648    }
1649
1650    #[test]
1651    fn lists_share_by_reference_and_copy_apart() {
1652        assert_eq!(
1653            ok("{% set a = list() %}{% set b = a %}{% set _ = b.append(1) %}{{ a }}"),
1654            "[1]"
1655        );
1656        assert_eq!(
1657            ok("{% set a = list([1]) %}{% set b = a.copy() %}{% set _ = b.append(2) %}{{ a }} {{ b }}"),
1658            "[1] [1, 2]"
1659        );
1660    }
1661
1662    #[test]
1663    fn a_list_changed_inside_its_own_loop_does_not_skip_or_repeat() {
1664        assert_eq!(
1665            ok(
1666                "{% set l = list([1, 2, 3]) %}{% for x in l %}{% set _ = l.append(x * 10) %}{{ x }} {% endfor %}{{ l }}"
1667            ),
1668            "1 2 3 [1, 2, 3, 10, 20, 30]"
1669        );
1670        assert_eq!(
1671            ok("{% set l = list([1, 2]) %}{% set _ = l.extend(l) %}{{ l }}"),
1672            "[1, 2, 1, 2]"
1673        );
1674    }
1675
1676    #[test]
1677    fn a_list_starts_from_a_var_without_changing_it() {
1678        assert_eq!(
1679            ok("{% set l = list(var('fixed')) %}{% set _ = l.append(4) %}{{ l }} {{ var('fixed') }}"),
1680            "[3, 1, 2, 4] [3, 1, 2]"
1681        );
1682    }
1683
1684    #[test]
1685    fn dict_methods_follow_python() {
1686        assert_eq!(
1687            ok("{% set d = dict(a=1) %}{% set _ = d.update(b=2) %}{% set _ = d.update({'c': 3}) %}{{ d }}"),
1688            "{'a': 1, 'b': 2, 'c': 3}"
1689        );
1690        // Insertion order, as in Python. (Keyword arguments and `{}` literals reach us already sorted
1691        // until `preserve_order` is on, and the `| items` filter sorts; so add keys one by one.)
1692        let make = "{% set d = dict() %}{% set _ = d.setdefault('b', 1) %}{% set _ = d.setdefault('a', 2) %}";
1693        assert_eq!(
1694            ok(&(make.to_string() + "{% for k, v in d.items() %}{{ k }}={{ v }} {% endfor %}")),
1695            "b=1 a=2 "
1696        );
1697        assert_eq!(
1698            ok(&(make.to_string() + "{% for k in d %}{{ k }} {% endfor %}{{ d.values() | list }}")),
1699            "b a [1, 2]"
1700        );
1701        assert_eq!(
1702            ok("{% set d = dict(a=1) %}{{ d.setdefault('a', 9) }} {{ d.setdefault('b', 2) }} {{ d }}"),
1703            "1 2 {'a': 1, 'b': 2}"
1704        );
1705        assert_eq!(
1706            ok("{% set d = dict(a=1) %}{{ d.pop('a') }} {{ d.pop('a', 'gone') }} {{ d }}"),
1707            "1 gone {}"
1708        );
1709        assert_eq!(
1710            ok(
1711                "{% set d = dict(a=1) %}{{ d.get('a') }} {{ d['a'] }} {{ d.a }} {{ 'in' if 'a' in d }} {{ d.keys() | list }}"
1712            ),
1713            "1 1 1 in ['a']"
1714        );
1715        assert_eq!(
1716            ok("{% set d = dict(a=1) %}{% set e = d.copy() %}{% set _ = e.update(a=2) %}{{ d.a }} {{ e.a }}"),
1717            "1 2"
1718        );
1719        assert_eq!(
1720            ok("{% set d = dict(a=1) %}{% set _ = d.update(a=5, b=6) %}{{ d | length }} {{ d.a }}"),
1721            "2 5"
1722        );
1723        assert_eq!(
1724            ok("{% set d = dict(a=1) %}{% set _ = d.update(d) %}{{ d }}"),
1725            "{'a': 1}"
1726        );
1727    }
1728
1729    #[test]
1730    fn mutable_values_print_and_serialise_like_plain_ones() {
1731        assert_eq!(
1732            ok("{{ list([1, 'a']) }} {{ dict(k='v') }}"),
1733            "[1, 'a'] {'k': 'v'}"
1734        );
1735        assert_eq!(
1736            ok("{{ list(dict(k='v')) }} {{ list(list([2, 1])) | sort }}"),
1737            "['k'] [1, 2]"
1738        );
1739        assert_eq!(ok("{% set l = list() %}{{ 'yes' if l else 'no' }}"), "no");
1740    }
1741
1742    /// Templates follow Jinja2 (and so dbt) where MiniJinja 2 didn't; 0.5.0's upgrade notes list these.
1743    #[test]
1744    fn expressions_follow_jinja2() {
1745        assert_eq!(
1746            ok("{{ ['a', 1] }} {{ var('m') }} {{ var('m').items() | list }}"),
1747            "['a', 1] {'a': 1} [('a', 1)]"
1748        );
1749        assert_eq!(ok("{{ 2.5 | round }} {{ 3.5 | round }}"), "2.0 4.0");
1750        assert_eq!(ok("{{ -7 // 2 }} {{ -7 % 3 }}"), "-4 2");
1751        assert_eq!(
1752            ok("{{ ['b', 'P', 'n'] | max }} {{ \"don't\" | title }}"),
1753            "P Don't"
1754        );
1755        assert!(err("{{ 1 / 0 }}").contains("unable to calculate 1 / 0"));
1756    }
1757
1758    #[test]
1759    fn mutating_an_immutable_value_says_what_to_write() {
1760        let e = err("{% set l = [] %}{% set _ = l.append(1) %}");
1761        assert!(e.contains("append()") && e.contains("list()"), "{e}");
1762        let e = err("{% set _ = var('fixed').append(1) %}");
1763        assert!(e.contains("list()"), "{e}");
1764        let e = err("{% set d = {} %}{% set _ = d.update(a=1) %}");
1765        assert!(e.contains("dict()"), "{e}");
1766    }
1767
1768    #[test]
1769    fn bad_arguments_to_mutable_methods_are_clear_errors() {
1770        assert!(err("{% set l = list() %}{% set _ = l.append() %}").contains("append()"));
1771        assert!(err("{% set l = list() %}{% set _ = l.pop() %}").contains("pop()"));
1772        assert!(err("{% set l = list([1]) %}{% set _ = l.remove(2) %}").contains("not in the list"));
1773        assert!(err("{% set d = dict() %}{% set _ = d.pop('x') %}").contains("no key"));
1774        assert!(err("{% set d = dict('nope') %}").contains("mapping"));
1775    }
1776
1777    #[test]
1778    fn run_context_and_date_formats() {
1779        let (_d, r) = renderer(Json::Null, &[], &[]);
1780        let out = render(
1781            &r,
1782            "{{ run.report }}/{{ run.set }}/{{ run.target }}/{{ target.name }}/{{ target }} {{ run.date }} {{ run.date.yyyymmdd }} \
1783             {{ run.date.ddmmyyyy }} {{ run.date.yyyy }}-{{ run.date.mm }}-{{ run.date.dd }} {{ run.date_format('%Y-W%V') }}",
1784        )
1785        .unwrap();
1786        assert_eq!(
1787            out,
1788            "monthly/client_a/prod/prod/prod 2026-01-25 20260125 25012026 2026-01-25 2026-W04"
1789        );
1790    }
1791
1792    #[test]
1793    fn var_precedence_is_cli_then_binding_then_default() {
1794        let (_d, r) = renderer(
1795            serde_json::json!({"a": "binding", "b": "binding", "n": 5}),
1796            &[("a", "cli")],
1797            &[],
1798        );
1799        assert_eq!(
1800            render(
1801                &r,
1802                "{{ var('a') }} {{ var('b') }} {{ var('c', 'dflt') }} {{ var('n') + 1 }}"
1803            )
1804            .unwrap(),
1805            "cli binding dflt 6"
1806        );
1807        let e = render(&r, "select\n{{ var('missing') }}").unwrap_err();
1808        assert_eq!(
1809            (e.line, e.message.contains("`var('missing')` has no value")),
1810            (Some(2), true),
1811            "{e}"
1812        );
1813    }
1814
1815    #[test]
1816    fn env_var_reads_the_environment_or_errors() {
1817        let (_d, r) = renderer(Json::Null, &[], &[]);
1818        assert_eq!(
1819            render(&r, "{{ env_var('DRE_TEST_UNSET_VAR', 'x') }}").unwrap(),
1820            "x"
1821        );
1822        let home = std::env::var("PATH").unwrap();
1823        assert_eq!(render(&r, "{{ env_var('PATH') }}").unwrap(), home);
1824        assert!(
1825            render(&r, "{{ env_var('DRE_TEST_UNSET_VAR') }}")
1826                .unwrap_err()
1827                .message
1828                .contains("is not set")
1829        );
1830    }
1831
1832    #[test]
1833    fn macros_from_every_file_are_callable_and_keep_line_numbers() {
1834        let (_d, r) = renderer(
1835            Json::Null,
1836            &[],
1837            &[
1838                ("a.sql", "{% macro double(x) %}{{ x * 2 }}{% endmacro %}\n"),
1839                (
1840                    "b.sql",
1841                    "{% macro broken() %}\n{{ var('nope') }}\n{% endmacro %}\n{% macro quad(x) %}{{ x * 4 }}{% endmacro %}\n",
1842                ),
1843            ],
1844        );
1845        assert_eq!(
1846            render(&r, "select {{ double(2) }}, {{ quad(1) }}\n").unwrap(),
1847            "select 4, 4\n"
1848        );
1849        let e = render(&r, "line one\nselect {{ broken() }}").unwrap_err();
1850        assert_eq!(e.file, PathBuf::from("macros/b.sql"), "{e}");
1851        assert_eq!(e.line, Some(2), "{e}");
1852        let e = render(&r, "one\ntwo\n{{ nope }}").unwrap_err();
1853        assert_eq!(
1854            (e.file.clone(), e.line),
1855            (PathBuf::from("reports/q.sql"), Some(3)),
1856            "{e}"
1857        );
1858    }
1859
1860    struct Fake;
1861    impl QueryRunner for Fake {
1862        fn run_query(&self, sql: &str, max_rows: u64, _profile: Option<&str>) -> Result<QueryRows, String> {
1863            if max_rows < 2 {
1864                return Err(format!("`{sql}` returned more than {max_rows} rows"));
1865            }
1866            Ok(QueryRows {
1867                columns: vec!["region".into(), "n".into()],
1868                rows: vec![
1869                    vec![Value::from("apac"), Value::from(1)],
1870                    vec![Value::from("emea"), Value::from(2)],
1871                ],
1872            })
1873        }
1874        fn columns(&self, _: &str, _: Option<&str>) -> Result<Vec<Column>, String> {
1875            Ok(Vec::new())
1876        }
1877    }
1878
1879    #[test]
1880    fn run_query_rows_are_accessible_by_name_and_index() {
1881        let dir = tempfile::tempdir().unwrap();
1882        let r = Renderer::new(RendererConfig {
1883            root: dir.path(),
1884            macros: &[],
1885            context: RunContext {
1886                report: "r".into(),
1887                set: None,
1888                target: "t".into(),
1889                schedule: None,
1890                date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
1891                now: Utc::now(),
1892                scheduled_at: None,
1893                calendar: Calendar::default(),
1894                locale: Default::default(),
1895            },
1896            vars: JsonMap::new(),
1897            cli_vars: BTreeMap::new(),
1898            runner: Some(Arc::new(Fake)),
1899            connections: None,
1900            mode: Mode::Run,
1901            connection: None,
1902            source_type: "duckdb".into(),
1903            sources: None,
1904            run_query_max_rows: 10_000,
1905            sql: BTreeMap::new(),
1906            lookups: BTreeMap::new(),
1907            lookup_inline_max_rows: 200,
1908            packages: Vec::new(),
1909            project_name: "acme".into(),
1910            dispatch: DispatchOrder::new(),
1911        })
1912        .unwrap();
1913        let src = "{% set res = run_query('select') %}{{ res.columns | join(',') }}|\
1914                   {% for row in res.rows %}{{ row.region }}={{ row[1] }}{{ ',' if not loop.last }}{% endfor %}|{{ res | length }}|{{ res[1]['region'] }}";
1915        assert_eq!(render(&r, src).unwrap(), "region,n|apac=1,emea=2|2|emea");
1916        let e = render(&r, "{{ run_query('select', max_rows=1) }}").unwrap_err();
1917        assert!(e.message.contains("more than 1 rows"), "{e}");
1918        assert!(render(&r, "{% if run.set is none %}none{% endif %}").unwrap() == "none");
1919    }
1920
1921    #[test]
1922    fn removed_names_say_what_to_write_instead() {
1923        let e = err("{{ run.profile }}");
1924        assert!(
1925            e.contains("`run.profile` was removed") && e.contains("connection.name"),
1926            "{e}"
1927        );
1928        let e = err("{{ target.schema }}");
1929        assert!(
1930            e.contains("`target.schema` was removed") && e.contains("connection.schema"),
1931            "{e}"
1932        );
1933    }
1934
1935    #[test]
1936    fn a_source_relation_quotes_the_parts_asked_for() {
1937        let r = ResolvedSource {
1938            source: "sales".into(),
1939            table: "orders".into(),
1940            profile: None,
1941            database: None,
1942            schema: "Sales".into(),
1943            identifier: "Or\"ders".into(),
1944            quoting: Quoting {
1945                database: true,
1946                schema: false,
1947                identifier: true,
1948            },
1949        };
1950        assert_eq!(r.relation(Some("\"")), "Sales.\"Or\"\"ders\"");
1951        assert_eq!(r.relation(None), "Sales.Or\"ders");
1952        let r = ResolvedSource {
1953            database: Some("main".into()),
1954            ..r
1955        };
1956        assert_eq!(r.relation(Some("`")), "`main`.Sales.`Or\"ders`");
1957    }
1958
1959    #[test]
1960    fn a_relation_is_mentioned_only_as_a_whole_name() {
1961        assert!(mentions("select * from sales.orders where 1", "sales.orders"));
1962        assert!(!mentions("select * from sales.orders_old", "sales.orders"));
1963        assert!(!mentions("select * from x.sales.orders", "sales.orders"));
1964    }
1965}
1966
1967#[cfg(test)]
1968mod cli_var_tests {
1969    use super::cli_var_value as v;
1970    use serde_json::json;
1971
1972    #[test]
1973    fn yaml_1_2_core_rules() {
1974        assert_eq!(v("false"), json!(false));
1975        assert_eq!(v("TRUE"), json!(true));
1976        assert_eq!(v("~"), json!(null));
1977        assert_eq!(v("5"), json!(5));
1978        assert_eq!(v("-12"), json!(-12));
1979        assert_eq!(v("2.5"), json!(2.5));
1980        assert_eq!(v("[\"NAM\", EMEA]"), json!(["NAM", "EMEA"]));
1981        assert_eq!(v("{k: 1}"), json!({"k": 1}));
1982        for s in [
1983            "yes",
1984            "NO",
1985            "2026-01-31",
1986            "010",
1987            "1.0.0",
1988            "abc",
1989            "[unclosed",
1990            "0x1F",
1991        ] {
1992            assert_eq!(v(s), json!(s), "{s}");
1993        }
1994        assert_eq!(v("\"false\""), json!("false"));
1995        assert_eq!(v("'5'"), json!("5"));
1996        assert_eq!(v(""), json!(""));
1997    }
1998}