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