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