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