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