1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum Mode {
33 #[default]
35 Run,
36 Parse,
38}
39
40#[derive(Debug, Clone)]
42pub struct RunContext {
43 pub report: String,
44 pub set: Option<String>,
45 pub target: String,
47 pub schedule: Option<String>,
49 pub date: NaiveDate,
50 pub now: chrono::DateTime<Utc>,
52 pub scheduled_at: Option<chrono::DateTime<Utc>>,
54 pub calendar: Calendar,
56}
57
58#[derive(Debug, Clone, Default)]
60pub struct QueryRows {
61 pub columns: Vec<String>,
62 pub rows: Vec<Vec<Value>>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Column {
68 pub name: String,
69 pub data_type: String,
71}
72
73pub trait QueryRunner: Send + Sync {
76 fn run_query(&self, sql: &str, max_rows: u64, profile: Option<&str>) -> Result<QueryRows, String>;
78 fn columns(&self, sql: &str, profile: Option<&str>) -> Result<Vec<Column>, String>;
80 fn load(&self, _name: &str, _table: &Table) -> Result<Option<(String, Option<String>)>, String> {
83 Ok(None)
84 }
85}
86
87#[derive(Debug, Clone, Default)]
90pub struct Connection {
91 pub profile: String,
93 pub target: String,
95 pub kind: String,
97 pub fields: JsonMap<String, Json>,
99 pub secrets: Vec<String>,
101}
102
103pub trait Connections: Send + Sync {
106 fn profile(&self, name: &str, role: Option<&str>) -> Result<Connection, String>;
107 fn identifier_quote(&self, kind: &str) -> Result<Option<String>, String>;
109}
110
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
113pub struct Quoting {
114 pub database: bool,
115 pub schema: bool,
116 pub identifier: bool,
117}
118
119impl Quoting {
120 pub fn any(&self) -> bool {
121 self.database || self.schema || self.identifier
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
127pub struct ResolvedSource {
128 pub source: String,
129 pub table: String,
130 pub profile: Option<String>,
132 pub database: Option<String>,
133 pub schema: String,
134 pub identifier: String,
135 pub quoting: Quoting,
136}
137
138impl ResolvedSource {
139 pub fn relation(&self, quote: Option<&str>) -> String {
142 let part = |s: &str, q: bool| match (q, quote) {
143 (true, Some(c)) => format!("{c}{}{c}", s.replace(c, &format!("{c}{c}"))),
144 _ => s.to_string(),
145 };
146 let mut parts = Vec::new();
147 if let Some(d) = &self.database {
148 parts.push(part(d, self.quoting.database));
149 }
150 parts.push(part(&self.schema, self.quoting.schema));
151 parts.push(part(&self.identifier, self.quoting.identifier));
152 parts.join(".")
153 }
154
155 pub fn key(&self) -> String {
157 format!("{}.{}", self.source, self.table)
158 }
159}
160
161pub type SourceResolver = Arc<dyn Fn(&str, &str) -> Result<ResolvedSource, String> + Send + Sync>;
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct RenderError {
166 pub file: PathBuf,
167 pub line: Option<usize>,
168 pub message: String,
169}
170
171impl fmt::Display for RenderError {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 match self.line {
174 Some(l) => write!(f, "{}:{l}: {}", self.file.display(), self.message),
175 None => write!(f, "{}: {}", self.file.display(), self.message),
176 }
177 }
178}
179
180type ColumnsCache = BTreeMap<(String, Option<String>), Value>;
182
183pub struct Renderer {
184 env: Environment<'static>,
185 raised: Arc<Mutex<Option<String>>>,
187 columns_cache: Arc<Mutex<ColumnsCache>>,
189 source_calls: Arc<Mutex<Vec<(String, String)>>>,
191 destination: Arc<Mutex<Option<Connection>>>,
193 warnings: Arc<Mutex<Vec<String>>>,
195 import: String,
196 macro_files: BTreeMap<String, Vec<(usize, PathBuf, usize)>>,
198}
199
200pub struct RendererConfig<'a> {
201 pub root: &'a Path,
202 pub macros: &'a [PathBuf],
203 pub context: RunContext,
204 pub vars: JsonMap<String, Json>,
206 pub cli_vars: BTreeMap<String, String>,
208 pub runner: Option<Arc<dyn QueryRunner>>,
209 pub connections: Option<Arc<dyn Connections>>,
211 pub mode: Mode,
212 pub connection: Option<String>,
215 pub source_type: String,
217 pub sources: Option<SourceResolver>,
219 pub run_query_max_rows: u64,
220 pub sql: BTreeMap<String, PathBuf>,
222 pub lookups: BTreeMap<String, Lookup>,
224 pub lookup_inline_max_rows: u64,
225 pub packages: Vec<Package>,
227 pub project_name: String,
229 pub dispatch: DispatchOrder,
230}
231
232impl Renderer {
233 pub fn new(cfg: RendererConfig<'_>) -> Result<Renderer, RenderError> {
234 let mut env = Environment::new();
235 let parse = cfg.mode == Mode::Parse;
236 env.set_undefined_behavior(if parse {
238 UndefinedBehavior::Chainable
239 } else {
240 UndefinedBehavior::Strict
241 });
242 env.set_keep_trailing_newline(true);
243 crate::mutable::register(&mut env);
244
245 add_vars(&mut env, cfg.vars, cfg.cli_vars);
246 let raised: Arc<Mutex<Option<String>>> = Arc::default();
250 let raised_c = raised.clone();
251 env.add_function("raise_error", move |message: String| -> Result<Value, Error> {
252 if parse {
253 raised_c.lock().unwrap().get_or_insert(message);
254 return Ok(Value::from(""));
255 }
256 Err(Error::new(ErrorKind::InvalidOperation, message))
257 });
258 let source_type = cfg.source_type.clone();
259 crate::dates::register(&mut env, cfg.context.calendar, cfg.context.date);
260 env.add_global("target", Value::from_object(Target(cfg.context.target.clone())));
261 env.add_global("run", Value::from_object(Run(cfg.context)));
262 let destination: Arc<Mutex<Option<Connection>>> = Arc::default();
263 if parse {
264 env.add_global("connection", Value::from_object(Lenient));
265 env.add_global("destination", Value::from_object(Lenient));
266 env.add_function(
267 "profile",
268 |_name: String, kwargs: Kwargs| -> Result<Value, Error> {
269 let role: Option<String> = kwargs.get("role")?;
270 kwargs.assert_all_used()?;
271 check_role(role.as_deref())?;
272 Ok(Value::from_object(Lenient))
273 },
274 );
275 } else {
276 env.add_global(
277 "connection",
278 Value::from_object(LazyConnection {
279 connections: cfg.connections.clone(),
280 name: cfg.connection.clone(),
281 resolved: std::sync::OnceLock::new(),
282 }),
283 );
284 env.add_global(
285 "destination",
286 Value::from_object(DestinationSlot(destination.clone())),
287 );
288 let c = cfg.connections.clone();
289 env.add_function(
290 "profile",
291 move |name: String, kwargs: Kwargs| -> Result<Value, Error> {
292 let role: Option<String> = kwargs.get("role")?;
293 kwargs.assert_all_used()?;
294 check_role(role.as_deref())?;
295 let Some(c) = &c else {
296 return Err(Error::new(
297 ErrorKind::InvalidOperation,
298 format!("`profile('{name}')`: no profiles.yml is loaded here"),
299 ));
300 };
301 c.profile(&name, role.as_deref())
302 .map(|t| Value::from_object(ConnectionValue(t)))
303 .map_err(|e| {
304 Error::new(ErrorKind::InvalidOperation, format!("`profile('{name}')`: {e}"))
305 })
306 },
307 );
308 }
309 let source_calls: Arc<Mutex<Vec<(String, String)>>> = Arc::default();
312 let relations: Arc<Mutex<Vec<(String, ResolvedSource)>>> = Arc::default();
313 {
314 let calls = source_calls.clone();
315 let relations = relations.clone();
316 let resolver = cfg.sources.clone();
317 let connections = cfg.connections.clone();
318 let kind = cfg.source_type.clone();
319 let quote: Arc<std::sync::OnceLock<Result<Option<String>, String>>> = Arc::default();
320 env.add_function(
321 "source",
322 move |source: String, table: String| -> Result<Value, Error> {
323 let fail = |e: String| {
324 Error::new(
325 ErrorKind::InvalidOperation,
326 format!("`source('{source}', '{table}')`: {e}"),
327 )
328 };
329 let Some(resolve) = &resolver else {
330 return Err(fail("the project declares no `sources:`".into()));
331 };
332 let r = resolve(&source, &table).map_err(fail)?;
333 calls.lock().unwrap().push((source.clone(), table.clone()));
334 let q = if parse || !r.quoting.any() {
335 None
336 } else {
337 let got = quote.get_or_init(|| match &connections {
338 Some(c) if !kind.is_empty() => c.identifier_quote(&kind),
339 _ => Err("no connection is known here to quote with".into()),
340 });
341 match got {
342 Ok(Some(q)) => Some(q.clone()),
343 Ok(None) => {
344 return Err(fail(format!(
345 "`quoting` needs the identifier quote character, but the `{kind}` plugin doesn't report one; update it (`dre plugin update {kind}`)"
346 )));
347 }
348 Err(e) => return Err(fail(format!("can't quote: {e}"))),
349 }
350 };
351 let text = r.relation(q.as_deref());
352 relations.lock().unwrap().push((text.clone(), r));
353 Ok(Value::from(text))
354 },
355 );
356 }
357 let runner = cfg.runner;
358 let warnings: Arc<Mutex<Vec<String>>> = Arc::default();
359 let lookups = Arc::new(Lookups {
360 root: cfg.root.to_path_buf(),
361 defs: cfg.lookups,
362 inline_max: cfg.lookup_inline_max_rows,
363 runner: runner.clone(),
364 tables: Mutex::default(),
365 loaded: Mutex::default(),
366 warnings: warnings.clone(),
367 });
368 let l = lookups.clone();
369 env.add_function("lookup", move |name: String| -> Result<Value, Error> {
370 let t = l.table(&name)?;
371 let rows = QueryRows {
372 columns: t.columns.clone(),
373 rows: t
374 .rows
375 .iter()
376 .map(|r| r.iter().map(cell_value).collect())
377 .collect(),
378 };
379 Ok(Value::from_object(QueryResult::new(rows)))
380 });
381 let default_max = cfg.run_query_max_rows;
382 let runner_c = runner.clone();
383 let rels = relations.clone();
384 env.add_function(
385 "run_query",
386 move |sql: String, kwargs: Kwargs| -> Result<Value, Error> {
387 let max_rows: Option<u64> = kwargs.get("max_rows")?;
388 let profile: Option<String> = kwargs.get("profile")?;
389 kwargs.assert_all_used()?;
390 if parse {
391 let mut empty = QueryResult::new(QueryRows::default());
392 empty.lenient = true;
393 return Ok(Value::from_object(empty));
394 }
395 let profile = choose_profile("run_query()", &sql, profile, &rels)?;
396 let Some(runner) = &runner else {
397 return Err(Error::new(
398 ErrorKind::InvalidOperation,
399 "`run_query()` has no connection here",
400 ));
401 };
402 let rows = runner
403 .run_query(&sql, max_rows.unwrap_or(default_max), profile.as_deref())
404 .map_err(|e| Error::new(ErrorKind::InvalidOperation, e))?;
405 Ok(Value::from_object(QueryResult::new(rows)))
406 },
407 );
408 let columns_cache: Arc<Mutex<ColumnsCache>> = Arc::default();
410 let cache = columns_cache.clone();
411 let rels = relations.clone();
412 env.add_function(
413 "columns",
414 move |rel: String, kwargs: Kwargs| -> Result<Value, Error> {
415 let profile: Option<String> = kwargs.get("profile")?;
416 kwargs.assert_all_used()?;
417 if parse {
418 return Ok(Value::from(Vec::<Value>::new()));
419 }
420 let what = format!("columns('{}')", crate::secrets::mask(&rel));
421 let profile = choose_profile(&what, &rel, profile, &rels)?;
422 let key = (rel.clone(), profile.clone());
423 if let Some(v) = cache.lock().unwrap().get(&key) {
424 return Ok(v.clone());
425 }
426 let Some(runner) = &runner_c else {
427 return Err(Error::new(
428 ErrorKind::InvalidOperation,
429 format!("`{what}` has no connection here"),
430 ));
431 };
432 let sql = format!("select * from {rel} as _dre_cols where 1=0");
433 let cols = runner
434 .columns(&sql, profile.as_deref())
435 .map_err(|e| Error::new(ErrorKind::InvalidOperation, format!("`{what}` failed: {e}")))?;
436 let v = Value::from(
437 cols.into_iter()
438 .map(|c| {
439 Value::from_iter([
440 ("name", Value::from(c.name)),
441 ("type", Value::from(c.data_type)),
442 ])
443 })
444 .collect::<Vec<_>>(),
445 );
446 cache.lock().unwrap().insert(key, v.clone());
447 Ok(v)
448 },
449 );
450
451 let root = cfg.root.to_path_buf();
455 let display = |p: &Path| {
456 p.strip_prefix(&root)
457 .map(Path::to_path_buf)
458 .unwrap_or_else(|_| p.to_path_buf())
459 };
460 let own: Vec<(PathBuf, PathBuf)> = cfg.macros.iter().map(|m| (cfg.root.join(m), m.clone())).collect();
461 let (combined, names, spans) = combine(&own)?;
462 let mut import = if names.is_empty() {
463 String::new()
464 } else {
465 format!("{{% from \"{MACROS}\" import {} %}}", names.join(", "))
466 };
467 let mut templates = vec![(MACROS.to_string(), combined, spans, !names.is_empty())];
468 for p in &cfg.packages {
469 let files: Vec<(PathBuf, PathBuf)> = p.macros.iter().map(|m| (m.clone(), display(m))).collect();
470 let (src, _, spans) = combine(&files)?;
471 let t = package_template(&p.name);
472 import.push_str(&format!("{{% import \"{t}\" as {} %}}", p.name));
473 templates.push((t, src, spans, true));
474 }
475 add_ref(&mut env, cfg.root, cfg.sql, lookups, &import);
476 add_dispatch(
477 &mut env,
478 &cfg.project_name,
479 !names.is_empty(),
480 &cfg.packages,
481 cfg.dispatch,
482 source_type,
483 parse,
484 );
485 let mut r = Renderer {
486 env,
487 raised,
488 columns_cache,
489 source_calls,
490 destination,
491 warnings,
492 import,
493 macro_files: BTreeMap::new(),
494 };
495 for (name, src, spans, add) in templates {
496 r.macro_files.insert(name.clone(), spans);
497 if add {
498 r.env
499 .add_template_owned(name.clone(), src)
500 .map_err(|e| r.error(Path::new(&name), &e))?;
501 }
502 }
503 Ok(r)
504 }
505
506 pub fn take_warnings(&self) -> Vec<String> {
508 std::mem::take(&mut *self.warnings.lock().unwrap())
509 }
510
511 pub fn take_sources(&self) -> Vec<(String, String)> {
513 let mut out: Vec<(String, String)> = Vec::new();
514 for c in std::mem::take(&mut *self.source_calls.lock().unwrap()) {
515 if !out.contains(&c) {
516 out.push(c);
517 }
518 }
519 out
520 }
521
522 pub fn set_destination(&self, d: Option<Connection>) {
524 *self.destination.lock().unwrap() = d;
525 }
526
527 pub fn render(&self, file: &Path, src: &str) -> Result<String, RenderError> {
529 self.columns_cache.lock().unwrap().clear();
530 *self.raised.lock().unwrap() = None;
531 let full = format!("{}{src}", self.import);
532 let name = file.to_string_lossy().to_string();
533 let tmpl = self
534 .env
535 .template_from_named_str(&name, &full)
536 .map_err(|e| self.error(file, &e))?;
537 tmpl.render(()).map_err(|e| {
538 let mut err = self.error(file, &e);
539 if let Some(m) = self.raised.lock().unwrap().take() {
540 err.message = m;
541 }
542 err
543 })
544 }
545
546 fn error(&self, file: &Path, e: &Error) -> RenderError {
547 let mut deepest: &Error = e;
549 while let Some(src) = std::error::Error::source(deepest).and_then(|s| s.downcast_ref::<Error>()) {
550 deepest = src;
551 }
552 let (name, line) = match (deepest.name(), deepest.line()) {
553 (Some(n), l) => (n.to_string(), l),
554 _ => (e.name().unwrap_or_default().to_string(), e.line()),
555 };
556 let (file, line) = if let Some(spans) = self.macro_files.get(&name) {
557 match line.and_then(|l| {
558 spans
559 .iter()
560 .find(|(s, _, n)| l >= *s && l < s + n)
561 .map(|(s, f, _)| (f, l - s + 1))
562 }) {
563 Some((f, l)) => (f.clone(), Some(l)),
564 None => (file.to_path_buf(), line),
565 }
566 } else {
567 (file.to_path_buf(), line)
568 };
569 let message = deepest
570 .detail()
571 .map(str::to_string)
572 .unwrap_or_else(|| deepest.kind().to_string());
573 RenderError { file, line, message }
574 }
575}
576
577fn add_vars(env: &mut Environment<'static>, vars: JsonMap<String, Json>, cli: BTreeMap<String, String>) {
579 env.add_function(
580 "var",
581 move |name: String, default: Option<Value>| -> Result<Value, Error> {
582 if let Some(v) = cli.get(&name) {
583 return Ok(Value::from(v.clone()));
584 }
585 if let Some(v) = vars.get(&name) {
586 return Ok(Value::from_serialize(v));
587 }
588 default.ok_or_else(|| {
589 Error::new(
590 ErrorKind::UndefinedError,
591 format!("`var('{name}')` has no value and no default"),
592 )
593 })
594 },
595 );
596 env.add_function("env_var", |name: String, default: Option<Value>| -> Result<Value, Error> {
597 match std::env::var(&name) {
598 Ok(v) => Ok(Value::from(v)),
599 Err(_) => default.ok_or_else(|| {
600 Error::new(
601 ErrorKind::UndefinedError,
602 format!("`env_var('{name}')`: environment variable `{name}` is not set and no default is given"),
603 )
604 }),
605 }
606 });
607}
608
609pub struct Limited {
614 env: Environment<'static>,
615}
616
617const LIMITED_FUNCTIONS: &[&str] = &[
619 "run_query",
620 "columns",
621 "source",
622 "ref",
623 "lookup",
624 "dispatch",
625 "profile",
626];
627const LIMITED_GLOBALS: &[&str] = &["connection", "destination"];
628
629impl Limited {
630 pub fn new(
631 context: RunContext,
632 vars: JsonMap<String, Json>,
633 cli_vars: BTreeMap<String, String>,
634 ) -> Limited {
635 let mut env = Environment::new();
636 env.set_undefined_behavior(UndefinedBehavior::Strict);
637 crate::mutable::register(&mut env);
638 add_vars(&mut env, vars, cli_vars);
639 env.add_function("raise_error", |message: String| -> Result<Value, Error> {
640 Err(Error::new(ErrorKind::InvalidOperation, message))
641 });
642 crate::dates::register(&mut env, context.calendar, context.date);
643 env.add_global("target", Value::from_object(Target(context.target.clone())));
644 env.add_global("run", Value::from_object(Run(context)));
645 for f in LIMITED_FUNCTIONS {
646 let name = *f;
647 env.add_function(name, move |_args: minijinja::value::Rest<Value>| -> Result<Value, Error> {
648 Err(Error::new(
649 ErrorKind::InvalidOperation,
650 format!("`{name}()` can't be used here: this value chooses a connection or a source, so it's rendered before any connection is open"),
651 ))
652 });
653 }
654 for g in LIMITED_GLOBALS {
655 env.add_global(*g, Value::from_object(Forbidden(g)));
656 }
657 Limited { env }
658 }
659
660 pub fn render(&self, key: &str, src: &str) -> Result<String, String> {
662 if !crate::preflight::is_templated(src) {
663 return Ok(src.to_string());
664 }
665 self.env
666 .render_str(src, ())
667 .map(|v| v.trim().to_string())
668 .map_err(|e| {
669 let mut deepest: &Error = &e;
670 while let Some(s) = std::error::Error::source(deepest).and_then(|s| s.downcast_ref::<Error>())
671 {
672 deepest = s;
673 }
674 let msg = deepest
675 .detail()
676 .map(str::to_string)
677 .unwrap_or_else(|| deepest.kind().to_string());
678 format!("{key} `{src}`: {msg}")
679 })
680 }
681}
682
683#[derive(Debug)]
685struct Forbidden(&'static str);
686
687impl Object for Forbidden {
688 fn get_value(self: &Arc<Self>, _key: &Value) -> Option<Value> {
689 Some(Value::from(Error::new(
690 ErrorKind::InvalidOperation,
691 format!(
692 "`{}.*` can't be used here: this value chooses a connection or a source, so it's rendered before any connection is open",
693 self.0
694 ),
695 )))
696 }
697
698 fn render(self: &Arc<Self>, _f: &mut fmt::Formatter<'_>) -> fmt::Result {
699 Err(fmt::Error)
700 }
701}
702
703fn package_template(name: &str) -> String {
704 format!("__dre_package_{name}__")
705}
706
707#[allow(clippy::type_complexity)]
710fn combine(
711 files: &[(PathBuf, PathBuf)],
712) -> Result<(String, Vec<String>, Vec<(usize, PathBuf, usize)>), RenderError> {
713 let re = regex::Regex::new(r"\{%-?\s*macro\s+([A-Za-z_]\w*)").unwrap();
714 let mut combined = String::new();
715 let mut names = Vec::new();
716 let mut spans = Vec::new();
717 for (read, shown) in files {
718 let src = std::fs::read_to_string(read).map_err(|e| RenderError {
719 file: shown.clone(),
720 line: None,
721 message: format!("can't read macro file: {e}"),
722 })?;
723 let start = combined.matches('\n').count() + 1;
724 names.extend(re.captures_iter(&src).map(|c| c[1].to_string()));
725 combined.push_str(&src);
726 if !combined.ends_with('\n') {
727 combined.push('\n');
728 }
729 spans.push((start, shown.clone(), src.matches('\n').count() + 1));
730 }
731 Ok((combined, names, spans))
732}
733
734fn add_dispatch(
739 env: &mut Environment<'static>,
740 project: &str,
741 project_has_macros: bool,
742 packages: &[Package],
743 order: DispatchOrder,
744 source_type: String,
745 parse: bool,
746) {
747 let project = project.to_string();
748 let templates: BTreeMap<String, String> = packages
749 .iter()
750 .map(|p| (p.name.clone(), package_template(&p.name)))
751 .collect();
752 env.add_function(
753 "dispatch",
754 move |state: &minijinja::State<'_, '_>,
755 name: String,
756 namespace: Option<String>|
757 -> Result<Value, Error> {
758 let ns = namespace.unwrap_or_else(|| project.clone());
759 let search = order.get(&ns).cloned().unwrap_or_else(|| {
760 if ns == project {
761 vec![ns.clone()]
762 } else {
763 vec![project.clone(), ns.clone()]
764 }
765 });
766 let candidates = [format!("{source_type}__{name}"), format!("default__{name}")];
767 for n in &search {
768 let template = if n == &project {
769 if !project_has_macros {
770 continue;
771 }
772 MACROS.to_string()
773 } else {
774 match templates.get(n) {
775 Some(t) => t.clone(),
776 None => continue,
777 }
778 };
779 let tmpl = state.env().get_template(&template)?;
780 let captured = tmpl.render_captured(())?;
781 let st = captured.state();
782 for c in &candidates {
783 if st.lookup(c).is_some_and(|v| !v.is_undefined()) {
784 return Ok(Value::from_object(Dispatched {
785 template,
786 name: c.clone(),
787 }));
788 }
789 }
790 }
791 if parse {
792 return Ok(Value::from_object(Dispatched {
795 template: String::new(),
796 name: String::new(),
797 }));
798 }
799 Err(Error::new(
800 ErrorKind::InvalidOperation,
801 format!(
802 "`dispatch('{name}', '{ns}')`: no `{}` or `{}` in {}",
803 candidates[0],
804 candidates[1],
805 search.join(", ")
806 ),
807 ))
808 },
809 );
810}
811
812#[derive(Debug)]
814struct Dispatched {
815 template: String,
816 name: String,
817}
818
819impl Object for Dispatched {
820 fn call(self: &Arc<Self>, state: &minijinja::State<'_, '_>, args: &[Value]) -> Result<Value, Error> {
821 if self.template.is_empty() {
822 return Ok(Value::from(""));
823 }
824 let tmpl = state.env().get_template(&self.template)?;
825 let out = tmpl.render_captured(())?.state().call_macro(&self.name, args)?;
826 Ok(Value::from(out))
827 }
828}
829
830fn add_ref(
834 env: &mut Environment<'static>,
835 root: &Path,
836 sql: BTreeMap<String, PathBuf>,
837 lookups: Arc<Lookups>,
838 import: &str,
839) {
840 let root = root.to_path_buf();
841 let import = import.to_string();
842 let stack: Arc<Mutex<Vec<String>>> = Arc::default();
844 env.add_function(
845 "ref",
846 move |state: &minijinja::State<'_, '_>, name: String| -> Result<Value, Error> {
847 if lookups.defs.contains_key(&name) {
848 return lookups.relation(&name).map(Value::from);
849 }
850 let Some(rel) = sql.get(&name) else {
851 return Err(Error::new(
852 ErrorKind::InvalidOperation,
853 format!("`ref('{name}')`: no `{name}.sql` and no lookup `{name}` in the project"),
854 ));
855 };
856 {
857 let mut chain = stack.lock().unwrap();
858 if chain.contains(&name) {
859 chain.push(name.clone());
860 let cycle = chain.join(" → ");
861 chain.clear();
862 return Err(Error::new(
863 ErrorKind::InvalidOperation,
864 format!("`ref()` cycle: {cycle}"),
865 ));
866 }
867 chain.push(name.clone());
868 }
869 let result = (|| {
870 let src = std::fs::read_to_string(root.join(rel)).map_err(|e| {
871 Error::new(
872 ErrorKind::InvalidOperation,
873 format!("can't read {}: {e}", rel.display()),
874 )
875 })?;
876 let file = rel.to_string_lossy().to_string();
877 let rendered = state
878 .env()
879 .template_from_named_str(&file, &format!("{import}{src}"))?
880 .render(())?;
881 let statements = crate::sqlsplit::split(&rendered);
882 match statements.as_slice() {
883 [one] => Ok(Value::from(format!("(\n{}\n)", one.text))),
884 _ => Err(Error::new(
885 ErrorKind::InvalidOperation,
886 format!(
887 "`ref('{name}')` needs {} to hold exactly one statement, but it has {}",
888 rel.display(),
889 statements.len()
890 ),
891 )),
892 }
893 })();
894 let mut chain = stack.lock().unwrap();
895 if chain.last() == Some(&name) {
896 chain.pop();
897 }
898 result
899 },
900 );
901}
902
903struct Lookups {
905 root: PathBuf,
906 defs: BTreeMap<String, Lookup>,
907 inline_max: u64,
908 runner: Option<Arc<dyn QueryRunner>>,
909 tables: Mutex<BTreeMap<String, Arc<Table>>>,
910 loaded: Mutex<BTreeMap<String, String>>,
912 warnings: Arc<Mutex<Vec<String>>>,
913}
914
915impl Lookups {
916 fn table(&self, name: &str) -> Result<Arc<Table>, Error> {
917 let def = self.defs.get(name).ok_or_else(|| {
918 Error::new(
919 ErrorKind::InvalidOperation,
920 format!("no lookup `{name}` under lookups/"),
921 )
922 })?;
923 if let Some(t) = self.tables.lock().unwrap().get(name) {
924 return Ok(t.clone());
925 }
926 let t = Arc::new(lookups::read(&self.root, def).map_err(|e| {
927 Error::new(
928 ErrorKind::InvalidOperation,
929 format!("{}: {e}", def.file.display()),
930 )
931 })?);
932 self.tables.lock().unwrap().insert(name.to_string(), t.clone());
933 Ok(t)
934 }
935
936 fn relation(&self, name: &str) -> Result<String, Error> {
937 if let Some(r) = self.loaded.lock().unwrap().get(name) {
938 return Ok(r.clone());
939 }
940 let t = self.table(name)?;
941 let rows = t.rows.len() as u64;
942 let load = match self.defs[name].load {
943 Load::Inline => false,
944 Load::TempTable => true,
945 Load::Auto => rows > self.inline_max,
946 };
947 let loaded = match (&self.runner, load) {
948 (Some(runner), true) => runner.load(name, &t).map_err(|e| {
949 Error::new(
950 ErrorKind::InvalidOperation,
951 format!("loading lookup `{name}`: {e}"),
952 )
953 })?,
954 _ => None,
955 };
956 let relation = match loaded {
957 Some((relation, warning)) => {
958 if let Some(w) = warning {
959 self.warn(format!("lookup `{name}`: {w}"));
960 }
961 relation
962 }
963 None => {
964 if load && self.runner.is_some() {
965 self.warn(format!(
966 "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"
967 ));
968 }
969 t.inline_sql()
970 }
971 };
972 self.loaded
973 .lock()
974 .unwrap()
975 .insert(name.to_string(), relation.clone());
976 Ok(relation)
977 }
978
979 fn warn(&self, w: String) {
980 self.warnings.lock().unwrap().push(w);
981 }
982}
983
984fn cell_value(c: &Cell) -> Value {
985 match c {
986 Cell::Null => Value::from(()),
987 Cell::Text(s) => Value::from(s.clone()),
988 Cell::Int(n) => Value::from(*n),
989 Cell::Num(n) => Value::from(*n),
990 Cell::Bool(b) => Value::from(*b),
991 Cell::Date(d) => Value::from(d.to_string()),
992 }
993}
994
995#[derive(Debug)]
996struct Run(RunContext);
997
998impl Object for Run {
999 fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1000 let c = &self.0;
1001 Some(match key.as_str()? {
1002 "report" => Value::from(c.report.clone()),
1003 "set" => c.set.clone().map(Value::from).unwrap_or(Value::from(())),
1004 "target" => Value::from(c.target.clone()),
1005 "profile" => removed("run.profile", "`connection.name` (the query's connection)"),
1006 "source_type" => removed("run.source_type", "`connection.type` (the query's connection)"),
1007 "schedule" => c.schedule.clone().map(Value::from).unwrap_or(Value::from(())),
1008 "date" => Date::value(c.date, c.calendar),
1009 "now" => DateTime::now(c.now, c.calendar),
1010 "scheduled_at" => c
1011 .scheduled_at
1012 .map(|t| DateTime::now(t, c.calendar))
1013 .unwrap_or(Value::from(())),
1014 "timezone" => Value::from(c.calendar.tz.name()),
1015 _ => return None,
1016 })
1017 }
1018
1019 fn call_method(
1020 self: &Arc<Self>,
1021 _: &minijinja::State<'_, '_>,
1022 method: &str,
1023 args: &[Value],
1024 ) -> Result<Value, Error> {
1025 match method {
1026 "date_format" => {
1027 let fmt = args.first().and_then(|a| a.as_str()).ok_or_else(|| {
1028 Error::new(
1029 ErrorKind::InvalidOperation,
1030 "`run.date_format()` takes a strftime format string",
1031 )
1032 })?;
1033 let mut out = String::new();
1034 use std::fmt::Write;
1035 write!(out, "{}", self.0.date.format(fmt)).map_err(|_| {
1036 Error::new(
1037 ErrorKind::InvalidOperation,
1038 format!("invalid date format `{fmt}`"),
1039 )
1040 })?;
1041 Ok(Value::from(out))
1042 }
1043 _ => Err(Error::from(ErrorKind::UnknownMethod)),
1044 }
1045 }
1046}
1047
1048fn removed(name: &str, instead: &str) -> Value {
1050 Value::from(Error::new(
1051 ErrorKind::InvalidOperation,
1052 format!("`{name}` was removed in DRE 0.2: use {instead}"),
1053 ))
1054}
1055
1056fn check_role(role: Option<&str>) -> Result<(), Error> {
1058 match role {
1059 None | Some("connection") | Some("destination") => Ok(()),
1060 Some("source") => Err(Error::new(
1061 ErrorKind::InvalidOperation,
1062 "`role='source'` was renamed in DRE 0.2: use `role='connection'`",
1063 )),
1064 Some(r) => Err(Error::new(
1065 ErrorKind::InvalidOperation,
1066 format!("`role='{r}'`: role must be 'connection' or 'destination'"),
1067 )),
1068 }
1069}
1070
1071fn choose_profile(
1075 what: &str,
1076 sql: &str,
1077 explicit: Option<String>,
1078 relations: &Mutex<Vec<(String, ResolvedSource)>>,
1079) -> Result<Option<String>, Error> {
1080 let mut used: Vec<(String, String)> = Vec::new();
1081 for (text, r) in relations.lock().unwrap().iter() {
1082 if let Some(p) = &r.profile
1083 && mentions(sql, text)
1084 && !used.iter().any(|(k, _)| *k == r.key())
1085 {
1086 used.push((r.key(), p.clone()));
1087 }
1088 }
1089 let fail = |m: String| Error::new(ErrorKind::InvalidOperation, format!("`{what}`: {m}"));
1090 match explicit {
1091 Some(p) => {
1092 if let Some((k, q)) = used.iter().find(|(_, q)| *q != p) {
1093 return Err(fail(format!(
1094 "`profile='{p}'` disagrees with source `{k}`, which is on connection `{q}`"
1095 )));
1096 }
1097 Ok(Some(p))
1098 }
1099 None => match used.as_slice() {
1100 [] => Ok(None),
1101 [(_, p), rest @ ..] => {
1102 if let Some((k, q)) = rest.iter().find(|(_, q)| q != p) {
1103 return Err(fail(format!(
1104 "reads source `{}` on connection `{p}` and source `{k}` on `{q}`; one statement runs on one connection",
1105 used[0].0
1106 )));
1107 }
1108 Ok(Some(p.clone()))
1109 }
1110 },
1111 }
1112}
1113
1114fn mentions(sql: &str, text: &str) -> bool {
1116 let ident = |c: char| c.is_alphanumeric() || c == '_';
1117 sql.match_indices(text).any(|(i, _)| {
1118 let before = sql[..i].chars().next_back();
1119 let after = sql[i + text.len()..].chars().next();
1120 !before.is_some_and(|c| ident(c) || c == '.') && !after.is_some_and(|c| ident(c) || c == '.')
1121 })
1122}
1123
1124#[derive(Debug)]
1127struct Target(String);
1128
1129impl Object for Target {
1130 fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1131 let k = key.as_str()?;
1132 Some(match k {
1133 "name" => Value::from(self.0.clone()),
1134 "type" => removed("target.type", "`connection.type` (the query's connection)"),
1135 "profile" => removed("target.profile", "`connection.name` (the query's connection)"),
1136 _ => removed(
1137 &format!("target.{k}"),
1138 &format!(
1139 "`connection.{k}` (the query's connection) or `profile('<name>').{k}`; `target` is now only the environment, `target.name`"
1140 ),
1141 ),
1142 })
1143 }
1144
1145 fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1146 f.write_str(&self.0)
1147 }
1148}
1149
1150#[derive(Debug)]
1153struct Lenient;
1154
1155impl Object for Lenient {
1156 fn get_value(self: &Arc<Self>, _key: &Value) -> Option<Value> {
1157 None
1158 }
1159
1160 fn render(self: &Arc<Self>, _f: &mut fmt::Formatter<'_>) -> fmt::Result {
1161 Ok(())
1162 }
1163}
1164
1165#[derive(Debug)]
1167struct ConnectionValue(Connection);
1168
1169impl Object for ConnectionValue {
1170 fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1171 connection_field(&self.0, key)
1172 }
1173
1174 fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1175 write!(f, "{}", self.0.profile)
1176 }
1177}
1178
1179struct LazyConnection {
1182 connections: Option<Arc<dyn Connections>>,
1183 name: Option<String>,
1184 resolved: std::sync::OnceLock<Result<Connection, String>>,
1185}
1186
1187impl fmt::Debug for LazyConnection {
1188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1189 f.write_str("connection")
1190 }
1191}
1192
1193impl LazyConnection {
1194 fn get(&self) -> &Result<Connection, String> {
1195 self.resolved.get_or_init(|| {
1196 let name = self.name.as_deref().ok_or(
1197 "no connection here: this isn't a query's SQL and the Binding inherits no `profile`",
1198 )?;
1199 match &self.connections {
1200 Some(c) => c.profile(name, Some("connection")),
1201 None => Err("no profiles.yml is loaded here".into()),
1202 }
1203 })
1204 }
1205}
1206
1207impl Object for LazyConnection {
1208 fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1209 match self.get() {
1210 Ok(c) => connection_field(c, key),
1211 Err(e) => Some(Value::from(Error::new(
1212 ErrorKind::InvalidOperation,
1213 format!("`connection`: {e}"),
1214 ))),
1215 }
1216 }
1217
1218 fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1219 match &self.name {
1220 Some(n) => f.write_str(n),
1221 None => Err(fmt::Error),
1222 }
1223 }
1224}
1225
1226#[derive(Debug)]
1228struct DestinationSlot(Arc<Mutex<Option<Connection>>>);
1229
1230impl Object for DestinationSlot {
1231 fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1232 match &*self.0.lock().unwrap() {
1233 Some(c) => connection_field(c, key),
1234 None => Some(Value::from(Error::new(
1235 ErrorKind::InvalidOperation,
1236 "`destination` only exists while rendering a destination's `path` and options",
1237 ))),
1238 }
1239 }
1240
1241 fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1242 match &*self.0.lock().unwrap() {
1243 Some(c) => f.write_str(&c.profile),
1244 None => Err(fmt::Error),
1245 }
1246 }
1247}
1248
1249fn connection_field(c: &Connection, key: &Value) -> Option<Value> {
1251 {
1252 let k = key.as_str()?;
1253 Some(match k {
1254 "name" | "profile" => Value::from(c.profile.clone()),
1255 "target" => Value::from(c.target.clone()),
1256 "type" => Value::from(c.kind.clone()),
1257 _ if c.secrets.iter().any(|s| s == k) => Value::from(Error::new(
1258 ErrorKind::InvalidOperation,
1259 format!(
1260 "`{k}` of profile `{}` holds a secret, so templates can't read it (it would end up in compiled SQL and logs)",
1261 c.profile
1262 ),
1263 )),
1264 _ => match c.fields.get(k) {
1265 Some(v) => Value::from_serialize(v),
1266 None => Value::from(Error::new(
1267 ErrorKind::UndefinedError,
1268 format!(
1269 "profile `{}` (target `{}`) has no field `{k}`; it has: {}",
1270 c.profile,
1271 c.target,
1272 c.fields
1273 .keys()
1274 .filter(|f| !c.secrets.contains(f))
1275 .cloned()
1276 .collect::<Vec<_>>()
1277 .join(", ")
1278 ),
1279 )),
1280 },
1281 })
1282 }
1283}
1284
1285#[derive(Debug)]
1286struct QueryResult {
1287 columns: Vec<String>,
1288 rows: Vec<Value>,
1289 lenient: bool,
1291}
1292
1293impl QueryResult {
1294 fn new(q: QueryRows) -> QueryResult {
1295 let cols: Arc<Vec<String>> = Arc::new(q.columns.clone());
1296 let rows = q
1297 .rows
1298 .into_iter()
1299 .map(|values| {
1300 Value::from_object(Row {
1301 columns: cols.clone(),
1302 values,
1303 })
1304 })
1305 .collect();
1306 QueryResult {
1307 columns: q.columns,
1308 rows,
1309 lenient: false,
1310 }
1311 }
1312}
1313
1314impl Object for QueryResult {
1315 fn repr(self: &Arc<Self>) -> ObjectRepr {
1316 ObjectRepr::Seq
1317 }
1318
1319 fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1320 match key.as_str() {
1321 Some("rows") => Some(Value::from(self.rows.clone())),
1322 Some("columns") => Some(Value::from(self.columns.clone())),
1323 _ => self.rows.get(key.as_usize()?).cloned(),
1324 }
1325 }
1326
1327 fn enumerate(self: &Arc<Self>) -> Enumerator {
1328 Enumerator::Seq(self.rows.len())
1329 }
1330
1331 fn call_method(
1333 self: &Arc<Self>,
1334 _: &minijinja::State<'_, '_>,
1335 method: &str,
1336 args: &[Value],
1337 ) -> Result<Value, Error> {
1338 if method != "column" {
1339 return Err(Error::from(ErrorKind::UnknownMethod));
1340 }
1341 if self.lenient {
1342 return Ok(Value::from(Vec::<Value>::new()));
1343 }
1344 let [key] = args else {
1345 return Err(Error::new(
1346 ErrorKind::InvalidOperation,
1347 "`column()` takes one column name or index",
1348 ));
1349 };
1350 let i = match key.as_str() {
1351 Some(n) => self.columns.iter().position(|c| c == n).ok_or_else(|| {
1352 Error::new(
1353 ErrorKind::InvalidOperation,
1354 format!("no column `{n}` (columns: {})", self.columns.join(", ")),
1355 )
1356 })?,
1357 None => key
1358 .as_usize()
1359 .filter(|i| *i < self.columns.len())
1360 .ok_or_else(|| Error::new(ErrorKind::InvalidOperation, format!("no column {key}")))?,
1361 };
1362 Ok(Value::from(
1363 self.rows
1364 .iter()
1365 .map(|r| r.get_item_by_index(i).unwrap_or_default())
1366 .collect::<Vec<_>>(),
1367 ))
1368 }
1369}
1370
1371#[derive(Debug)]
1373struct Row {
1374 columns: Arc<Vec<String>>,
1375 values: Vec<Value>,
1376}
1377
1378impl Object for Row {
1379 fn repr(self: &Arc<Self>) -> ObjectRepr {
1380 ObjectRepr::Seq
1381 }
1382
1383 fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1384 if let Some(name) = key.as_str() {
1385 let i = self.columns.iter().position(|c| c == name)?;
1386 return self.values.get(i).cloned();
1387 }
1388 self.values.get(key.as_usize()?).cloned()
1389 }
1390
1391 fn enumerate(self: &Arc<Self>) -> Enumerator {
1392 Enumerator::Seq(self.values.len())
1393 }
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398 use super::*;
1399
1400 fn renderer(vars: Json, cli: &[(&str, &str)], macros: &[(&str, &str)]) -> (tempfile::TempDir, Renderer) {
1401 let dir = tempfile::tempdir().unwrap();
1402 let mut files = Vec::new();
1403 for (name, src) in macros {
1404 let p = PathBuf::from("macros").join(name);
1405 std::fs::create_dir_all(dir.path().join("macros")).unwrap();
1406 std::fs::write(dir.path().join(&p), src).unwrap();
1407 files.push(p);
1408 }
1409 let r = Renderer::new(RendererConfig {
1410 root: dir.path(),
1411 macros: &files,
1412 context: RunContext {
1413 report: "monthly".into(),
1414 set: Some("client_a".into()),
1415 target: "prod".into(),
1416 schedule: None,
1417 date: NaiveDate::from_ymd_opt(2026, 1, 25).unwrap(),
1418 now: Utc::now(),
1419 scheduled_at: None,
1420 calendar: Calendar::default(),
1421 },
1422 vars: vars.as_object().cloned().unwrap_or_default(),
1423 cli_vars: cli.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
1424 runner: None,
1425 connections: None,
1426 mode: Mode::Run,
1427 connection: None,
1428 source_type: "duckdb".into(),
1429 sources: None,
1430 run_query_max_rows: 10_000,
1431 sql: BTreeMap::new(),
1432 lookups: BTreeMap::new(),
1433 lookup_inline_max_rows: 200,
1434 packages: Vec::new(),
1435 project_name: "acme".into(),
1436 dispatch: DispatchOrder::new(),
1437 })
1438 .unwrap();
1439 (dir, r)
1440 }
1441
1442 fn render(r: &Renderer, src: &str) -> Result<String, RenderError> {
1443 r.render(Path::new("reports/q.sql"), src)
1444 }
1445
1446 fn ok(src: &str) -> String {
1447 let (_d, r) = renderer(serde_json::json!({"fixed": [3, 1, 2], "m": {"a": 1}}), &[], &[]);
1448 render(&r, src).unwrap_or_else(|e| panic!("{src}: {e}"))
1449 }
1450
1451 fn err(src: &str) -> String {
1452 let (_d, r) = renderer(serde_json::json!({"fixed": [3, 1, 2], "m": {"a": 1}}), &[], &[]);
1453 render(&r, src).unwrap_err().to_string()
1454 }
1455
1456 #[test]
1457 fn python_string_and_dict_methods_work() {
1458 assert_eq!(ok("{{ 'a,b,c'.split(',') | join('-') }}"), "a-b-c");
1459 assert_eq!(
1460 ok("{{ 'y' if 'abc'.startswith('a') }}{{ 'y' if 'abc'.endswith('x') }}"),
1461 "y"
1462 );
1463 assert_eq!(
1464 ok("{{ ' x '.strip() }}|{{ 'abc'.upper() }}|{{ 'a-b'.replace('-', '_') }}"),
1465 "x|ABC|a_b"
1466 );
1467 assert_eq!(
1468 ok("{{ var('m').keys() | list }} {{ var('m').items() | list }}"),
1469 r#"["a"] [["a", 1]]"#
1470 );
1471 assert_eq!(
1472 ok("{{ var('m').get('a') }} {{ var('m').get('z', 'none') }}"),
1473 "1 none"
1474 );
1475 }
1476
1477 #[test]
1478 fn list_appends_in_a_loop_and_joins() {
1479 assert_eq!(
1480 ok(
1481 "{% set sq = list() %}{% for i in range(1, 5) %}{% set _ = sq.append(i * i) %}{% endfor %}{{ sq | join('+') }}"
1482 ),
1483 "1+4+9+16"
1484 );
1485 }
1486
1487 #[test]
1488 fn list_methods_follow_python() {
1489 assert_eq!(
1490 ok("{% set l = list([3, 1, 2]) %}{% set _ = l.sort() %}{{ l }}"),
1491 "[1, 2, 3]"
1492 );
1493 assert_eq!(
1494 ok("{% set l = list([3, 1, 2]) %}{% set _ = l.sort(reverse=true) %}{{ l }}"),
1495 "[3, 2, 1]"
1496 );
1497 assert_eq!(
1498 ok("{% set l = list([1, 2]) %}{% set _ = l.extend([3, 4]) %}{{ l }} {{ l | length }}"),
1499 "[1, 2, 3, 4] 4"
1500 );
1501 assert_eq!(
1502 ok("{% set l = list([1, 2, 3]) %}{{ l.pop() }} {{ l.pop(0) }} {{ l }}"),
1503 "3 1 [2]"
1504 );
1505 assert_eq!(
1506 ok("{% set l = list([1, 2]) %}{% set _ = l.insert(0, 9) %}{% set _ = l.insert(99, 7) %}{{ l }}"),
1507 "[9, 1, 2, 7]"
1508 );
1509 assert_eq!(
1510 ok(
1511 "{% set l = list([1, 2, 1]) %}{% set _ = l.remove(1) %}{{ l }} {{ l.index(1) }} {{ l.count(1) }}"
1512 ),
1513 "[2, 1] 1 1"
1514 );
1515 assert_eq!(
1516 ok("{% set l = list([1, 2]) %}{% set _ = l.reverse() %}{{ l }}"),
1517 "[2, 1]"
1518 );
1519 assert_eq!(
1520 ok("{% set l = list([1, 2]) %}{% set _ = l.clear() %}{{ l }} {{ 'empty' if not l }}"),
1521 "[] empty"
1522 );
1523 assert_eq!(
1524 ok("{% set l = list([1, 2, 3]) %}{{ l[0] }} {{ l[-1] }} {{ l[1:] }}"),
1525 "1 3 [2, 3]"
1526 );
1527 }
1528
1529 #[test]
1530 fn lists_share_by_reference_and_copy_apart() {
1531 assert_eq!(
1532 ok("{% set a = list() %}{% set b = a %}{% set _ = b.append(1) %}{{ a }}"),
1533 "[1]"
1534 );
1535 assert_eq!(
1536 ok("{% set a = list([1]) %}{% set b = a.copy() %}{% set _ = b.append(2) %}{{ a }} {{ b }}"),
1537 "[1] [1, 2]"
1538 );
1539 }
1540
1541 #[test]
1542 fn a_list_changed_inside_its_own_loop_does_not_skip_or_repeat() {
1543 assert_eq!(
1544 ok(
1545 "{% set l = list([1, 2, 3]) %}{% for x in l %}{% set _ = l.append(x * 10) %}{{ x }} {% endfor %}{{ l }}"
1546 ),
1547 "1 2 3 [1, 2, 3, 10, 20, 30]"
1548 );
1549 assert_eq!(
1550 ok("{% set l = list([1, 2]) %}{% set _ = l.extend(l) %}{{ l }}"),
1551 "[1, 2, 1, 2]"
1552 );
1553 }
1554
1555 #[test]
1556 fn a_list_starts_from_a_var_without_changing_it() {
1557 assert_eq!(
1558 ok("{% set l = list(var('fixed')) %}{% set _ = l.append(4) %}{{ l }} {{ var('fixed') }}"),
1559 "[3, 1, 2, 4] [3, 1, 2]"
1560 );
1561 }
1562
1563 #[test]
1564 fn dict_methods_follow_python() {
1565 assert_eq!(
1566 ok("{% set d = dict(a=1) %}{% set _ = d.update(b=2) %}{% set _ = d.update({'c': 3}) %}{{ d }}"),
1567 r#"{"a": 1, "b": 2, "c": 3}"#
1568 );
1569 let make = "{% set d = dict() %}{% set _ = d.setdefault('b', 1) %}{% set _ = d.setdefault('a', 2) %}";
1572 assert_eq!(
1573 ok(&(make.to_string() + "{% for k, v in d.items() %}{{ k }}={{ v }} {% endfor %}")),
1574 "b=1 a=2 "
1575 );
1576 assert_eq!(
1577 ok(&(make.to_string() + "{% for k in d %}{{ k }} {% endfor %}{{ d.values() | list }}")),
1578 "b a [1, 2]"
1579 );
1580 assert_eq!(
1581 ok("{% set d = dict(a=1) %}{{ d.setdefault('a', 9) }} {{ d.setdefault('b', 2) }} {{ d }}"),
1582 r#"1 2 {"a": 1, "b": 2}"#
1583 );
1584 assert_eq!(
1585 ok("{% set d = dict(a=1) %}{{ d.pop('a') }} {{ d.pop('a', 'gone') }} {{ d }}"),
1586 "1 gone {}"
1587 );
1588 assert_eq!(
1589 ok(
1590 "{% set d = dict(a=1) %}{{ d.get('a') }} {{ d['a'] }} {{ d.a }} {{ 'in' if 'a' in d }} {{ d.keys() | list }}"
1591 ),
1592 r#"1 1 1 in ["a"]"#
1593 );
1594 assert_eq!(
1595 ok("{% set d = dict(a=1) %}{% set e = d.copy() %}{% set _ = e.update(a=2) %}{{ d.a }} {{ e.a }}"),
1596 "1 2"
1597 );
1598 assert_eq!(
1599 ok("{% set d = dict(a=1) %}{% set _ = d.update(a=5, b=6) %}{{ d | length }} {{ d.a }}"),
1600 "2 5"
1601 );
1602 assert_eq!(
1603 ok("{% set d = dict(a=1) %}{% set _ = d.update(d) %}{{ d }}"),
1604 r#"{"a": 1}"#
1605 );
1606 }
1607
1608 #[test]
1609 fn mutable_values_print_and_serialise_like_plain_ones() {
1610 assert_eq!(
1611 ok("{{ list([1, 'a']) }} {{ dict(k='v') }}"),
1612 r#"[1, "a"] {"k": "v"}"#
1613 );
1614 assert_eq!(
1615 ok("{{ list(dict(k='v')) }} {{ list(list([2, 1])) | sort }}"),
1616 r#"["k"] [1, 2]"#
1617 );
1618 assert_eq!(ok("{% set l = list() %}{{ 'yes' if l else 'no' }}"), "no");
1619 }
1620
1621 #[test]
1622 fn mutating_an_immutable_value_says_what_to_write() {
1623 let e = err("{% set l = [] %}{% set _ = l.append(1) %}");
1624 assert!(e.contains("append()") && e.contains("list()"), "{e}");
1625 let e = err("{% set _ = var('fixed').append(1) %}");
1626 assert!(e.contains("list()"), "{e}");
1627 let e = err("{% set d = {} %}{% set _ = d.update(a=1) %}");
1628 assert!(e.contains("dict()"), "{e}");
1629 }
1630
1631 #[test]
1632 fn bad_arguments_to_mutable_methods_are_clear_errors() {
1633 assert!(err("{% set l = list() %}{% set _ = l.append() %}").contains("append()"));
1634 assert!(err("{% set l = list() %}{% set _ = l.pop() %}").contains("pop()"));
1635 assert!(err("{% set l = list([1]) %}{% set _ = l.remove(2) %}").contains("not in the list"));
1636 assert!(err("{% set d = dict() %}{% set _ = d.pop('x') %}").contains("no key"));
1637 assert!(err("{% set d = dict('nope') %}").contains("mapping"));
1638 }
1639
1640 #[test]
1641 fn run_context_and_date_formats() {
1642 let (_d, r) = renderer(Json::Null, &[], &[]);
1643 let out = render(
1644 &r,
1645 "{{ run.report }}/{{ run.set }}/{{ run.target }}/{{ target.name }}/{{ target }} {{ run.date }} {{ run.date.yyyymmdd }} \
1646 {{ run.date.ddmmyyyy }} {{ run.date.yyyy }}-{{ run.date.mm }}-{{ run.date.dd }} {{ run.date_format('%Y-W%V') }}",
1647 )
1648 .unwrap();
1649 assert_eq!(
1650 out,
1651 "monthly/client_a/prod/prod/prod 2026-01-25 20260125 25012026 2026-01-25 2026-W04"
1652 );
1653 }
1654
1655 #[test]
1656 fn var_precedence_is_cli_then_binding_then_default() {
1657 let (_d, r) = renderer(
1658 serde_json::json!({"a": "binding", "b": "binding", "n": 5}),
1659 &[("a", "cli")],
1660 &[],
1661 );
1662 assert_eq!(
1663 render(
1664 &r,
1665 "{{ var('a') }} {{ var('b') }} {{ var('c', 'dflt') }} {{ var('n') + 1 }}"
1666 )
1667 .unwrap(),
1668 "cli binding dflt 6"
1669 );
1670 let e = render(&r, "select\n{{ var('missing') }}").unwrap_err();
1671 assert_eq!(
1672 (e.line, e.message.contains("`var('missing')` has no value")),
1673 (Some(2), true),
1674 "{e}"
1675 );
1676 }
1677
1678 #[test]
1679 fn env_var_reads_the_environment_or_errors() {
1680 let (_d, r) = renderer(Json::Null, &[], &[]);
1681 assert_eq!(
1682 render(&r, "{{ env_var('DRE_TEST_UNSET_VAR', 'x') }}").unwrap(),
1683 "x"
1684 );
1685 let home = std::env::var("PATH").unwrap();
1686 assert_eq!(render(&r, "{{ env_var('PATH') }}").unwrap(), home);
1687 assert!(
1688 render(&r, "{{ env_var('DRE_TEST_UNSET_VAR') }}")
1689 .unwrap_err()
1690 .message
1691 .contains("is not set")
1692 );
1693 }
1694
1695 #[test]
1696 fn macros_from_every_file_are_callable_and_keep_line_numbers() {
1697 let (_d, r) = renderer(
1698 Json::Null,
1699 &[],
1700 &[
1701 ("a.sql", "{% macro double(x) %}{{ x * 2 }}{% endmacro %}\n"),
1702 (
1703 "b.sql",
1704 "{% macro broken() %}\n{{ var('nope') }}\n{% endmacro %}\n{% macro quad(x) %}{{ x * 4 }}{% endmacro %}\n",
1705 ),
1706 ],
1707 );
1708 assert_eq!(
1709 render(&r, "select {{ double(2) }}, {{ quad(1) }}\n").unwrap(),
1710 "select 4, 4\n"
1711 );
1712 let e = render(&r, "line one\nselect {{ broken() }}").unwrap_err();
1713 assert_eq!(e.file, PathBuf::from("macros/b.sql"), "{e}");
1714 assert_eq!(e.line, Some(2), "{e}");
1715 let e = render(&r, "one\ntwo\n{{ nope }}").unwrap_err();
1716 assert_eq!(
1717 (e.file.clone(), e.line),
1718 (PathBuf::from("reports/q.sql"), Some(3)),
1719 "{e}"
1720 );
1721 }
1722
1723 struct Fake;
1724 impl QueryRunner for Fake {
1725 fn run_query(&self, sql: &str, max_rows: u64, _profile: Option<&str>) -> Result<QueryRows, String> {
1726 if max_rows < 2 {
1727 return Err(format!("`{sql}` returned more than {max_rows} rows"));
1728 }
1729 Ok(QueryRows {
1730 columns: vec!["region".into(), "n".into()],
1731 rows: vec![
1732 vec![Value::from("apac"), Value::from(1)],
1733 vec![Value::from("emea"), Value::from(2)],
1734 ],
1735 })
1736 }
1737 fn columns(&self, _: &str, _: Option<&str>) -> Result<Vec<Column>, String> {
1738 Ok(Vec::new())
1739 }
1740 }
1741
1742 #[test]
1743 fn run_query_rows_are_accessible_by_name_and_index() {
1744 let dir = tempfile::tempdir().unwrap();
1745 let r = Renderer::new(RendererConfig {
1746 root: dir.path(),
1747 macros: &[],
1748 context: RunContext {
1749 report: "r".into(),
1750 set: None,
1751 target: "t".into(),
1752 schedule: None,
1753 date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
1754 now: Utc::now(),
1755 scheduled_at: None,
1756 calendar: Calendar::default(),
1757 },
1758 vars: JsonMap::new(),
1759 cli_vars: BTreeMap::new(),
1760 runner: Some(Arc::new(Fake)),
1761 connections: None,
1762 mode: Mode::Run,
1763 connection: None,
1764 source_type: "duckdb".into(),
1765 sources: None,
1766 run_query_max_rows: 10_000,
1767 sql: BTreeMap::new(),
1768 lookups: BTreeMap::new(),
1769 lookup_inline_max_rows: 200,
1770 packages: Vec::new(),
1771 project_name: "acme".into(),
1772 dispatch: DispatchOrder::new(),
1773 })
1774 .unwrap();
1775 let src = "{% set res = run_query('select') %}{{ res.columns | join(',') }}|\
1776 {% for row in res.rows %}{{ row.region }}={{ row[1] }}{{ ',' if not loop.last }}{% endfor %}|{{ res | length }}|{{ res[1]['region'] }}";
1777 assert_eq!(render(&r, src).unwrap(), "region,n|apac=1,emea=2|2|emea");
1778 let e = render(&r, "{{ run_query('select', max_rows=1) }}").unwrap_err();
1779 assert!(e.message.contains("more than 1 rows"), "{e}");
1780 assert!(render(&r, "{% if run.set is none %}none{% endif %}").unwrap() == "none");
1781 }
1782
1783 #[test]
1784 fn removed_names_say_what_to_write_instead() {
1785 let e = err("{{ run.profile }}");
1786 assert!(
1787 e.contains("`run.profile` was removed") && e.contains("connection.name"),
1788 "{e}"
1789 );
1790 let e = err("{{ target.schema }}");
1791 assert!(
1792 e.contains("`target.schema` was removed") && e.contains("connection.schema"),
1793 "{e}"
1794 );
1795 }
1796
1797 #[test]
1798 fn a_source_relation_quotes_the_parts_asked_for() {
1799 let r = ResolvedSource {
1800 source: "sales".into(),
1801 table: "orders".into(),
1802 profile: None,
1803 database: None,
1804 schema: "Sales".into(),
1805 identifier: "Or\"ders".into(),
1806 quoting: Quoting {
1807 database: true,
1808 schema: false,
1809 identifier: true,
1810 },
1811 };
1812 assert_eq!(r.relation(Some("\"")), "Sales.\"Or\"\"ders\"");
1813 assert_eq!(r.relation(None), "Sales.Or\"ders");
1814 let r = ResolvedSource {
1815 database: Some("main".into()),
1816 ..r
1817 };
1818 assert_eq!(r.relation(Some("`")), "`main`.Sales.`Or\"ders`");
1819 }
1820
1821 #[test]
1822 fn a_relation_is_mentioned_only_as_a_whole_name() {
1823 assert!(mentions("select * from sales.orders where 1", "sales.orders"));
1824 assert!(!mentions("select * from sales.orders_old", "sales.orders"));
1825 assert!(!mentions("select * from x.sales.orders", "sales.orders"));
1826 }
1827}