1use std::collections::BTreeMap;
8use std::fs::File;
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant};
13
14use arrow::array::RecordBatch;
15use arrow::datatypes::SchemaRef;
16use arrow::ipc::reader::FileReader;
17use arrow::ipc::writer::FileWriter;
18use chrono::NaiveDate;
19use dre_protocol::host::{Execution, HostError, LogSink, PluginProcess};
20use dre_protocol::msg::{ColumnOptions, DeliveryFile, ResultSetMeta};
21use dre_protocol::{CAP_LOAD, CAP_MESSAGE, CAP_MESSAGE_ONLY, CAP_MULTI_FILE, CAP_READ_ONLY, CAP_SESSIONS};
22use serde::Serialize;
23use serde_json::{Map as JsonMap, Value as Json, json};
24
25use crate::codes::{Code, ErrorCode};
26use crate::dates::Calendar;
27use crate::engine::{CancelToken, Events, RunEvent, RunStore};
28use crate::lookups::Table;
29use crate::message::QueryResult;
30use crate::parse::ParsedBinding;
31use crate::profiles::{Entry, LOCAL_TYPE, ProfileTarget, Profiles, Role};
32use crate::project::{Binding, PluginKind, Project, QueryEntry, Report};
33use crate::render::{
34 Column, Connection, Connections, Mode, QueryRows, QueryRunner, RenderError, Renderer, RendererConfig,
35};
36use crate::run_results::{self, Delivery, RunResults};
37use crate::selector;
38use crate::sqlsplit::{self, StatementKind};
39
40pub(crate) mod per_row;
41
42#[derive(Debug, Clone, Default)]
44pub struct RunOptions {
45 pub selector: Option<String>,
46 pub set: Option<String>,
48 pub target: Option<String>,
50 pub profile: Option<String>,
52 pub vars: BTreeMap<String, String>,
53 pub output_name: Option<String>,
54 pub output_path: Option<String>,
55 pub dry_run: bool,
56 pub no_deliver: bool,
57 pub output_json: Option<PathBuf>,
59 pub from_results: Option<PathBuf>,
62 pub row_concurrency: Option<usize>,
64 pub hold: bool,
66 pub retry_failed: Option<String>,
69 pub only_keys: Option<Vec<String>>,
71 pub limit_rows: Option<usize>,
73 pub unmask: bool,
75 pub rate: Option<crate::rate::Rate>,
77 pub preview: Option<u64>,
79 pub accept_schema_change: bool,
80 pub interactive: bool,
82 pub date: Option<NaiveDate>,
84 pub scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
87 pub live_check: bool,
89 pub schedule: Option<String>,
91 pub timezone: Option<String>,
93 pub manifest_checksum: Option<String>,
95 pub cancel: CancelToken,
97 pub keep_runs: Option<usize>,
99 pub threads: Option<usize>,
102}
103
104impl RunOptions {
105 pub fn params(&self, date: NaiveDate) -> run_results::Params {
107 run_results::Params {
108 selector: self.selector.clone(),
109 set: self.set.clone(),
110 schedule: self.schedule.clone(),
111 target: self.target.clone(),
112 profile: self.profile.clone(),
113 vars: self.vars.clone(),
114 run_date: date.to_string(),
115 scheduled_at: self.scheduled_at.map(rfc3339),
116 timezone: self.timezone.clone(),
117 output_name: self.output_name.clone(),
118 output_path: self.output_path.clone(),
119 dry_run: self.dry_run,
120 preview: self.preview,
121 accept_schema_change: self.accept_schema_change,
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
128pub enum Level {
129 Debug,
130 Info,
131}
132
133pub trait Ui {
136 fn targets(&mut self, _targets: &RunTargets) {}
138 fn plan(&mut self, _bindings: usize) {}
140 fn binding_start(&mut self, _report: &str, _set: Option<&str>) {}
141 fn binding_log(&mut self, _path: &Path) {}
143 fn binding_vars(
145 &mut self,
146 _schedule: Option<&str>,
147 _schedule_vars: Option<&JsonMap<String, Json>>,
148 _vars: &JsonMap<String, Json>,
149 ) {
150 }
151 fn step(&mut self, level: Level, verb: &str, detail: &str, elapsed: Option<Duration>);
153 fn warn(&mut self, msg: &str);
154 fn binding_end(&mut self, _outcome: &BindingOutcome) {}
155 fn compiled(&mut self, _plan: &BindingPlan) {}
157 fn message(&mut self, _message: &ShownMessage) {}
159 fn choose_set(&mut self, report: &str, sets: &[String]) -> Result<Option<String>, String>;
161 fn running(&mut self, _label: &str, _running: bool) {}
164 fn plugin_log(&self) -> LogSink;
166 fn sql_log(&self) -> LogSink {
168 Arc::new(|_, _| {})
169 }
170}
171
172#[derive(Debug, Clone, Serialize)]
174pub struct ShownMessage {
175 pub output: Option<String>,
177 pub title: String,
178 pub text: String,
180 pub notes: Vec<String>,
182}
183
184pub use crate::run_results::Status;
185
186#[derive(Debug, Clone, Serialize)]
187pub struct BindingOutcome {
188 pub report: String,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub set: Option<String>,
191 pub binding: String,
192 pub status: Status,
193 #[serde(skip_serializing_if = "Option::is_none")]
194 pub error: Option<String>,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub error_code: Option<ErrorCode>,
198 pub files: Vec<PathBuf>,
199 pub summary: String,
201 pub schedule: Option<String>,
203 pub schedule_vars: Option<JsonMap<String, Json>>,
204 pub vars: JsonMap<String, Json>,
206 #[serde(skip_serializing_if = "String::is_empty")]
208 pub timezone: String,
209 #[serde(skip)]
210 pub elapsed: Duration,
211}
212
213#[derive(Debug, Clone, Serialize)]
215pub struct BindingPlan {
216 pub report: String,
217 pub set: Option<String>,
218 pub compiled: Vec<PathBuf>,
220 pub profile: Option<String>,
222 pub queries: Vec<PlannedQuery>,
224 pub target: String,
225 pub format: String,
226 pub output: PathBuf,
229 pub destinations: Vec<PlannedDestination>,
230 pub outputs: Vec<PlannedOutput>,
232 pub schedules: Vec<String>,
233}
234
235#[derive(Debug, Clone, Serialize)]
236pub struct PlannedOutput {
237 pub name: Option<String>,
238 pub format: String,
239 pub output: PathBuf,
241 pub destinations: Vec<PlannedDestination>,
242}
243
244#[derive(Debug, Clone, Serialize)]
245pub struct PlannedQuery {
246 pub query: String,
247 pub connection: String,
248 #[serde(rename = "type")]
249 pub kind: String,
250 pub target: String,
252 #[serde(skip_serializing_if = "Vec::is_empty")]
253 pub sources: Vec<String>,
254}
255
256#[derive(Debug, Clone, Serialize)]
257pub struct PlannedDestination {
258 pub profile: String,
259 pub kind: Option<String>,
261 pub target: Option<String>,
263 pub path: Option<String>,
264 pub delivers: bool,
266}
267
268#[derive(Debug, Default)]
269pub struct RunSummary {
270 pub outcomes: Vec<BindingOutcome>,
271 pub error: Option<String>,
273 pub missing_entry: bool,
275 pub cancelled: Option<crate::engine::CancelReason>,
277 pub not_run: usize,
279}
280
281impl RunSummary {
282 pub fn failed(&self) -> bool {
283 self.error.is_some()
284 || self.cancelled.is_some()
285 || self
286 .outcomes
287 .iter()
288 .any(|o| matches!(o.status, Status::Error | Status::Cancelled | Status::TimedOut))
289 }
290
291 pub fn error_code(&self) -> Option<ErrorCode> {
293 if self.error.is_some() {
294 return Some(ErrorCode::Core(if self.missing_entry {
295 Code::MissingTargetEntry
296 } else {
297 Code::InvalidSelector
298 }));
299 }
300 self.outcomes
301 .iter()
302 .find(|o| matches!(o.status, Status::Error | Status::Cancelled | Status::TimedOut))
303 .map(|o| o.error_code.clone().unwrap_or(ErrorCode::Core(Code::RunFailed)))
304 .or_else(|| {
305 self.cancelled.map(|r| {
306 ErrorCode::Core(match r {
307 crate::engine::CancelReason::Timeout => Code::RunTimedOut,
308 _ => Code::RunCancelled,
309 })
310 })
311 })
312 }
313
314 pub fn exit_code(&self) -> u8 {
318 if let Some(r) = self.cancelled {
319 return r.exit_code();
320 }
321 if self.error.is_some() {
322 return self.error_code().map_or(2, |c| c.kind().exit_code());
324 }
325 let failed: Vec<&BindingOutcome> = self
326 .outcomes
327 .iter()
328 .filter(|o| matches!(o.status, Status::Error | Status::Cancelled | Status::TimedOut))
329 .collect();
330 if !failed.is_empty()
332 && failed.iter().all(|o| {
333 o.error_code
334 .as_ref()
335 .is_some_and(|c| c.kind() == crate::codes::Kind::Refused)
336 })
337 && self.outcomes.len() == failed.len()
338 {
339 return 2;
340 }
341 if failed.is_empty() { 0 } else { 1 }
343 }
344}
345
346pub struct Plan<'a> {
348 pub bindings: Vec<(&'a Report, Binding)>,
349 pub targets: RunTargets,
350}
351
352pub fn run(project: &Project, opts: &RunOptions, ui: &mut dyn Ui) -> RunSummary {
355 let mut summary = RunSummary::default();
356 let Some(plan) = plan(project, opts, ui, &mut summary) else {
357 return summary;
358 };
359 ui.targets(&plan.targets);
360 ui.plan(plan.bindings.len());
361 let (plugin_log, sql_log) = (ui.plugin_log(), ui.sql_log());
362 let (tx, rx) = std::sync::mpsc::channel();
363 let cancel = opts.cancel.clone();
364 let outcomes = std::thread::scope(|s| {
365 let worker = s.spawn(|| execute(project, &plan, opts, &cancel, Events::new(tx)));
366 for e in rx {
367 crate::engine::dispatch(ui, e, &plugin_log, &sql_log);
368 }
369 worker.join().expect("the run's worker doesn't panic")
370 });
371 summary.not_run = plan.bindings.len().saturating_sub(outcomes.len());
372 summary.cancelled = cancel.reason();
373 summary.outcomes.extend(outcomes);
374 summary
375}
376
377pub fn plan<'a>(
382 project: &'a Project,
383 opts: &RunOptions,
384 ui: &mut dyn Ui,
385 summary: &mut RunSummary,
386) -> Option<Plan<'a>> {
387 if let Err(e) = crate::target::ensure(&project.target_dir) {
388 summary.error = Some(format!(
389 "can't write to the target path {} (from {}): {e}",
390 project.target_dir.display(),
391 project.target_source
392 ));
393 return None;
394 }
395 let planned: Vec<(&Report, Binding)> = match &opts.schedule {
396 Some(name) => {
397 if !project.schedules.iter().any(|e| &e.name == name) {
398 summary.error = Some(unknown_schedule(project, name));
399 return None;
400 }
401 match schedule_bindings(project, name, opts) {
402 Ok(p) => p,
403 Err(e) => {
404 summary.error = Some(e);
405 return None;
406 }
407 }
408 }
409 None => {
410 let reports: Vec<&Report> = match &opts.selector {
411 None => project.reports.iter().collect(),
412 Some(s) => match selector::resolve(project, s) {
413 Ok(r) if r.is_empty() => {
414 summary.error = Some(format!("selector `{s}` matches no report"));
415 return None;
416 }
417 Ok(r) => r,
418 Err(e) => {
419 summary.error = Some(e.to_string());
420 return None;
421 }
422 },
423 };
424 let mut planned = Vec::new();
427 for report in reports {
428 match choose_bindings(project, report, opts, ui) {
429 Ok(bs) => planned.extend(bs.into_iter().map(|b| (report, b))),
430 Err(e) => {
431 let outcome = BindingOutcome {
432 report: report.name.clone(),
433 set: None,
434 binding: "-".into(),
435 status: Status::Error,
436 error: Some(e),
437 error_code: Some(Code::InvalidSelector.into()),
438 files: Vec::new(),
439 summary: String::new(),
440 schedule: opts.schedule.clone(),
441 schedule_vars: None,
442 vars: JsonMap::new(),
443 timezone: String::new(),
444 elapsed: Duration::ZERO,
445 };
446 ui.binding_end(&outcome);
447 summary.outcomes.push(outcome);
448 }
449 }
450 }
451 planned
452 }
453 };
454 match run_targets(project, &planned, opts) {
456 Ok(targets) => Some(Plan {
457 bindings: planned,
458 targets,
459 }),
460 Err(e) => {
461 summary.error = Some(e);
462 summary.missing_entry = true;
463 None
464 }
465 }
466}
467
468pub fn execute(
472 project: &Project,
473 plan: &Plan<'_>,
474 opts: &RunOptions,
475 cancel: &CancelToken,
476 events: Events,
477) -> Vec<BindingOutcome> {
478 let limits = Limits::new(project, plan, opts);
479 let target = opts
480 .output_json
481 .as_ref()
482 .map(|p| project.root.join(p))
483 .unwrap_or_else(|| project.target_dir.clone());
484 if limits.sequential() {
485 let store = RunStore::new(&target);
486 let mut outcomes = Vec::new();
487 for (report, b) in &plan.bindings {
488 if cancel.is_cancelled() {
489 break;
490 }
491 events.emit(RunEvent::BindingStart {
492 report: report.name.clone(),
493 set: b.set.clone(),
494 });
495 let mut r = BindingRun::new(project, report, b, opts, events.clone(), &store);
496 let outcome = r.run();
497 events.emit(RunEvent::BindingEnd(outcome.clone()));
498 outcomes.push(outcome);
499 }
500 return outcomes;
501 }
502 let store = RunStore::concurrent(&target);
503 let n = plan.bindings.len();
504 let state = std::sync::Mutex::new(Running::new(n));
505 let wake = std::sync::Condvar::new();
506 let outcomes: std::sync::Mutex<Vec<Option<BindingOutcome>>> = std::sync::Mutex::new(vec![None; n]);
507 std::thread::scope(|s| {
508 for _ in 0..limits.cap.min(n) {
509 s.spawn(|| {
510 loop {
511 let i = {
512 let mut st = state.lock().unwrap();
513 loop {
514 if cancel.is_cancelled() || st.started.iter().all(|x| *x) {
515 break None;
516 }
517 match limits.next(&st) {
518 Some(i) => {
519 st.start(i, &limits.uses[i]);
520 break Some(i);
521 }
522 None => st = wake.wait(st).unwrap(),
523 }
524 }
525 };
526 let Some(i) = i else {
527 wake.notify_all();
528 return;
529 };
530 let (report, b) = &plan.bindings[i];
531 let label = match &b.set {
532 Some(set) => format!("{} [{set}]", report.name),
533 None => report.name.clone(),
534 };
535 let held = events.held();
536 held.emit_now(RunEvent::Running {
537 label: label.clone(),
538 running: true,
539 });
540 held.emit(RunEvent::BindingStart {
541 report: report.name.clone(),
542 set: b.set.clone(),
543 });
544 let mut r = BindingRun::new(project, report, b, opts, held.clone(), &store);
545 let outcome = r.run();
546 held.emit(RunEvent::BindingEnd(outcome.clone()));
547 held.emit_now(RunEvent::Running {
548 label,
549 running: false,
550 });
551 held.flush();
552 outcomes.lock().unwrap()[i] = Some(outcome);
553 state.lock().unwrap().finish(&limits.uses[i]);
554 wake.notify_all();
555 }
556 });
557 }
558 });
559 outcomes.into_inner().unwrap().into_iter().flatten().collect()
561}
562
563struct Limits {
567 uses: Vec<Vec<String>>,
569 per_connection: BTreeMap<String, usize>,
570 cap: usize,
571}
572
573struct Running {
575 started: Vec<bool>,
576 total: usize,
577 on: BTreeMap<String, usize>,
578 warm: std::collections::BTreeSet<String>,
581}
582
583impl Running {
584 fn new(n: usize) -> Running {
585 Running {
586 started: vec![false; n],
587 total: 0,
588 on: BTreeMap::new(),
589 warm: Default::default(),
590 }
591 }
592
593 fn start(&mut self, i: usize, uses: &[String]) {
594 self.started[i] = true;
595 self.total += 1;
596 for c in uses {
597 *self.on.entry(c.clone()).or_default() += 1;
598 }
599 }
600
601 fn finish(&mut self, uses: &[String]) {
602 self.total -= 1;
603 for c in uses {
604 *self.on.entry(c.clone()).or_default() -= 1;
605 self.warm.insert(c.clone());
606 }
607 }
608}
609
610impl Limits {
611 fn new(project: &Project, plan: &Plan<'_>, opts: &RunOptions) -> Limits {
612 let schedule_vars = opts
613 .schedule
614 .as_ref()
615 .and_then(|n| project.schedules.iter().find(|e| &e.name == n))
616 .map(|e| e.vars.clone());
617 let inputs = crate::parse::Inputs {
618 target: project.target_name.clone(),
619 cli_vars: opts.vars.clone(),
620 date: opts.date,
621 scheduled_at: opts.scheduled_at,
622 timezone: opts.timezone.clone(),
623 schedule: opts.schedule.clone(),
624 started_at: None,
625 };
626 let uses: Vec<Vec<String>> = plan
627 .bindings
628 .iter()
629 .map(|(report, b)| {
630 let mut vars = b.vars.clone();
631 vars.extend(schedule_vars.clone().unwrap_or_default());
632 crate::parse::binding(project, report, b, &vars, &inputs)
633 .connections()
634 .into_iter()
635 .map(str::to_string)
636 .collect()
637 })
638 .collect();
639 let mut per_connection = BTreeMap::new();
640 for c in uses.iter().flatten() {
641 per_connection
642 .entry(c.clone())
643 .or_insert_with(|| connection_threads(project, c));
644 }
645 let cap = if opts.dry_run || opts.live_check {
647 1
648 } else {
649 opts.threads.unwrap_or(usize::MAX).max(1)
650 };
651 Limits {
652 uses,
653 per_connection,
654 cap,
655 }
656 }
657
658 fn sequential(&self) -> bool {
660 self.cap == 1 || (self.cap == usize::MAX && self.per_connection.values().all(|n| *n <= 1))
661 }
662
663 fn next(&self, st: &Running) -> Option<usize> {
665 if st.total >= self.cap {
666 return None;
667 }
668 (0..st.started.len()).find(|&i| {
669 !st.started[i]
670 && self.uses[i].iter().all(|c| {
671 let on = st.on.get(c).copied().unwrap_or(0);
672 let limit = if st.warm.contains(c) {
673 self.per_connection[c]
674 } else {
675 1
676 };
677 on < limit
678 })
679 })
680 }
681}
682
683fn connection_threads(project: &Project, connection: &str) -> usize {
685 let Some(t) = project.profiles.target(Role::Connection, connection) else {
686 return 1;
687 };
688 let n = match t.fields.get("threads") {
689 Some(Json::Number(n)) => n.as_u64().unwrap_or(1),
690 Some(Json::String(s)) => s.trim().parse().unwrap_or(1),
691 _ => 1,
692 } as usize;
693 let duckdb_file = t.kind == "duckdb"
694 && t.fields
695 .get("path")
696 .and_then(Json::as_str)
697 .is_some_and(|p| p != ":memory:");
698 if duckdb_file { 1 } else { n.max(1) }
699}
700
701#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
703pub struct UsedProfile {
704 pub role: &'static str,
706 pub profile: String,
707 pub target: String,
709 pub deliver: bool,
711}
712
713#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
715pub struct RunTargets {
716 pub name: String,
718 pub from: String,
720 pub profiles: Vec<UsedProfile>,
721}
722
723impl RunTargets {
724 pub fn line(&self) -> String {
726 let mut out = format!("{} ({})", self.name, self.from);
727 for p in self.profiles.iter().filter(|p| p.target != self.name) {
728 out.push_str(&format!("; {} `{}`: {}", p.role, p.profile, p.target));
729 }
730 out
731 }
732
733 pub fn mismatch(&self) -> Option<String> {
736 let first = &self.profiles.first()?.target;
737 if first == &self.name || self.profiles.iter().any(|p| &p.target != first) {
738 return None;
739 }
740 Some(format!(
741 "every profile is on `{first}` but the run's target is `{}`: pass `--target {first}` or set `{}`",
742 self.name,
743 crate::profiles::TARGET_ENV
744 ))
745 }
746}
747
748pub fn run_targets(
752 project: &Project,
753 planned: &[(&Report, Binding)],
754 opts: &RunOptions,
755) -> Result<RunTargets, String> {
756 let profiles = &project.profiles;
757 let schedule_vars = opts
758 .schedule
759 .as_ref()
760 .and_then(|n| project.schedules.iter().find(|e| &e.name == n))
761 .map(|e| e.vars.clone());
762 let inputs = crate::parse::Inputs {
763 target: project.target_name.clone(),
764 cli_vars: opts.vars.clone(),
765 date: opts.date,
766 scheduled_at: opts.scheduled_at,
767 timezone: opts.timezone.clone(),
768 schedule: opts.schedule.clone(),
769 started_at: None,
770 };
771 let mut used: Vec<(Role, String)> = Vec::new();
772 for (report, b) in planned {
773 let mut vars = b.vars.clone();
774 vars.extend(schedule_vars.clone().unwrap_or_default());
775 let parsed = crate::parse::binding(project, report, b, &vars, &inputs);
777 let names = parsed
778 .connections()
779 .into_iter()
780 .map(|c| (Role::Connection, c.to_string()))
781 .chain(
782 parsed
783 .destinations
784 .iter()
785 .flatten()
786 .filter(|_| !opts.no_deliver)
787 .filter(|d| !profiles.is_builtin_local(d))
788 .map(|d| (Role::Destination, d.clone())),
789 );
790 for u in names {
791 if !used.contains(&u) {
792 used.push(u);
793 }
794 }
795 }
796 let mut missing = Vec::new();
797 let mut out = Vec::new();
798 for (role, name) in &used {
799 let deliver = match profiles.entry(*role, name) {
800 Entry::Use(_) => true,
801 Entry::Nowhere => false,
802 Entry::Missing => {
803 missing.push(profiles.missing_entry(*role, name));
804 continue;
805 }
806 Entry::Unknown => continue,
808 };
809 out.push(UsedProfile {
810 role: role.as_str(),
811 profile: name.clone(),
812 target: profiles.target_of(*role, name),
813 deliver,
814 });
815 }
816 match missing.len() {
817 0 => Ok(RunTargets {
818 name: project.target_name.clone(),
819 from: project.target_from.to_string(),
820 profiles: out,
821 }),
822 1 => Err(format!("{}; nothing was run", missing[0])),
823 n => Err(format!(
824 "{n} profiles this run uses have no entry for it; nothing was run:\n {}",
825 missing.join("\n ")
826 )),
827 }
828}
829
830fn schedule_bindings<'a>(
833 project: &'a Project,
834 name: &str,
835 opts: &RunOptions,
836) -> Result<Vec<(&'a Report, Binding)>, String> {
837 let reports: Option<Vec<&str>> = match &opts.selector {
838 None => None,
839 Some(s) => Some(
840 selector::resolve(project, s)
841 .map_err(|e| e.to_string())?
842 .into_iter()
843 .map(|r| r.name.as_str())
844 .collect(),
845 ),
846 };
847 let set = opts.set.as_deref().filter(|s| *s != "all");
848 let all: Vec<(&Report, &Binding)> = project
849 .reports
850 .iter()
851 .flat_map(|r| {
852 r.bindings
853 .iter()
854 .filter(|b| b.schedules.iter().any(|s| s == name))
855 .map(move |b| (r, b))
856 })
857 .collect();
858 let picked: Vec<(&Report, Binding)> = all
859 .iter()
860 .filter(|(r, _)| reports.as_ref().is_none_or(|rs| rs.contains(&r.name.as_str())))
861 .filter(|(_, b)| set.is_none() || b.set.as_deref() == set)
862 .map(|(r, b)| {
863 let mut b = (*b).clone();
864 if let Some(p) = &opts.profile {
865 b.profile = Some(p.clone());
866 b.profile_at = Some(profile_flag_at());
867 }
868 (*r, b)
869 })
870 .collect();
871 if picked.is_empty() && (reports.is_some() || set.is_some()) {
872 let mut asked = Vec::new();
873 if let Some(s) = &opts.selector {
874 asked.push(format!("`{s}`"));
875 }
876 if let Some(s) = set {
877 asked.push(format!("Set `{s}`"));
878 }
879 let runs: Vec<String> = all
880 .iter()
881 .map(|(r, b)| match &b.set {
882 Some(s) => format!("{}/{s}", r.name),
883 None => r.name.clone(),
884 })
885 .collect();
886 return Err(format!(
887 "schedule `{name}` doesn't run {}; it runs: {}",
888 asked.join(" with "),
889 if runs.is_empty() {
890 "nothing".to_string()
891 } else {
892 runs.join(", ")
893 }
894 ));
895 }
896 Ok(picked)
897}
898
899pub fn unknown_schedule(project: &Project, name: &str) -> String {
901 let names: Vec<&str> = project.schedules.iter().map(|e| e.name.as_str()).collect();
902 if names.is_empty() {
903 format!("no schedule `{name}`: this project's schedules.yml declares none")
904 } else {
905 format!("no schedule `{name}`; valid names: {}", names.join(", "))
906 }
907}
908
909pub fn choose_bindings(
911 project: &Project,
912 report: &Report,
913 opts: &RunOptions,
914 ui: &mut dyn Ui,
915) -> Result<Vec<Binding>, String> {
916 let mut out: Vec<Binding> = match opts.set.as_deref() {
917 Some("all") => report.bindings.clone(),
918 Some(name) => match report.binding(name) {
919 Some(b) => vec![b.clone()],
920 None => vec![ad_hoc(project, report, name)],
921 },
922 None if !report.has_sets => report.bindings.clone(),
923 None => {
924 let names: Vec<String> = report.bindings.iter().filter_map(|b| b.set.clone()).collect();
925 let pick = report
926 .default_set
927 .clone()
928 .or_else(|| project.default_set.clone().filter(|d| names.contains(d)))
929 .or_else(|| (names.len() == 1).then(|| names[0].clone()));
930 match pick {
931 Some(p) => report.binding(&p).cloned().into_iter().collect(),
932 None if opts.interactive => match ui.choose_set(&report.name, &names)? {
933 Some(p) => report.binding(&p).cloned().into_iter().collect(),
934 None => report.bindings.clone(),
935 },
936 None => {
937 return Err(format!(
938 "report `{}` has several Sets ({}) and no `default_set`; declare `default_set:` or pass `--set <name>` or `--set all`",
939 report.name,
940 names.join(", ")
941 ));
942 }
943 }
944 }
945 };
946 if let Some(p) = &opts.profile {
947 for b in &mut out {
948 b.profile = Some(p.clone());
949 b.profile_at = Some(profile_flag_at());
950 }
951 }
952 Ok(out)
953}
954
955fn ad_hoc(project: &Project, report: &Report, name: &str) -> Binding {
958 let mut b = report.base.clone();
959 b.set = Some(name.to_string());
960 if let Some(reg) = project.sets.get(name) {
961 if let Some(p) = ®.profile {
962 b.profile = Some(p.clone());
963 b.profile_at = Some(crate::project::ProfileAt {
964 file: reg.file.clone(),
965 line: reg.line,
966 key: format!("`profile` of Set `{name}`"),
967 });
968 }
969 b.vars.extend(reg.vars.clone());
970 }
971 b
972}
973
974fn profile_flag_at() -> crate::project::ProfileAt {
976 crate::project::ProfileAt {
977 file: PathBuf::from("--profile"),
978 line: None,
979 key: "`--profile`".into(),
980 }
981}
982
983struct Produced {
985 query: String,
986 connection: String,
987 schema: SchemaRef,
988 rows: u64,
989 spool: PathBuf,
990 name: String,
991 anchor: Option<String>,
992 header: Option<bool>,
993 columns: BTreeMap<String, ColumnOptions>,
994 autofit: Option<bool>,
995 style: Option<dre_protocol::style::SheetStyle>,
996}
997
998struct Statement {
999 query: String,
1000 connection: String,
1002 file: PathBuf,
1003 line: usize,
1004 text: String,
1005 kind: StatementKind,
1006 sensitive: bool,
1009 tab: bool,
1012}
1013
1014struct BindingRun<'a> {
1015 project: &'a Project,
1016 report: &'a Report,
1017 b: &'a Binding,
1018 vars: JsonMap<String, Json>,
1020 rendered_vars: JsonMap<String, Json>,
1022 schedule_vars: Option<JsonMap<String, Json>>,
1023 opts: &'a RunOptions,
1024 date: NaiveDate,
1026 calendar: Calendar,
1027 ui: Events,
1028 store: &'a RunStore,
1029 compiled_dir: PathBuf,
1030 runs: crate::runs::BindingRuns,
1032 run_id: String,
1034 run_dir: PathBuf,
1035 becomes_current: bool,
1038 schema_dir: PathBuf,
1039 target: String,
1041 parsed: ParsedBinding,
1043 pool: Option<Arc<Pool>>,
1045 renderers: BTreeMap<Option<String>, Arc<Renderer>>,
1047 started: Instant,
1048 started_at: chrono::DateTime<chrono::Utc>,
1049 produced: Vec<Produced>,
1050 outs: Vec<OutputRun>,
1052 attempts: u32,
1054 drift: Vec<String>,
1055 protected_snapshot: Option<Json>,
1057 retry: Option<per_row::Retry>,
1059 held: Vec<crate::release::HeldEntry>,
1061}
1062
1063#[derive(Default)]
1065struct OutputRun {
1066 dests: Vec<RenderedDest>,
1068 files: Vec<(PathBuf, Option<String>)>,
1070 deliveries: Vec<Delivery>,
1072 delivery_note: Option<String>,
1073 status: Option<OutputStatus>,
1075 error: Option<String>,
1076 when: Option<bool>,
1078 message: Option<(String, String)>,
1080 reason: Option<String>,
1082 no_data: Vec<String>,
1084 dropped: Vec<String>,
1086 for_each: Option<run_results::ForEachResult>,
1088 row_files: std::collections::HashMap<String, Vec<PathBuf>>,
1090}
1091
1092#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1094enum OutputStatus {
1095 Delivered,
1096 Kept,
1098 Skipped,
1100 Failed,
1101 Held,
1103}
1104
1105impl OutputStatus {
1106 fn as_str(self) -> &'static str {
1107 match self {
1108 OutputStatus::Delivered => "delivered",
1109 OutputStatus::Kept => "kept",
1110 OutputStatus::Skipped => "skipped",
1111 OutputStatus::Failed => "failed",
1112 OutputStatus::Held => "held",
1113 }
1114 }
1115}
1116
1117struct RenderedDest {
1119 profile: String,
1120 path: Option<String>,
1121 options: JsonMap<String, Json>,
1122 attach: Vec<String>,
1124 hold: bool,
1126 rate: Option<crate::rate::Rate>,
1128}
1129
1130type Fail = crate::error::Error;
1132
1133fn masked_source_error(error: &HostError, sensitive: bool) -> String {
1134 let error = error.to_string();
1135 crate::secrets::mask_source_error(&error, sensitive).into_owned()
1136}
1137
1138fn masked_sql_summary(sql: &str, max: usize, sensitive: bool) -> String {
1139 if sensitive {
1140 crate::secrets::MASK.to_string()
1141 } else {
1142 dre_protocol::util::summarize(&crate::secrets::mask(sql), max)
1143 }
1144}
1145
1146fn protected_sql(sql: &str, sensitive: bool) -> std::borrow::Cow<'_, str> {
1147 if sensitive {
1148 std::borrow::Cow::Borrowed(crate::secrets::MASK)
1149 } else {
1150 crate::secrets::mask(sql)
1151 }
1152}
1153
1154impl<'a> BindingRun<'a> {
1155 fn new(
1156 project: &'a Project,
1157 report: &'a Report,
1158 b: &'a Binding,
1159 opts: &'a RunOptions,
1160 ui: Events,
1161 store: &'a RunStore,
1162 ) -> Self {
1163 let schedule = opts
1164 .schedule
1165 .as_ref()
1166 .and_then(|n| project.schedules.iter().find(|e| &e.name == n));
1167 let schedule_vars = schedule.map(|e| e.vars.clone());
1168 let calendar: Calendar = crate::parse::calendar(
1170 project,
1171 report,
1172 opts.timezone.as_deref(),
1173 opts.schedule.as_deref(),
1174 );
1175 let date = crate::parse::run_date(&calendar, opts.date, opts.scheduled_at);
1176 let mut vars = b.vars.clone();
1178 vars.extend(schedule_vars.clone().unwrap_or_default());
1179 let rendered_vars = {
1180 let mut v = vars.clone();
1181 v.extend(
1182 opts.vars
1183 .iter()
1184 .map(|(k, x)| (k.clone(), Json::String(x.clone()))),
1185 );
1186 v
1187 };
1188 ui.binding_vars(opts.schedule.as_deref(), schedule_vars.as_ref(), &rendered_vars);
1189 let started_at = chrono::Utc::now();
1190 let runs = crate::runs::BindingRuns::new(&store.run_dir(&report.name, b.dir_name()));
1191 let run_id = if opts.dry_run || opts.live_check {
1193 "<run id>".to_string()
1194 } else {
1195 crate::runs::new_run_id(started_at)
1196 };
1197 BindingRun {
1198 project,
1199 report,
1200 b,
1201 vars,
1202 rendered_vars,
1203 schedule_vars,
1204 opts,
1205 date,
1206 calendar,
1207 ui,
1208 store,
1209 compiled_dir: store.compiled_dir(&report.name, b.dir_name()),
1210 run_dir: runs.run_dir(&run_id),
1211 runs,
1212 run_id,
1213 becomes_current: true,
1214 schema_dir: store.schema_dir(&report.name, b.dir_name()),
1215 target: project.target_name.clone(),
1216 parsed: ParsedBinding::default(),
1217 pool: None,
1218 renderers: BTreeMap::new(),
1219 started: Instant::now(),
1220 started_at,
1221 produced: Vec::new(),
1222 outs: b.outputs.iter().map(|_| OutputRun::default()).collect(),
1223 attempts: 1,
1224 drift: Vec::new(),
1225 protected_snapshot: None,
1226 retry: None,
1227 held: Vec::new(),
1228 }
1229 }
1230
1231 fn connections(&self) -> Arc<ProfileConnections> {
1233 Arc::new(ProfileConnections {
1234 profiles: self.project.profiles.clone(),
1235 root: self.project.root.clone(),
1236 plugins: self.project.plugins.clone(),
1237 log: self.ui.plugin_log(),
1238 })
1239 }
1240
1241 fn renderer(&mut self, connection: Option<&str>) -> Result<Arc<Renderer>, Fail> {
1244 let key = connection.map(str::to_string);
1245 if let Some(r) = self.renderers.get(&key) {
1246 return Ok(r.clone());
1247 }
1248 let pool = self.pool.clone().expect("the pool is made before rendering");
1249 let name = key.clone().or_else(|| self.parsed.inherited.clone());
1251 let source_type = match (&key, &name) {
1252 (Some(n), _) => pool.kind(n)?,
1253 (None, Some(n)) => pool.kind(n).unwrap_or_default(),
1255 (None, None) => String::new(),
1256 };
1257 let context =
1258 crate::parse::run_context(self.project, self.report, self.b, &self.inputs(), self.started_at);
1259 let limited = Arc::new(crate::render::Limited::new(
1260 context.clone(),
1261 self.vars.clone(),
1262 self.opts.vars.clone(),
1263 ));
1264 let r = Renderer::new(RendererConfig {
1265 root: &self.project.root,
1266 macros: &self.project.macros,
1267 context,
1268 vars: self.vars.clone(),
1269 cli_vars: self.opts.vars.clone(),
1270 runner: Some(Arc::new(PoolRunner {
1271 pool: pool.clone(),
1272 default: name.clone(),
1273 log: self.ui.sql_log(),
1274 })),
1275 connections: Some(pool.connections.clone()),
1276 mode: Mode::Run,
1277 connection: name,
1278 source_type,
1279 sources: crate::parse::resolver(self.project, limited),
1280 run_query_max_rows: self.project.run_query_max_rows,
1281 sql: self.project.sql.clone(),
1282 lookups: self.project.lookups.clone(),
1283 lookup_inline_max_rows: self.project.lookup_inline_max_rows,
1284 packages: self.project.packages.clone(),
1285 project_name: self.project.name.clone(),
1286 dispatch: self.project.dispatch.clone(),
1287 })
1288 .map_err(|e| e.to_string())?;
1289 let r = Arc::new(r);
1290 self.renderers.insert(key, r.clone());
1291 Ok(r)
1292 }
1293
1294 fn inputs(&self) -> crate::parse::Inputs {
1296 crate::parse::Inputs {
1297 target: self.target.clone(),
1298 cli_vars: self.opts.vars.clone(),
1299 date: self.opts.date,
1300 scheduled_at: self.opts.scheduled_at,
1301 timezone: self.opts.timezone.clone(),
1302 schedule: self.opts.schedule.clone(),
1303 started_at: Some(self.started_at),
1304 }
1305 }
1306
1307 fn connection_of(&self, query: &str) -> Result<String, Fail> {
1309 self.parsed
1310 .query(query)
1311 .and_then(|q| q.connection.clone())
1312 .ok_or_else(|| format!("query `{query}` has no connection").into())
1313 }
1314
1315 fn label(&self) -> String {
1317 match &self.b.set {
1318 Some(s) => format!("{} (Set {s})", self.report.name),
1319 None => self.report.name.clone(),
1320 }
1321 }
1322
1323 fn lock_failure(&self, e: crate::runs::LockError) -> Fail {
1325 let unlock = match &self.b.set {
1326 Some(s) => format!("dre unlock {} --binding {s}", self.report.name),
1327 None => format!("dre unlock {}", self.report.name),
1328 };
1329 let message = match e {
1330 crate::runs::LockError::Held(h) => format!(
1331 "{} is already running ({}); this run changed nothing. Wait for it, or if it's gone, `{unlock}`",
1332 self.label(),
1333 h.describe()
1334 ),
1335 crate::runs::LockError::Unreadable(p) => format!(
1336 "{} has an unreadable lock at {}; if no run is going on, `{unlock}`",
1337 self.label(),
1338 p.display()
1339 ),
1340 crate::runs::LockError::Io(e) => format!("can't lock {}: {e}", self.label()),
1341 };
1342 Fail::new(Code::RunInProgress, message)
1343 }
1344
1345 fn older_than_current(&self) -> bool {
1348 let Some(dir) = self.runs.current_dir() else {
1349 return false;
1350 };
1351 let Ok(text) = std::fs::read_to_string(dir.join("run_results.json")) else {
1352 return false;
1353 };
1354 let Ok(v) = serde_json::from_str::<Json>(&text) else {
1355 return false;
1356 };
1357 let theirs = v
1358 .get("scheduled_at")
1359 .and_then(Json::as_str)
1360 .or_else(|| v.get("started_at").and_then(Json::as_str))
1361 .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
1362 .map(|t| t.with_timezone(&chrono::Utc));
1363 let mine = self.opts.scheduled_at.unwrap_or(self.started_at);
1364 theirs.is_some_and(|t| mine < t)
1365 }
1366
1367 fn row_limit(&self) -> Option<u64> {
1371 if self.b.outputs.iter().any(|o| o.for_each.is_some()) {
1372 None
1373 } else {
1374 self.opts.preview
1375 }
1376 }
1377
1378 fn not_cancelled(&self) -> Result<(), Fail> {
1379 match self.opts.cancel.reason() {
1380 None => Ok(()),
1381 Some(crate::engine::CancelReason::Timeout) => {
1382 Err(Fail::new(Code::RunTimedOut, "the run timed out"))
1383 }
1384 Some(_) => Err(Fail::new(Code::RunCancelled, "the run was cancelled")),
1385 }
1386 }
1387
1388 fn outcome(&self, status: Status, error: Option<Fail>) -> BindingOutcome {
1389 BindingOutcome {
1390 report: self.report.name.clone(),
1391 set: self.b.set.clone(),
1392 binding: self.b.dir_name().to_string(),
1393 status,
1394 error_code: error.as_ref().map(|e| e.code.clone()),
1395 error: error.map(|e| e.message),
1396 files: self.files().map(|(p, _)| p.clone()).collect(),
1397 summary: self.summary_line(),
1398 schedule: self.opts.schedule.clone(),
1399 schedule_vars: self.schedule_vars.clone(),
1400 vars: self.rendered_vars.clone(),
1401 timezone: self.calendar.tz.name().to_string(),
1402 elapsed: self.started.elapsed(),
1403 }
1404 }
1405
1406 fn run(&mut self) -> BindingOutcome {
1407 let dry = self.opts.dry_run || self.opts.live_check;
1408 let guard = if dry {
1410 None
1411 } else {
1412 match self.runs.lock(&self.run_id, self.started_at) {
1413 Ok(g) => Some(g),
1414 Err(e) => {
1415 let error = self.lock_failure(e);
1416 return self.outcome(Status::Error, Some(error));
1417 }
1418 }
1419 };
1420 if let Some(g) = &guard {
1421 if let Some(h) = &g.took_over {
1422 self.ui.warn(&format!(
1423 "took over the lock of {} for {}: that process is gone (it crashed or was killed)",
1424 h.describe(),
1425 self.label()
1426 ));
1427 }
1428 match self.runs.migrate_legacy(self.started_at) {
1429 Ok(Some(id)) => self.ui.step(
1430 Level::Debug,
1431 "Moved",
1432 &format!("the files of the run before run folders into runs/{id}/"),
1433 None,
1434 ),
1435 Ok(None) => {}
1436 Err(e) => self
1437 .ui
1438 .warn(&format!("can't move the earlier run's files into runs/: {e}")),
1439 }
1440 self.becomes_current = !self.older_than_current() && self.opts.retry_failed.is_none();
1442 if std::fs::create_dir_all(&self.run_dir).is_ok() {
1444 self.ui.emit(RunEvent::BindingLog(self.run_dir.join("dre.log")));
1445 }
1446 }
1447 let result = self.run_inner();
1448 let stopped =
1451 matches!(&result, Err(e) if e.code == Code::RunCancelled || e.code == Code::RunTimedOut);
1452 let result = match (result, self.opts.cancel.reason()) {
1453 (Err(e), Some(reason)) if !stopped => {
1454 let (code, what) = match reason {
1455 crate::engine::CancelReason::Timeout => (Code::RunTimedOut, "timed out"),
1456 _ => (Code::RunCancelled, "was cancelled"),
1457 };
1458 Err(Fail::new(code, format!("the run {what}: {}", e.message)))
1459 }
1460 (r, _) => r,
1461 };
1462 let status = match (&result, dry, self.opts.live_check) {
1463 (Err(e), _, _) if e.code == Code::RunCancelled => Status::Cancelled,
1464 (Err(e), _, _) if e.code == Code::RunTimedOut => Status::TimedOut,
1465 (Err(_), _, _) => Status::Error,
1466 (Ok(()), _, true) => Status::Checked,
1467 (Ok(()), true, _) => Status::DryRun,
1468 (Ok(()), false, _) => Status::Success,
1469 };
1470 let err = result.err();
1471 if !dry && let Err(e) = self.write_results(&status, err.as_ref()) {
1472 self.ui.warn(&format!("can't write run_results.json: {e}"));
1473 }
1474 if guard.is_some() {
1475 if self.becomes_current {
1476 if let Err(e) = self.runs.switch(&self.run_id) {
1477 self.ui.warn(&format!("can't make this run the current one: {e}"));
1478 }
1479 } else {
1480 self.ui.warn(&format!(
1481 "{}: a run for a later instant is current, so this one doesn't replace it; its files are in {}",
1482 self.label(),
1483 rel(&self.project.root, &self.run_dir).display()
1484 ));
1485 }
1486 for id in self
1487 .runs
1488 .prune(self.opts.keep_runs.unwrap_or(self.project.keep_runs))
1489 {
1490 self.ui.step(Level::Debug, "Removed", &format!("run {id}"), None);
1491 }
1492 }
1493 let _ = std::fs::remove_dir_all(self.run_dir.join(".spool"));
1495 self.outcome(status, err)
1496 }
1497
1498 fn run_inner(&mut self) -> Result<(), Fail> {
1499 let errors = crate::variables::check_run_values(&self.report.var_schema, &self.vars, &self.opts.vars);
1500 if !errors.is_empty() {
1501 return Err(errors.join("; ").into());
1502 }
1503 self.parsed = crate::parse::binding(self.project, self.report, self.b, &self.vars, &self.inputs());
1505 if !self.parsed.errors.is_empty() {
1506 let msgs: Vec<String> = self
1507 .parsed
1508 .errors
1509 .iter()
1510 .map(|p| match p.line {
1511 Some(l) => format!("{}:{l}: {}", p.file.display(), p.message),
1512 None => format!("{}: {}", p.file.display(), p.message),
1513 })
1514 .collect();
1515 return Err(Fail::new(Code::ParseFailed, msgs.join("\n ")));
1516 }
1517 let pool = Arc::new(Pool::new(
1520 self.connections(),
1521 self.project.root.clone(),
1522 self.ui.plugin_log(),
1523 !self.report.managed,
1524 ));
1525 self.pool = Some(pool.clone());
1526
1527 if !self.report.managed {
1528 self.ui.warn(&format!(
1529 "`{}` is an unmanaged report ({}): for quick tests only — add a YAML to make it a managed report",
1530 self.report.name,
1531 self.report.file.display()
1532 ));
1533 }
1534
1535 std::fs::create_dir_all(&self.compiled_dir).map_err(|e| e.to_string())?;
1537 let mut per_connection: BTreeMap<String, usize> = BTreeMap::new();
1539 for q in &self.b.queries {
1540 *per_connection.entry(self.connection_of(&q.query)?).or_default() += 1;
1541 }
1542 let interleave = !self.opts.dry_run && !self.opts.live_check && self.report.managed;
1546 let mut statements = Vec::new();
1547 let mut two_tabs: Option<String> = None;
1549 let retry_snapshot = match &self.opts.retry_failed {
1550 Some(id) => self.start_retry(id)?,
1551 None => None,
1552 };
1553 if let Some(dir) = retry_snapshot {
1554 self.render_destinations().map_err(|e| e.or(Code::RenderFailed))?;
1555 self.replay_from(&dir, "the saved results")?;
1556 } else if let Some(dir) = self.opts.from_results.clone() {
1557 self.replay(&dir)?;
1558 } else if interleave {
1559 self.render_destinations().map_err(|e| e.or(Code::RenderFailed))?;
1560 let mut checked: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1561 for (c, n) in &per_connection {
1562 if *n > 1 {
1563 self.check_sessions(&pool, c, *n)?;
1564 checked.insert(c.clone());
1565 }
1566 }
1567 std::fs::create_dir_all(self.run_dir.join(".spool")).map_err(|e| e.to_string())?;
1568 let mut i = 0;
1569 let queries = self.b.queries.clone();
1570 for q in &queries {
1571 let conn = self.connection_of(&q.query)?;
1572 let renderer = self.renderer(Some(&conn))?;
1573 let sts = self.render_query(&renderer, q, &conn, &mut two_tabs)?;
1574 for w in renderer.take_warnings() {
1575 self.ui.warn(&w);
1576 }
1577 if let Some(e) = two_tabs.take() {
1578 return Err(e.into());
1579 }
1580 if sts.len() > 1 && checked.insert(conn.clone()) {
1581 self.check_sessions(&pool, &conn, sts.len())?;
1582 }
1583 let session = pool.session(&conn)?;
1584 for st in &sts {
1585 self.execute_statement(&session, i, st)?;
1586 i += 1;
1587 }
1588 }
1589 } else {
1590 let queries = self.b.queries.clone();
1591 for q in &queries {
1592 let conn = self.connection_of(&q.query)?;
1593 let renderer = self.renderer(Some(&conn))?;
1594 statements.extend(self.render_query(&renderer, q, &conn, &mut two_tabs)?);
1595 }
1596 self.render_destinations().map_err(|e| e.or(Code::RenderFailed))?;
1597 for r in self.renderers.values() {
1598 for w in r.take_warnings() {
1599 self.ui.warn(&w);
1600 }
1601 }
1602
1603 if !self.report.managed {
1605 if let Some(bad) = statements.iter().find(|s| !s.kind.is_read_only_safe()) {
1606 return Err(format!(
1607 "{}:{}: unmanaged report `{}` may only run SELECT/WITH or CREATE [OR REPLACE] TEMP|TEMPORARY TABLE|VIEW, but found `{}`; nothing was run — rewrite the statement, or give the report a YAML to declare it",
1608 bad.file.display(),
1609 bad.line,
1610 self.report.name,
1611 masked_sql_summary(&bad.text, 60, bad.sensitive)
1612 ).into());
1613 }
1614 for conn in per_connection.keys() {
1615 let session = pool.session(conn)?;
1616 let creates_temp = statements
1617 .iter()
1618 .any(|s| &s.connection == conn && s.kind == StatementKind::TempCreate)
1619 || session.lock().unwrap().loaded;
1620 session.lock().unwrap().want_read_only(!creates_temp);
1621 }
1622 }
1623 if let Some(e) = two_tabs {
1624 return Err(e.into());
1625 }
1626
1627 if self.opts.dry_run {
1628 let plan = self.plan();
1629 self.ui.compiled(&plan);
1630 return Ok(());
1631 }
1632
1633 for conn in per_connection.keys() {
1635 let n = statements.iter().filter(|s| &s.connection == conn).count();
1636 if n > 1 {
1637 self.check_sessions(&pool, conn, n)?;
1638 }
1639 }
1640
1641 if self.opts.live_check {
1642 return self.live_check(&statements);
1643 }
1644
1645 std::fs::create_dir_all(self.run_dir.join(".spool")).map_err(|e| e.to_string())?;
1647 for (i, st) in statements.iter().enumerate() {
1648 let session = pool.session(&st.connection)?;
1649 self.execute_statement(&session, i, st)?;
1650 }
1651 }
1652 pool.close_all();
1653
1654 self.name_result_sets()?;
1655 if let Some(dir) = &self.opts.output_json {
1656 let dir = self
1657 .project
1658 .root
1659 .join(dir)
1660 .join(&self.report.name)
1661 .join(self.b.dir_name());
1662 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
1663 let mut index = crate::saved::Index {
1664 schema_version: crate::saved::SCHEMA_VERSION.into(),
1665 run_id: self.run_id.clone(),
1666 results: Vec::new(),
1667 };
1668 for result in &self.produced {
1669 crate::rows::write_spool(
1670 &result.spool,
1671 &dir.join(format!("{}.json", result.name)),
1672 result.rows,
1673 )
1674 .map_err(|e| e.to_string())?;
1675 let file = format!("{}.arrows", result.name);
1677 crate::saved::spool_to_stream(&result.spool, &dir.join(&file))?;
1678 index.results.push(crate::saved::SavedResult {
1679 query: result.query.clone(),
1680 name: result.name.clone(),
1681 connection: result.connection.clone(),
1682 rows: result.rows,
1683 sql_sha256: std::fs::read_to_string(
1684 self.compiled_dir.join(format!("{}.sha256", result.query)),
1685 )
1686 .ok()
1687 .map(|h| h.trim().to_string()),
1688 file,
1689 });
1690 }
1691 crate::saved::write_index(&dir, &index).map_err(|e| e.to_string())?;
1692 }
1693
1694 let (file_outs, message_outs): (Vec<usize>, Vec<usize>) =
1696 (0..self.outs.len()).partition(|&i| !self.b.outputs[i].is_message());
1697 if self.retry.is_none()
1699 && self.opts.from_results.is_none()
1700 && self.opts.preview.is_none()
1701 && self
1702 .b
1703 .outputs
1704 .iter()
1705 .any(|o| o.for_each.as_ref().is_some_and(|f| f.keep_results))
1706 {
1707 self.save_snapshot()?;
1708 }
1709 for oi in 0..self.outs.len() {
1711 self.has_data(oi)?;
1712 }
1713 if self.retry.is_some() {
1714 for oi in 0..self.outs.len() {
1715 if self.b.outputs[oi].for_each.is_none() {
1716 self.skip(oi, "--retry-failed redoes per-row outputs only");
1717 self.outs[oi].reason = Some("retry".into());
1718 }
1719 }
1720 }
1721 for &oi in &file_outs {
1722 if self.outs[oi].status == Some(OutputStatus::Skipped) || !self.passes_when(oi)? {
1723 continue;
1724 }
1725 if self.b.outputs[oi].for_each.is_some() {
1727 continue;
1728 }
1729 let filename = self.file_names(oi);
1730 self.format(oi, &filename).map_err(|e| e.or(Code::FormatFailed))?;
1731 }
1732
1733 if self.opts.preview.is_none() {
1735 self.drift = self.schema_drift();
1736 if !self.drift.is_empty() {
1737 if self.opts.accept_schema_change {
1738 self.ui.warn(&format!(
1739 " schema changed since the last successful run (accepted): {}",
1740 self.drift.join("; ")
1741 ));
1742 } else {
1743 return Err(format!(
1744 "schema drift since the last successful run: {}; the output is in {} but was not delivered — pass --accept-schema-change to deliver it and accept the new schema",
1745 self.drift.join("; "),
1746 rel(&self.project.root, &self.run_dir).display()
1747 ).into());
1748 }
1749 }
1750 }
1751
1752 let preview = self.opts.preview.is_some() || self.opts.no_deliver;
1755 let mut failures = Vec::new();
1756 for oi in file_outs.into_iter().chain(message_outs) {
1757 if self.outs[oi].status == Some(OutputStatus::Skipped) {
1758 continue;
1759 }
1760 if self.b.outputs[oi].for_each.is_some() {
1761 if let Err(e) = self.run_rows(oi) {
1762 if e.code == Code::RunCancelled || e.code == Code::RunTimedOut {
1763 return Err(e);
1764 }
1765 self.outs[oi].status = Some(OutputStatus::Failed);
1766 self.outs[oi].error.get_or_insert_with(|| e.message.clone());
1767 failures.push(e);
1768 }
1769 continue;
1770 }
1771 if self.b.outputs[oi].is_message() {
1772 match self.render_message(oi) {
1773 Ok(true) => {}
1774 Ok(false) => continue,
1775 Err(e) => {
1776 let e = e.or(Code::RenderFailed);
1777 let e = Fail::new(e.code.clone(), format!("{}: {e}", self.b.outputs[oi].label(oi)));
1778 self.outs[oi].status = Some(OutputStatus::Failed);
1779 self.outs[oi].error = Some(e.message.clone());
1780 failures.push(e);
1781 continue;
1782 }
1783 }
1784 }
1785 if preview {
1786 self.outs[oi].delivery_note = Some(if self.opts.no_deliver {
1787 "--no-deliver: output kept locally".into()
1788 } else {
1789 "preview: not delivered".into()
1790 });
1791 self.outs[oi].status = Some(OutputStatus::Kept);
1792 } else if let Err(e) = self.deliver(oi) {
1793 failures.push(e.or(Code::DeliveryFailed));
1794 }
1795 }
1796 if preview {
1797 let dir = rel(&self.project.root, &self.run_dir);
1798 self.ui.step(
1799 Level::Info,
1800 "Preview",
1801 &format!("not delivered; output stays in {}", dir.display()),
1802 None,
1803 );
1804 }
1805 if let Some(first) = failures.first() {
1806 let messages: Vec<&str> = failures.iter().map(|f| f.message.as_str()).collect();
1807 return Err(Fail::new(first.code.clone(), messages.join("; ")));
1808 }
1809
1810 if self.opts.preview.is_none()
1813 && self.becomes_current
1814 && let Err(error) = self.write_snapshot()
1815 {
1816 self.ui.warn(&format!(
1817 "delivery completed, but the schema snapshot could not be written: {error}; the next run may compare against an older schema"
1818 ));
1819 }
1820 Ok(())
1821 }
1822
1823 fn results_for(&mut self, oi: usize, max_rows: u64) -> Result<Vec<(String, Arc<QueryResult>)>, Fail> {
1825 let o = &self.b.outputs[oi];
1826 let mut out = Vec::new();
1827 for p in self.produced.iter().filter(|p| o.feeds(&p.query)) {
1828 let r = QueryResult::read(&p.spool, p.rows, max_rows, self.calendar)
1829 .map_err(|e| format!("can't read the result of `{}`: {e}", p.query))?;
1830 out.push((p.query.clone(), Arc::new(r)));
1831 }
1832 Ok(out)
1833 }
1834
1835 fn template_context(&self, oi: usize, results: &[(String, Arc<QueryResult>)]) -> minijinja::Value {
1838 use minijinja::Value;
1839 let results: BTreeMap<String, Value> = results
1840 .iter()
1841 .map(|(q, r)| (q.clone(), r.clone().value()))
1842 .collect();
1843 let mut outputs: BTreeMap<String, Value> = BTreeMap::new();
1844 for (j, (o, run)) in self.b.outputs.iter().zip(&self.outs).enumerate() {
1845 let Some(name) = &o.name else { continue };
1846 if j == oi {
1847 continue;
1848 }
1849 let locations: Vec<&str> = run
1850 .deliveries
1851 .iter()
1852 .filter_map(|d| d.location.as_deref())
1853 .collect();
1854 let files: Vec<String> = run
1855 .files
1856 .iter()
1857 .map(|(f, _)| record_path(self.project, f).to_string_lossy().to_string())
1858 .collect();
1859 let status = run.status.map_or("pending", OutputStatus::as_str);
1860 outputs.insert(
1861 name.clone(),
1862 Value::from(minijinja::value::Serde(json!({
1863 "location": locations.join(", "),
1864 "files": files,
1865 "status": status,
1866 }))),
1867 );
1868 }
1869 Value::from(BTreeMap::from([
1870 ("results".to_string(), Value::from(results)),
1871 ("outputs".to_string(), Value::from(outputs)),
1872 ]))
1873 }
1874
1875 fn has_data(&mut self, oi: usize) -> Result<bool, Fail> {
1878 let o = &self.b.outputs[oi];
1879 let label = o.label(oi);
1880 let queries: Vec<crate::no_data::Query<'_>> = self
1882 .produced
1883 .iter()
1884 .filter(|p| match &o.for_each {
1885 Some(f) => p.query == f.query,
1886 None => o.feeds(&p.query),
1887 })
1888 .map(|p| crate::no_data::Query {
1889 name: &p.query,
1890 rows: p.rows,
1891 on_empty: self
1892 .b
1893 .queries
1894 .iter()
1895 .find(|q| q.query == p.query)
1896 .and_then(|q| q.on_empty),
1897 })
1898 .collect();
1899 let droppable = !(o.is_message()
1900 || crate::document::FORMATS.contains(&o.format.as_str())
1901 || o.for_each.is_some());
1902 let d = crate::no_data::decide(o.on_empty, &queries, droppable);
1903 self.outs[oi].no_data = d.empty.clone();
1904 for w in &d.warnings {
1905 self.ui.warn(&format!(" {label}: {w}"));
1906 }
1907 if let Some(info) = &d.info {
1908 self.ui
1909 .step(Level::Info, "No data", &format!("{label}: {info}"), None);
1910 }
1911 if let Some(msg) = d.fail {
1912 self.outs[oi].reason = Some("no_data".into());
1913 return Err(Fail::new(Code::NoData, format!("{label}: {msg}")));
1914 }
1915 if let Some(why) = d.skip {
1916 self.outs[oi].reason = Some("no_data".into());
1917 self.skip(oi, &why);
1918 return Ok(false);
1919 }
1920 self.outs[oi].dropped = d.drop;
1921 Ok(true)
1922 }
1923
1924 fn passes_when(&mut self, oi: usize) -> Result<bool, Fail> {
1926 if self.b.outputs[oi].when.is_none() {
1927 return Ok(true);
1928 }
1929 let results = self.results_for(oi, crate::message::DEFAULT_MAX_ROWS)?;
1930 self.when_holds(oi, &results)
1931 }
1932
1933 fn when_holds(&mut self, oi: usize, results: &[(String, Arc<QueryResult>)]) -> Result<bool, Fail> {
1935 let Some(when) = self.b.outputs[oi].when.clone() else {
1936 return Ok(true);
1937 };
1938 let ctx = self.template_context(oi, results);
1939 let renderer = self.renderer(None)?;
1940 let label = self.b.outputs[oi].label(oi);
1941 let src = format!("{{% if {when} %}}true{{% endif %}}");
1942 let passed = renderer
1943 .render_with(&self.report.file, &src, ctx, false)
1944 .map_err(|e| format!("{label}: `when` `{when}`: {}", e.message))?
1945 == "true";
1946 self.outs[oi].when = Some(passed);
1947 if !passed {
1948 self.skip(oi, &format!("`when` is false ({when})"));
1949 }
1950 Ok(passed)
1951 }
1952
1953 fn skip(&mut self, oi: usize, why: &str) {
1954 let label = self.b.outputs[oi].label(oi);
1955 self.outs[oi].status = Some(OutputStatus::Skipped);
1956 self.outs[oi].delivery_note = Some(format!("skipped: {why}"));
1957 self.ui
1958 .step(Level::Info, "Skipped", &format!("{label}: {why}"), None);
1959 }
1960
1961 fn render_message(&mut self, oi: usize) -> Result<bool, Fail> {
1964 let o = &self.b.outputs[oi];
1965 let opt = |k: &str| o.options.get(k).and_then(Json::as_str).map(str::to_string);
1966 let (text_src, file_src, title_src) = (opt("text"), opt("file"), opt("title"));
1967 let max_rows = o
1968 .options
1969 .get("max_rows")
1970 .and_then(Json::as_u64)
1971 .unwrap_or(crate::message::DEFAULT_MAX_ROWS);
1972 let results = self.results_for(oi, max_rows)?;
1973 if !self.when_holds(oi, &results)? {
1974 return Ok(false);
1975 }
1976 for (q, r) in results.iter().filter(|(_, r)| r.capped()) {
1977 self.ui.warn(&format!(
1978 " results.{q}.rows holds the first {} of {} rows (`max_rows`); aggregate in SQL, or raise `max_rows`",
1979 thousands(r.rows.len() as u64),
1980 thousands(r.row_count)
1981 ));
1982 }
1983 let ctx = self.template_context(oi, &results);
1984 let renderer = self.renderer(None)?;
1985 let file = self.report.file.clone();
1986 let text = match (text_src, file_src) {
1987 (Some(t), _) => renderer
1988 .render_with(&file, &t, ctx.clone(), true)
1989 .map_err(|e| e.message)?,
1990 (None, Some(f)) => {
1991 let root = &self.project.root;
1992 let path = crate::project::find_template(root, &f)
1993 .ok_or_else(|| format!("message file `{f}` doesn't exist"))?;
1994 let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
1995 let shown = rel(root, &path);
1996 renderer
1997 .render_with(&shown, &src, ctx.clone(), true)
1998 .map_err(|e| e.to_string())?
1999 }
2000 (None, None) => crate::message::default_text(&results, self.locale()),
2001 };
2002 let text = text.trim().to_string();
2003 if text.is_empty() {
2004 self.skip(oi, "the message is empty");
2005 return Ok(false);
2006 }
2007 let title = match title_src {
2008 Some(t) => renderer
2009 .render_with(&file, &t, ctx, false)
2010 .map_err(|e| e.message)?,
2011 None => format!("{}: {}", self.report.name, self.date),
2012 };
2013 let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
2014 let name = self.file_names(oi);
2015 let path = self.run_dir.join(&name);
2016 if let Some(prev) = (0..oi).find(|&j| self.outs[j].files.iter().any(|(f, _)| f == &path)) {
2017 return Err(format!(
2018 "it writes {name}, as {} does; give them different `name:`s",
2019 self.b.outputs[prev].label(prev)
2020 )
2021 .into());
2022 }
2023 std::fs::create_dir_all(&self.run_dir).map_err(|e| e.to_string())?;
2024 std::fs::write(&path, crate::message::file_body(&title, &text)).map_err(|e| e.to_string())?;
2025 self.outs[oi].files.push((path, None));
2026 let shown_title = crate::secrets::mask_message_title(&title);
2028 let shown_text = crate::secrets::mask_markdown(&text);
2029 if self.opts.preview.is_some() {
2030 let mut notes = Vec::new();
2031 if let Some(n) = self.opts.preview {
2032 notes.push(format!(
2033 "numbers come from a sample of at most {} rows per query (--preview)",
2034 thousands(n)
2035 ));
2036 }
2037 if let Some(w) = &self.b.outputs[oi].when {
2038 notes.push(format!("`when` passed ({w})"));
2039 }
2040 let length = text.chars().count();
2041 notes.push(format!("{} characters", thousands(length as u64)));
2042 notes.extend(self.destination_limits(oi, length));
2043 self.ui.message(&ShownMessage {
2044 output: self.b.outputs[oi].name.clone(),
2045 title: shown_title.into_owned(),
2046 text: shown_text.into_owned(),
2047 notes,
2048 });
2049 } else {
2050 self.ui.step(
2051 Level::Info,
2052 "Message",
2053 &crate::message::excerpt(&title, &text, 100),
2054 None,
2055 );
2056 }
2057 self.outs[oi].message = Some((title, text));
2058 Ok(true)
2059 }
2060
2061 fn destination_limits(&self, oi: usize, length: usize) -> Vec<String> {
2063 let mut out = Vec::new();
2064 let Some(pool) = &self.pool else { return out };
2065 for d in &self.outs[oi].dests {
2066 let Some(t) = self.project.profiles.target(Role::Destination, &d.profile) else {
2067 out.push(format!("{}: delivers nowhere on this target", d.profile));
2068 continue;
2069 };
2070 let described = pool.connections.described(Role::Destination, &t.kind);
2071 let takes = described
2072 .as_ref()
2073 .is_some_and(|x| x.capabilities.iter().any(|c| c == CAP_MESSAGE));
2074 let limit = described.and_then(|x| x.message_limit);
2075 out.push(match (takes, limit) {
2076 (false, _) => format!("{} ({}): delivers the .md file", d.profile, t.kind),
2077 (true, Some(l)) if length as u64 > l => format!(
2078 "{} ({}): {} of {} characters: over the limit, so it will be cut short",
2079 d.profile,
2080 t.kind,
2081 thousands(length as u64),
2082 thousands(l)
2083 ),
2084 (true, Some(l)) => format!(
2085 "{} ({}): {} of {} characters",
2086 d.profile,
2087 t.kind,
2088 thousands(length as u64),
2089 thousands(l)
2090 ),
2091 (true, None) => format!("{} ({}): posts the message", d.profile, t.kind),
2092 });
2093 }
2094 out
2095 }
2096
2097 fn locale(&self) -> crate::numbers::Locale {
2099 self.b
2100 .locale
2101 .as_deref()
2102 .and_then(|l| crate::numbers::Locale::parse(l).ok())
2103 .unwrap_or_default()
2104 }
2105
2106 fn render_query(
2108 &mut self,
2109 renderer: &Renderer,
2110 q: &QueryEntry,
2111 connection: &str,
2112 two_tabs: &mut Option<String>,
2113 ) -> Result<Vec<Statement>, Fail> {
2114 let mut out = Vec::new();
2115 let src = std::fs::read_to_string(self.project.root.join(&q.path))
2116 .map_err(|e| format!("{}: {e}", q.path.display()))?;
2117 let _ = renderer.take_sources();
2118 let sql = renderer
2119 .render(&q.path, &src)
2120 .map_err(|e: RenderError| e.to_string())?;
2121 let known = self
2124 .parsed
2125 .query(&q.query)
2126 .map(|p| p.sources.clone())
2127 .unwrap_or_default();
2128 if let Some((s, t)) = renderer
2129 .take_sources()
2130 .into_iter()
2131 .find(|(s, t)| !known.contains(&format!("{s}.{t}")))
2132 {
2133 return Err(format!(
2134 "{}: `source('{s}', '{t}')` was reached while rendering `{}`, but the parse pass didn't find it. The parse pass renders without data (`run_query()` returns no rows, `connection.*` nothing), so it can't see a `source()` that depends on them; call it where it's reached either way",
2135 q.path.display(),
2136 q.query
2137 ).into());
2138 }
2139 let sensitive = crate::secrets::contains_secret(&sql);
2140 std::fs::write(
2141 self.compiled_dir.join(format!("{}.sql", q.query)),
2142 crate::secrets::mask(&sql).as_bytes(),
2143 )
2144 .map_err(|e| e.to_string())?;
2145 {
2148 use sha2::Digest;
2149 let digest = sha2::Sha256::digest(sql.as_bytes());
2150 let hash = digest
2151 .iter()
2152 .map(|byte| format!("{byte:02x}"))
2153 .collect::<String>();
2154 std::fs::write(self.compiled_dir.join(format!("{}.sha256", q.query)), hash)
2155 .map_err(|e| e.to_string())?;
2156 }
2157 self.ui
2158 .step(Level::Debug, "Rendered", &q.path.display().to_string(), None);
2159 let parts = sqlsplit::split(&sql);
2162 if q.tab && parts.is_empty() {
2163 return Err(format!(
2164 "{}: `{}` makes a tab but has no statements; add a query, or `tab: false` if it's meant to be empty",
2165 q.path.display(),
2166 q.query
2167 ).into());
2168 }
2169 let n = parts.len();
2170 for (i, st) in parts.into_iter().enumerate() {
2171 let body = sqlsplit::strip_leading_comments(&st.text);
2172 let line = st.line + st.text[..st.text.len() - body.len()].matches('\n').count();
2173 let kind = sqlsplit::classify(&st.text);
2174 let last = i + 1 == n;
2175 if q.tab && !last && kind == StatementKind::Read && two_tabs.is_none() {
2176 *two_tabs = Some(format!(
2177 "{}:{line}: `{}` has more than one SELECT, but one .sql file makes one tab (from its last statement); put each tab's query in its own .sql file and list each in `queries:`",
2178 q.path.display(),
2179 q.query
2180 ));
2181 }
2182 out.push(Statement {
2183 query: q.query.clone(),
2184 connection: connection.to_string(),
2185 file: q.path.clone(),
2186 line,
2187 kind,
2188 text: st.text,
2189 sensitive,
2190 tab: q.tab && last,
2191 });
2192 }
2193 Ok(out)
2194 }
2195
2196 fn check_sessions(&self, pool: &Pool, connection: &str, n: usize) -> Result<(), Fail> {
2198 let session = pool.session(connection)?;
2199 let mut s = session.lock().unwrap();
2200 if !s
2201 .get()
2202 .map_err(|e| Fail::new(Code::ConnectionFailed, e))?
2203 .has(CAP_SESSIONS)
2204 {
2205 let kind = pool.kind(connection).unwrap_or_default();
2206 return Err(format!(
2207 "this Binding runs {n} statements on connection `{connection}`, but the `{kind}` source plugin can't hold one session across them; nothing was run"
2208 ).into());
2209 }
2210 Ok(())
2211 }
2212
2213 fn execute_statement(
2215 &mut self,
2216 session: &Arc<Mutex<Session>>,
2217 i: usize,
2218 st: &Statement,
2219 ) -> Result<(), Fail> {
2220 self.not_cancelled()?;
2221 let sql_log = self.ui.sql_log();
2222 sql_log(
2223 &format!("{}:{}", st.file.display(), st.line),
2224 &protected_sql(&st.text, st.sensitive),
2225 );
2226 let spool_path = self.run_dir.join(".spool").join(format!("{i}.arrow"));
2227 let mut writer: Option<FileWriter<File>> = None;
2228 let t = Instant::now();
2229 let exec = {
2230 let mut s = session.lock().unwrap();
2231 let (p, _log_scope) = s
2232 .statement(st.sensitive)
2233 .map_err(|e| Fail::new(Code::ConnectionFailed, e))?;
2234 p.execute(&st.text, self.row_limit(), |schema, batch| {
2235 if writer.is_none() {
2236 let f = File::create(&spool_path).map_err(|e| e.to_string())?;
2237 writer = Some(FileWriter::try_new(f, schema).map_err(|e| e.to_string())?);
2238 }
2239 writer.as_mut().unwrap().write(&batch).map_err(|e| e.to_string())
2240 })
2241 .map_err(|e| {
2242 let message = format!(
2243 "{}:{}: {}",
2244 st.file.display(),
2245 st.line,
2246 masked_source_error(&e, st.sensitive)
2247 );
2248 Fail::from_plugin(&e, message).or(Code::QueryFailed)
2249 })?
2250 };
2251 let what = match &exec {
2252 Execution::Result { rows, .. } => {
2253 format!("{} row{}", thousands(*rows), if *rows == 1 { "" } else { "s" })
2254 }
2255 Execution::NoResult {
2256 rows_affected: Some(n),
2257 } => format!("no result set ({n} affected)"),
2258 Execution::NoResult { .. } => "no result set".to_string(),
2259 };
2260 let at = format!("{}:{}", st.file.display(), st.line);
2261 self.ui.step(
2262 Level::Debug,
2263 "Executed",
2264 &format!("{at} {what}"),
2265 Some(t.elapsed()),
2266 );
2267 let (schema, rows) = match (st.tab, exec) {
2270 (true, Execution::Result { schema, rows }) => (schema, rows),
2271 (true, Execution::NoResult { .. }) => {
2272 return Err(Fail::new(
2273 Code::NoResultSet,
2274 format!(
2275 "{at}: `{}` makes a tab, but its last statement returned no result set; if the query only prepares data (a temp view, a SET), add `tab: false` to it in the YAML",
2276 st.query
2277 ),
2278 ));
2279 }
2280 (false, _) => {
2281 drop(writer);
2282 let _ = std::fs::remove_file(&spool_path);
2283 return Ok(());
2284 }
2285 };
2286 {
2287 let mut w = match writer {
2288 Some(w) => w,
2289 None => FileWriter::try_new(File::create(&spool_path).map_err(|e| e.to_string())?, &schema)
2290 .map_err(|e| e.to_string())?,
2291 };
2292 w.finish().map_err(|e| e.to_string())?;
2293 self.produced.push(Produced {
2294 query: st.query.clone(),
2295 connection: st.connection.clone(),
2296 schema,
2297 rows,
2298 spool: spool_path,
2299 name: String::new(),
2300 anchor: None,
2301 header: None,
2302 columns: Default::default(),
2303 autofit: None,
2304 style: None,
2305 });
2306 }
2307 Ok(())
2308 }
2309
2310 fn replay(&mut self, dir: &Path) -> Result<(), Fail> {
2314 let shown = dir.display().to_string();
2315 let dir = self
2316 .project
2317 .root
2318 .join(dir)
2319 .join(&self.report.name)
2320 .join(self.b.dir_name());
2321 if let Err(e) = crate::saved::read_index(&dir) {
2322 return Err(format!(
2323 "--from-results {shown}: no saved results for this report and Set ({e}); save them with `dre run {} --no-deliver --output-json {shown}`",
2324 self.report.name
2325 )
2326 .into());
2327 }
2328 self.replay_from(&dir, &format!("--from-results {shown}"))
2329 }
2330
2331 fn replay_from(&mut self, dir: &Path, shown: &str) -> Result<(), Fail> {
2334 let dir = dir.to_path_buf();
2335 let index = crate::saved::read_index(&dir).map_err(|e| format!("{shown}: {e}"))?;
2336 let queries: Vec<QueryEntry> = self.b.queries.iter().filter(|q| q.tab).cloned().collect();
2337 let missing: Vec<&str> = queries
2338 .iter()
2339 .filter(|q| index.find(&q.query).is_none())
2340 .map(|q| q.query.as_str())
2341 .collect();
2342 if !missing.is_empty() {
2343 return Err(format!(
2344 "{shown}: no saved result for {} (saved: {}); save them again with `--output-json`",
2345 missing
2346 .iter()
2347 .map(|q| format!("`{q}`"))
2348 .collect::<Vec<_>>()
2349 .join(", "),
2350 index
2351 .results
2352 .iter()
2353 .map(|r| r.query.as_str())
2354 .collect::<Vec<_>>()
2355 .join(", ")
2356 )
2357 .into());
2358 }
2359 for q in &queries {
2361 let saved = index.find(&q.query).and_then(|r| r.sql_sha256.clone());
2362 let rendered = self
2363 .connection_of(&q.query)
2364 .and_then(|conn| {
2365 let renderer = self.renderer(Some(&conn))?;
2366 let mut two = None;
2367 self.render_query(&renderer, q, &conn, &mut two)
2368 })
2369 .map(|_| {
2370 std::fs::read_to_string(self.compiled_dir.join(format!("{}.sha256", q.query)))
2371 .ok()
2372 .map(|h| h.trim().to_string())
2373 });
2374 match (rendered, saved) {
2375 (Ok(Some(now)), Some(then)) if now != then => self.ui.warn(&format!(
2376 " `{}`'s SQL changed since its results were saved; re-run with `--output-json` to refresh them",
2377 q.query
2378 )),
2379 (Err(e), _) => self.ui.warn(&format!(
2380 " `{}`: can't render its SQL to compare with the saved results ({}); using them as saved",
2381 q.query, e.message
2382 )),
2383 _ => {}
2384 }
2385 }
2386 std::fs::create_dir_all(self.run_dir.join(".spool")).map_err(|e| e.to_string())?;
2387 for (i, q) in queries.iter().enumerate() {
2388 let r = index.find(&q.query).expect("checked above");
2389 let spool = self.run_dir.join(".spool").join(format!("{i}.arrow"));
2390 let (schema, rows) = crate::saved::stream_to_spool(&dir.join(&r.file), &spool)?;
2391 self.produced.push(Produced {
2392 query: q.query.clone(),
2393 connection: r.connection.clone(),
2394 schema,
2395 rows,
2396 spool,
2397 name: String::new(),
2398 anchor: None,
2399 header: None,
2400 columns: Default::default(),
2401 autofit: None,
2402 style: None,
2403 });
2404 }
2405 self.ui.step(
2406 Level::Info,
2407 "Replayed",
2408 &format!("{} result(s) from {shown}; no query ran", queries.len()),
2409 None,
2410 );
2411 Ok(())
2412 }
2413
2414 fn render_destinations(&mut self) -> Result<(), Fail> {
2417 if self.opts.no_deliver {
2418 return Ok(());
2419 }
2420 if self.b.destinations().next().is_none() {
2421 return Ok(());
2422 }
2423 let renderer = self.renderer(None)?;
2424 let connections = self.pool.as_ref().expect("pool").connections.clone();
2425 let numbered: Vec<(usize, usize, &crate::project::Destination)> = self
2427 .b
2428 .outputs
2429 .iter()
2430 .enumerate()
2431 .flat_map(|(oi, o)| o.destinations.iter().map(move |d| (oi, d)))
2432 .enumerate()
2433 .map(|(i, (oi, d))| (i, oi, d))
2434 .collect();
2435 for (i, oi, d) in numbered {
2436 if self.b.outputs[oi].for_each.is_some() {
2438 continue;
2439 }
2440 let profile = self
2441 .parsed
2442 .destinations
2443 .get(i)
2444 .cloned()
2445 .flatten()
2446 .ok_or_else(|| format!("destination `{}` has no profile", d.profile))?;
2447 if matches!(
2449 self.project.profiles.entry(Role::Destination, &profile),
2450 Entry::Nowhere
2451 ) {
2452 self.outs[oi].dests.push(RenderedDest {
2453 profile,
2454 path: None,
2455 options: JsonMap::new(),
2456 attach: Vec::new(),
2457 hold: d.hold,
2458 rate: self.opts.rate.or(d.rate),
2459 });
2460 continue;
2461 }
2462 renderer.set_destination(connections.profile(&profile, Some("destination")).ok());
2463 let rendered = (|| {
2464 let path = match &d.path {
2465 Some(p) => Some(
2466 renderer
2467 .render(&self.report.file, p)
2468 .map_err(|e| format!("output path: {e}"))?,
2469 ),
2470 None => None,
2471 };
2472 let mut options = JsonMap::new();
2473 for (k, v) in &d.options {
2474 let v = render_json(&renderer, &self.report.file, v)
2475 .map_err(|e| format!("destination `{profile}` option `{k}`: {e}"))?;
2476 options.insert(k.clone(), v);
2477 }
2478 Ok::<_, Fail>(RenderedDest {
2479 profile: profile.clone(),
2480 path,
2481 options,
2482 attach: d.attach.clone(),
2483 hold: d.hold,
2484 rate: self.opts.rate.or(d.rate),
2485 })
2486 })();
2487 renderer.set_destination(None);
2488 self.outs[oi].dests.push(rendered?);
2489 }
2490 Ok(())
2491 }
2492
2493 fn name_result_sets(&mut self) -> Result<(), Fail> {
2495 let entries: BTreeMap<&str, &QueryEntry> =
2496 self.b.queries.iter().map(|q| (q.query.as_str(), q)).collect();
2497 for p in &mut self.produced {
2498 let q = entries[p.query.as_str()];
2499 p.name = q.tab_name.clone().unwrap_or_else(|| q.query.clone());
2500 p.anchor = q.anchor.clone();
2501 p.header = q.header;
2502 p.columns = q.columns.clone();
2503 p.autofit = q.autofit;
2504 p.style = q.style.clone();
2505 }
2506 for o in self.b.outputs.iter().filter(|o| o.format == "xlsx") {
2507 let mut seen: BTreeMap<String, String> = BTreeMap::new();
2508 for p in self.produced.iter().filter(|p| o.feeds(&p.query)) {
2509 check_sheet_name(&p.name)?;
2510 if let Some(prev) = seen.insert(p.name.to_lowercase(), p.query.clone()) {
2511 return Err(format!(
2512 "sheet name `{}` is used twice (by `{prev}` and `{}`); Excel sheet names must be unique",
2513 p.name, p.query
2514 ).into());
2515 }
2516 }
2517 }
2518 Ok(())
2519 }
2520
2521 fn plan(&mut self) -> BindingPlan {
2523 let compiled = self
2524 .b
2525 .queries
2526 .iter()
2527 .map(|q| {
2528 rel(
2529 &self.project.root,
2530 &self.compiled_dir.join(format!("{}.sql", q.query)),
2531 )
2532 })
2533 .collect();
2534 let mut outputs = Vec::new();
2535 for oi in 0..self.outs.len() {
2536 let file = self.file_names(oi);
2537 let o = &self.b.outputs[oi];
2538 let destinations = self.outs[oi]
2539 .dests
2540 .iter()
2541 .map(|d| {
2542 let out = self.project.profiles.target(Role::Destination, &d.profile);
2543 PlannedDestination {
2544 profile: d.profile.clone(),
2545 kind: out.map(|o| o.kind.clone()),
2546 target: Some(self.project.profiles.target_of(Role::Destination, &d.profile)),
2547 path: d.path.clone(),
2548 delivers: out.is_some(),
2549 }
2550 })
2551 .collect();
2552 outputs.push(PlannedOutput {
2553 name: o.name.clone(),
2554 format: o.format.clone(),
2555 output: rel(&self.project.root, &self.run_dir.join(file)),
2556 destinations,
2557 });
2558 }
2559 let first = outputs.first().cloned();
2560 let pool = self.pool.clone().expect("pool");
2561 let queries = self
2562 .parsed
2563 .queries
2564 .iter()
2565 .map(|q| {
2566 let connection = q.connection.clone().unwrap_or_default();
2567 PlannedQuery {
2568 query: q.query.clone(),
2569 kind: pool.kind(&connection).unwrap_or_default(),
2570 target: self.project.profiles.target_of(Role::Connection, &connection),
2571 connection,
2572 sources: q.sources.clone(),
2573 }
2574 })
2575 .collect();
2576 BindingPlan {
2577 report: self.report.name.clone(),
2578 set: self.b.set.clone(),
2579 compiled,
2580 profile: self.parsed.inherited.clone(),
2581 queries,
2582 target: self.target.clone(),
2583 format: first.as_ref().map(|o| o.format.clone()).unwrap_or_default(),
2584 output: first.as_ref().map(|o| o.output.clone()).unwrap_or_default(),
2585 destinations: first.map(|o| o.destinations).unwrap_or_default(),
2586 outputs,
2587 schedules: self.b.schedules.clone(),
2588 }
2589 }
2590
2591 fn file_names(&mut self, oi: usize) -> String {
2596 let first = oi == 0;
2597 let (output_path, output_name) = if first {
2598 (self.opts.output_path.clone(), self.opts.output_name.clone())
2599 } else {
2600 (None, None)
2601 };
2602 for d in self.outs[oi].dests.iter_mut().filter(|d| d.path.is_some()) {
2603 if let Some(p) = &output_path {
2604 d.path = Some(p.clone());
2605 }
2606 if let (Some(n), Some(r)) = (&output_name, d.path.as_mut()) {
2607 *r = match r.rfind(['/', '\\']) {
2608 Some(i) => format!("{}{n}", &r[..=i]),
2609 None => n.clone(),
2610 };
2611 }
2612 }
2613 let o = &self.b.outputs[oi];
2614 let ext = match &o.extension {
2615 Some(e) => e.as_str(),
2616 None => extension(&o.format),
2617 };
2618 let stem = o.name.clone().unwrap_or_else(|| self.report.name.clone());
2619 let from_remote = self.outs[oi]
2620 .dests
2621 .iter()
2622 .find_map(|d| d.path.as_deref())
2623 .and_then(|r| r.rsplit(['/', '\\']).next())
2624 .filter(|s| !s.is_empty())
2625 .map(str::to_string);
2626 output_name.or(from_remote).unwrap_or_else(|| match ext {
2627 "" => stem,
2628 ext => format!("{stem}.{ext}"),
2629 })
2630 }
2631
2632 fn format(&mut self, oi: usize, filename: &str) -> Result<(), Fail> {
2634 self.not_cancelled()?;
2635 std::fs::create_dir_all(&self.run_dir).map_err(|e| e.to_string())?;
2636 let b = self.b;
2637 let out = &b.outputs[oi];
2638 let mine: Vec<usize> = (0..self.produced.len())
2639 .filter(|&i| {
2640 out.feeds(&self.produced[i].query) && !self.outs[oi].dropped.contains(&self.produced[i].query)
2641 })
2642 .collect();
2643 if mine.is_empty() {
2644 let what = if self.outs.len() > 1 {
2645 format!("{} gets no tab", out.label(oi))
2646 } else {
2647 "no query makes a tab (every query has `tab: false`)".to_string()
2648 };
2649 self.ui.warn(&format!(" {what}; no output file was written"));
2650 return Ok(());
2651 }
2652 let multi = out.format == "xlsx" || out.template.is_some() || !is_single_table(&out.format);
2653 let groups: Vec<(String, Vec<usize>)> = if multi || mine.len() == 1 {
2654 vec![(filename.to_string(), mine)]
2655 } else {
2656 let (stem, ext) = split_ext(filename);
2657 let mut g = Vec::new();
2658 for i in mine {
2659 let r = &self.produced[i];
2660 if r.name.contains(['/', '\\']) {
2661 return Err(format!("`{}` can't be used in a file name", r.name).into());
2662 }
2663 g.push((format!("{stem}_{}{ext}", r.name), vec![i]));
2664 }
2665 g
2666 };
2667 for (name, _) in &groups {
2669 let path = self.run_dir.join(name);
2670 if let Some(prev) = (0..oi).find(|&j| self.outs[j].files.iter().any(|(f, _)| f == &path)) {
2671 return Err(format!(
2672 "{} and {} both write {name}; give them different `name:`s or destination paths",
2673 self.b.outputs[prev].label(prev),
2674 out.label(oi)
2675 )
2676 .into());
2677 }
2678 }
2679 let plugin = find_plugin(self.project, PluginKind::Format, &out.format)
2680 .map_err(|e| Fail::new(Code::FormatFailed, e.to_string()))?;
2681 let mut p = plugin
2682 .start(self.ui.plugin_log(), Some(&self.project.root))
2683 .map_err(|e| e.to_string())?;
2684 let template = match &out.template {
2685 _ if crate::document::FORMATS.contains(&out.format.as_str()) => {
2686 Some(self.document_payload(out.template.as_ref())?)
2687 }
2688 Some(t) => Some(self.template_payload(t)?),
2689 None => None,
2690 };
2691 for (name, idxs) in groups {
2692 let t = Instant::now();
2693 let path = self.run_dir.join(&name);
2694 let metas: Vec<ResultSetMeta> = idxs
2695 .iter()
2696 .map(|&i| {
2697 let r = &self.produced[i];
2698 ResultSetMeta {
2699 name: r.name.clone(),
2700 query: r.query.clone(),
2701 result_index: 1,
2702 anchor: r.anchor.clone(),
2703 header: r.header,
2704 columns: r.columns.clone(),
2705 autofit: r.autofit,
2706 style: r.style.clone(),
2707 }
2708 })
2709 .collect();
2710 p.write_begin(
2711 &path.to_string_lossy(),
2712 &out.format,
2713 out.options.clone(),
2714 metas,
2715 template.clone(),
2716 )
2717 .map_err(|e| e.to_string())?;
2718 for &i in &idxs {
2719 let r = &self.produced[i];
2720 let reader = FileReader::try_new(File::open(&r.spool).map_err(|e| e.to_string())?, None)
2721 .map_err(|e| e.to_string())?;
2722 let schema = r.schema.clone();
2723 let mut any = false;
2725 for b in reader {
2726 let b = b.map_err(|e| e.to_string())?;
2727 any = true;
2728 p.send_batch(&b)
2729 .map_err(|e| format!("{} format: {e}", out.format))?;
2730 }
2731 if !any {
2732 p.send_batch(&RecordBatch::new_empty(schema))
2733 .map_err(|e| format!("{} format: {e}", out.format))?;
2734 }
2735 p.send(&dre_protocol::msg::Request::ResultSetEnd {})
2736 .map_err(|e| format!("{} format: {e}", out.format))?;
2737 }
2738 let (files, warnings) = p
2739 .write_finish_with_warnings()
2740 .map_err(|e| Fail::from_plugin(&e, format!("{} format: {e}", out.format)))?;
2741 for w in warnings {
2742 self.ui.warn(&format!(" {} format: {w}", out.format));
2743 }
2744 for f in files {
2745 let f = PathBuf::from(f);
2746 let size = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0);
2747 let shown = rel(&self.project.root, &f);
2748 self.ui.step(
2749 Level::Debug,
2750 "Wrote",
2751 &format!("{} ({})", shown.display(), human_bytes(size)),
2752 Some(t.elapsed()),
2753 );
2754 self.outs[oi].files.push((f, None));
2755 }
2756 }
2757 let _ = p.close();
2758 Ok(())
2759 }
2760
2761 fn template_payload(&mut self, t: &crate::project::Template) -> Result<Json, Fail> {
2762 let file = crate::project::resolve_template(&self.project.root, &self.report.file, &t.file)?;
2763 let renderer = self.renderer(None)?;
2764 let mut values = JsonMap::new();
2765 for b in &t.bindings {
2766 if let (Some(cell), Some(v)) = (&b.cell, &b.value) {
2767 let rendered = renderer
2768 .render(&self.report.file, v)
2769 .map_err(|e| format!("template value: {e}"))?;
2770 values.insert(format!("{}!{}", b.sheet, cell), Json::String(rendered));
2771 }
2772 }
2773 Ok(json!({"file": file.to_string_lossy(), "bindings": t.bindings, "values": values}))
2774 }
2775
2776 fn document_payload(&mut self, t: Option<&crate::project::Template>) -> Result<Json, Fail> {
2779 let file = match t {
2780 Some(t) => Some(crate::project::resolve_template(
2781 &self.project.root,
2782 &self.report.file,
2783 &t.file,
2784 )?),
2785 None => None,
2786 };
2787 let run =
2788 crate::parse::run_context(self.project, self.report, self.b, &self.inputs(), self.started_at);
2789 let ctx = crate::document::Context::new(
2790 file.as_deref(),
2791 &self.project.root,
2792 &run,
2793 self.vars.clone(),
2794 self.opts.vars.clone(),
2795 );
2796 Ok(serde_json::to_value(ctx).map_err(|e| e.to_string())?)
2797 }
2798
2799 fn deliver(&mut self, oi: usize) -> Result<(), Fail> {
2802 let result = self.deliver_output(oi);
2803 let o = &mut self.outs[oi];
2804 o.status = Some(match &result {
2805 Err(_) => OutputStatus::Failed,
2806 Ok(()) if o.deliveries.iter().any(|d| d.status == "delivered") => OutputStatus::Delivered,
2807 Ok(()) if o.deliveries.iter().any(|d| d.status == "held") => OutputStatus::Held,
2808 Ok(()) => OutputStatus::Kept,
2809 });
2810 o.error = result.as_ref().err().map(|f| f.message.clone());
2811 result
2812 }
2813
2814 fn deliver_output(&mut self, oi: usize) -> Result<(), Fail> {
2815 if self.outs[oi].dests.is_empty() {
2816 self.outs[oi].delivery_note = Some("no destination declared; output stays in target/".into());
2817 let dir = rel(&self.project.root, &self.run_dir);
2818 self.ui.step(
2819 Level::Debug,
2820 "Kept",
2821 &format!("no destination declared: output stays in {}", dir.display()),
2822 None,
2823 );
2824 return Ok(());
2825 }
2826 if self.outs[oi].files.is_empty() {
2827 return Ok(());
2828 }
2829 let dests = std::mem::take(&mut self.outs[oi].dests);
2830 let mut failures = Vec::new();
2831 let mut nowhere = Vec::new();
2832 for d in &dests {
2833 let profiles = &self.project.profiles;
2834 let target = profiles.target_of(Role::Destination, &d.profile);
2835 let kind = profiles
2837 .target(Role::Destination, &d.profile)
2838 .map(|o| o.kind.clone());
2839 self.attempts = 1;
2840 if d.hold || self.opts.hold {
2841 self.hold(oi, d);
2842 self.outs[oi].deliveries.push(Delivery {
2843 profile: d.profile.clone(),
2844 kind,
2845 target,
2846 status: "held",
2847 location: None,
2848 attempts: None,
2849 error_code: None,
2850 error: None,
2851 });
2852 continue;
2853 }
2854 let (status, location, error) =
2855 match self.deliver_one(oi, d).map_err(|e| e.or(Code::DeliveryFailed)) {
2856 Ok(Some(loc)) => ("delivered", Some(loc), None),
2857 Ok(None) => {
2858 nowhere.push(nowhere_note(&d.profile, &target));
2859 ("not_delivered", None, None)
2860 }
2861 Err(e) => {
2862 failures.push(e.clone());
2863 ("failed", None, Some(e))
2864 }
2865 };
2866 let record = Delivery {
2867 profile: d.profile.clone(),
2868 kind,
2869 target,
2870 status,
2871 location,
2872 attempts: Some(self.attempts).filter(|n| *n > 1),
2873 error_code: error.as_ref().map(|e| e.code.clone()),
2874 error: error.map(|e| e.message),
2875 };
2876 self.outs[oi].deliveries.push(record);
2877 }
2878 let o = &mut self.outs[oi];
2879 o.dests = dests;
2880 if o.files.iter().all(|(_, d)| d.is_none()) && !nowhere.is_empty() {
2881 o.delivery_note = Some(nowhere.join("; "));
2882 }
2883 match failures.len() {
2884 0 => Ok(()),
2885 1 if o.dests.len() == 1 => Err(failures.remove(0)),
2886 n => Err(Fail::new(
2887 failures[0].code.clone(),
2888 format!(
2889 "{n} of {} destinations failed: {}",
2890 o.dests.len(),
2891 failures
2892 .iter()
2893 .map(|f| f.message.as_str())
2894 .collect::<Vec<_>>()
2895 .join("; ")
2896 ),
2897 )),
2898 }
2899 }
2900
2901 fn hold(&mut self, oi: usize, d: &RenderedDest) {
2903 let attach: Vec<crate::release::HeldFile> = d
2904 .attach
2905 .iter()
2906 .filter_map(|name| {
2907 self.b
2908 .outputs
2909 .iter()
2910 .position(|o| o.name.as_deref() == Some(name))
2911 })
2912 .flat_map(|j| {
2913 self.outs[j]
2914 .files
2915 .iter()
2916 .map(|(f, _)| crate::release::held_file(f))
2917 })
2918 .collect();
2919 let idx = self.outs[oi].deliveries.len();
2920 self.held.push(crate::release::HeldEntry {
2921 id: format!(
2922 "{}/{idx}",
2923 self.b.outputs[oi]
2924 .name
2925 .clone()
2926 .unwrap_or_else(|| format!("output{}", oi + 1))
2927 ),
2928 output: self.b.outputs[oi].name.clone(),
2929 key: None,
2930 profile: d.profile.clone(),
2931 path: d.path.clone(),
2932 options: d.options.clone(),
2933 attach,
2934 rate: d.rate,
2935 message: self.outs[oi].message.clone(),
2936 files: self.outs[oi]
2937 .files
2938 .iter()
2939 .map(|(f, _)| crate::release::HeldFile {
2940 path: f.clone(),
2941 sha256: crate::release::sha256_file(f).unwrap_or_default(),
2942 })
2943 .collect(),
2944 });
2945 self.ui.step(
2946 Level::Info,
2947 "Held",
2948 &format!(
2949 "{} to `{}`: held for `dre release`",
2950 self.b.outputs[oi].label(oi),
2951 d.profile
2952 ),
2953 None,
2954 );
2955 }
2956
2957 fn deliver_one(&mut self, oi: usize, d: &RenderedDest) -> Result<Option<String>, Fail> {
2960 self.not_cancelled()?;
2962 let profiles = &self.project.profiles;
2963 let out = match profiles.entry(Role::Destination, &d.profile) {
2964 Entry::Use(o) => o,
2965 Entry::Nowhere => {
2966 let note = nowhere_note(&d.profile, &profiles.target_of(Role::Destination, &d.profile));
2967 self.ui.step(Level::Info, "Kept", ¬e, None);
2968 return Ok(None);
2969 }
2970 Entry::Missing => {
2972 return Err(Fail::new(
2973 Code::MissingTargetEntry,
2974 profiles.missing_entry(Role::Destination, &d.profile),
2975 ));
2976 }
2977 Entry::Unknown => {
2978 return Err(format!("destination profile `{}` isn't in profiles.yml", d.profile).into());
2979 }
2980 };
2981 let kind = out.kind.clone();
2982 let connection = render_connection(out)?;
2983 let files = &self.outs[oi].files;
2984 let targets: Vec<DeliveryFile> = files
2985 .iter()
2986 .map(|(f, _)| {
2987 let name = f.file_name().unwrap().to_string_lossy().to_string();
2988 let r = d.path.as_deref().map(|r| {
2989 if files.len() == 1 {
2990 r.to_string()
2991 } else {
2992 match r.rfind(['/', '\\']) {
2993 Some(i) => format!("{}{name}", &r[..=i]),
2994 None => name.clone(),
2995 }
2996 }
2997 });
2998 DeliveryFile {
2999 local_path: f.to_string_lossy().to_string(),
3000 remote_path: r,
3001 }
3002 })
3003 .collect();
3004 for t in &targets {
3006 if let Some(r) = &t.remote_path
3007 && let Some(other) = self.store.claim(&d.profile, r, &self.label())
3008 {
3009 return Err(Fail::new(
3010 Code::DeliveryFailed,
3011 format!(
3012 "`{other}` delivers to `{r}` on destination `{}` in this run too; Bindings running at once (`threads:`) need different paths (put the Set or a var in the path)",
3013 d.profile
3014 ),
3015 ));
3016 }
3017 }
3018 let mut locations = Vec::new();
3019 if kind == LOCAL_TYPE {
3020 let errors = crate::options::local_option_errors(&d.options);
3021 if !errors.is_empty() {
3022 return Err(format!("destination `{}`: {}", d.profile, errors.join("; ")).into());
3023 }
3024 let (rules, _) = dre_protocol::delivery::Rules::from_settings(
3027 dre_protocol::delivery::Rules {
3028 retries: 0,
3029 ..Default::default()
3030 },
3031 &JsonMap::new(),
3032 &d.options,
3033 &[],
3034 )?;
3035 for (i, f) in targets.iter().enumerate() {
3036 let t = Instant::now();
3037 let r = f
3038 .remote_path
3039 .as_ref()
3040 .ok_or("the local destination needs `output.destination.path`")?;
3041 let dst = self.project.root.join(r).to_string_lossy().to_string();
3042 let delivered = dre_protocol::delivery::deliver(
3043 &mut dre_protocol::delivery::LocalStore,
3044 Path::new(&f.local_path),
3045 &dst,
3046 &rules,
3047 )
3048 .map_err(|e| {
3049 let message = format!("delivery to {dst} failed: {e}; the output is still in target/");
3050 if e.exists {
3051 let code = crate::codes::ErrorCode::Plugin {
3053 code: "local/file-exists".into(),
3054 kind: crate::codes::Kind::Delivery,
3055 };
3056 Fail::new(code, message)
3057 } else {
3058 Fail::new(Code::DeliveryFailed, message)
3059 }
3060 })?;
3061 let loc = delivered.path;
3062 self.ui.step(Level::Debug, "Delivered", &loc, Some(t.elapsed()));
3063 self.outs[oi].files[i].1.get_or_insert_with(|| loc.clone());
3064 locations.push(loc);
3065 }
3066 return Ok(Some(locations.join(", ")));
3067 }
3068 let failed = |e: dre_protocol::host::HostError| {
3069 Fail::from_plugin(
3070 &e,
3071 format!("delivery through `{kind}` failed: {e}; the output is still in target/"),
3072 )
3073 };
3074 let plugin = find_plugin(self.project, PluginKind::Destination, &kind)
3075 .map_err(|e| Fail::new(Code::DeliveryFailed, e.to_string()))?;
3076 let mut p = plugin
3077 .start(self.ui.plugin_log(), Some(&self.project.root))
3078 .map_err(|e| e.to_string())?;
3079 let is_message = self.b.outputs[oi].is_message();
3080 if is_message
3083 && p.has(CAP_MESSAGE)
3084 && let Some((title, text)) = self.outs[oi].message.clone()
3085 {
3086 let t = Instant::now();
3087 let message = dre_protocol::msg::Message {
3088 title,
3089 text,
3090 html: None,
3091 path: targets[0].local_path.clone(),
3092 };
3093 let attach: Vec<DeliveryFile> = d
3094 .attach
3095 .iter()
3096 .filter_map(|name| {
3097 self.b
3098 .outputs
3099 .iter()
3100 .position(|o| o.name.as_deref() == Some(name))
3101 })
3102 .flat_map(|j| self.outs[j].files.iter())
3103 .map(|(f, _)| DeliveryFile {
3104 local_path: f.to_string_lossy().to_string(),
3105 remote_path: None,
3106 })
3107 .collect();
3108 let loc = p
3109 .deliver_message(&message, &attach, connection, d.options.clone())
3110 .map_err(failed)?;
3111 self.attempts = p.last_attempts();
3112 self.ui.step(Level::Debug, "Delivered", &loc, Some(t.elapsed()));
3113 self.outs[oi].files[0].1.get_or_insert_with(|| loc.clone());
3114 let _ = p.close();
3115 return Ok(Some(loc));
3116 }
3117 if !is_message && p.has(CAP_MESSAGE_ONLY) {
3118 let _ = p.close();
3119 return Err(format!(
3120 "`{kind}` only takes messages, but this output is `{}`; deliver the file elsewhere and link it from a message",
3121 self.b.outputs[oi].format
3122 ).into());
3123 }
3124 let batches: Vec<Vec<usize>> = if targets.len() > 1 && p.has(CAP_MULTI_FILE) {
3125 vec![(0..targets.len()).collect()]
3126 } else {
3127 (0..targets.len()).map(|i| vec![i]).collect()
3128 };
3129 for batch in batches {
3130 let t = Instant::now();
3131 let files: Vec<DeliveryFile> = batch.iter().map(|&i| targets[i].clone()).collect();
3132 let loc = p
3133 .deliver_files(&files, connection.clone(), d.options.clone())
3134 .map_err(failed)?;
3135 self.attempts = self.attempts.max(p.last_attempts());
3136 self.ui.step(Level::Debug, "Delivered", &loc, Some(t.elapsed()));
3137 for i in batch {
3138 self.outs[oi].files[i].1.get_or_insert_with(|| loc.clone());
3139 }
3140 locations.push(loc);
3141 }
3142 let _ = p.close();
3143 Ok(Some(locations.join(", ")))
3144 }
3145
3146 fn snapshot(&self) -> Json {
3147 let sets: Vec<Json> = self
3148 .produced
3149 .iter()
3150 .map(|p| {
3151 json!({
3152 "name": p.name,
3153 "columns": p.schema.fields().iter().map(|f| json!({
3154 "name": f.name(),
3155 "type": f.data_type().to_string(),
3156 "type_shape": crate::schema::type_shape(f.data_type()),
3157 })).collect::<Vec<_>>(),
3158 })
3159 })
3160 .collect();
3161 json!({ "result_sets": sets })
3162 }
3163
3164 fn schema_drift(&mut self) -> Vec<String> {
3165 let path = self.schema_dir.join("last_success.json");
3166 let mut current = self.snapshot();
3167 let Ok(prev) = std::fs::read_to_string(&path) else {
3168 crate::schema::redact(&mut current);
3169 self.protected_snapshot = Some(current);
3170 return Vec::new();
3171 };
3172 let Ok(mut prev) = serde_json::from_str::<Json>(&prev) else {
3173 crate::schema::redact(&mut current);
3174 self.protected_snapshot = Some(current);
3175 return Vec::new();
3176 };
3177 let original = prev.clone();
3178 crate::schema::redact(&mut prev);
3179 crate::schema::redact(&mut current);
3180 if crate::secrets::enabled() {
3181 crate::schema::align_protection(&mut prev, &mut current);
3182 }
3183 if prev != original {
3184 let protected =
3185 serde_json::to_string_pretty(&prev).expect("serializing a serde_json::Value cannot fail");
3186 if let Err(error) = crate::fs::write_atomic(&path, protected.as_bytes()) {
3187 self.ui.warn(&format!(
3188 "can't protect the existing schema snapshot at {}: {error}; continuing without rewriting it",
3189 path.display()
3190 ));
3191 }
3192 }
3193 let drift = crate::schema::drift(&prev, ¤t);
3194 self.protected_snapshot = Some(current);
3195 drift
3196 }
3197
3198 fn write_snapshot(&self) -> std::io::Result<()> {
3199 std::fs::create_dir_all(&self.schema_dir)?;
3200 let snapshot = self.protected_snapshot.clone().unwrap_or_else(|| {
3201 let mut snapshot = self.snapshot();
3202 crate::schema::redact(&mut snapshot);
3203 snapshot
3204 });
3205 crate::fs::write_atomic(
3206 &self.schema_dir.join("last_success.json"),
3207 serde_json::to_string_pretty(&snapshot)?.as_bytes(),
3208 )
3209 }
3210
3211 fn write_results(&self, status: &Status, error: Option<&Fail>) -> std::io::Result<()> {
3212 std::fs::create_dir_all(&self.run_dir)?;
3213 if !self.held.is_empty() {
3214 crate::release::write_held(&self.run_dir, &self.run_id, &self.held)?;
3215 }
3216 let mut outputs: Vec<run_results::OutputFile> = Vec::new();
3218 let mut output_results: Vec<run_results::OutputResult> = Vec::new();
3219 for (o, run) in self.b.outputs.iter().zip(&self.outs) {
3220 let files: Vec<run_results::OutputFileRef> = run
3221 .files
3222 .iter()
3223 .map(|(f, delivered)| run_results::OutputFileRef {
3224 path: record_path(self.project, f),
3225 size: std::fs::metadata(f).map(|m| m.len()).unwrap_or(0),
3226 delivered_to: delivered.clone(),
3227 })
3228 .collect();
3229 outputs.extend(files.iter().map(|f| run_results::OutputFile {
3230 path: f.path.clone(),
3231 size: f.size,
3232 delivered_to: f.delivered_to.clone(),
3233 output: o.name.clone(),
3234 }));
3235 let queries: Vec<String> = self
3236 .b
3237 .queries
3238 .iter()
3239 .filter(|q| q.tab && o.feeds(&q.query))
3240 .map(|q| q.query.clone())
3241 .collect();
3242 let status = match (run.status, status) {
3243 (Some(s), _) => s,
3244 (None, Status::Error) => OutputStatus::Failed,
3245 (None, _) => OutputStatus::Kept,
3246 }
3247 .as_str();
3248 output_results.push(run_results::OutputResult {
3249 name: o.name.clone(),
3250 format: o.format.clone(),
3251 queries,
3252 status,
3253 error: run.error.clone(),
3254 files,
3255 delivery: run.delivery_note.clone(),
3256 deliveries: run.deliveries.clone(),
3257 when: run.when,
3258 reason: run.reason.clone(),
3259 no_data: run.no_data.clone(),
3260 dropped: run.dropped.clone(),
3261 for_each: run.for_each.clone(),
3262 message: run.message.clone().map(|(title, text)| run_results::Message {
3263 title: crate::secrets::mask_message_title(&title).into_owned(),
3264 text: crate::secrets::mask_markdown(&text).into_owned(),
3265 }),
3266 });
3267 }
3268 let order = (0..self.outs.len())
3270 .filter(|&i| !self.b.outputs[i].is_message())
3271 .chain((0..self.outs.len()).filter(|&i| self.b.outputs[i].is_message()));
3272 let deliveries: Vec<Delivery> = order
3273 .flat_map(|i| self.outs[i].deliveries.iter().cloned())
3274 .collect();
3275 let notes: Vec<&str> = self
3276 .outs
3277 .iter()
3278 .filter_map(|o| o.delivery_note.as_deref())
3279 .collect();
3280 let delivery = (!notes.is_empty()).then(|| notes.join("; "));
3281 let results = RunResults {
3282 schema_version: run_results::SCHEMA_VERSION,
3283 run_id: self.run_id.clone(),
3284 report: self.report.name.clone(),
3285 set: self.b.set.clone(),
3286 binding: self.b.dir_name().to_string(),
3287 managed: self.report.managed,
3288 profile: self.parsed.inherited.clone(),
3289 connections: self
3290 .parsed
3291 .connections()
3292 .into_iter()
3293 .map(str::to_string)
3294 .collect(),
3295 target: self.target.clone(),
3296 schedule: self.opts.schedule.clone(),
3297 schedule_vars: self.schedule_vars.clone(),
3298 vars: self.rendered_vars.clone(),
3299 run_date: self.date.to_string(),
3300 scheduled_at: self.opts.scheduled_at.map(rfc3339),
3301 timezone: self.calendar.tz.name().to_string(),
3302 params: self.opts.params(self.date),
3303 status: *status,
3304 error: error.map(|e| e.message.clone()),
3305 error_code: error.map(|e| e.code.clone()),
3306 error_kind: error.map(|e| e.code.kind()),
3307 preview: self.opts.preview.is_some(),
3308 row_limit: self.opts.preview,
3309 started_at: self.started_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
3310 duration_ms: self.started.elapsed().as_millis() as u64,
3311 result_sets: self
3312 .produced
3313 .iter()
3314 .map(|p| run_results::ResultSet {
3315 name: p.name.clone(),
3316 query: p.query.clone(),
3317 connection: p.connection.clone(),
3318 rows: p.rows,
3319 columns: p.schema.fields().iter().map(|f| f.name().clone()).collect(),
3320 })
3321 .collect(),
3322 outputs,
3323 output_results,
3324 delivery,
3325 deliveries,
3326 schema_drift: self.drift.clone(),
3327 target_path: self.project.target_dir.clone(),
3328 settings: self.project.settings.clone(),
3329 manifest_checksum: self.opts.manifest_checksum.clone(),
3330 retry_of: self.retry.as_ref().map(|r| r.original.clone()),
3331 release_of: None,
3332 released_by: None,
3333 released: Vec::new(),
3334 };
3335 self.store.write_results(&self.run_dir, &results)
3336 }
3337
3338 fn live_check(&mut self, statements: &[Statement]) -> Result<(), Fail> {
3341 let pool = self.pool.clone().expect("pool");
3342 let mut failures = Vec::new();
3343 let mut unexecuted_setup: BTreeMap<String, (PathBuf, usize)> = BTreeMap::new();
3344 let mut uncheckable: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3345 let sql_log = self.ui.sql_log();
3346 for st in statements {
3347 if uncheckable.contains(&st.connection) {
3348 continue;
3349 }
3350 let session = pool.session(&st.connection)?;
3351 let mut s = session.lock().unwrap();
3352 if !s.get()?.has(dre_protocol::CAP_CHECK) {
3353 let kind = pool.kind(&st.connection).unwrap_or_default();
3354 self.ui.warn(&format!(
3355 " not checkable: the `{kind}` source plugin (connection `{}`) can't check statements without running them",
3356 st.connection
3357 ));
3358 uncheckable.insert(st.connection.clone());
3359 continue;
3360 }
3361 let verb = if st.kind == StatementKind::TempCreate {
3362 ""
3363 } else {
3364 " (check)"
3365 };
3366 sql_log(
3367 &format!("{}:{}{verb}", st.file.display(), st.line),
3368 &protected_sql(&st.text, st.sensitive),
3369 );
3370 if st.kind == StatementKind::TempCreate {
3371 let result = {
3372 let (process, _log_scope) = s.statement(st.sensitive)?;
3373 process.execute(&st.text, None, |_, _| Ok(()))
3374 };
3375 if let Err(e) = result {
3376 failures.push(format!(
3377 "{}:{}: {}",
3378 st.file.display(),
3379 st.line,
3380 masked_source_error(&e, st.sensitive)
3381 ));
3382 }
3383 continue;
3384 }
3385 let result = {
3386 let (process, _log_scope) = s.statement(st.sensitive)?;
3387 process.check(&st.text)
3388 };
3389 if let Err(e) = result {
3390 let mut msg = format!(
3391 "{}:{}: {}",
3392 st.file.display(),
3393 st.line,
3394 masked_source_error(&e, st.sensitive)
3395 );
3396 if let Some((f, l)) = unexecuted_setup.get(&st.connection) {
3397 msg.push_str(&format!(
3398 " (may be a false positive: the setup statement at {}:{l} wasn't executed during the check)",
3399 f.display()
3400 ));
3401 }
3402 failures.push(msg);
3403 }
3404 if st.kind == StatementKind::Other {
3405 unexecuted_setup.insert(st.connection.clone(), (st.file.clone(), st.line));
3406 }
3407 }
3408 self.check_source_columns(&pool, &mut failures);
3409 if failures.is_empty() {
3410 Ok(())
3411 } else {
3412 Err(Fail::new(Code::QueryFailed, failures.join("\n ")))
3413 }
3414 }
3415
3416 fn check_source_columns(&mut self, pool: &Arc<Pool>, failures: &mut Vec<String>) {
3419 type Checked = std::collections::BTreeSet<(PathBuf, String, String, String)>;
3421 static CHECKED: std::sync::LazyLock<Mutex<Checked>> = std::sync::LazyLock::new(Mutex::default);
3422 for q in self.parsed.queries.clone() {
3423 let Some(conn) = q.connection.clone() else {
3424 continue;
3425 };
3426 for key in &q.sources {
3427 let Some(r) = self.parsed.sources.get(key).cloned() else {
3428 continue;
3429 };
3430 let Some(declared) = self
3431 .project
3432 .sources
3433 .get(&r.source)
3434 .and_then(|s| s.table(&r.table))
3435 .map(|t| t.columns.clone())
3436 .filter(|c| !c.is_empty())
3437 else {
3438 continue;
3439 };
3440 let id = (
3441 self.project.root.clone(),
3442 key.clone(),
3443 conn.clone(),
3444 self.project.profiles.target_of(Role::Connection, &conn),
3445 );
3446 if !CHECKED.lock().unwrap().insert(id) {
3447 continue;
3448 }
3449 let quote = if r.quoting.any() {
3450 let kind = pool.kind(&conn).unwrap_or_default();
3451 match pool.connections.identifier_quote(&kind) {
3452 Ok(q) => q,
3453 Err(e) => {
3454 failures.push(format!("source `{key}`: can't quote its name: {e}"));
3455 continue;
3456 }
3457 }
3458 } else {
3459 None
3460 };
3461 let rel = r.relation(quote.as_deref());
3462 let runner = PoolRunner {
3463 pool: pool.clone(),
3464 default: Some(conn.clone()),
3465 log: self.ui.sql_log(),
3466 };
3467 let actual = match runner.columns(&format!("select * from {rel} where 1=0"), None) {
3468 Ok(c) => c,
3469 Err(e) => {
3470 failures.push(format!("source `{key}` ({rel} on connection `{conn}`): {e}"));
3471 continue;
3472 }
3473 };
3474 for c in &declared {
3475 let Some(a) = actual.iter().find(|a| a.name.eq_ignore_ascii_case(&c.name)) else {
3476 failures.push(format!(
3477 "source `{key}`: declared column `{}` isn't in {rel} on connection `{conn}` (it has: {})",
3478 c.name,
3479 actual.iter().map(|a| a.name.as_str()).collect::<Vec<_>>().join(", ")
3480 ));
3481 continue;
3482 };
3483 let Some(dt) = &c.data_type else { continue };
3484 match crate::coltypes::matches(dt, &a.data_type) {
3485 Some(true) => {}
3486 Some(false) => failures.push(format!(
3487 "source `{key}`: column `{}` is declared `{dt}`, but {rel} on connection `{conn}` returns {}",
3488 c.name, a.data_type
3489 )),
3490 None => self.ui.warn(&format!(
3491 " source `{key}`: column `{}`'s `data_type: {dt}` isn't a type DRE can compare, so only its presence was checked",
3492 c.name
3493 )),
3494 }
3495 }
3496 }
3497 }
3498 }
3499}
3500
3501struct Session {
3503 path: Result<crate::plugins::Located, String>,
3504 connection: Result<JsonMap<String, Json>, String>,
3505 cwd: PathBuf,
3506 log: LogSink,
3507 opaque_log: Arc<AtomicBool>,
3510 unmanaged: bool,
3512 read_only: bool,
3513 loaded: bool,
3515 proc_: Option<PluginProcess>,
3516}
3517
3518impl Session {
3519 fn new(
3520 path: Result<crate::plugins::Located, String>,
3521 connection: Result<JsonMap<String, Json>, String>,
3522 cwd: PathBuf,
3523 log: LogSink,
3524 unmanaged: bool,
3525 ) -> Session {
3526 let opaque_log = Arc::new(AtomicBool::new(false));
3527 let protect = Arc::clone(&opaque_log);
3528 let downstream = log;
3529 let log: LogSink = Arc::new(move |plugin, line| {
3530 let line = crate::secrets::mask_source_error(line, protect.load(Ordering::SeqCst));
3531 downstream(plugin, &line);
3532 });
3533 Session {
3534 path,
3535 connection,
3536 cwd,
3537 log,
3538 opaque_log,
3539 unmanaged,
3540 read_only: unmanaged,
3541 loaded: false,
3542 proc_: None,
3543 }
3544 }
3545
3546 fn want_read_only(&mut self, ro: bool) {
3547 if self.read_only != ro {
3548 self.close();
3550 self.read_only = ro;
3551 }
3552 }
3553
3554 fn get(&mut self) -> Result<&mut PluginProcess, String> {
3555 if self.proc_.is_none() {
3556 let plugin = self.path.clone()?;
3557 let connection = self.connection.clone()?;
3558 let mut p = plugin
3559 .start(self.log.clone(), Some(&self.cwd))
3560 .map_err(|e| masked_source_error(&e, false))?;
3561 let ro = self.unmanaged && self.read_only && p.has(CAP_READ_ONLY);
3562 p.open(connection, ro).map_err(|e| {
3563 let protected = masked_source_error(&e, false);
3564 format!("can't open the source connection: {protected}")
3565 })?;
3566 self.proc_ = Some(p);
3567 }
3568 Ok(self.proc_.as_mut().unwrap())
3569 }
3570
3571 fn statement(&mut self, sensitive: bool) -> Result<(&mut PluginProcess, OpaqueLogScope), String> {
3572 let opaque_log = Arc::clone(&self.opaque_log);
3573 let process = self.get()?;
3574 let scope = OpaqueLogScope::new(opaque_log, sensitive);
3575 Ok((process, scope))
3576 }
3577
3578 fn close(&mut self) {
3579 if let Some(p) = self.proc_.take() {
3580 let _ = p.close();
3581 }
3582 }
3583}
3584
3585struct OpaqueLogScope(Arc<AtomicBool>);
3586
3587impl OpaqueLogScope {
3588 fn new(active: Arc<AtomicBool>, sensitive: bool) -> Self {
3589 active.store(sensitive, Ordering::SeqCst);
3590 Self(active)
3591 }
3592}
3593
3594impl Drop for OpaqueLogScope {
3595 fn drop(&mut self) {
3596 self.0.store(false, Ordering::SeqCst);
3597 }
3598}
3599
3600fn nowhere_note(profile: &str, target: &str) -> String {
3602 format!(
3603 "destination `{profile}`: `{target}` delivers nowhere (`deliver: false`); output stays in target/"
3604 )
3605}
3606
3607struct Pool {
3610 connections: Arc<ProfileConnections>,
3611 cwd: PathBuf,
3612 log: LogSink,
3613 unmanaged: bool,
3614 sessions: Mutex<BTreeMap<String, Arc<Mutex<Session>>>>,
3615}
3616
3617impl Pool {
3618 fn new(connections: Arc<ProfileConnections>, cwd: PathBuf, log: LogSink, unmanaged: bool) -> Pool {
3619 Pool {
3620 connections,
3621 cwd,
3622 log,
3623 unmanaged,
3624 sessions: Mutex::default(),
3625 }
3626 }
3627
3628 fn target(&self, name: &str) -> Result<ProfileTarget, String> {
3630 let profiles = &self.connections.profiles;
3631 match profiles.entry(Role::Connection, name) {
3632 Entry::Use(o) => Ok(o.clone()),
3633 Entry::Unknown => Err(format!(
3634 "connection `{name}` isn't under `{}:` in {}",
3635 profiles.section_key(Role::Connection),
3636 profiles.path.display()
3637 )),
3638 Entry::Missing | Entry::Nowhere => Err(profiles.missing_entry(Role::Connection, name)),
3640 }
3641 }
3642
3643 fn kind(&self, name: &str) -> Result<String, String> {
3645 Ok(self.target(name)?.kind)
3646 }
3647
3648 fn session(&self, name: &str) -> Result<Arc<Mutex<Session>>, String> {
3649 if let Some(s) = self.sessions.lock().unwrap().get(name) {
3650 return Ok(s.clone());
3651 }
3652 let output = self.target(name)?;
3653 let id = crate::project::PluginId::new(PluginKind::Source, output.kind.clone());
3654 let path = crate::plugins::locate_in(&self.connections.root, &self.connections.plugins, &id)
3655 .map_err(String::from);
3656 let session = Arc::new(Mutex::new(Session::new(
3657 path,
3658 render_connection(&output),
3659 self.cwd.clone(),
3660 self.log.clone(),
3661 self.unmanaged,
3662 )));
3663 self.sessions
3664 .lock()
3665 .unwrap()
3666 .insert(name.to_string(), session.clone());
3667 Ok(session)
3668 }
3669
3670 fn close_all(&self) {
3671 for s in self.sessions.lock().unwrap().values() {
3672 if let Ok(mut s) = s.lock() {
3673 s.close();
3674 }
3675 }
3676 }
3677}
3678
3679struct PoolRunner {
3682 pool: Arc<Pool>,
3683 default: Option<String>,
3684 log: LogSink,
3685}
3686
3687impl PoolRunner {
3688 fn session(&self, profile: Option<&str>) -> Result<Arc<Mutex<Session>>, String> {
3689 let name = profile.or(self.default.as_deref()).ok_or(
3690 "no connection here: pass `profile=`, or give the report a `profile:` (this isn't a query's SQL)",
3691 )?;
3692 self.pool.session(name)
3693 }
3694}
3695
3696impl QueryRunner for PoolRunner {
3697 fn run_query(&self, sql: &str, max_rows: u64, profile: Option<&str>) -> Result<QueryRows, String> {
3698 let session = self.session(profile)?;
3699 let mut s = session.lock().map_err(|_| "session lock poisoned".to_string())?;
3700 let sensitive = crate::secrets::contains_secret(sql);
3701 if s.unmanaged
3704 && let Some(bad) = sqlsplit::split(sql)
3705 .into_iter()
3706 .find(|st| sqlsplit::classify(&st.text) != StatementKind::Read)
3707 {
3708 return Err(format!(
3709 "run_query() in an unmanaged report may only read, but got `{}`; give the report a YAML to declare it",
3710 masked_sql_summary(&bad.text, 60, sensitive)
3711 ));
3712 }
3713 (self.log)("run_query()", &protected_sql(sql, sensitive));
3714 let (p, _log_scope) = s.statement(sensitive)?;
3715 let mut out = QueryRows::default();
3716 let mut too_many = false;
3717 let exec = p
3718 .execute(sql, Some(max_rows + 1), |schema, batch| {
3719 if out.columns.is_empty() {
3720 out.columns = schema.fields().iter().map(|f| f.name().clone()).collect();
3721 }
3722 if out.rows.len() as u64 + batch.num_rows() as u64 > max_rows {
3723 too_many = true;
3724 return Ok(());
3725 }
3726 out.rows.extend(crate::values::batch_rows(&batch));
3727 Ok(())
3728 })
3729 .map_err(|e| format!("run_query() failed: {}", masked_source_error(&e, sensitive)))?;
3730 if too_many {
3731 return Err(format!(
3732 "run_query() returned more than {max_rows} rows; it's meant for small lookups — raise the cap with `run_query(sql, max_rows=N)` or `run_query_max_rows` in dre_project.yml"
3733 ));
3734 }
3735 if let Execution::Result { schema, .. } = exec
3736 && out.columns.is_empty()
3737 {
3738 out.columns = schema.fields().iter().map(|f| f.name().clone()).collect();
3739 }
3740 Ok(out)
3741 }
3742
3743 fn columns(&self, sql: &str, profile: Option<&str>) -> Result<Vec<Column>, String> {
3744 let session = self.session(profile)?;
3745 let mut s = session.lock().map_err(|_| "session lock poisoned".to_string())?;
3746 let sensitive = crate::secrets::contains_secret(sql);
3747 (self.log)("columns()", &protected_sql(sql, sensitive));
3748 let (p, _log_scope) = s.statement(sensitive)?;
3749 let mut schema: Option<SchemaRef> = None;
3750 let exec = p
3751 .execute(sql, Some(1), |sch, _| {
3752 schema.get_or_insert_with(|| sch.clone());
3753 Ok(())
3754 })
3755 .map_err(|e| masked_source_error(&e, sensitive))?;
3756 if let Execution::Result { schema: sch, .. } = exec {
3757 schema.get_or_insert(sch);
3758 }
3759 let schema = schema.ok_or("the query returned no result set")?;
3760 Ok(schema
3761 .fields()
3762 .iter()
3763 .map(|f| Column {
3764 name: f.name().clone(),
3765 data_type: f.data_type().to_string(),
3766 })
3767 .collect())
3768 }
3769
3770 fn load(&self, name: &str, table: &Table) -> Result<Option<(String, Option<String>)>, String> {
3771 let session = self.session(None)?;
3772 let mut s = session.lock().map_err(|_| "session lock poisoned".to_string())?;
3773 s.want_read_only(false);
3775 if !s.get()?.has(CAP_LOAD) {
3776 return Ok(None);
3777 }
3778 let batch = table.to_batch()?;
3779 let schema = batch.schema();
3780 (self.log)(
3781 &format!("lookup `{name}`"),
3782 &format!(
3783 "-- {} rows loaded through the plugin's `load` request",
3784 batch.num_rows()
3785 ),
3786 );
3787 let r = s.get()?.load(name, &schema, [batch]).map_err(|e| e.to_string())?;
3788 s.loaded = true;
3789 Ok(Some(r))
3790 }
3791}
3792
3793fn render_json(renderer: &Renderer, file: &Path, v: &Json) -> Result<Json, RenderError> {
3795 Ok(match v {
3796 Json::String(s) => Json::String(renderer.render(file, s)?),
3797 Json::Array(a) => Json::Array(
3798 a.iter()
3799 .map(|v| render_json(renderer, file, v))
3800 .collect::<Result<_, _>>()?,
3801 ),
3802 Json::Object(o) => Json::Object(
3803 o.iter()
3804 .map(|(k, v)| Ok((k.clone(), render_json(renderer, file, v)?)))
3805 .collect::<Result<_, RenderError>>()?,
3806 ),
3807 other => other.clone(),
3808 })
3809}
3810
3811pub(crate) fn render_connection(output: &ProfileTarget) -> Result<JsonMap<String, Json>, String> {
3813 let mut env = minijinja::Environment::new();
3814 env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
3815 env.add_function("env_var", |name: String, default: Option<String>| -> Result<String, minijinja::Error> {
3816 std::env::var(&name).ok().or(default).ok_or_else(|| {
3817 minijinja::Error::new(
3818 minijinja::ErrorKind::UndefinedError,
3819 format!("`env_var('{name}')`: environment variable `{name}` is not set and no default is given"),
3820 )
3821 })
3822 });
3823 fn walk(env: &minijinja::Environment<'_>, v: &Json) -> Result<Json, String> {
3824 Ok(match v {
3825 Json::String(s) if crate::preflight::is_templated(s) => Json::String(
3826 env.render_str(s, ())
3827 .map_err(|e| format!("profiles.yml: {}", e.detail().unwrap_or("render error")))?,
3828 ),
3829 Json::Array(a) => Json::Array(a.iter().map(|x| walk(env, x)).collect::<Result<_, _>>()?),
3830 Json::Object(o) => Json::Object(
3831 o.iter()
3832 .map(|(k, x)| Ok((k.clone(), walk(env, x)?)))
3833 .collect::<Result<_, String>>()?,
3834 ),
3835 other => other.clone(),
3836 })
3837 }
3838 match walk(&env, &Json::Object(output.fields.clone()))? {
3839 Json::Object(o) => Ok(o),
3840 _ => unreachable!(),
3841 }
3842}
3843
3844pub fn find_plugin(
3847 project: &Project,
3848 kind: PluginKind,
3849 name: &str,
3850) -> Result<crate::plugins::Located, crate::plugins::LocateError> {
3851 crate::plugins::locate(project, &crate::project::PluginId::new(kind, name))
3852}
3853
3854struct ProfileConnections {
3858 profiles: Profiles,
3860 root: PathBuf,
3861 plugins: Vec<crate::project::PluginRequirement>,
3862 log: LogSink,
3863}
3864
3865static DESCRIBED: std::sync::LazyLock<Mutex<BTreeMap<DescribeKey, Option<Described>>>> =
3868 std::sync::LazyLock::new(Mutex::default);
3869
3870type DescribeKey = (PathBuf, Role, String);
3872
3873#[derive(Clone)]
3874struct Described {
3875 secrets: Vec<String>,
3876 identifier_quote: Option<String>,
3877 string_literal: Option<String>,
3878 capabilities: Vec<String>,
3879 message_limit: Option<u64>,
3881}
3882
3883const SECRET_NAME_WORDS: &[&str] = &["password", "secret", "token", "key", "credential"];
3884
3885impl ProfileConnections {
3886 fn view(&self, role: Role, name: &str) -> Result<Connection, String> {
3887 let target = self.profiles.target_of(role, name);
3888 let out: ProfileTarget = match self.profiles.entry(role, name) {
3889 Entry::Use(o) => o.clone(),
3890 Entry::Nowhere => {
3891 return Err(format!(
3892 "destination `{name}`: `{target}` delivers nowhere (`deliver: false`), so it has no settings"
3893 ));
3894 }
3895 Entry::Missing => return Err(self.profiles.missing_entry(role, name)),
3896 Entry::Unknown => {
3897 return Err(format!(
3898 "no {} profile `{name}` in {}",
3899 role.as_str(),
3900 self.profiles.path.display()
3901 ));
3902 }
3903 };
3904 let mut secrets: Vec<String> = out
3905 .fields
3906 .iter()
3907 .filter(|(_, v)| v.as_str().is_some_and(|s| s.contains(crate::secrets::PREFIX)))
3908 .map(|(k, _)| k.clone())
3909 .collect();
3910 match self.described(role, &out.kind) {
3911 Some(d) => secrets.extend(d.secrets),
3912 None => secrets.extend(
3913 out.fields
3914 .keys()
3915 .filter(|k| {
3916 let k = k.to_lowercase();
3917 SECRET_NAME_WORDS.iter().any(|w| k.contains(w))
3918 })
3919 .cloned(),
3920 ),
3921 }
3922 crate::secrets::register_secret_fields(&out.fields, &secrets);
3923 let fields = render_connection(&out)?;
3924 Ok(Connection {
3925 profile: name.to_string(),
3926 target,
3927 kind: out.kind.clone(),
3928 fields,
3929 secrets,
3930 })
3931 }
3932
3933 fn described(&self, role: Role, kind: &str) -> Option<Described> {
3935 let key = (self.root.clone(), role, kind.to_string());
3936 if let Some(v) = DESCRIBED.lock().unwrap().get(&key) {
3937 return v.clone();
3938 }
3939 let plugin_kind = match role {
3940 Role::Connection => PluginKind::Source,
3941 Role::Destination => PluginKind::Destination,
3942 };
3943 let asked = (|| {
3944 if kind == LOCAL_TYPE {
3945 return Some(Described {
3946 secrets: Vec::new(),
3947 identifier_quote: None,
3948 string_literal: None,
3949 capabilities: Vec::new(),
3950 message_limit: None,
3951 });
3952 }
3953 let id = crate::project::PluginId::new(plugin_kind, kind);
3954 let plugin = crate::plugins::locate_in(&self.root, &self.plugins, &id).ok()?;
3955 let mut p = plugin.start(self.log.clone(), Some(&self.root)).ok()?;
3956 let capabilities = p.info().capabilities.clone();
3957 let d = p.description().ok();
3958 let _ = p.close();
3959 let d = d?;
3960 Some(Described {
3961 secrets: d
3962 .connection_fields
3963 .into_iter()
3964 .filter(|f| f.secret)
3965 .map(|f| f.name)
3966 .collect(),
3967 identifier_quote: d.identifier_quote,
3968 string_literal: d.string_literal,
3969 capabilities,
3970 message_limit: d.message_limit,
3971 })
3972 })();
3973 DESCRIBED.lock().unwrap().insert(key, asked.clone());
3974 asked
3975 }
3976}
3977
3978impl Connections for ProfileConnections {
3979 fn profile(&self, name: &str, role: Option<&str>) -> Result<Connection, String> {
3980 let role = match role {
3981 Some("connection") => Role::Connection,
3982 Some(_) => Role::Destination,
3983 None => {
3984 let c = self.profiles.get(Role::Connection, name).is_some();
3985 let d = self.profiles.get(Role::Destination, name).is_some();
3986 match (c, d) {
3987 (true, true) => {
3988 return Err(format!(
3989 "`{name}` is both a connection and a destination profile; say which with `profile('{name}', role='connection')` or `role='destination'`"
3990 ));
3991 }
3992 (true, false) => Role::Connection,
3993 _ => Role::Destination,
3994 }
3995 }
3996 };
3997 self.view(role, name)
3998 }
3999
4000 fn identifier_quote(&self, kind: &str) -> Result<Option<String>, String> {
4001 match self.described(Role::Connection, kind) {
4002 Some(d) => Ok(d.identifier_quote),
4003 None => Err(format!(
4004 "the `{kind}` source plugin couldn't be asked for its identifier quote character"
4005 )),
4006 }
4007 }
4008
4009 fn string_literal(&self, kind: &str) -> Result<Option<String>, String> {
4010 Ok(self
4011 .described(Role::Connection, kind)
4012 .and_then(|d| d.string_literal))
4013 }
4014}
4015
4016fn check_sheet_name(n: &str) -> Result<(), String> {
4017 if n.is_empty() || n.chars().count() > 31 {
4018 return Err(format!("sheet name `{n}` must be 1 to 31 characters long"));
4019 }
4020 if let Some(c) = n.chars().find(|c| "[]:*?/\\".contains(*c)) {
4021 return Err(format!(
4022 "sheet name `{n}` contains `{c}`, which Excel doesn't allow in sheet names"
4023 ));
4024 }
4025 if n.starts_with('\'') || n.ends_with('\'') {
4026 return Err(format!("sheet name `{n}` can't start or end with an apostrophe"));
4027 }
4028 if n.eq_ignore_ascii_case("history") {
4029 return Err("`History` is reserved by Excel and can't be a sheet name".into());
4030 }
4031 Ok(())
4032}
4033
4034fn is_single_table(format: &str) -> bool {
4035 matches!(format, "csv" | "delimited" | "fixed_width" | "parquet")
4036}
4037
4038fn extension(format: &str) -> &str {
4039 match format {
4040 "delimited" | "fixed_width" => "txt",
4041 crate::project::MESSAGE_FORMAT => "md",
4042 f => f,
4043 }
4044}
4045
4046fn split_ext(name: &str) -> (&str, &str) {
4047 match name.rfind('.') {
4048 Some(i) if i > 0 => (&name[..i], &name[i..]),
4049 _ => (name, ""),
4050 }
4051}
4052
4053fn rel(root: &Path, p: &Path) -> PathBuf {
4054 crate::slash(p.strip_prefix(root).unwrap_or(p))
4055}
4056
4057pub fn rfc3339(t: chrono::DateTime<chrono::Utc>) -> String {
4059 t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
4060}
4061
4062fn record_path(project: &Project, p: &Path) -> PathBuf {
4066 match p.strip_prefix(&project.root) {
4067 Ok(r) => crate::slash(r),
4068 Err(_) => rel(&project.target_dir, p),
4069 }
4070}
4071
4072impl BindingRun<'_> {
4073 fn summary_line(&self) -> String {
4075 let rows: u64 = self.produced.iter().map(|p| p.rows).sum();
4076 let mut parts = Vec::new();
4077 if !self.produced.is_empty() {
4078 let n = self.produced.len();
4079 parts.push(format!(
4080 "{n} result set{}, {} row{}",
4081 if n == 1 { "" } else { "s" },
4082 thousands(rows),
4083 if rows == 1 { "" } else { "s" }
4084 ));
4085 }
4086 let names: Vec<String> = self
4087 .files()
4088 .map(|(f, _)| f.file_name().unwrap_or_default().to_string_lossy().to_string())
4089 .collect();
4090 if !names.is_empty() {
4091 parts.push(format!("→ {}", names.join(", ")));
4092 }
4093 let delivered: Vec<&str> = self
4095 .outs
4096 .iter()
4097 .flat_map(|o| o.deliveries.iter())
4098 .filter_map(|d| d.location.as_deref())
4099 .collect();
4100 let notes: Vec<&str> = self
4101 .outs
4102 .iter()
4103 .filter(|o| !o.dests.is_empty())
4104 .filter_map(|o| o.delivery_note.as_deref())
4105 .collect();
4106 if !delivered.is_empty() {
4107 parts.push(format!("→ {}", delivered.join(", ")));
4108 } else if !notes.is_empty() && self.opts.preview.is_none() {
4109 parts.push(format!("({})", notes.join("; ")));
4110 }
4111 parts.join(" ")
4112 }
4113
4114 fn files(&self) -> impl Iterator<Item = &(PathBuf, Option<String>)> {
4116 self.outs.iter().flat_map(|o| o.files.iter())
4117 }
4118}
4119
4120pub fn thousands(n: u64) -> String {
4122 let s = n.to_string();
4123 let mut out = String::new();
4124 for (i, c) in s.chars().enumerate() {
4125 if i > 0 && (s.len() - i).is_multiple_of(3) {
4126 out.push(',');
4127 }
4128 out.push(c);
4129 }
4130 out
4131}
4132
4133fn human_bytes(n: u64) -> String {
4134 match n {
4135 n if n < 1024 => format!("{n} B"),
4136 n if n < 1024 * 1024 => format!("{:.1} KB", n as f64 / 1024.0),
4137 n => format!("{:.1} MB", n as f64 / (1024.0 * 1024.0)),
4138 }
4139}
4140
4141pub fn clean(t: &Path) -> std::io::Result<bool> {
4143 if t.exists() {
4144 std::fs::remove_dir_all(t)?;
4145 Ok(true)
4146 } else {
4147 Ok(false)
4148 }
4149}