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