1use std::collections::BTreeMap;
7use std::fs::File;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, Instant};
11
12use arrow::array::RecordBatch;
13use arrow::datatypes::SchemaRef;
14use arrow::ipc::reader::FileReader;
15use arrow::ipc::writer::FileWriter;
16use chrono::NaiveDate;
17use dre_protocol::host::{Execution, LogSink, PluginProcess};
18use dre_protocol::msg::{ColumnOptions, DeliveryFile, ResultSetMeta};
19use dre_protocol::{CAP_LOAD, CAP_MULTI_FILE, CAP_READ_ONLY, CAP_SESSIONS};
20use serde::Serialize;
21use serde_json::{Map as JsonMap, Value as Json, json};
22
23use crate::dates::Calendar;
24use crate::lookups::Table;
25use crate::profiles::{BUILTIN_LOCAL, LOCAL_TYPE, ProfileTarget, Profiles, Role};
26use crate::project::{Binding, PluginKind, Project, QueryEntry, Report};
27use crate::render::{
28 Column, Connection, Connections, QueryRows, QueryRunner, RenderError, Renderer, RendererConfig,
29 RunContext,
30};
31use crate::selector;
32use crate::sqlsplit::{self, StatementKind};
33
34#[derive(Debug, Clone, Default)]
36pub struct RunOptions {
37 pub selector: Option<String>,
38 pub set: Option<String>,
40 pub target: Option<String>,
41 pub profile: Option<String>,
42 pub vars: BTreeMap<String, String>,
43 pub output_name: Option<String>,
44 pub output_path: Option<String>,
45 pub dry_run: bool,
46 pub preview: Option<u64>,
48 pub accept_schema_change: bool,
49 pub interactive: bool,
51 pub date: Option<NaiveDate>,
53 pub scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
56 pub live_check: bool,
58 pub schedule: Option<String>,
60 pub timezone: Option<String>,
62 pub manifest_checksum: Option<String>,
64}
65
66impl RunOptions {
67 pub fn params(&self, date: NaiveDate) -> Json {
69 json!({
70 "selector": self.selector,
71 "set": self.set,
72 "schedule": self.schedule,
73 "target": self.target,
74 "profile": self.profile,
75 "vars": self.vars,
76 "run_date": date.to_string(),
77 "scheduled_at": self.scheduled_at.map(rfc3339),
78 "timezone": self.timezone,
79 "output_name": self.output_name,
80 "output_path": self.output_path,
81 "dry_run": self.dry_run,
82 "preview": self.preview,
83 "accept_schema_change": self.accept_schema_change,
84 })
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
90pub enum Level {
91 Debug,
92 Info,
93}
94
95pub trait Ui {
98 fn plan(&mut self, _bindings: usize) {}
100 fn binding_start(&mut self, _report: &str, _set: Option<&str>) {}
101 fn binding_vars(
103 &mut self,
104 _schedule: Option<&str>,
105 _schedule_vars: Option<&JsonMap<String, Json>>,
106 _vars: &JsonMap<String, Json>,
107 ) {
108 }
109 fn step(&mut self, level: Level, verb: &str, detail: &str, elapsed: Option<Duration>);
111 fn warn(&mut self, msg: &str);
112 fn binding_end(&mut self, _outcome: &BindingOutcome) {}
113 fn compiled(&mut self, _plan: &BindingPlan) {}
115 fn choose_set(&mut self, report: &str, sets: &[String]) -> Result<Option<String>, String>;
117 fn plugin_log(&self) -> LogSink;
119 fn sql_log(&self) -> LogSink {
121 Arc::new(|_, _| {})
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
126#[serde(rename_all = "snake_case")]
127pub enum Status {
128 Success,
129 Error,
130 DryRun,
131 Checked,
132}
133
134#[derive(Debug, Clone, Serialize)]
135pub struct BindingOutcome {
136 pub report: String,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub set: Option<String>,
139 pub binding: String,
140 pub status: Status,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub error: Option<String>,
143 pub files: Vec<PathBuf>,
144 pub summary: String,
146 pub schedule: Option<String>,
148 pub schedule_vars: Option<JsonMap<String, Json>>,
149 pub vars: JsonMap<String, Json>,
151 #[serde(skip_serializing_if = "String::is_empty")]
153 pub timezone: String,
154 #[serde(skip)]
155 pub elapsed: Duration,
156}
157
158#[derive(Debug, Clone, Serialize)]
160pub struct BindingPlan {
161 pub report: String,
162 pub set: Option<String>,
163 pub compiled: Vec<PathBuf>,
165 pub profile: String,
166 pub source_type: String,
167 pub target: String,
168 pub format: String,
169 pub output: PathBuf,
172 pub destinations: Vec<PlannedDestination>,
173 pub schedules: Vec<String>,
174}
175
176#[derive(Debug, Clone, Serialize)]
177pub struct PlannedDestination {
178 pub profile: String,
179 pub kind: Option<String>,
181 pub target: Option<String>,
182 pub path: Option<String>,
183 pub delivers: bool,
185}
186
187#[derive(Debug, Default)]
188pub struct RunSummary {
189 pub outcomes: Vec<BindingOutcome>,
190 pub error: Option<String>,
192}
193
194impl RunSummary {
195 pub fn failed(&self) -> bool {
196 self.error.is_some() || self.outcomes.iter().any(|o| o.status == Status::Error)
197 }
198}
199
200pub fn run(project: &Project, opts: &RunOptions, ui: &mut dyn Ui) -> RunSummary {
201 let mut summary = RunSummary::default();
202 if let Err(e) = crate::target::ensure(&project.target_dir) {
203 summary.error = Some(format!(
204 "can't write to the target path {} (from {}): {e}",
205 project.target_dir.display(),
206 project.target_source
207 ));
208 return summary;
209 }
210 if let Some(name) = &opts.schedule {
211 if !project.schedules.iter().any(|e| &e.name == name) {
212 summary.error = Some(unknown_schedule(project, name));
213 return summary;
214 }
215 let planned = match schedule_bindings(project, name, opts) {
216 Ok(p) => p,
217 Err(e) => {
218 summary.error = Some(e);
219 return summary;
220 }
221 };
222 ui.plan(planned.len());
223 for (report, b) in &planned {
224 ui.binding_start(&report.name, b.set.as_deref());
225 let mut r = BindingRun::new(project, report, b, opts, ui);
226 let outcome = r.run();
227 ui.binding_end(&outcome);
228 summary.outcomes.push(outcome);
229 }
230 return summary;
231 }
232 let reports: Vec<&Report> = match &opts.selector {
233 None => project.reports.iter().collect(),
234 Some(s) => match selector::resolve(project, s) {
235 Ok(r) if r.is_empty() => {
236 summary.error = Some(format!("selector `{s}` matches no report"));
237 return summary;
238 }
239 Ok(r) => r,
240 Err(e) => {
241 summary.error = Some(e.to_string());
242 return summary;
243 }
244 },
245 };
246 let mut planned: Vec<(&Report, Binding)> = Vec::new();
248 for report in reports {
249 match choose_bindings(project, report, opts, ui) {
250 Ok(bs) => planned.extend(bs.into_iter().map(|b| (report, b))),
251 Err(e) => {
252 let outcome = BindingOutcome {
253 report: report.name.clone(),
254 set: None,
255 binding: "-".into(),
256 status: Status::Error,
257 error: Some(e),
258 files: Vec::new(),
259 summary: String::new(),
260 schedule: opts.schedule.clone(),
261 schedule_vars: None,
262 vars: JsonMap::new(),
263 timezone: String::new(),
264 elapsed: Duration::ZERO,
265 };
266 ui.binding_end(&outcome);
267 summary.outcomes.push(outcome);
268 }
269 }
270 }
271 ui.plan(planned.len());
272 for (report, b) in &planned {
273 ui.binding_start(&report.name, b.set.as_deref());
274 let mut r = BindingRun::new(project, report, b, opts, ui);
275 let outcome = r.run();
276 ui.binding_end(&outcome);
277 summary.outcomes.push(outcome);
278 }
279 summary
280}
281
282fn schedule_bindings<'a>(
285 project: &'a Project,
286 name: &str,
287 opts: &RunOptions,
288) -> Result<Vec<(&'a Report, Binding)>, String> {
289 let reports: Option<Vec<&str>> = match &opts.selector {
290 None => None,
291 Some(s) => Some(
292 selector::resolve(project, s)
293 .map_err(|e| e.to_string())?
294 .into_iter()
295 .map(|r| r.name.as_str())
296 .collect(),
297 ),
298 };
299 let set = opts.set.as_deref().filter(|s| *s != "all");
300 let all: Vec<(&Report, &Binding)> = project
301 .reports
302 .iter()
303 .flat_map(|r| {
304 r.bindings
305 .iter()
306 .filter(|b| b.schedules.iter().any(|s| s == name))
307 .map(move |b| (r, b))
308 })
309 .collect();
310 let picked: Vec<(&Report, Binding)> = all
311 .iter()
312 .filter(|(r, _)| reports.as_ref().is_none_or(|rs| rs.contains(&r.name.as_str())))
313 .filter(|(_, b)| set.is_none() || b.set.as_deref() == set)
314 .map(|(r, b)| {
315 let mut b = (*b).clone();
316 if let Some(p) = &opts.profile {
317 b.profile = Some(p.clone());
318 }
319 (*r, b)
320 })
321 .collect();
322 if picked.is_empty() && (reports.is_some() || set.is_some()) {
323 let mut asked = Vec::new();
324 if let Some(s) = &opts.selector {
325 asked.push(format!("`{s}`"));
326 }
327 if let Some(s) = set {
328 asked.push(format!("Set `{s}`"));
329 }
330 let runs: Vec<String> = all
331 .iter()
332 .map(|(r, b)| match &b.set {
333 Some(s) => format!("{}/{s}", r.name),
334 None => r.name.clone(),
335 })
336 .collect();
337 return Err(format!(
338 "schedule `{name}` doesn't run {}; it runs: {}",
339 asked.join(" with "),
340 if runs.is_empty() {
341 "nothing".to_string()
342 } else {
343 runs.join(", ")
344 }
345 ));
346 }
347 Ok(picked)
348}
349
350pub fn unknown_schedule(project: &Project, name: &str) -> String {
352 let names: Vec<&str> = project.schedules.iter().map(|e| e.name.as_str()).collect();
353 if names.is_empty() {
354 format!("no schedule `{name}`: this project's schedules.yml declares none")
355 } else {
356 format!("no schedule `{name}`; valid names: {}", names.join(", "))
357 }
358}
359
360pub fn choose_bindings(
362 project: &Project,
363 report: &Report,
364 opts: &RunOptions,
365 ui: &mut dyn Ui,
366) -> Result<Vec<Binding>, String> {
367 let mut out: Vec<Binding> = match opts.set.as_deref() {
368 Some("all") => report.bindings.clone(),
369 Some(name) => match report.binding(name) {
370 Some(b) => vec![b.clone()],
371 None => vec![ad_hoc(project, report, name)],
372 },
373 None if !report.has_sets => report.bindings.clone(),
374 None => {
375 let names: Vec<String> = report.bindings.iter().filter_map(|b| b.set.clone()).collect();
376 let pick = report
377 .default_set
378 .clone()
379 .or_else(|| project.default_set.clone().filter(|d| names.contains(d)))
380 .or_else(|| (names.len() == 1).then(|| names[0].clone()));
381 match pick {
382 Some(p) => report.binding(&p).cloned().into_iter().collect(),
383 None if opts.interactive => match ui.choose_set(&report.name, &names)? {
384 Some(p) => report.binding(&p).cloned().into_iter().collect(),
385 None => report.bindings.clone(),
386 },
387 None => {
388 return Err(format!(
389 "report `{}` has several Sets ({}) and no `default_set`; declare `default_set:` or pass `--set <name>` or `--set all`",
390 report.name,
391 names.join(", ")
392 ));
393 }
394 }
395 }
396 };
397 if let Some(p) = &opts.profile {
398 for b in &mut out {
399 b.profile = Some(p.clone());
400 }
401 }
402 Ok(out)
403}
404
405fn ad_hoc(project: &Project, report: &Report, name: &str) -> Binding {
408 let mut b = report.base.clone();
409 b.set = Some(name.to_string());
410 if let Some(reg) = project.sets.get(name) {
411 if let Some(p) = ®.profile {
412 b.profile = Some(p.clone());
413 }
414 b.vars.extend(reg.vars.clone());
415 }
416 b
417}
418
419struct Produced {
421 query: String,
422 schema: SchemaRef,
423 rows: u64,
424 spool: PathBuf,
425 name: String,
426 anchor: Option<String>,
427 header: Option<bool>,
428 columns: BTreeMap<String, ColumnOptions>,
429}
430
431struct Statement {
432 query: String,
433 file: PathBuf,
434 line: usize,
435 text: String,
436 kind: StatementKind,
437 tab: bool,
440}
441
442struct BindingRun<'a> {
443 project: &'a Project,
444 report: &'a Report,
445 b: &'a Binding,
446 vars: JsonMap<String, Json>,
448 rendered_vars: JsonMap<String, Json>,
450 schedule_vars: Option<JsonMap<String, Json>>,
451 opts: &'a RunOptions,
452 date: NaiveDate,
454 calendar: Calendar,
455 ui: &'a mut dyn Ui,
456 compiled_dir: PathBuf,
457 run_dir: PathBuf,
458 schema_dir: PathBuf,
459 target: String,
460 source_type: String,
462 started: Instant,
463 started_at: chrono::DateTime<chrono::Utc>,
464 produced: Vec<Produced>,
465 files: Vec<(PathBuf, Option<String>)>,
466 dests: Vec<RenderedDest>,
468 deliveries: Vec<Json>,
470 delivery_note: Option<String>,
471 drift: Vec<String>,
472}
473
474struct RenderedDest {
476 profile: String,
477 path: Option<String>,
478 options: JsonMap<String, Json>,
479}
480
481type Fail = String;
482
483impl<'a> BindingRun<'a> {
484 fn new(
485 project: &'a Project,
486 report: &'a Report,
487 b: &'a Binding,
488 opts: &'a RunOptions,
489 ui: &'a mut dyn Ui,
490 ) -> Self {
491 let t = &project.target_dir;
492 let rel = Path::new(&report.name).join(b.dir_name());
493 let schedule = opts
494 .schedule
495 .as_ref()
496 .and_then(|n| project.schedules.iter().find(|e| &e.name == n));
497 let schedule_vars = schedule.map(|e| e.vars.clone());
498 let tz = opts
500 .timezone
501 .as_ref()
502 .or(schedule.and_then(|e| e.timezone.as_ref()))
503 .or(report.timezone.as_ref())
504 .and_then(|t| crate::dates::parse_tz(t).ok())
505 .unwrap_or(chrono_tz::Tz::UTC);
506 let calendar = Calendar {
507 tz,
508 week_start: project.week_start,
509 numbering: project.week_numbering,
510 };
511 let date = opts
512 .date
513 .or(opts
514 .scheduled_at
515 .map(|t| t.with_timezone(&calendar.tz).date_naive()))
516 .unwrap_or_else(|| calendar.today());
517 let mut vars = b.vars.clone();
519 vars.extend(schedule_vars.clone().unwrap_or_default());
520 let rendered_vars = {
521 let mut v = vars.clone();
522 v.extend(
523 opts.vars
524 .iter()
525 .map(|(k, x)| (k.clone(), Json::String(x.clone()))),
526 );
527 v
528 };
529 ui.binding_vars(opts.schedule.as_deref(), schedule_vars.as_ref(), &rendered_vars);
530 BindingRun {
531 project,
532 report,
533 b,
534 vars,
535 rendered_vars,
536 schedule_vars,
537 opts,
538 date,
539 calendar,
540 ui,
541 compiled_dir: t.join("compiled").join(&rel),
542 run_dir: t.join("run").join(&rel),
543 schema_dir: t.join("schema").join(&rel),
544 target: String::new(),
545 source_type: String::new(),
546 started: Instant::now(),
547 started_at: chrono::Utc::now(),
548 produced: Vec::new(),
549 files: Vec::new(),
550 dests: Vec::new(),
551 deliveries: Vec::new(),
552 delivery_note: None,
553 drift: Vec::new(),
554 }
555 }
556
557 fn connections(&self, source: Option<&str>) -> Arc<dyn Connections> {
559 Arc::new(ProfileConnections {
560 profiles: self.project.profiles.clone(),
561 source: source.map(str::to_string),
562 target: self.opts.target.clone(),
563 root: self.project.root.clone(),
564 plugins: self.project.plugins.clone(),
565 log: self.ui.plugin_log(),
566 })
567 }
568
569 fn outcome(&self, status: Status, error: Option<String>) -> BindingOutcome {
570 BindingOutcome {
571 report: self.report.name.clone(),
572 set: self.b.set.clone(),
573 binding: self.b.dir_name().to_string(),
574 status,
575 error,
576 files: self.files.iter().map(|(p, _)| p.clone()).collect(),
577 summary: self.summary_line(),
578 schedule: self.opts.schedule.clone(),
579 schedule_vars: self.schedule_vars.clone(),
580 vars: self.rendered_vars.clone(),
581 timezone: self.calendar.tz.name().to_string(),
582 elapsed: self.started.elapsed(),
583 }
584 }
585
586 fn run(&mut self) -> BindingOutcome {
587 let dry = self.opts.dry_run || self.opts.live_check;
588 if !dry {
589 let _ = std::fs::remove_dir_all(&self.run_dir);
590 }
591 let result = self.run_inner();
592 let status = match (&result, dry, self.opts.live_check) {
593 (Err(_), _, _) => Status::Error,
594 (Ok(()), _, true) => Status::Checked,
595 (Ok(()), true, _) => Status::DryRun,
596 (Ok(()), false, _) => Status::Success,
597 };
598 let err = result.err();
599 if !dry && let Err(e) = self.write_results(&status, err.as_deref()) {
600 self.ui.warn(&format!("can't write run_results.json: {e}"));
601 }
602 let _ = std::fs::remove_dir_all(self.run_dir.join(".spool"));
604 self.outcome(status, err)
605 }
606
607 fn run_inner(&mut self) -> Result<(), Fail> {
608 let profile_name = self
609 .b
610 .profile
611 .clone()
612 .ok_or("no source profile resolves for this Binding")?;
613 let profiles = &self.project.profiles;
614 let profile = profiles
615 .get(Role::Source, &profile_name)
616 .ok_or_else(|| format!("source profile `{profile_name}` isn't in profiles.yml"))?;
617 let target = self.opts.target.clone().unwrap_or_else(|| profile.target.clone());
618 let (_, output) = profiles
619 .target(Role::Source, &profile_name, Some(&target))
620 .ok_or_else(|| {
621 format!(
622 "source profile `{profile_name}` has no `{target}` target (it has: {})",
623 profile.targets.keys().cloned().collect::<Vec<_>>().join(", ")
624 )
625 })?;
626 self.target = target.clone();
627 self.source_type = output.kind.clone();
628 let source_path = find_plugin(self.project, PluginKind::Source, &output.kind).map_err(String::from);
631 let connection = render_connection(output);
632
633 let session = Arc::new(Mutex::new(Session::new(
634 source_path,
635 connection,
636 self.project.root.clone(),
637 self.ui.plugin_log(),
638 !self.report.managed,
639 )));
640
641 if !self.report.managed {
642 self.ui.warn(&format!(
643 "`{}` is an unmanaged report ({}): for quick tests only — add a YAML to make it a managed report",
644 self.report.name,
645 self.report.file.display()
646 ));
647 }
648
649 let renderer = Renderer::new(RendererConfig {
651 root: &self.project.root,
652 macros: &self.project.macros,
653 context: RunContext {
654 report: self.report.name.clone(),
655 set: self.b.set.clone(),
656 target: target.clone(),
657 profile: profile_name.clone(),
658 source_type: output.kind.clone(),
659 schedule: self.opts.schedule.clone(),
660 date: self.date,
661 now: self.opts.scheduled_at.unwrap_or(self.started_at),
662 scheduled_at: self.opts.scheduled_at,
663 calendar: self.calendar,
664 },
665 vars: self.vars.clone(),
666 cli_vars: self.opts.vars.clone(),
667 runner: Some(Arc::new(SessionRunner(session.clone(), self.ui.sql_log()))),
668 connections: Some(self.connections(Some(&profile_name))),
669 run_query_max_rows: self.project.run_query_max_rows,
670 sql: self.project.sql.clone(),
671 lookups: self.project.lookups.clone(),
672 lookup_inline_max_rows: self.project.lookup_inline_max_rows,
673 packages: self.project.packages.clone(),
674 project_name: self.project.name.clone(),
675 dispatch: self.project.dispatch.clone(),
676 })
677 .map_err(|e| e.to_string())?;
678 std::fs::create_dir_all(&self.compiled_dir).map_err(|e| e.to_string())?;
679 let interleave = !self.opts.dry_run && !self.opts.live_check && self.report.managed;
683 let mut statements = Vec::new();
684 let mut two_tabs: Option<String> = None;
686 if interleave {
687 self.dests = self.render_destinations(&renderer)?;
688 if self.b.queries.len() > 1 {
689 self.check_sessions(&session, self.b.queries.len(), &output.kind)?;
690 }
691 std::fs::create_dir_all(self.run_dir.join(".spool")).map_err(|e| e.to_string())?;
692 let mut i = 0;
693 for q in &self.b.queries {
694 let sts = self.render_query(&renderer, q, &mut two_tabs)?;
695 for w in renderer.take_warnings() {
696 self.ui.warn(&w);
697 }
698 if let Some(e) = two_tabs.take() {
699 return Err(e);
700 }
701 if i == 0 && sts.len() > 1 {
702 self.check_sessions(&session, sts.len(), &output.kind)?;
703 }
704 for st in &sts {
705 self.execute_statement(&session, i, st)?;
706 i += 1;
707 }
708 }
709 } else {
710 for q in &self.b.queries {
711 statements.extend(self.render_query(&renderer, q, &mut two_tabs)?);
712 }
713 self.dests = self.render_destinations(&renderer)?;
714 for w in renderer.take_warnings() {
715 self.ui.warn(&w);
716 }
717
718 if !self.report.managed {
720 if let Some(bad) = statements.iter().find(|s| !s.kind.is_read_only_safe()) {
721 return Err(format!(
722 "{}:{}: 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",
723 bad.file.display(),
724 bad.line,
725 self.report.name,
726 dre_protocol::util::summarize(&bad.text, 60)
727 ));
728 }
729 let creates_temp = statements.iter().any(|s| s.kind == StatementKind::TempCreate)
730 || session.lock().unwrap().loaded;
731 session.lock().unwrap().want_read_only(!creates_temp);
732 }
733 if let Some(e) = two_tabs {
734 return Err(e);
735 }
736
737 if self.opts.dry_run {
738 let plan = self.plan(&profile_name, &output.kind);
739 self.ui.compiled(&plan);
740 return Ok(());
741 }
742
743 if statements.len() > 1 {
745 self.check_sessions(&session, statements.len(), &output.kind)?;
746 }
747
748 if self.opts.live_check {
749 return self.live_check(&session, &statements, &output.kind);
750 }
751
752 std::fs::create_dir_all(self.run_dir.join(".spool")).map_err(|e| e.to_string())?;
754 for (i, st) in statements.iter().enumerate() {
755 self.execute_statement(&session, i, st)?;
756 }
757 }
758 if let Ok(mut s) = session.lock() {
759 s.close();
760 }
761
762 self.name_result_sets()?;
763
764 let filename = self.file_names();
766 self.format(&filename)?;
767
768 if self.opts.preview.is_none() {
770 self.drift = self.schema_drift();
771 if !self.drift.is_empty() {
772 if self.opts.accept_schema_change {
773 self.ui.warn(&format!(
774 " schema changed since the last successful run (accepted): {}",
775 self.drift.join("; ")
776 ));
777 } else {
778 return Err(format!(
779 "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",
780 self.drift.join("; "),
781 rel(&self.project.root, &self.run_dir).display()
782 ));
783 }
784 }
785 }
786
787 if self.opts.preview.is_some() {
789 self.delivery_note = Some("preview: not delivered".into());
790 let dir = rel(&self.project.root, &self.run_dir);
791 self.ui.step(
792 Level::Info,
793 "Preview",
794 &format!("not delivered; output stays in {}", dir.display()),
795 None,
796 );
797 } else {
798 self.deliver()?;
799 }
800
801 if self.opts.preview.is_none() {
803 self.write_snapshot()
804 .map_err(|e| format!("can't write the schema snapshot: {e}"))?;
805 }
806 Ok(())
807 }
808
809 fn render_query(
811 &mut self,
812 renderer: &Renderer,
813 q: &QueryEntry,
814 two_tabs: &mut Option<String>,
815 ) -> Result<Vec<Statement>, Fail> {
816 let mut out = Vec::new();
817 let src = std::fs::read_to_string(self.project.root.join(&q.path))
818 .map_err(|e| format!("{}: {e}", q.path.display()))?;
819 let sql = renderer
820 .render(&q.path, &src)
821 .map_err(|e: RenderError| e.to_string())?;
822 std::fs::write(
823 self.compiled_dir.join(format!("{}.sql", q.query)),
824 crate::secrets::mask(&sql).as_bytes(),
825 )
826 .map_err(|e| e.to_string())?;
827 self.ui
828 .step(Level::Debug, "Rendered", &q.path.display().to_string(), None);
829 let parts = sqlsplit::split(&sql);
832 if q.tab && parts.is_empty() {
833 return Err(format!(
834 "{}: `{}` makes a tab but has no statements; add a query, or `tab: false` if it's meant to be empty",
835 q.path.display(),
836 q.query
837 ));
838 }
839 let n = parts.len();
840 for (i, st) in parts.into_iter().enumerate() {
841 let body = sqlsplit::strip_leading_comments(&st.text);
842 let line = st.line + st.text[..st.text.len() - body.len()].matches('\n').count();
843 let kind = sqlsplit::classify(&st.text);
844 let last = i + 1 == n;
845 if q.tab && !last && kind == StatementKind::Read && two_tabs.is_none() {
846 *two_tabs = Some(format!(
847 "{}:{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:`",
848 q.path.display(),
849 q.query
850 ));
851 }
852 out.push(Statement {
853 query: q.query.clone(),
854 file: q.path.clone(),
855 line,
856 kind,
857 text: st.text,
858 tab: q.tab && last,
859 });
860 }
861 Ok(out)
862 }
863
864 fn check_sessions(&self, session: &Arc<Mutex<Session>>, n: usize, kind: &str) -> Result<(), Fail> {
866 let mut s = session.lock().unwrap();
867 if !s.get()?.has(CAP_SESSIONS) {
868 return Err(format!(
869 "this Binding runs {n} statements, but the `{kind}` source plugin can't hold one session across them; nothing was run"
870 ));
871 }
872 Ok(())
873 }
874
875 fn execute_statement(
877 &mut self,
878 session: &Arc<Mutex<Session>>,
879 i: usize,
880 st: &Statement,
881 ) -> Result<(), Fail> {
882 let sql_log = self.ui.sql_log();
883 sql_log(&format!("{}:{}", st.file.display(), st.line), &st.text);
884 let spool_path = self.run_dir.join(".spool").join(format!("{i}.arrow"));
885 let mut writer: Option<FileWriter<File>> = None;
886 let t = Instant::now();
887 let exec = {
888 let mut s = session.lock().unwrap();
889 let p = s.get()?;
890 p.execute(&st.text, self.opts.preview, |schema, batch| {
891 if writer.is_none() {
892 let f = File::create(&spool_path).map_err(|e| e.to_string())?;
893 writer = Some(FileWriter::try_new(f, schema).map_err(|e| e.to_string())?);
894 }
895 writer.as_mut().unwrap().write(&batch).map_err(|e| e.to_string())
896 })
897 .map_err(|e| format!("{}:{}: {e}", st.file.display(), st.line))?
898 };
899 let what = match &exec {
900 Execution::Result { rows, .. } => {
901 format!("{} row{}", thousands(*rows), if *rows == 1 { "" } else { "s" })
902 }
903 Execution::NoResult {
904 rows_affected: Some(n),
905 } => format!("no result set ({n} affected)"),
906 Execution::NoResult { .. } => "no result set".to_string(),
907 };
908 let at = format!("{}:{}", st.file.display(), st.line);
909 self.ui.step(
910 Level::Debug,
911 "Executed",
912 &format!("{at} {what}"),
913 Some(t.elapsed()),
914 );
915 let (schema, rows) = match (st.tab, exec) {
918 (true, Execution::Result { schema, rows }) => (schema, rows),
919 (true, Execution::NoResult { .. }) => {
920 return Err(format!(
921 "{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",
922 st.query
923 ));
924 }
925 (false, _) => {
926 drop(writer);
927 let _ = std::fs::remove_file(&spool_path);
928 return Ok(());
929 }
930 };
931 {
932 let mut w = match writer {
933 Some(w) => w,
934 None => FileWriter::try_new(File::create(&spool_path).map_err(|e| e.to_string())?, &schema)
935 .map_err(|e| e.to_string())?,
936 };
937 w.finish().map_err(|e| e.to_string())?;
938 self.produced.push(Produced {
939 query: st.query.clone(),
940 schema,
941 rows,
942 spool: spool_path,
943 name: String::new(),
944 anchor: None,
945 header: None,
946 columns: Default::default(),
947 });
948 }
949 Ok(())
950 }
951
952 fn render_destinations(&self, renderer: &Renderer) -> Result<Vec<RenderedDest>, Fail> {
953 self.b
954 .output
955 .destinations
956 .iter()
957 .map(|d| {
958 let path = match &d.path {
959 Some(p) => Some(
960 renderer
961 .render(&self.report.file, p)
962 .map_err(|e| format!("output path: {e}"))?,
963 ),
964 None => None,
965 };
966 let mut options = JsonMap::new();
967 for (k, v) in &d.options {
968 let v = render_json(renderer, &self.report.file, v)
969 .map_err(|e| format!("destination `{}` option `{k}`: {e}", d.profile))?;
970 options.insert(k.clone(), v);
971 }
972 Ok(RenderedDest {
973 profile: d.profile.clone(),
974 path,
975 options,
976 })
977 })
978 .collect()
979 }
980
981 fn name_result_sets(&mut self) -> Result<(), Fail> {
983 let entries: BTreeMap<&str, &QueryEntry> =
984 self.b.queries.iter().map(|q| (q.query.as_str(), q)).collect();
985 for p in &mut self.produced {
986 let q = entries[p.query.as_str()];
987 p.name = q.tab_name.clone().unwrap_or_else(|| q.query.clone());
988 p.anchor = q.anchor.clone();
989 p.header = q.header;
990 p.columns = q.columns.clone();
991 }
992 if self.b.output.format == "xlsx" {
993 let mut seen: BTreeMap<String, String> = BTreeMap::new();
994 for p in &self.produced {
995 check_sheet_name(&p.name)?;
996 if let Some(prev) = seen.insert(p.name.to_lowercase(), p.query.clone()) {
997 return Err(format!(
998 "sheet name `{}` is used twice (by `{prev}` and `{}`); Excel sheet names must be unique",
999 p.name, p.query
1000 ));
1001 }
1002 }
1003 }
1004 Ok(())
1005 }
1006
1007 fn plan(&mut self, profile: &str, source_type: &str) -> BindingPlan {
1009 let file = self.file_names();
1010 let compiled = self
1011 .b
1012 .queries
1013 .iter()
1014 .map(|q| {
1015 rel(
1016 &self.project.root,
1017 &self.compiled_dir.join(format!("{}.sql", q.query)),
1018 )
1019 })
1020 .collect();
1021 let destinations = self
1022 .dests
1023 .iter()
1024 .map(|d| {
1025 let resolved = self.dest_output(&d.profile);
1026 PlannedDestination {
1027 profile: d.profile.clone(),
1028 kind: resolved.as_ref().map(|(_, o)| o.kind.clone()),
1029 target: resolved.map(|(t, _)| t).or_else(|| self.dest_target(&d.profile)),
1030 path: d.path.clone(),
1031 delivers: self.dest_output(&d.profile).is_some(),
1032 }
1033 })
1034 .collect();
1035 BindingPlan {
1036 report: self.report.name.clone(),
1037 set: self.b.set.clone(),
1038 compiled,
1039 profile: profile.to_string(),
1040 source_type: source_type.to_string(),
1041 target: self.target.clone(),
1042 format: self.b.output.format.clone(),
1043 output: rel(&self.project.root, &self.run_dir.join(file)),
1044 destinations,
1045 schedules: self.b.schedules.clone(),
1046 }
1047 }
1048
1049 fn file_names(&mut self) -> String {
1053 for d in self.dests.iter_mut().filter(|d| d.path.is_some()) {
1054 if let Some(p) = &self.opts.output_path {
1055 d.path = Some(p.clone());
1056 }
1057 if let (Some(n), Some(r)) = (&self.opts.output_name, d.path.as_mut()) {
1058 *r = match r.rfind(['/', '\\']) {
1059 Some(i) => format!("{}{n}", &r[..=i]),
1060 None => n.clone(),
1061 };
1062 }
1063 }
1064 let ext = match &self.b.output.extension {
1065 Some(e) => e.as_str(),
1066 None => extension(&self.b.output.format),
1067 };
1068 let from_remote = self
1069 .dests
1070 .iter()
1071 .find_map(|d| d.path.as_deref())
1072 .and_then(|r| r.rsplit(['/', '\\']).next())
1073 .filter(|s| !s.is_empty())
1074 .map(str::to_string);
1075 self.opts
1076 .output_name
1077 .clone()
1078 .or(from_remote)
1079 .unwrap_or_else(|| match ext {
1080 "" => self.report.name.clone(),
1081 ext => format!("{}.{ext}", self.report.name),
1082 })
1083 }
1084
1085 fn format(&mut self, filename: &str) -> Result<(), Fail> {
1086 std::fs::create_dir_all(&self.run_dir).map_err(|e| e.to_string())?;
1087 if self.produced.is_empty() {
1088 self.ui
1089 .warn(" no query makes a tab (every query has `tab: false`); no output file was written");
1090 return Ok(());
1091 }
1092 let out = &self.b.output;
1093 let plugin = find_plugin(self.project, PluginKind::Format, &out.format)?;
1094 let mut p = plugin
1095 .start(self.ui.plugin_log(), Some(&self.project.root))
1096 .map_err(|e| e.to_string())?;
1097 let multi = out.format == "xlsx" || out.template.is_some() || !is_single_table(&out.format);
1098 let groups: Vec<(String, Vec<usize>)> = if multi || self.produced.len() == 1 {
1099 vec![(filename.to_string(), (0..self.produced.len()).collect())]
1100 } else {
1101 let (stem, ext) = split_ext(filename);
1102 let mut g = Vec::new();
1103 for (i, r) in self.produced.iter().enumerate() {
1104 if r.name.contains(['/', '\\']) {
1105 return Err(format!("`{}` can't be used in a file name", r.name));
1106 }
1107 g.push((format!("{stem}_{}{ext}", r.name), vec![i]));
1108 }
1109 g
1110 };
1111 let template = match &out.template {
1112 Some(t) => Some(self.template_payload(t)?),
1113 None => None,
1114 };
1115 for (name, idxs) in groups {
1116 let t = Instant::now();
1117 let path = self.run_dir.join(&name);
1118 let metas: Vec<ResultSetMeta> = idxs
1119 .iter()
1120 .map(|&i| {
1121 let r = &self.produced[i];
1122 ResultSetMeta {
1123 name: r.name.clone(),
1124 query: r.query.clone(),
1125 result_index: 1,
1126 anchor: r.anchor.clone(),
1127 header: r.header,
1128 columns: r.columns.clone(),
1129 }
1130 })
1131 .collect();
1132 p.write_begin(
1133 &path.to_string_lossy(),
1134 &out.format,
1135 out.options.clone(),
1136 metas,
1137 template.clone(),
1138 )
1139 .map_err(|e| e.to_string())?;
1140 for &i in &idxs {
1141 let r = &self.produced[i];
1142 let reader = FileReader::try_new(File::open(&r.spool).map_err(|e| e.to_string())?, None)
1143 .map_err(|e| e.to_string())?;
1144 let schema = r.schema.clone();
1145 let mut any = false;
1147 for b in reader {
1148 let b = b.map_err(|e| e.to_string())?;
1149 any = true;
1150 p.send_batch(&b)
1151 .map_err(|e| format!("{} format: {e}", out.format))?;
1152 }
1153 if !any {
1154 p.send_batch(&RecordBatch::new_empty(schema))
1155 .map_err(|e| format!("{} format: {e}", out.format))?;
1156 }
1157 p.send(&dre_protocol::msg::Request::ResultSetEnd {})
1158 .map_err(|e| format!("{} format: {e}", out.format))?;
1159 }
1160 let (files, warnings) = p
1161 .write_finish_with_warnings()
1162 .map_err(|e| format!("{} format: {e}", out.format))?;
1163 for w in warnings {
1164 self.ui.warn(&format!(" {} format: {w}", out.format));
1165 }
1166 for f in files {
1167 let f = PathBuf::from(f);
1168 let size = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0);
1169 let shown = rel(&self.project.root, &f);
1170 self.ui.step(
1171 Level::Debug,
1172 "Wrote",
1173 &format!("{} ({})", shown.display(), human_bytes(size)),
1174 Some(t.elapsed()),
1175 );
1176 self.files.push((f, None));
1177 }
1178 }
1179 let _ = p.close();
1180 Ok(())
1181 }
1182
1183 fn template_payload(&self, t: &crate::project::Template) -> Result<Json, Fail> {
1184 let root = &self.project.root;
1185 let file = [root.join(&t.file), root.join("templates").join(&t.file)]
1186 .into_iter()
1187 .find(|p| p.is_file())
1188 .ok_or_else(|| format!("template file `{}` doesn't exist", t.file))?;
1189 let renderer = Renderer::new(RendererConfig {
1190 root,
1191 macros: &self.project.macros,
1192 context: RunContext {
1193 report: self.report.name.clone(),
1194 set: self.b.set.clone(),
1195 target: self.target.clone(),
1196 profile: self.b.profile.clone().unwrap_or_default(),
1197 source_type: self.source_type.clone(),
1198 schedule: self.opts.schedule.clone(),
1199 date: self.date,
1200 now: self.opts.scheduled_at.unwrap_or(self.started_at),
1201 scheduled_at: self.opts.scheduled_at,
1202 calendar: self.calendar,
1203 },
1204 vars: self.vars.clone(),
1205 cli_vars: self.opts.vars.clone(),
1206 runner: None,
1207 connections: Some(self.connections(self.b.profile.as_deref())),
1208 run_query_max_rows: self.project.run_query_max_rows,
1209 sql: self.project.sql.clone(),
1210 lookups: self.project.lookups.clone(),
1211 lookup_inline_max_rows: self.project.lookup_inline_max_rows,
1212 packages: self.project.packages.clone(),
1213 project_name: self.project.name.clone(),
1214 dispatch: self.project.dispatch.clone(),
1215 })
1216 .map_err(|e| e.to_string())?;
1217 let mut values = JsonMap::new();
1218 for b in &t.bindings {
1219 if let (Some(cell), Some(v)) = (&b.cell, &b.value) {
1220 let rendered = renderer
1221 .render(&self.report.file, v)
1222 .map_err(|e| format!("template value: {e}"))?;
1223 values.insert(format!("{}!{}", b.sheet, cell), Json::String(rendered));
1224 }
1225 }
1226 Ok(json!({"file": file.to_string_lossy(), "bindings": t.bindings, "values": values}))
1227 }
1228
1229 fn deliver(&mut self) -> Result<(), Fail> {
1232 if self.dests.is_empty() {
1233 self.delivery_note = Some("no destination declared; output stays in target/".into());
1234 let dir = rel(&self.project.root, &self.run_dir);
1235 self.ui.step(
1236 Level::Debug,
1237 "Kept",
1238 &format!("no destination declared: output stays in {}", dir.display()),
1239 None,
1240 );
1241 return Ok(());
1242 }
1243 if self.files.is_empty() {
1244 return Ok(());
1245 }
1246 let dests = std::mem::take(&mut self.dests);
1247 let mut failures = Vec::new();
1248 let mut skipped = Vec::new();
1249 for d in &dests {
1250 let kind = self
1252 .dest_output(&d.profile)
1253 .map(|(_, o)| o.kind.clone())
1254 .or_else(|| {
1255 let p = self.project.profiles.get(Role::Destination, &d.profile)?;
1256 p.targets
1257 .get(&p.target)
1258 .or(p.targets.values().next())
1259 .map(|o| o.kind.clone())
1260 });
1261 let (status, location, error) = match self.deliver_one(d) {
1262 Ok(Some(loc)) => ("delivered", Some(loc), None),
1263 Ok(None) => {
1264 skipped.push(self.skip_note(&d.profile));
1265 ("skipped", None, None)
1266 }
1267 Err(e) => {
1268 failures.push(e.clone());
1269 ("failed", None, Some(e))
1270 }
1271 };
1272 let mut record = json!({"profile": d.profile, "type": kind, "status": status});
1273 if let Some(l) = location {
1274 record["location"] = json!(l);
1275 }
1276 if let Some(e) = error {
1277 record["error"] = json!(e);
1278 }
1279 self.deliveries.push(record);
1280 }
1281 self.dests = dests;
1282 if self.files.iter().all(|(_, d)| d.is_none()) && !skipped.is_empty() {
1283 self.delivery_note = Some(skipped.join("; "));
1284 }
1285 match failures.len() {
1286 0 => Ok(()),
1287 1 if self.dests.len() == 1 => Err(failures.remove(0)),
1288 n => Err(format!(
1289 "{n} of {} destinations failed: {}",
1290 self.dests.len(),
1291 failures.join("; ")
1292 )),
1293 }
1294 }
1295
1296 fn dest_output(&self, profile: &str) -> Option<(String, &ProfileTarget)> {
1298 if self.project.profiles.is_builtin_local(profile) {
1299 return Some((self.target.clone(), &BUILTIN_LOCAL));
1300 }
1301 let dtarget = self.dest_target(profile)?;
1302 self.project
1303 .profiles
1304 .target(Role::Destination, profile, Some(&dtarget))
1305 .map(|(_, o)| (dtarget, o))
1306 }
1307
1308 fn dest_target(&self, profile: &str) -> Option<String> {
1310 let p = self.project.profiles.get(Role::Destination, profile)?;
1311 Some(self.opts.target.clone().unwrap_or_else(|| p.target.clone()))
1312 }
1313
1314 fn skip_note(&self, profile: &str) -> String {
1315 let dtarget = self.dest_target(profile).unwrap_or_default();
1316 format!(
1317 "destination profile `{profile}` has no `{dtarget}` target: not delivered, output stays in target/"
1318 )
1319 }
1320
1321 fn deliver_one(&mut self, d: &RenderedDest) -> Result<Option<String>, Fail> {
1324 if self.project.profiles.get(Role::Destination, &d.profile).is_none()
1325 && !self.project.profiles.is_builtin_local(&d.profile)
1326 {
1327 return Err(format!(
1328 "destination profile `{}` isn't in profiles.yml",
1329 d.profile
1330 ));
1331 }
1332 let Some((_, out)) = self.dest_output(&d.profile) else {
1333 let note = self.skip_note(&d.profile);
1334 self.ui.step(Level::Info, "Kept", ¬e, None);
1335 return Ok(None);
1336 };
1337 let kind = out.kind.clone();
1338 let connection = render_connection(out)?;
1339 let targets: Vec<DeliveryFile> = self
1340 .files
1341 .iter()
1342 .map(|(f, _)| {
1343 let name = f.file_name().unwrap().to_string_lossy().to_string();
1344 let r = d.path.as_deref().map(|r| {
1345 if self.files.len() == 1 {
1346 r.to_string()
1347 } else {
1348 match r.rfind(['/', '\\']) {
1349 Some(i) => format!("{}{name}", &r[..=i]),
1350 None => name.clone(),
1351 }
1352 }
1353 });
1354 DeliveryFile {
1355 local_path: f.to_string_lossy().to_string(),
1356 remote_path: r,
1357 }
1358 })
1359 .collect();
1360 let mut locations = Vec::new();
1361 if kind == LOCAL_TYPE {
1362 if let Some(k) = d.options.keys().next() {
1363 return Err(format!(
1364 "the local destination takes no options, but `{}` has `{k}`; check the key's spelling",
1365 d.profile
1366 ));
1367 }
1368 for (i, f) in targets.iter().enumerate() {
1369 let t = Instant::now();
1370 let r = f
1371 .remote_path
1372 .as_ref()
1373 .ok_or("the local destination needs `output.destination.path`")?;
1374 let dst = self.project.root.join(r);
1375 if let Some(parent) = dst.parent() {
1376 std::fs::create_dir_all(parent)
1377 .map_err(|e| format!("can't create {}: {e}", parent.display()))?;
1378 }
1379 std::fs::copy(&f.local_path, &dst).map_err(|e| {
1380 format!(
1381 "delivery to {} failed: {e}; the output is still in target/",
1382 dst.display()
1383 )
1384 })?;
1385 let loc = dst.to_string_lossy().to_string();
1386 self.ui.step(Level::Debug, "Delivered", &loc, Some(t.elapsed()));
1387 self.files[i].1.get_or_insert_with(|| loc.clone());
1388 locations.push(loc);
1389 }
1390 return Ok(Some(locations.join(", ")));
1391 }
1392 let failed = |e: dre_protocol::host::HostError| {
1393 format!("delivery through `{kind}` failed: {e}; the output is still in target/")
1394 };
1395 let plugin = find_plugin(self.project, PluginKind::Destination, &kind)?;
1396 let mut p = plugin
1397 .start(self.ui.plugin_log(), Some(&self.project.root))
1398 .map_err(|e| e.to_string())?;
1399 let batches: Vec<Vec<usize>> = if targets.len() > 1 && p.has(CAP_MULTI_FILE) {
1400 vec![(0..targets.len()).collect()]
1401 } else {
1402 (0..targets.len()).map(|i| vec![i]).collect()
1403 };
1404 for batch in batches {
1405 let t = Instant::now();
1406 let files: Vec<DeliveryFile> = batch.iter().map(|&i| targets[i].clone()).collect();
1407 let loc = p
1408 .deliver_files(&files, connection.clone(), d.options.clone())
1409 .map_err(failed)?;
1410 self.ui.step(Level::Debug, "Delivered", &loc, Some(t.elapsed()));
1411 for i in batch {
1412 self.files[i].1.get_or_insert_with(|| loc.clone());
1413 }
1414 locations.push(loc);
1415 }
1416 let _ = p.close();
1417 Ok(Some(locations.join(", ")))
1418 }
1419
1420 fn snapshot(&self) -> Json {
1421 let sets: Vec<Json> = self
1422 .produced
1423 .iter()
1424 .map(|p| {
1425 json!({
1426 "name": p.name,
1427 "columns": p.schema.fields().iter().map(|f| json!({"name": f.name(), "type": f.data_type().to_string()})).collect::<Vec<_>>(),
1428 })
1429 })
1430 .collect();
1431 json!({ "result_sets": sets })
1432 }
1433
1434 fn schema_drift(&self) -> Vec<String> {
1435 let path = self.schema_dir.join("last_success.json");
1436 let Ok(prev) = std::fs::read_to_string(&path) else {
1437 return Vec::new();
1438 };
1439 let Ok(prev) = serde_json::from_str::<Json>(&prev) else {
1440 return Vec::new();
1441 };
1442 let cols = |v: &Json| -> BTreeMap<String, Vec<(String, String)>> {
1443 v["result_sets"]
1444 .as_array()
1445 .into_iter()
1446 .flatten()
1447 .map(|s| {
1448 let c = s["columns"]
1449 .as_array()
1450 .into_iter()
1451 .flatten()
1452 .map(|c| {
1453 (
1454 c["name"].as_str().unwrap_or("").to_string(),
1455 c["type"].as_str().unwrap_or("").to_string(),
1456 )
1457 })
1458 .collect();
1459 (s["name"].as_str().unwrap_or("").to_string(), c)
1460 })
1461 .collect()
1462 };
1463 let (old, new) = (cols(&prev), cols(&self.snapshot()));
1464 let mut drift = Vec::new();
1465 for (name, oc) in &old {
1466 let Some(nc) = new.get(name) else {
1467 drift.push(format!("result set `{name}` is gone"));
1468 continue;
1469 };
1470 let om: BTreeMap<_, _> = oc.iter().cloned().collect();
1471 let nm: BTreeMap<_, _> = nc.iter().cloned().collect();
1472 for (c, t) in &om {
1473 match nm.get(c) {
1474 None => drift.push(format!("`{name}`: column `{c}` removed")),
1475 Some(t2) if t2 != t => {
1476 drift.push(format!("`{name}`: column `{c}` changed type from {t} to {t2}"))
1477 }
1478 _ => {}
1479 }
1480 }
1481 for c in nm.keys().filter(|c| !om.contains_key(*c)) {
1482 drift.push(format!("`{name}`: column `{c}` added"));
1483 }
1484 }
1485 for name in new.keys().filter(|n| !old.contains_key(*n)) {
1486 drift.push(format!("result set `{name}` is new"));
1487 }
1488 drift
1489 }
1490
1491 fn write_snapshot(&self) -> std::io::Result<()> {
1492 std::fs::create_dir_all(&self.schema_dir)?;
1493 std::fs::write(
1494 self.schema_dir.join("last_success.json"),
1495 serde_json::to_string_pretty(&self.snapshot())?,
1496 )
1497 }
1498
1499 fn write_results(&self, status: &Status, error: Option<&str>) -> std::io::Result<()> {
1500 std::fs::create_dir_all(&self.run_dir)?;
1501 let outputs: Vec<Json> = self
1502 .files
1503 .iter()
1504 .map(|(f, delivered)| {
1505 json!({
1506 "path": record_path(self.project, f),
1507 "size": std::fs::metadata(f).map(|m| m.len()).unwrap_or(0),
1508 "delivered_to": delivered,
1509 })
1510 })
1511 .collect();
1512 let results = json!({
1513 "report": self.report.name,
1514 "set": self.b.set,
1515 "binding": self.b.dir_name(),
1516 "managed": self.report.managed,
1517 "profile": self.b.profile,
1518 "target": if self.target.is_empty() { Json::Null } else { json!(self.target) },
1519 "schedule": self.opts.schedule,
1520 "schedule_vars": self.schedule_vars,
1521 "vars": self.rendered_vars,
1522 "run_date": self.date.to_string(),
1523 "scheduled_at": self.opts.scheduled_at.map(rfc3339),
1524 "timezone": self.calendar.tz.name(),
1525 "params": self.opts.params(self.date),
1526 "status": status,
1527 "error": error,
1528 "preview": self.opts.preview.is_some(),
1529 "row_limit": self.opts.preview,
1530 "started_at": self.started_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1531 "duration_ms": self.started.elapsed().as_millis() as u64,
1532 "result_sets": self.produced.iter().map(|p| json!({
1533 "name": p.name,
1534 "query": p.query,
1535 "rows": p.rows,
1536 "columns": p.schema.fields().iter().map(|f| f.name().clone()).collect::<Vec<_>>(),
1537 })).collect::<Vec<_>>(),
1538 "outputs": outputs,
1539 "delivery": self.delivery_note,
1540 "deliveries": self.deliveries,
1541 "schema_drift": self.drift,
1542 "target_path": self.project.target_dir,
1543 "manifest_checksum": self.opts.manifest_checksum,
1544 });
1545 std::fs::write(
1546 self.run_dir.join("run_results.json"),
1547 (crate::secrets::to_json_pretty(&results)? + "\n").as_bytes(),
1548 )
1549 }
1550
1551 fn live_check(
1553 &mut self,
1554 session: &Arc<Mutex<Session>>,
1555 statements: &[Statement],
1556 kind: &str,
1557 ) -> Result<(), Fail> {
1558 let mut s = session.lock().unwrap();
1559 let p = s.get()?;
1560 if !p.has(dre_protocol::CAP_CHECK) {
1561 self.ui.warn(&format!(
1562 " not checkable: the `{kind}` source plugin can't check statements without running them"
1563 ));
1564 return Ok(());
1565 }
1566 let mut failures = Vec::new();
1567 let mut unexecuted_setup: Option<(PathBuf, usize)> = None;
1568 let sql_log = self.ui.sql_log();
1569 for st in statements {
1570 let verb = if st.kind == StatementKind::TempCreate {
1571 ""
1572 } else {
1573 " (check)"
1574 };
1575 sql_log(&format!("{}:{}{verb}", st.file.display(), st.line), &st.text);
1576 if st.kind == StatementKind::TempCreate {
1577 if let Err(e) = p.execute(&st.text, None, |_, _| Ok(())) {
1578 failures.push(format!("{}:{}: {e}", st.file.display(), st.line));
1579 }
1580 continue;
1581 }
1582 match p.check(&st.text) {
1583 Ok(()) => {}
1584 Err(e) => {
1585 let mut msg = format!("{}:{}: {e}", st.file.display(), st.line);
1586 if let Some((f, l)) = &unexecuted_setup {
1587 msg.push_str(&format!(
1588 " (may be a false positive: the setup statement at {}:{l} wasn't executed during the check)",
1589 f.display()
1590 ));
1591 }
1592 failures.push(msg);
1593 }
1594 }
1595 if st.kind == StatementKind::Other {
1596 unexecuted_setup = Some((st.file.clone(), st.line));
1597 }
1598 }
1599 if failures.is_empty() {
1600 Ok(())
1601 } else {
1602 Err(failures.join("\n "))
1603 }
1604 }
1605}
1606
1607struct Session {
1609 path: Result<crate::plugins::Located, String>,
1610 connection: Result<JsonMap<String, Json>, String>,
1611 cwd: PathBuf,
1612 log: LogSink,
1613 unmanaged: bool,
1615 read_only: bool,
1616 loaded: bool,
1618 proc_: Option<PluginProcess>,
1619}
1620
1621impl Session {
1622 fn new(
1623 path: Result<crate::plugins::Located, String>,
1624 connection: Result<JsonMap<String, Json>, String>,
1625 cwd: PathBuf,
1626 log: LogSink,
1627 unmanaged: bool,
1628 ) -> Session {
1629 Session {
1630 path,
1631 connection,
1632 cwd,
1633 log,
1634 unmanaged,
1635 read_only: unmanaged,
1636 loaded: false,
1637 proc_: None,
1638 }
1639 }
1640
1641 fn want_read_only(&mut self, ro: bool) {
1642 if self.read_only != ro {
1643 self.close();
1645 self.read_only = ro;
1646 }
1647 }
1648
1649 fn get(&mut self) -> Result<&mut PluginProcess, String> {
1650 if self.proc_.is_none() {
1651 let plugin = self.path.clone()?;
1652 let connection = self.connection.clone()?;
1653 let mut p = plugin
1654 .start(self.log.clone(), Some(&self.cwd))
1655 .map_err(|e| e.to_string())?;
1656 let ro = self.unmanaged && self.read_only && p.has(CAP_READ_ONLY);
1657 p.open(connection, ro)
1658 .map_err(|e| format!("can't open the source connection: {e}"))?;
1659 self.proc_ = Some(p);
1660 }
1661 Ok(self.proc_.as_mut().unwrap())
1662 }
1663
1664 fn close(&mut self) {
1665 if let Some(p) = self.proc_.take() {
1666 let _ = p.close();
1667 }
1668 }
1669}
1670
1671struct SessionRunner(Arc<Mutex<Session>>, LogSink);
1672
1673impl QueryRunner for SessionRunner {
1674 fn run_query(&self, sql: &str, max_rows: u64) -> Result<QueryRows, String> {
1675 let mut s = self.0.lock().map_err(|_| "session lock poisoned".to_string())?;
1676 if s.unmanaged
1679 && let Some(bad) = sqlsplit::split(sql)
1680 .into_iter()
1681 .find(|st| sqlsplit::classify(&st.text) != StatementKind::Read)
1682 {
1683 return Err(format!(
1684 "run_query() in an unmanaged report may only read, but got `{}`; give the report a YAML to declare it",
1685 dre_protocol::util::summarize(&bad.text, 60)
1686 ));
1687 }
1688 (self.1)("run_query()", sql);
1689 let p = s.get()?;
1690 let mut out = QueryRows::default();
1691 let mut too_many = false;
1692 let exec = p
1693 .execute(sql, Some(max_rows + 1), |schema, batch| {
1694 if out.columns.is_empty() {
1695 out.columns = schema.fields().iter().map(|f| f.name().clone()).collect();
1696 }
1697 if out.rows.len() as u64 + batch.num_rows() as u64 > max_rows {
1698 too_many = true;
1699 return Ok(());
1700 }
1701 out.rows.extend(crate::values::batch_rows(&batch));
1702 Ok(())
1703 })
1704 .map_err(|e| format!("run_query() failed: {e}"))?;
1705 if too_many {
1706 return Err(format!(
1707 "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"
1708 ));
1709 }
1710 if let Execution::Result { schema, .. } = exec
1711 && out.columns.is_empty()
1712 {
1713 out.columns = schema.fields().iter().map(|f| f.name().clone()).collect();
1714 }
1715 Ok(out)
1716 }
1717
1718 fn columns(&self, sql: &str) -> Result<Vec<Column>, String> {
1719 let mut s = self.0.lock().map_err(|_| "session lock poisoned".to_string())?;
1720 (self.1)("columns()", sql);
1721 let p = s.get()?;
1722 let mut schema: Option<SchemaRef> = None;
1723 let exec = p
1724 .execute(sql, Some(1), |sch, _| {
1725 schema.get_or_insert_with(|| sch.clone());
1726 Ok(())
1727 })
1728 .map_err(|e| e.to_string())?;
1729 if let Execution::Result { schema: sch, .. } = exec {
1730 schema.get_or_insert(sch);
1731 }
1732 let schema = schema.ok_or("the query returned no result set")?;
1733 Ok(schema
1734 .fields()
1735 .iter()
1736 .map(|f| Column {
1737 name: f.name().clone(),
1738 data_type: f.data_type().to_string(),
1739 })
1740 .collect())
1741 }
1742
1743 fn load(&self, name: &str, table: &Table) -> Result<Option<(String, Option<String>)>, String> {
1744 let mut s = self.0.lock().map_err(|_| "session lock poisoned".to_string())?;
1745 s.want_read_only(false);
1747 if !s.get()?.has(CAP_LOAD) {
1748 return Ok(None);
1749 }
1750 let batch = table.to_batch()?;
1751 let schema = batch.schema();
1752 (self.1)(
1753 &format!("lookup `{name}`"),
1754 &format!(
1755 "-- {} rows loaded through the plugin's `load` request",
1756 batch.num_rows()
1757 ),
1758 );
1759 let r = s.get()?.load(name, &schema, [batch]).map_err(|e| e.to_string())?;
1760 s.loaded = true;
1761 Ok(Some(r))
1762 }
1763}
1764
1765fn render_json(renderer: &Renderer, file: &Path, v: &Json) -> Result<Json, RenderError> {
1767 Ok(match v {
1768 Json::String(s) => Json::String(renderer.render(file, s)?),
1769 Json::Array(a) => Json::Array(
1770 a.iter()
1771 .map(|v| render_json(renderer, file, v))
1772 .collect::<Result<_, _>>()?,
1773 ),
1774 Json::Object(o) => Json::Object(
1775 o.iter()
1776 .map(|(k, v)| Ok((k.clone(), render_json(renderer, file, v)?)))
1777 .collect::<Result<_, RenderError>>()?,
1778 ),
1779 other => other.clone(),
1780 })
1781}
1782
1783fn render_connection(output: &ProfileTarget) -> Result<JsonMap<String, Json>, String> {
1785 let mut env = minijinja::Environment::new();
1786 env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
1787 env.add_function("env_var", |name: String, default: Option<String>| -> Result<String, minijinja::Error> {
1788 std::env::var(&name).ok().or(default).ok_or_else(|| {
1789 minijinja::Error::new(
1790 minijinja::ErrorKind::UndefinedError,
1791 format!("`env_var('{name}')`: environment variable `{name}` is not set and no default is given"),
1792 )
1793 })
1794 });
1795 fn walk(env: &minijinja::Environment<'_>, v: &Json) -> Result<Json, String> {
1796 Ok(match v {
1797 Json::String(s) if crate::preflight::is_templated(s) => Json::String(
1798 env.render_str(s, ())
1799 .map_err(|e| format!("profiles.yml: {}", e.detail().unwrap_or("render error")))?,
1800 ),
1801 Json::Array(a) => Json::Array(a.iter().map(|x| walk(env, x)).collect::<Result<_, _>>()?),
1802 Json::Object(o) => Json::Object(
1803 o.iter()
1804 .map(|(k, x)| Ok((k.clone(), walk(env, x)?)))
1805 .collect::<Result<_, String>>()?,
1806 ),
1807 other => other.clone(),
1808 })
1809 }
1810 match walk(&env, &Json::Object(output.fields.clone()))? {
1811 Json::Object(o) => Ok(o),
1812 _ => unreachable!(),
1813 }
1814}
1815
1816pub fn find_plugin(
1819 project: &Project,
1820 kind: PluginKind,
1821 name: &str,
1822) -> Result<crate::plugins::Located, crate::plugins::LocateError> {
1823 crate::plugins::locate(project, &crate::project::PluginId::new(kind, name))
1824}
1825
1826struct ProfileConnections {
1830 profiles: Profiles,
1831 source: Option<String>,
1833 target: Option<String>,
1835 root: PathBuf,
1836 plugins: Vec<crate::project::PluginRequirement>,
1837 log: LogSink,
1838}
1839
1840static DESCRIBED: std::sync::LazyLock<Mutex<BTreeMap<DescribeKey, SecretFields>>> =
1843 std::sync::LazyLock::new(Mutex::default);
1844
1845type DescribeKey = (PathBuf, Role, String);
1847
1848type SecretFields = Option<Vec<String>>;
1849
1850const SECRET_NAME_WORDS: &[&str] = &["password", "secret", "token", "key", "credential"];
1851
1852impl ProfileConnections {
1853 fn view(&self, role: Role, name: &str) -> Result<Connection, String> {
1854 let (target, out): (String, ProfileTarget) = match self.profiles.get(role, name) {
1855 Some(p) => {
1856 let t = self
1857 .target
1858 .as_ref()
1859 .filter(|t| p.targets.contains_key(t.as_str()))
1860 .unwrap_or(&p.target);
1861 let o = p
1862 .targets
1863 .get(t)
1864 .ok_or_else(|| format!("profile `{name}` has no `{t}` target"))?;
1865 (t.clone(), o.clone())
1866 }
1867 None if role == Role::Destination && self.profiles.is_builtin_local(name) => {
1868 ("default".into(), BUILTIN_LOCAL.clone())
1869 }
1870 None => {
1871 return Err(format!(
1872 "no {} profile `{name}` in {}",
1873 role.as_str(),
1874 self.profiles.path.display()
1875 ));
1876 }
1877 };
1878 let mut secrets: Vec<String> = out
1879 .fields
1880 .iter()
1881 .filter(|(_, v)| v.as_str().is_some_and(|s| s.contains(crate::secrets::PREFIX)))
1882 .map(|(k, _)| k.clone())
1883 .collect();
1884 match self.described(role, &out.kind) {
1885 Some(named) => secrets.extend(named),
1886 None => secrets.extend(
1887 out.fields
1888 .keys()
1889 .filter(|k| {
1890 let k = k.to_lowercase();
1891 SECRET_NAME_WORDS.iter().any(|w| k.contains(w))
1892 })
1893 .cloned(),
1894 ),
1895 }
1896 let fields = render_connection(&out)?;
1897 Ok(Connection {
1898 profile: name.to_string(),
1899 target,
1900 kind: out.kind.clone(),
1901 fields,
1902 secrets,
1903 })
1904 }
1905
1906 fn described(&self, role: Role, kind: &str) -> Option<Vec<String>> {
1908 let key = (self.root.clone(), role, kind.to_string());
1909 if let Some(v) = DESCRIBED.lock().unwrap().get(&key) {
1910 return v.clone();
1911 }
1912 let plugin_kind = match role {
1913 Role::Source => PluginKind::Source,
1914 Role::Destination => PluginKind::Destination,
1915 };
1916 let asked = (|| {
1917 if kind == LOCAL_TYPE {
1918 return Some(Vec::new());
1919 }
1920 let id = crate::project::PluginId::new(plugin_kind, kind);
1921 let plugin = crate::plugins::locate_in(&self.root, &self.plugins, &id).ok()?;
1922 let mut p = plugin.start(self.log.clone(), Some(&self.root)).ok()?;
1923 let fields = p.describe().ok();
1924 let _ = p.close();
1925 Some(fields?.into_iter().filter(|f| f.secret).map(|f| f.name).collect())
1926 })();
1927 DESCRIBED.lock().unwrap().insert(key, asked.clone());
1928 asked
1929 }
1930}
1931
1932impl Connections for ProfileConnections {
1933 fn source(&self) -> Result<Connection, String> {
1934 let name = self
1935 .source
1936 .as_deref()
1937 .ok_or("no source profile resolves for this Binding")?;
1938 self.view(Role::Source, name)
1939 }
1940
1941 fn profile(&self, name: &str, role: Option<&str>) -> Result<Connection, String> {
1942 let role = match role {
1943 Some("source") => Role::Source,
1944 Some(_) => Role::Destination,
1945 None => {
1946 let s = self.profiles.get(Role::Source, name).is_some();
1947 let d = self.profiles.get(Role::Destination, name).is_some();
1948 match (s, d) {
1949 (true, true) => {
1950 return Err(format!(
1951 "`{name}` is both a source and a destination profile; say which with `profile('{name}', role='source')` or `role='destination'`"
1952 ));
1953 }
1954 (true, false) => Role::Source,
1955 _ => Role::Destination,
1956 }
1957 }
1958 };
1959 self.view(role, name)
1960 }
1961}
1962
1963fn check_sheet_name(n: &str) -> Result<(), String> {
1964 if n.is_empty() || n.chars().count() > 31 {
1965 return Err(format!("sheet name `{n}` must be 1 to 31 characters long"));
1966 }
1967 if let Some(c) = n.chars().find(|c| "[]:*?/\\".contains(*c)) {
1968 return Err(format!(
1969 "sheet name `{n}` contains `{c}`, which Excel doesn't allow in sheet names"
1970 ));
1971 }
1972 if n.starts_with('\'') || n.ends_with('\'') {
1973 return Err(format!("sheet name `{n}` can't start or end with an apostrophe"));
1974 }
1975 if n.eq_ignore_ascii_case("history") {
1976 return Err("`History` is reserved by Excel and can't be a sheet name".into());
1977 }
1978 Ok(())
1979}
1980
1981fn is_single_table(format: &str) -> bool {
1982 matches!(format, "csv" | "delimited" | "fixed_width" | "parquet")
1983}
1984
1985fn extension(format: &str) -> &str {
1986 match format {
1987 "delimited" | "fixed_width" => "txt",
1988 f => f,
1989 }
1990}
1991
1992fn split_ext(name: &str) -> (&str, &str) {
1993 match name.rfind('.') {
1994 Some(i) if i > 0 => (&name[..i], &name[i..]),
1995 _ => (name, ""),
1996 }
1997}
1998
1999fn rel(root: &Path, p: &Path) -> PathBuf {
2000 crate::slash(p.strip_prefix(root).unwrap_or(p))
2001}
2002
2003pub fn rfc3339(t: chrono::DateTime<chrono::Utc>) -> String {
2005 t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
2006}
2007
2008fn record_path(project: &Project, p: &Path) -> PathBuf {
2012 match p.strip_prefix(&project.root) {
2013 Ok(r) => crate::slash(r),
2014 Err(_) => rel(&project.target_dir, p),
2015 }
2016}
2017
2018impl BindingRun<'_> {
2019 fn summary_line(&self) -> String {
2021 let rows: u64 = self.produced.iter().map(|p| p.rows).sum();
2022 let mut parts = Vec::new();
2023 if !self.produced.is_empty() {
2024 let n = self.produced.len();
2025 parts.push(format!(
2026 "{n} result set{}, {} row{}",
2027 if n == 1 { "" } else { "s" },
2028 thousands(rows),
2029 if rows == 1 { "" } else { "s" }
2030 ));
2031 }
2032 let names: Vec<String> = self
2033 .files
2034 .iter()
2035 .map(|(f, _)| f.file_name().unwrap_or_default().to_string_lossy().to_string())
2036 .collect();
2037 if !names.is_empty() {
2038 parts.push(format!("→ {}", names.join(", ")));
2039 }
2040 let delivered: Vec<&str> = self
2042 .deliveries
2043 .iter()
2044 .filter_map(|d| d.get("location").and_then(Json::as_str))
2045 .collect();
2046 if !delivered.is_empty() {
2047 parts.push(format!("→ {}", delivered.join(", ")));
2048 } else if let Some(n) = &self.delivery_note
2049 && self.opts.preview.is_none()
2050 && !self.b.output.destinations.is_empty()
2051 {
2052 parts.push(format!("({n})"));
2053 }
2054 parts.join(" ")
2055 }
2056}
2057
2058pub fn thousands(n: u64) -> String {
2060 let s = n.to_string();
2061 let mut out = String::new();
2062 for (i, c) in s.chars().enumerate() {
2063 if i > 0 && (s.len() - i).is_multiple_of(3) {
2064 out.push(',');
2065 }
2066 out.push(c);
2067 }
2068 out
2069}
2070
2071fn human_bytes(n: u64) -> String {
2072 match n {
2073 n if n < 1024 => format!("{n} B"),
2074 n if n < 1024 * 1024 => format!("{:.1} KB", n as f64 / 1024.0),
2075 n => format!("{:.1} MB", n as f64 / (1024.0 * 1024.0)),
2076 }
2077}
2078
2079pub fn clean(t: &Path) -> std::io::Result<bool> {
2081 if t.exists() {
2082 std::fs::remove_dir_all(t)?;
2083 Ok(true)
2084 } else {
2085 Ok(false)
2086 }
2087}