1use std::collections::{BTreeMap, BTreeSet};
8use std::path::{Path, PathBuf};
9use std::rc::Rc;
10
11use serde::Serialize;
12use serde_json::Value;
13use serde_json::{Map as JsonMap, Value as Json};
14
15type Mapping = JsonMap<String, Json>;
17
18use crate::codes::Code;
19use crate::dates::{WeekNumbering, WeekStart};
20use crate::diag::Diagnostics;
21use crate::lookups::{self, DEFAULT_INLINE_MAX_ROWS, LOOKUPS_DIR, Lookup};
22use crate::packages::{self, DispatchOrder, Package};
23
24const RESERVED_NAMES: &[&str] = &[
26 "run",
27 "var",
28 "env_var",
29 "run_query",
30 "columns",
31 "ref",
32 "lookup",
33 "dispatch",
34 "target",
35 "profile",
36 "date",
37 "datetime",
38 "date_range",
39 "month_of",
40 "quarter_of",
41 "year_of",
42 "week_of",
43 "period",
44 "raise_error",
45 "source",
46 "connection",
47 "destination",
48];
49use crate::config;
50use crate::config::de::{self, Loose};
51use crate::config::project::ProjectFile;
52use crate::config::report::{Output as OutputConfig, QueryItem, ReportFile, SetEntry, SetItem};
53use crate::profiles::{LOCAL_TYPE, Profiles, Role};
54use crate::yaml::YamlFile;
55use crate::{constraints, options, preflight, schedule, selector, sqlsplit};
56
57pub const PROJECT_FILE: &str = "dre_project.yml";
58pub const REPORTS_DIR: &str = "reports";
59pub const MACROS_DIR: &str = "macros";
60pub const TARGET_DIR: &str = "target";
61pub const LOGS_DIR: &str = "logs";
62pub use crate::plugins::DEPS_DIR;
63pub const DEFAULT_RUN_QUERY_MAX_ROWS: u64 = 10_000;
64
65pub const REPORT_KEYS: &[&str] = &[
66 "name",
67 "description",
68 "meta",
69 "tags",
70 "queries",
71 "output",
72 "profile",
73 "sets",
74 "default_set",
75 "schedule",
76 "vars",
77 "var_schema",
78 "timezone",
79 "locale",
80];
81const PLUGINS_KEY: &str = "plugins";
83pub const PLUGIN_KEYS: &[&str] = &[PLUGINS_KEY];
84const OLD_PLUGIN_KEYS: &[&str] = &["destinations", "formats"];
87pub const SOURCES_KEY: &str = "sources";
89pub const PROJECT_KEYS: &[&str] = &[
90 "name",
91 "meta",
92 "default_profile",
93 "default_output",
94 "format_options",
95 "default_set",
96 "vars",
97 "run_query_max_rows",
98 "lookup_inline_max_rows",
99 "flags",
100 "dispatch",
101 "mask_secrets",
102 "mask_row_values",
103 "timezone",
104 "locale",
105 "week_start",
106 "week_numbering",
107 "reports",
108 SOURCES_KEY,
109 crate::target::KEY,
110];
111pub const SCHEDULE_ENTRY_KEYS: &[&str] = &[
113 "name", "select", "report", "set", "vars", "timezone", "enabled", "timing",
114];
115pub const TIMINGS_FILE: &str = "timings.yml";
117pub const FOLDER_CONFIG_KEYS: &[&str] = &[
118 "+meta",
119 "+tags",
120 "+output",
121 "+profile",
122 "+schedule",
123 "+vars",
124 "+timezone",
125 "+locale",
126];
127pub const SET_ENTRY_KEYS: &[&str] = &[
128 "name",
129 "profile",
130 "vars",
131 "exclude",
132 "queries",
133 "tab_names",
134 "output",
135 "schedule",
136 "locale",
137];
138pub const QUERY_ENTRY_KEYS: &[&str] = &[
139 "query",
140 "profile",
141 "tab",
142 "tab_name",
143 "anchor",
144 "header",
145 "columns",
146 "autofit",
147 "style",
148 "on_empty",
149 "secret_columns",
150];
151const FORMAT_INDEPENDENT_KEYS: &[&str] = &[
153 "name",
154 "queries",
155 "when",
156 "destination",
157 "template",
158 "on_empty",
159 "for_each",
160 "file_name",
161];
162pub const DESTINATION_KEYS: &[&str] = &["profile", "path", "attach", "hold", "rate", "burst", "zip"];
164pub const OUTPUT_SHARED_KEYS: &[&str] = &[
165 "name",
166 "format",
167 "queries",
168 "when",
169 "destination",
170 "template",
171 "extension",
172 "on_empty",
173 "for_each",
174 "file_name",
175];
176pub const TEMPLATE_BINDING_KEYS: &[&str] = &[
178 "sheet",
179 "query",
180 "result_index",
181 "anchor",
182 "header",
183 "columns",
184 "cell",
185 "value",
186 "column",
187];
188pub const PLUGIN_ENTRY_KEYS: &[&str] = &["name", "version", "github", "local", "registry"];
190
191#[derive(Debug, Clone, Serialize)]
196pub struct Project {
197 pub name: String,
198 #[serde(skip_serializing_if = "JsonMap::is_empty")]
199 pub meta: JsonMap<String, Json>,
200 #[serde(skip)]
201 pub root: PathBuf,
202 #[serde(skip)]
205 pub target_dir: PathBuf,
206 #[serde(skip)]
208 pub target_source: crate::target::Source,
209 #[serde(skip)]
212 pub target_name: String,
213 #[serde(skip)]
215 pub target_from: crate::profiles::TargetSource,
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub default_profile: Option<String>,
218 #[serde(skip)]
220 pub default_profile_line: Option<usize>,
221 #[serde(skip_serializing_if = "Option::is_none")]
222 pub default_set: Option<String>,
223 pub vars: JsonMap<String, Json>,
224 pub run_query_max_rows: u64,
225 #[serde(skip)]
227 pub keep_runs: usize,
228 #[serde(skip)]
230 pub run_timeout: Option<std::time::Duration>,
231 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
233 pub format_options: BTreeMap<String, JsonMap<String, Json>>,
234 pub reports: Vec<Report>,
235 pub sets: BTreeMap<String, SetDef>,
236 pub plugins: Vec<PluginRequirement>,
238 #[serde(skip)]
241 pub plugin_uses: Vec<PluginUse>,
242 #[serde(skip)]
244 pub plugins_incomplete: bool,
245 #[serde(skip_serializing_if = "Vec::is_empty")]
246 pub schedules: Vec<ScheduleEntry>,
247 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
249 pub timings: BTreeMap<String, Timing>,
250 pub macros: Vec<PathBuf>,
252 pub packages: Vec<Package>,
254 pub mask_secrets: bool,
256 pub mask_row_values: bool,
258 #[serde(skip_serializing_if = "Option::is_none")]
260 pub timezone: Option<String>,
261 #[serde(skip_serializing_if = "Option::is_none")]
263 pub locale: Option<String>,
264 #[serde(skip)]
266 pub week_start: WeekStart,
267 #[serde(skip)]
268 pub week_numbering: WeekNumbering,
269 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
271 pub dispatch: DispatchOrder,
272 #[serde(skip)]
274 pub sql: BTreeMap<String, PathBuf>,
275 #[serde(skip)]
277 pub lookups: BTreeMap<String, Lookup>,
278 pub lookup_inline_max_rows: u64,
280 #[serde(skip)]
282 pub folders: Vec<Vec<String>>,
283 #[serde(skip)]
287 pub files: Vec<PathBuf>,
288 #[serde(skip)]
291 pub parse_errors: BTreeMap<String, Vec<crate::Diagnostic>>,
292 #[serde(skip)]
294 pub inputs: crate::parse::Inputs,
295 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
297 pub sources: BTreeMap<String, SourceDef>,
298 #[serde(skip)]
299 pub profiles: Profiles,
300 #[serde(skip)]
302 pub settings: crate::settings::Settings,
303}
304
305impl Project {
306 pub fn report(&self, name: &str) -> Option<&Report> {
307 self.reports.iter().find(|r| r.name == name)
308 }
309
310 pub fn run_target(&self) -> crate::profiles::RunTarget {
312 crate::profiles::RunTarget {
313 name: self.target_name.clone(),
314 from: self.target_from,
315 }
316 }
317}
318
319#[derive(Debug, Clone, Serialize)]
320pub struct Report {
321 pub name: String,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub description: Option<String>,
324 #[serde(skip_serializing_if = "JsonMap::is_empty")]
325 pub meta: JsonMap<String, Json>,
326 #[serde(skip_serializing_if = "Vec::is_empty")]
327 pub meta_layers: Vec<MetaLayer>,
328 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
329 pub var_schema: crate::variables::Definitions,
330 pub managed: bool,
331 pub file: PathBuf,
333 pub folder: Vec<String>,
335 pub tags: Vec<String>,
336 pub queries: Vec<QueryEntry>,
337 #[serde(skip_serializing_if = "Option::is_none")]
338 pub default_set: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub timezone: Option<String>,
342 pub has_sets: bool,
344 pub bindings: Vec<Binding>,
345 #[serde(skip)]
347 pub base: Binding,
348}
349
350impl Report {
351 pub fn binding(&self, set: &str) -> Option<&Binding> {
352 self.bindings.iter().find(|b| b.set.as_deref() == Some(set))
353 }
354}
355
356#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
358pub struct MetaLayer {
359 pub file: String,
360 pub key: String,
361 pub meta: JsonMap<String, Json>,
362}
363
364#[derive(Debug, Clone, PartialEq, Serialize)]
365pub struct QueryEntry {
366 pub query: String,
367 pub path: PathBuf,
369 #[serde(skip_serializing_if = "Option::is_none")]
371 pub profile: Option<String>,
372 #[serde(skip_serializing_if = "is_true")]
376 pub tab: bool,
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub tab_name: Option<String>,
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub anchor: Option<String>,
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub header: Option<bool>,
383 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
385 pub columns: BTreeMap<String, ColumnOptions>,
386 #[serde(skip_serializing_if = "Option::is_none")]
388 pub autofit: Option<bool>,
389 #[serde(skip_serializing_if = "Option::is_none")]
391 pub style: Option<dre_protocol::style::SheetStyle>,
392 #[serde(skip_serializing_if = "Option::is_none")]
394 pub on_empty: Option<crate::no_data::OnEmpty>,
395 #[serde(skip_serializing_if = "Vec::is_empty")]
397 pub secret_columns: Vec<String>,
398}
399
400fn is_true(b: &bool) -> bool {
401 *b
402}
403
404fn one_tab_per_file(name: &str) -> String {
406 format!(
407 "`tab_name` of `{name}` is a list, but one .sql file makes one tab; put each tab's query in its own .sql file and list each in `queries:`"
408 )
409}
410
411#[derive(Debug, Clone, Default, Serialize)]
413pub struct Binding {
414 pub set: Option<String>,
416 #[serde(skip_serializing_if = "Option::is_none")]
419 pub profile: Option<String>,
420 pub vars: JsonMap<String, Json>,
422 pub queries: Vec<QueryEntry>,
423 #[serde(skip_serializing_if = "Option::is_none")]
425 pub locale: Option<String>,
426 pub outputs: Vec<Output>,
429 #[serde(skip_serializing_if = "Vec::is_empty")]
431 pub schedules: Vec<String>,
432 #[serde(skip)]
435 pub parsed: Option<std::sync::Arc<crate::parse::ParsedBinding>>,
436 #[serde(skip)]
438 pub profile_at: Option<ProfileAt>,
439}
440
441#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct ProfileAt {
444 pub file: PathBuf,
445 pub line: Option<usize>,
446 pub key: String,
448}
449
450impl Binding {
451 pub fn dir_name(&self) -> &str {
453 self.set.as_deref().unwrap_or("default")
454 }
455
456 pub fn destinations(&self) -> impl Iterator<Item = &Destination> {
458 self.outputs.iter().flat_map(|o| o.destinations.iter())
459 }
460
461 pub fn output(&self, name: &str) -> Option<&Output> {
463 self.outputs.iter().find(|o| o.name.as_deref() == Some(name))
464 }
465}
466
467pub fn mentions_each(src: &str) -> bool {
469 let re = regex::Regex::new(r"\beach\s*(\.|\[)").expect("valid");
470 re.is_match(src)
471}
472
473fn output_feeds(queries: &Option<Loose<Vec<String>>>, query: &str) -> bool {
475 match queries {
476 Some(Loose::Ok(q)) => q.iter().any(|n| n == query),
477 _ => true,
478 }
479}
480
481pub const TEMPLATE_FORMATS: &[&str] = &["xlsx", "html", "pdf"];
483
484pub const MESSAGE_FORMAT: &str = "message";
486
487#[derive(Debug, Clone, Default, PartialEq, Serialize)]
488pub struct Output {
489 #[serde(skip_serializing_if = "Option::is_none")]
491 pub name: Option<String>,
492 pub format: String,
493 #[serde(skip_serializing_if = "Option::is_none")]
495 pub queries: Option<Vec<String>>,
496 #[serde(skip_serializing_if = "Option::is_none")]
498 pub when: Option<String>,
499 pub options: JsonMap<String, Json>,
501 #[serde(skip_serializing_if = "Vec::is_empty")]
503 pub destinations: Vec<Destination>,
504 #[serde(skip_serializing_if = "Option::is_none")]
505 pub template: Option<Template>,
506 #[serde(skip_serializing_if = "Option::is_none")]
509 pub extension: Option<String>,
510 #[serde(skip_serializing_if = "Option::is_none")]
512 pub on_empty: Option<crate::no_data::OnEmpty>,
513 #[serde(skip_serializing_if = "Option::is_none")]
515 pub for_each: Option<ForEach>,
516 #[serde(skip_serializing_if = "Option::is_none")]
518 pub file_name: Option<String>,
519}
520
521#[derive(Debug, Clone, PartialEq, Serialize)]
523pub struct ForEach {
524 pub query: String,
526 pub key: Vec<String>,
528 #[serde(skip_serializing_if = "Option::is_none")]
530 pub concurrency: Option<usize>,
531 pub on_row_error: RowErrors,
532 pub on_orphan_rows: OrphanRows,
533 pub max_population_rows: u64,
534 pub keep_results: bool,
535 #[serde(skip_serializing_if = "Option::is_none")]
537 pub mask_row_values: Option<bool>,
538}
539
540pub const DEFAULT_MAX_POPULATION_ROWS: u64 = 5_000_000;
542pub const DEFAULT_ROW_CONCURRENCY: usize = 4;
544
545#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, serde::Deserialize, schemars::JsonSchema)]
547#[serde(rename_all = "lowercase")]
548pub enum RowErrors {
549 #[default]
551 Continue,
552 Fail,
554}
555
556#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, serde::Deserialize, schemars::JsonSchema)]
558#[serde(rename_all = "lowercase")]
559pub enum OrphanRows {
560 #[default]
561 Warn,
562 Fail,
563}
564
565impl Output {
566 pub fn sections(&self, query: &str) -> bool {
569 self.feeds(query) && self.for_each.as_ref().is_none_or(|f| f.query != query)
570 }
571
572 pub fn feeds(&self, query: &str) -> bool {
574 self.queries.as_ref().is_none_or(|q| q.iter().any(|n| n == query))
575 }
576
577 pub fn is_message(&self) -> bool {
578 self.format == MESSAGE_FORMAT
579 }
580
581 pub fn label(&self, index: usize) -> String {
583 output_label(self.name.as_deref(), index)
584 }
585}
586
587#[derive(Debug, Clone, PartialEq, Serialize)]
588pub struct Destination {
589 pub profile: String,
590 #[serde(skip_serializing_if = "Option::is_none")]
591 pub path: Option<String>,
592 #[serde(skip_serializing_if = "JsonMap::is_empty")]
595 pub options: JsonMap<String, Json>,
596 #[serde(skip_serializing_if = "Vec::is_empty")]
598 pub attach: Vec<String>,
599 #[serde(skip_serializing_if = "std::ops::Not::not")]
601 pub hold: bool,
602 #[serde(skip_serializing_if = "Option::is_none")]
604 pub rate: Option<crate::rate::Rate>,
605 #[serde(skip_serializing_if = "Option::is_none")]
607 pub zip: Option<crate::zip_pack::ZipSpec>,
608}
609
610#[derive(Debug, Clone, PartialEq, Serialize)]
611pub struct Template {
612 pub file: String,
613 pub bindings: Vec<TemplateBinding>,
614}
615
616#[derive(Debug, Clone, PartialEq, Serialize)]
617pub struct TemplateBinding {
618 pub sheet: String,
619 #[serde(skip_serializing_if = "Option::is_none")]
620 pub query: Option<String>,
621 #[serde(skip_serializing_if = "Option::is_none")]
622 pub result_index: Option<usize>,
623 #[serde(skip_serializing_if = "Option::is_none")]
624 pub anchor: Option<String>,
625 #[serde(skip_serializing_if = "Option::is_none")]
626 pub header: Option<bool>,
627 #[serde(skip_serializing_if = "Option::is_none")]
628 pub columns: Option<Vec<String>>,
629 #[serde(skip_serializing_if = "Option::is_none")]
630 pub cell: Option<String>,
631 #[serde(skip_serializing_if = "Option::is_none")]
632 pub value: Option<String>,
633 #[serde(skip_serializing_if = "Option::is_none")]
634 pub column: Option<String>,
635}
636
637#[derive(Debug, Clone, Serialize)]
638pub struct SetDef {
639 #[serde(skip_serializing_if = "Option::is_none")]
640 pub profile: Option<String>,
641 pub vars: JsonMap<String, Json>,
642 #[serde(skip_serializing_if = "Option::is_none")]
643 pub locale: Option<String>,
644 #[serde(skip)]
646 pub file: PathBuf,
647 #[serde(skip)]
648 pub line: Option<usize>,
649}
650
651#[derive(Debug, Clone, Serialize)]
653pub struct SourceDef {
654 pub name: String,
655 #[serde(skip_serializing_if = "Option::is_none")]
656 pub description: Option<String>,
657 #[serde(skip_serializing_if = "Option::is_none")]
659 pub database: Option<String>,
660 #[serde(skip_serializing_if = "Option::is_none")]
661 pub schema: Option<String>,
662 #[serde(skip_serializing_if = "Option::is_none")]
664 pub profile: Option<String>,
665 pub quoting: QuotingDef,
666 pub tags: Vec<String>,
667 pub meta: JsonMap<String, Json>,
668 pub tables: Vec<SourceTableDef>,
669 #[serde(skip)]
671 pub file: PathBuf,
672 #[serde(skip)]
673 pub line: Option<usize>,
674}
675
676impl SourceDef {
677 pub fn table(&self, name: &str) -> Option<&SourceTableDef> {
678 self.tables.iter().find(|t| t.name == name)
679 }
680}
681
682#[derive(Debug, Clone, Serialize)]
683pub struct SourceTableDef {
684 pub name: String,
685 #[serde(skip_serializing_if = "Option::is_none")]
686 pub identifier: Option<String>,
687 #[serde(skip_serializing_if = "Option::is_none")]
688 pub description: Option<String>,
689 pub quoting: QuotingDef,
691 pub tags: Vec<String>,
692 pub meta: JsonMap<String, Json>,
693 pub columns: Vec<SourceColumn>,
694}
695
696#[derive(Debug, Clone, Serialize)]
697pub struct SourceColumn {
698 pub name: String,
699 #[serde(skip_serializing_if = "Option::is_none")]
700 pub description: Option<String>,
701 #[serde(skip_serializing_if = "Option::is_none")]
702 pub data_type: Option<String>,
703}
704
705#[derive(Debug, Clone, Copy, Default, Serialize)]
707pub struct QuotingDef {
708 #[serde(skip_serializing_if = "Option::is_none")]
709 pub database: Option<bool>,
710 #[serde(skip_serializing_if = "Option::is_none")]
711 pub schema: Option<bool>,
712 #[serde(skip_serializing_if = "Option::is_none")]
713 pub identifier: Option<bool>,
714}
715
716impl QuotingDef {
717 pub fn over(&self, under: &QuotingDef) -> crate::render::Quoting {
719 crate::render::Quoting {
720 database: self.database.or(under.database).unwrap_or(false),
721 schema: self.schema.or(under.schema).unwrap_or(false),
722 identifier: self.identifier.or(under.identifier).unwrap_or(false),
723 }
724 }
725}
726
727pub const SOURCE_KEYS: &[&str] = &[
729 "name",
730 "description",
731 "database",
732 "schema",
733 "quoting",
734 "tags",
735 "meta",
736 "tables",
737 "profile",
738];
739pub const SOURCE_TABLE_KEYS: &[&str] = &[
740 "name",
741 "identifier",
742 "description",
743 "quoting",
744 "tags",
745 "meta",
746 "columns",
747];
748pub const SOURCE_COLUMN_KEYS: &[&str] = &["name", "description", "data_type"];
749pub const DBT_SOURCE_KEYS: &[&str] = &[
751 "loader",
752 "loaded_at_field",
753 "loaded_at_query",
754 "config",
755 "overrides",
756 "freshness",
757 "docs",
758];
759pub const DBT_SOURCE_TABLE_KEYS: &[&str] = &[
760 "loaded_at_field",
761 "loaded_at_query",
762 "tests",
763 "data_tests",
764 "freshness",
765 "external",
766 "config",
767 "docs",
768];
769pub const DBT_SOURCE_COLUMN_KEYS: &[&str] = &[
770 "meta",
771 "tags",
772 "quote",
773 "tests",
774 "data_tests",
775 "constraints",
776 "config",
777 "docs",
778 "granularity",
779];
780
781pub use dre_protocol::Kind as PluginKind;
783pub use dre_protocol::PluginId;
784pub use dre_protocol::msg::ColumnOptions;
785
786#[derive(Debug, Clone, Serialize)]
788pub struct PluginRequirement {
789 pub name: String,
791 pub version: String,
793 pub declared_in: Vec<PathBuf>,
795 #[serde(skip_serializing_if = "PluginSource::is_default")]
797 pub source: PluginSource,
798}
799
800#[derive(Debug, Clone, Serialize)]
802pub struct PluginUse {
803 pub plugin: PluginId,
804 pub file: Option<PathBuf>,
805 pub line: Option<usize>,
806 pub what: String,
808}
809
810#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
812#[serde(rename_all = "snake_case")]
813pub enum PluginSource {
814 #[default]
816 Default,
817 Registry(String),
819 Github(String),
821 Local(String),
823}
824
825impl PluginSource {
826 pub fn is_default(&self) -> bool {
827 *self == PluginSource::Default
828 }
829
830 pub fn lock_key(&self) -> Option<String> {
832 match self {
833 PluginSource::Default => None,
834 PluginSource::Registry(u) => Some(format!("registry:{u}")),
835 PluginSource::Github(r) => Some(format!("github:{r}")),
836 PluginSource::Local(p) => Some(format!("local:{p}")),
837 }
838 }
839}
840
841impl PluginRequirement {
842 pub fn req(&self) -> semver::VersionReq {
843 semver::VersionReq::parse(&self.version).unwrap_or(semver::VersionReq::STAR)
844 }
845}
846
847#[derive(Debug, Clone, Serialize)]
848pub struct ScheduleEntry {
849 pub name: String,
850 #[serde(skip_serializing_if = "Option::is_none")]
851 pub select: Option<String>,
852 #[serde(skip_serializing_if = "Option::is_none")]
853 pub report: Option<String>,
854 #[serde(skip_serializing_if = "Option::is_none")]
855 pub set: Option<String>,
856 pub schedule: JsonMap<String, Json>,
858 #[serde(skip_serializing_if = "Option::is_none")]
860 pub timing: Option<String>,
861 pub enabled: bool,
863 #[serde(skip_serializing_if = "JsonMap::is_empty")]
865 pub vars: JsonMap<String, Json>,
866 #[serde(skip_serializing_if = "Option::is_none")]
868 pub timezone: Option<String>,
869 #[serde(skip)]
871 pub location: (PathBuf, Option<usize>),
872}
873
874#[derive(Debug, Clone, Serialize)]
876pub struct Timing {
877 pub schedule: JsonMap<String, Json>,
879 #[serde(skip_serializing_if = "Option::is_none")]
880 pub timezone: Option<String>,
881 #[serde(skip)]
883 pub location: (PathBuf, Option<usize>),
884}
885
886#[derive(Debug, Clone, Default)]
891pub struct LoadOptions {
892 pub profiles_dir: Option<PathBuf>,
894 pub target: Option<String>,
896 pub date: Option<chrono::NaiveDate>,
899 pub scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
900 pub timezone: Option<String>,
901 pub vars: BTreeMap<String, String>,
903 pub target_path: Option<String>,
905 pub settings: crate::settings::Settings,
908}
909
910pub fn load(root: &Path, opts: &LoadOptions) -> (Option<Project>, Diagnostics) {
913 let mut l = Loader {
914 root: root.to_path_buf(),
915 target: crate::target::TargetPath::default_for(root),
916 target_inside: Some(PathBuf::from(TARGET_DIR)),
917 opts: opts.clone(),
918 diags: Diagnostics::default(),
919 format_options: Mapping::new(),
920 };
921 let project = l.run();
922 (project, l.diags)
923}
924
925struct Loader {
926 root: PathBuf,
927 target: crate::target::TargetPath,
929 target_inside: Option<PathBuf>,
931 opts: LoadOptions,
932 diags: Diagnostics,
933 format_options: Mapping,
935}
936
937struct Fragment {
939 file: Rc<YamlFile>,
940 name: String,
941 explicit_name: bool,
942 folder: Vec<String>,
943 map: Mapping,
944 typed: ReportFile,
946}
947
948fn key_line(yf: &YamlFile, key: &str) -> Option<usize> {
950 yf.node.entry(key).map(|(k, _)| k.line).filter(|l| *l > 0)
951}
952
953#[derive(Default, Clone)]
955struct FolderCfg {
956 meta: Option<MetaLayer>,
957 tags: Vec<String>,
958 output: Option<Mapping>,
959 profile: Option<(String, Option<usize>)>,
960 vars: Option<Mapping>,
961 timezone: Option<String>,
962 locale: Option<String>,
963}
964
965struct Discovered {
966 yaml: Vec<PathBuf>,
967 sql: Vec<PathBuf>,
969 macros: Vec<PathBuf>,
970 lookups: Vec<PathBuf>,
972 folders: Vec<Vec<String>>,
973 sources: Vec<PathBuf>,
975}
976
977struct Located<T> {
979 value: T,
980 file: Rc<YamlFile>,
981}
982
983impl Loader {
984 fn rel(&self, p: &Path) -> PathBuf {
985 crate::slash(p.strip_prefix(&self.root).unwrap_or(p))
986 }
987
988 fn run(&mut self) -> Option<Project> {
989 let pfile = self.root.join(PROJECT_FILE);
990 if !pfile.is_file() {
991 self.diags.error(
992 Code::ProjectFileMissing,
993 Some(PathBuf::from(PROJECT_FILE)),
994 None,
995 format!(
996 "no {PROJECT_FILE} found in {}",
997 crate::slash(&self.root).display()
998 ),
999 );
1000 return None;
1001 }
1002 let pyaml = Rc::new(YamlFile::load(
1003 &pfile,
1004 PathBuf::from(PROJECT_FILE),
1005 &mut self.diags,
1006 )?);
1007 let mut pfile_typed = match de::from_node::<Loose<ProjectFile>>(&pyaml.node) {
1008 Ok(Loose::Ok(p)) => p,
1009 Ok(Loose::Bad(_)) => {
1010 self.diags.error(
1011 Code::InvalidProject,
1012 Some(pyaml.display.clone()),
1013 None,
1014 format!("{PROJECT_FILE} must be a map"),
1015 );
1016 return None;
1017 }
1018 Err(e) => {
1019 self.diags.error(
1020 Code::InvalidProject,
1021 Some(pyaml.display.clone()),
1022 e.line,
1023 e.message,
1024 );
1025 return None;
1026 }
1027 };
1028 let target_line = pfile_typed.target_path.as_ref().and_then(de::Located::line);
1029 let project_target = match &pfile_typed.target_path {
1030 None => None,
1031 Some(de::Located {
1032 value: Loose::Ok(t), ..
1033 }) => Some(t.as_str()),
1034 Some(_) => {
1035 self.diags.error(
1036 Code::InvalidField,
1037 Some(PathBuf::from(PROJECT_FILE)),
1038 target_line,
1039 "`target_path` must be a path",
1040 );
1041 return None;
1042 }
1043 };
1044 match crate::target::resolve(&self.root, self.opts.target_path.as_deref(), project_target) {
1045 Ok(t) => {
1046 self.target_inside = crate::target::inside(&self.root, &t.dir);
1047 if crate::slash(&t.dir).to_string_lossy().starts_with("/Workspace/") {
1050 let from_file =
1051 self.opts.target_path.is_none() && crate::settings::env(crate::target::ENV).is_none();
1052 self.diags.warning(
1053 Code::TargetPathOnWorkspace,
1054 from_file.then(|| PathBuf::from(PROJECT_FILE)),
1055 from_file.then_some(target_line).flatten(),
1056 format!(
1057 "the target path {} is in Databricks Workspace files, which don't reliably keep DRE's atomic writes and run pointer; use a Volume (`/Volumes/<catalog>/<schema>/<volume>/...`) or local disk",
1058 t.dir.display()
1059 ),
1060 );
1061 }
1062 self.target = t;
1063 }
1064 Err(e) => {
1065 let from_file =
1066 self.opts.target_path.is_none() && crate::settings::env(crate::target::ENV).is_none();
1067 self.diags.error(
1068 Code::InvalidTargetPath,
1069 from_file.then(|| PathBuf::from(PROJECT_FILE)),
1070 from_file.then_some(target_line).flatten(),
1071 e,
1072 );
1073 return None;
1074 }
1075 }
1076 let folders_cfg = pfile_typed.reports.take();
1077 let default_profile_line = pfile_typed.default_profile.as_ref().and_then(de::Located::line);
1078 let mut project = self.parse_project_file(&pyaml, pfile_typed)?;
1079
1080 let found = self.discover();
1081 project.folders = found.folders.clone();
1082 project.macros = found.macros.clone();
1083 project.files = found.sources.clone();
1084 let declared = packages::declared(&self.root, &mut self.diags);
1085 project.packages = packages::resolve(&self.root, &declared, &mut self.diags);
1086 self.check_macro_namespaces(&project);
1087
1088 let (profiles_dir, found_by) =
1089 crate::profiles::locate(self.opts.profiles_dir.as_deref(), Some(&self.root));
1090 project.profiles = Profiles::load(&profiles_dir, found_by, &mut self.diags);
1091 project.profiles.run = project.run_target();
1092 self.record_settings(&mut project);
1093
1094 let folder_cfg = self.parse_folder_config(&pyaml.display, folders_cfg, &project.folders);
1096 let mut used = Usage::default();
1097 if let Some(p) = &project.default_profile {
1098 used.connection(p, Some(pyaml.display.clone()), default_profile_line);
1099 }
1100 for cfg in folder_cfg.values() {
1101 if let Some((p, line)) = &cfg.profile {
1102 used.connection(p, Some(pyaml.display.clone()), *line);
1103 }
1104 }
1105
1106 let mut fragments = Vec::new();
1108 let mut plugin_decls: Vec<(Rc<YamlFile>, Mapping)> = Vec::new();
1109 let mut set_files = Vec::new();
1110 let mut schedule_files = Vec::new();
1111 let mut timing_files = Vec::new();
1112 let mut source_decls: Vec<Rc<YamlFile>> = Vec::new();
1113 plugin_decls.push((pyaml.clone(), pick(&pyaml.value, PLUGIN_KEYS)));
1114 if pyaml.value.get(SOURCES_KEY).is_some() {
1115 source_decls.push(pyaml.clone());
1116 }
1117 for path in &found.yaml {
1118 let display = self.rel(path);
1119 let Some(yf) = YamlFile::load(path, display.clone(), &mut self.diags) else {
1120 continue;
1121 };
1122 let yf = Rc::new(yf);
1123 if yf.value.get(SOURCES_KEY).is_some() {
1124 source_decls.push(yf.clone());
1125 }
1126 self.classify(
1127 yf,
1128 &mut fragments,
1129 &mut plugin_decls,
1130 &mut set_files,
1131 &mut schedule_files,
1132 &mut timing_files,
1133 );
1134 }
1135
1136 project.sources = self.parse_sources(&source_decls);
1137 project.sets = self.parse_sets(&set_files);
1138 let sql_index = self.index_sql(&found.sql);
1139 project.sql = sql_index
1140 .iter()
1141 .filter(|(_, paths)| paths.len() == 1)
1142 .map(|(name, paths)| (name.clone(), paths[0].clone()))
1143 .collect();
1144 let mut referenced: BTreeSet<String> = fragments
1146 .iter()
1147 .filter_map(|f| f.map.get("queries").and_then(Value::as_array))
1148 .flatten()
1149 .filter_map(entry_name)
1150 .collect();
1151 project.lookups = lookups::discover(&self.root, &found.lookups, &mut self.diags);
1152 for (name, l) in &project.lookups {
1153 if let Some(sql) = sql_index.get(name) {
1154 self.diags.error(
1155 Code::DuplicateRefName,
1156 Some(l.file.clone()),
1157 None,
1158 format!(
1159 "lookup `{name}` has the same name as {}; `ref()` names must be unique",
1160 sql[0].display()
1161 ),
1162 );
1163 }
1164 if let Err(e) = lookups::read(&self.root, l) {
1165 self.diags
1166 .error(Code::InvalidLookup, Some(l.file.clone()), None, e);
1167 }
1168 }
1169 referenced.extend(self.check_refs(&found.sql, &found.macros, &sql_index, &project.lookups));
1171 let mut reports = self.merge_reports(fragments);
1172
1173 let mut built = Vec::new();
1174 for r in reports.iter_mut() {
1175 let queries = self.resolve_queries(r, &sql_index, &mut referenced);
1176 built.push((queries, r));
1177 }
1178 let mut resolved: Vec<Report> = Vec::new();
1179 for (queries, raw) in built {
1180 if let Some(rep) = self.resolve_managed(raw, queries, &project, &folder_cfg, &mut used) {
1181 resolved.push(rep);
1182 }
1183 }
1184 let managed_names: BTreeMap<String, PathBuf> = resolved
1185 .iter()
1186 .map(|r| (r.name.clone(), r.file.clone()))
1187 .collect();
1188 for (name, path) in &sql_index {
1189 if referenced.contains(name) || path.len() != 1 {
1190 continue;
1191 }
1192 let path = &path[0];
1193 if let Some(other) = managed_names.get(name) {
1194 self.diags.error(
1195 Code::DuplicateReportName,
1196 Some(path.clone()),
1197 None,
1198 format!(
1199 "unmanaged report `{name}` has the same name as the report declared in {}; report names must be unique project-wide",
1200 other.display()
1201 ),
1202 );
1203 continue;
1204 }
1205 resolved.push(self.resolve_unmanaged(name, path, &project, &folder_cfg, &mut used));
1206 }
1207 resolved.sort_by(|a, b| a.name.cmp(&b.name));
1208 project.reports = resolved;
1209
1210 self.preflight(&project, &used);
1212 self.parse_pass(&mut project, &mut used);
1213 self.check_profiles(&project, &used);
1214 self.check_plugins(&plugin_decls, &mut project, &used);
1215 let broken_timings;
1216 (project.timings, broken_timings) = self.parse_timings(&timing_files);
1217 project.schedules = self.parse_schedules(&schedule_files, &project, &broken_timings);
1218 self.apply_schedules(&mut project);
1219 self.check_template_files(&project);
1220
1221 Some(project)
1222 }
1223
1224 fn parse_project_file(&mut self, yf: &Rc<YamlFile>, pf: ProjectFile) -> Option<Project> {
1227 let file = Some(yf.display.clone());
1228 for (k, line) in [("destinations", &pf.destinations), ("formats", &pf.formats)] {
1229 if line.is_some() {
1230 self.old_plugin_key(yf, k);
1231 }
1232 }
1233 if let Some(t) = &pf.target {
1234 self.diags.error(
1235 Code::RemovedKey,
1236 file.clone(),
1237 t.line(),
1238 "`target` in dre_project.yml was removed in DRE 0.2.1: give each profile its default with `target:` in profiles.yml, or choose the run's target with DRE_TARGET or --target",
1239 );
1240 }
1241 for k in &pf.unknown.0 {
1242 self.diags.error(
1243 Code::UnknownKey,
1244 file.clone(),
1245 Some(k.line),
1246 format!("unknown key `{}`", k.name),
1247 );
1248 }
1249 let name = match pf.name {
1250 Some(de::Located {
1251 value: Loose::Ok(s), ..
1252 }) if !s.trim().is_empty() => Some(s),
1253 Some(n) => {
1254 self.diags.error(
1255 Code::InvalidField,
1256 file.clone(),
1257 n.line(),
1258 "`name` must be a non-empty string",
1259 );
1260 None
1261 }
1262 None => {
1263 self.diags.error(
1264 Code::MissingField,
1265 file.clone(),
1266 None,
1267 "missing required field `name`",
1268 );
1269 None
1270 }
1271 };
1272 let meta = self.typed_metadata(&yf.display, pf.meta, "meta");
1273 let default_profile_line = pf.default_profile.as_ref().and_then(de::Located::line);
1274 let default_profile = self.typed_string(&yf.display, pf.default_profile, "default_profile");
1275 let run_target = crate::profiles::RunTarget::resolve(self.opts.target.as_deref());
1276 let default_set = self.typed_string(&yf.display, pf.default_set, "default_set");
1277 let vars = match pf.vars {
1278 None
1279 | Some(de::Located {
1280 value: Loose::Ok(None),
1281 ..
1282 }) => JsonMap::new(),
1283 Some(de::Located {
1284 value: Loose::Ok(Some(m)),
1285 ..
1286 }) => m,
1287 Some(v) => {
1288 self.diags
1289 .error(Code::InvalidField, file.clone(), v.line(), "`vars` must be a map");
1290 JsonMap::new()
1291 }
1292 };
1293 let run_query_max_rows = match pf.run_query_max_rows {
1294 None => DEFAULT_RUN_QUERY_MAX_ROWS,
1295 Some(de::Located {
1296 value: Loose::Ok(n), ..
1297 }) if n > 0 => n,
1298 Some(v) => {
1299 self.diags.error(
1300 Code::InvalidField,
1301 file.clone(),
1302 v.line(),
1303 "`run_query_max_rows` must be a positive whole number",
1304 );
1305 DEFAULT_RUN_QUERY_MAX_ROWS
1306 }
1307 };
1308 let (run_timeout, keep_runs) = self.parse_flags(&file, pf.flags);
1309 let dispatch = self.parse_dispatch(&yf.display, pf.dispatch);
1310 match pf.format_options {
1311 None | Some(de::Located { value: Loose::Ok(None), .. }) => {}
1312 Some(de::Located { value: Loose::Ok(Some(f)), .. }) if f.iter().all(|(_, v)| v.ok().is_some()) => {
1313 self.format_options = f
1314 .0
1315 .into_iter()
1316 .filter_map(|(k, v)| match v {
1317 Loose::Ok(m) => Some((k.value, Value::Object(m))),
1318 Loose::Bad(_) => None,
1319 })
1320 .collect();
1321 }
1322 Some(f) => self.diags.error(
1323 Code::InvalidField,
1324 file.clone(),
1325 f.line(),
1326 "`format_options` must map format names to their options, e.g. `delimited: {delimiter: \"|\"}`",
1327 ),
1328 }
1329 let mask_secrets = match pf.mask_secrets {
1330 None => true,
1331 Some(de::Located {
1332 value: Loose::Ok(b), ..
1333 }) => b,
1334 Some(v) => {
1335 self.diags.error(
1336 Code::InvalidField,
1337 file.clone(),
1338 v.line(),
1339 "`mask_secrets` must be true or false",
1340 );
1341 true
1342 }
1343 };
1344 let mask_row_values = match pf.mask_row_values {
1345 None => true,
1346 Some(de::Located {
1347 value: Loose::Ok(b), ..
1348 }) => b,
1349 Some(v) => {
1350 self.diags.error(
1351 Code::InvalidField,
1352 file.clone(),
1353 v.line(),
1354 "`mask_row_values` must be true or false",
1355 );
1356 true
1357 }
1358 };
1359 let timezone = pf.timezone.and_then(|v| {
1360 let line = v.line();
1361 self.timezone_str(v.value.ok().map(String::as_str), &yf.display, line, "`timezone`")
1362 });
1363 let locale = pf.locale.and_then(|v| {
1364 let line = v.line();
1365 self.locale_str(v.value.ok().map(String::as_str), &yf.display, line, "`locale`")
1366 });
1367 let week_start = match pf.week_start {
1368 None => WeekStart::Monday,
1369 Some(de::Located {
1370 value: Loose::Ok(w), ..
1371 }) => w,
1372 Some(v) => {
1373 self.diags.error(
1374 Code::InvalidField,
1375 file.clone(),
1376 v.line(),
1377 "`week_start` must be `monday` or `sunday`",
1378 );
1379 WeekStart::Monday
1380 }
1381 };
1382 let week_numbering = match pf.week_numbering {
1383 None => WeekNumbering::Iso,
1384 Some(de::Located {
1385 value: Loose::Ok(w), ..
1386 }) => w,
1387 Some(v) => {
1388 self.diags.error(
1389 Code::InvalidField,
1390 file.clone(),
1391 v.line(),
1392 "`week_numbering` must be `iso` or `us`",
1393 );
1394 WeekNumbering::Iso
1395 }
1396 };
1397 let lookup_inline_max_rows = match pf.lookup_inline_max_rows {
1398 None => DEFAULT_INLINE_MAX_ROWS,
1399 Some(de::Located {
1400 value: Loose::Ok(n), ..
1401 }) => n,
1402 Some(v) => {
1403 self.diags.error(
1404 Code::InvalidField,
1405 file.clone(),
1406 v.line(),
1407 "`lookup_inline_max_rows` must be a whole number",
1408 );
1409 DEFAULT_INLINE_MAX_ROWS
1410 }
1411 };
1412 Some(Project {
1413 name: name?,
1414 meta,
1415 root: self.root.clone(),
1416 target_dir: self.target.dir.clone(),
1417 target_source: self.target.source,
1418 target_name: run_target.name,
1419 target_from: run_target.from,
1420 default_profile_line,
1421 default_profile,
1422 default_set,
1423 vars,
1424 run_query_max_rows,
1425 keep_runs,
1426 run_timeout,
1427 reports: Vec::new(),
1428 sets: BTreeMap::new(),
1429 plugins: Vec::new(),
1430 plugin_uses: Vec::new(),
1431 plugins_incomplete: false,
1432 schedules: Vec::new(),
1433 timings: BTreeMap::new(),
1434 macros: Vec::new(),
1435 packages: Vec::new(),
1436 mask_secrets,
1437 mask_row_values,
1438 timezone,
1439 locale,
1440 week_start,
1441 week_numbering,
1442 dispatch,
1443 sql: BTreeMap::new(),
1444 lookups: BTreeMap::new(),
1445 lookup_inline_max_rows,
1446 format_options: self
1447 .format_options
1448 .iter()
1449 .filter_map(|(k, v)| Some((k.clone(), v.as_object()?.clone())))
1450 .collect(),
1451 folders: Vec::new(),
1452 files: Vec::new(),
1453 inputs: crate::parse::Inputs::default(),
1454 parse_errors: BTreeMap::new(),
1455 sources: BTreeMap::new(),
1456 profiles: Profiles::default(),
1457 settings: self.opts.settings.clone(),
1458 })
1459 }
1460
1461 fn record_settings(&self, project: &mut Project) {
1463 use crate::settings::{self, Source};
1464 let s = &mut project.settings;
1465 let target = &project.profiles.run;
1466 let target_source = match target.from {
1467 crate::profiles::TargetSource::Flag => Source::Flag("--target"),
1468 crate::profiles::TargetSource::Env => Source::Env(settings::TARGET),
1469 crate::profiles::TargetSource::Default => Source::Default,
1470 };
1471 s.set("target", Some(target.name.clone()), target_source);
1472 let path_source = match self.target.source {
1473 crate::target::Source::Flag => Source::Flag("--target-path"),
1474 crate::target::Source::Env => Source::Env(settings::TARGET_PATH),
1475 crate::target::Source::Project => Source::Project(crate::target::KEY),
1476 crate::target::Source::Default => Source::Default,
1477 };
1478 s.set(
1479 "target_path",
1480 Some(crate::slash(&self.target.dir).display().to_string()),
1481 path_source,
1482 );
1483 let dir_source = match project.profiles.found_by {
1484 "--profiles-dir" => Source::Flag("--profiles-dir"),
1485 "DRE_PROFILES_DIR" => Source::Env(settings::PROFILES_DIR),
1486 "the project directory" => Source::Found("the project directory"),
1487 _ => Source::Default,
1488 };
1489 let dir = project.profiles.path.parent().map(|d| d.display().to_string());
1490 s.set("profiles_dir", dir, dir_source);
1491 let env_or_default = |var: &'static str| match settings::env(var) {
1492 Some(_) => Source::Env(var),
1493 None => Source::Default,
1494 };
1495 s.set(
1496 "plugins_dir",
1497 Some(
1498 crate::slash(&crate::plugins::plugins_dir(Some(&self.root)))
1499 .display()
1500 .to_string(),
1501 ),
1502 env_or_default(settings::PLUGINS_DIR),
1503 );
1504 s.set(
1505 "registry_url",
1506 Some(crate::manager::registry_url()),
1507 env_or_default(settings::REGISTRY_URL),
1508 );
1509 }
1510
1511 fn typed_metadata(
1513 &mut self,
1514 file: &Path,
1515 value: Option<de::Located<Loose<JsonMap<String, Json>>>>,
1516 key: &str,
1517 ) -> JsonMap<String, Json> {
1518 match value {
1519 None => JsonMap::new(),
1520 Some(de::Located {
1521 value: Loose::Ok(map),
1522 ..
1523 }) => map,
1524 Some(value) => {
1525 self.diags.error(
1526 Code::InvalidField,
1527 Some(file.to_path_buf()),
1528 value.line(),
1529 format!("`{key}` must be a map"),
1530 );
1531 JsonMap::new()
1532 }
1533 }
1534 }
1535
1536 fn typed_string(
1538 &mut self,
1539 file: &Path,
1540 v: Option<de::Located<Loose<String>>>,
1541 key: &str,
1542 ) -> Option<String> {
1543 match v? {
1544 de::Located {
1545 value: Loose::Ok(s), ..
1546 } => Some(s),
1547 v => {
1548 self.diags.error(
1549 Code::InvalidField,
1550 Some(file.to_path_buf()),
1551 v.line(),
1552 format!("`{key}` must be a string"),
1553 );
1554 None
1555 }
1556 }
1557 }
1558
1559 fn timezone_str(
1562 &mut self,
1563 v: Option<&str>,
1564 file: &Path,
1565 line: Option<usize>,
1566 what: &str,
1567 ) -> Option<String> {
1568 let msg = match v {
1569 Some(s) => match crate::dates::parse_tz(s) {
1570 Ok(_) => return Some(s.to_string()),
1571 Err(e) => format!("{what}: {e}"),
1572 },
1573 None => format!("{what} must be a string, an IANA timezone name such as `Australia/Sydney`"),
1574 };
1575 self.diags
1576 .error(Code::InvalidTimezone, Some(file.to_path_buf()), line, msg);
1577 None
1578 }
1579
1580 fn locale_str(
1583 &mut self,
1584 v: Option<&str>,
1585 file: &Path,
1586 line: Option<usize>,
1587 what: &str,
1588 ) -> Option<String> {
1589 let msg = match v {
1590 Some(s) => match crate::numbers::Locale::parse(s) {
1591 Ok(_) => return Some(s.to_string()),
1592 Err(e) => format!("{what}: {e}"),
1593 },
1594 None => format!("{what} must be a string, a locale such as `de-DE`"),
1595 };
1596 self.diags
1597 .error(Code::InvalidLocale, Some(file.to_path_buf()), line, msg);
1598 None
1599 }
1600
1601 fn parse_flags(
1604 &mut self,
1605 file: &Option<PathBuf>,
1606 v: Option<de::Located<Loose<config::project::Flags>>>,
1607 ) -> (Option<std::time::Duration>, usize) {
1608 let flags = match v {
1609 None => return (None, 1),
1610 Some(de::Located {
1611 value: Loose::Ok(f), ..
1612 }) => f,
1613 Some(v) => {
1614 self.diags.error(
1615 Code::InvalidField,
1616 file.clone(),
1617 v.line(),
1618 "`flags` must be a map",
1619 );
1620 return (None, 1);
1621 }
1622 };
1623 for k in &flags.unknown.0 {
1624 self.diags.error(
1625 Code::UnknownKey,
1626 file.clone(),
1627 Some(k.line),
1628 format!(
1629 "unknown key `flags.{}`; flags are: http_timeout, keep_runs, run_timeout",
1630 k.name
1631 ),
1632 );
1633 }
1634 match flags.http_timeout {
1635 None => {}
1636 Some(de::Located {
1637 value: Loose::Ok(n), ..
1638 }) if n > 0 => crate::manager::set_project_http_timeout(n),
1639 Some(v) => self.diags.error(
1640 Code::InvalidField,
1641 file.clone(),
1642 v.line(),
1643 "`flags.http_timeout` must be a positive whole number of seconds",
1644 ),
1645 }
1646 let keep_runs = match flags.keep_runs {
1647 None => 1,
1648 Some(de::Located {
1649 value: Loose::Ok(n), ..
1650 }) if n > 0 => n as usize,
1651 Some(v) => {
1652 self.diags.error(
1653 Code::InvalidField,
1654 file.clone(),
1655 v.line(),
1656 "`flags.keep_runs` must be a whole number of 1 or more",
1657 );
1658 1
1659 }
1660 };
1661 let Some(v) = flags.run_timeout else {
1662 return (None, keep_runs);
1663 };
1664 let line = v.line();
1665 let parsed = match v.value {
1666 Loose::Ok(v) => dre_protocol::delivery::parse_duration(&v),
1667 _ => Err("must be a duration such as `2h` or `90m`, or seconds".to_string()),
1668 };
1669 let run_timeout = match parsed {
1670 Ok(d) if !d.is_zero() => Some(d),
1671 Ok(_) => {
1672 self.diags.error(
1673 Code::InvalidField,
1674 file.clone(),
1675 line,
1676 "`flags.run_timeout` must be more than zero",
1677 );
1678 None
1679 }
1680 Err(e) => {
1681 self.diags.error(
1682 Code::InvalidField,
1683 file.clone(),
1684 line,
1685 format!("`flags.run_timeout` {e}"),
1686 );
1687 None
1688 }
1689 };
1690 (run_timeout, keep_runs)
1691 }
1692
1693 fn parse_dispatch(&mut self, file: &Path, v: Option<config::project::DispatchList>) -> DispatchOrder {
1695 let mut out = DispatchOrder::new();
1696 let Some(v) = v else { return out };
1697 let line = v.line();
1698 let Loose::Ok(list) = v.value else {
1699 self.diags.error(
1700 Code::InvalidField,
1701 Some(file.to_path_buf()),
1702 line,
1703 "`dispatch` must be a list of `{macro_namespace, search_order}`",
1704 );
1705 return out;
1706 };
1707 for e in list {
1708 let (ns, order) = match e {
1709 Loose::Ok(e) => {
1710 let ns = e.macro_namespace.and_then(|n| n.ok().cloned());
1711 let order: Option<Vec<String>> = e
1712 .search_order
1713 .and_then(|s| s.ok().map(|s| s.iter().filter_map(|x| x.ok().cloned()).collect()));
1714 (ns, order)
1715 }
1716 Loose::Bad(_) => (None, None),
1717 };
1718 match (ns, order) {
1719 (Some(ns), Some(order)) if !order.is_empty() => {
1720 out.insert(ns, order);
1721 }
1722 _ => self.diags.error(
1723 Code::InvalidField,
1724 Some(file.to_path_buf()),
1725 line,
1726 "each `dispatch` entry needs `macro_namespace` and a non-empty `search_order` list",
1727 ),
1728 }
1729 }
1730 out
1731 }
1732
1733 fn check_macro_namespaces(&mut self, project: &Project) {
1736 let own: BTreeSet<String> = project
1737 .macros
1738 .iter()
1739 .filter_map(|m| std::fs::read_to_string(self.root.join(m)).ok())
1740 .flat_map(|src| preflight::macro_defs(&src).into_iter().map(|d| d.name))
1741 .collect();
1742 for p in &project.packages {
1743 let clash = if RESERVED_NAMES.contains(&p.name.as_str()) {
1744 Some("a DRE function or variable".to_string())
1745 } else if own.contains(&p.name) {
1746 Some("a macro in macros/".to_string())
1747 } else if p.name == project.name {
1748 Some("this project".to_string())
1749 } else {
1750 None
1751 };
1752 if let Some(c) = clash {
1753 self.diags.error(
1754 Code::PackageNameClash,
1755 None,
1756 None,
1757 format!(
1758 "package `{}` has the same name as {c}; macros couldn't be called through it",
1759 p.name
1760 ),
1761 );
1762 }
1763 }
1764 for (ns, order) in &project.dispatch {
1765 for n in order {
1766 if n != &project.name && !project.packages.iter().any(|p| &p.name == n) {
1767 self.diags.error(
1768 Code::InvalidField,
1769 Some(PathBuf::from(PROJECT_FILE)),
1770 None,
1771 format!("`dispatch` for `{ns}` searches `{n}`, which is neither this project nor an installed package"),
1772 );
1773 }
1774 }
1775 }
1776 }
1777
1778 fn parse_folder_config(
1779 &mut self,
1780 file: &Path,
1781 tree: Option<de::Located<Loose<config::project::Folder>>>,
1782 folders: &[Vec<String>],
1783 ) -> BTreeMap<Vec<String>, FolderCfg> {
1784 let mut out = BTreeMap::new();
1785 let Some(tree) = tree else {
1786 return out;
1787 };
1788 let display = Some(file.to_path_buf());
1789 let mut stack = vec![(Vec::<String>::new(), tree)];
1790 while let Some((path, node)) = stack.pop() {
1791 let line = node.line();
1792 let Loose::Ok(f) = node.value else {
1793 self.diags.error(
1794 Code::InvalidFolderConfig,
1795 display.clone(),
1796 line,
1797 format!("folder config for `{}` must be a map", dotted(&path)),
1798 );
1799 continue;
1800 };
1801 let any = f.sets_anything();
1802 let mut cfg = FolderCfg::default();
1803 if let Some(meta) = f.meta {
1804 let value = self.typed_metadata(file, Some(meta), "+meta");
1805 cfg.meta = Some(MetaLayer {
1806 file: crate::slash(file).to_string_lossy().into_owned(),
1807 key: if path.is_empty() {
1808 "reports.+meta".into()
1809 } else {
1810 format!("reports.{}.+meta", path.join("."))
1811 },
1812 meta: value,
1813 });
1814 }
1815 let bad = |s: &mut Self, k: &str, kline: Option<usize>, what: &str| {
1816 s.diags.error(
1817 Code::InvalidField,
1818 display.clone(),
1819 kline,
1820 format!("`{k}` must be {what}"),
1821 )
1822 };
1823 if let Some(v) = f.tags {
1824 match v.value {
1825 Loose::Ok(t) => cfg.tags = t,
1826 Loose::Bad(_) => bad(self, "+tags", v.line(), "a list of strings"),
1827 }
1828 }
1829 if let Some(v) = f.output {
1830 match v.value {
1831 Loose::Ok(o) => cfg.output = Some(o),
1832 Loose::Bad(_) => bad(self, "+output", v.line(), "a map"),
1833 }
1834 }
1835 if let Some(v) = f.profile {
1836 let kline = v.line();
1837 match v.value {
1838 Loose::Ok(p) => cfg.profile = Some((p, kline)),
1839 Loose::Bad(_) => bad(self, "+profile", kline, "a string"),
1840 }
1841 }
1842 if let Some(v) = f.schedule {
1843 let ctx = format!("folder `{}`: `+schedule`", dotted(&path));
1844 self.moved_to_schedules(file, v.line(), &ctx);
1845 }
1846 if let Some(v) = f.timezone {
1847 let kline = v.line();
1848 cfg.timezone =
1849 self.timezone_str(v.value.ok().map(String::as_str), file, kline, "`+timezone`");
1850 }
1851 if let Some(v) = f.locale {
1852 let kline = v.line();
1853 cfg.locale = self.locale_str(v.value.ok().map(String::as_str), file, kline, "`+locale`");
1854 }
1855 if let Some(v) = f.vars {
1856 match v.value {
1857 Loose::Ok(s) => cfg.vars = Some(s),
1858 Loose::Bad(_) => bad(self, "+vars", v.line(), "a map"),
1859 }
1860 }
1861 for (k, v) in f.rest.0 {
1862 if k.value.starts_with('+') {
1863 self.diags.error(
1864 Code::UnknownKey,
1865 display.clone(),
1866 k.line(),
1867 format!(
1868 "unknown folder config `{}`; folder config keys are {}",
1869 k.value,
1870 FOLDER_CONFIG_KEYS.join(", ")
1871 ),
1872 );
1873 } else {
1874 let mut p = path.clone();
1875 p.push(k.value);
1876 stack.push((p, v));
1877 }
1878 }
1879 if any && !path.is_empty() {
1880 if !folders.contains(&path) {
1881 self.diags.warning(
1882 Code::UnknownFolder,
1883 display.clone(),
1884 line,
1885 format!(
1886 "folder config for `{}` matches no folder under reports/",
1887 dotted(&path)
1888 ),
1889 );
1890 }
1891 out.insert(path, cfg);
1892 } else if any {
1893 out.insert(path, cfg);
1894 }
1895 }
1896 out
1897 }
1898
1899 fn discover(&mut self) -> Discovered {
1902 let mut d = Discovered {
1903 yaml: Vec::new(),
1904 sql: Vec::new(),
1905 macros: Vec::new(),
1906 lookups: Vec::new(),
1907 folders: Vec::new(),
1908 sources: Vec::new(),
1909 };
1910 let target_inside = self.target_inside.clone();
1911 let root = self.root.clone();
1912 let walker = walkdir::WalkDir::new(&self.root)
1913 .sort_by_file_name()
1914 .into_iter()
1915 .filter_entry(|e| {
1916 let name = e.file_name().to_string_lossy();
1917 e.depth() == 0
1919 || !(name.starts_with('.')
1920 || target_inside
1921 .as_deref()
1922 .is_some_and(|t| e.path().strip_prefix(&root).is_ok_and(|r| r == t))
1923 || (e.depth() == 1 && [TARGET_DIR, DEPS_DIR, LOGS_DIR].contains(&name.as_ref())))
1924 });
1925 for e in walker.filter_map(Result::ok) {
1926 let rel = self.rel(e.path());
1927 let in_reports = rel.starts_with(REPORTS_DIR);
1928 if e.file_type().is_dir() {
1929 if in_reports && rel != Path::new(REPORTS_DIR) {
1930 d.folders.push(folder_segments(&rel));
1931 }
1932 continue;
1933 }
1934 let ext = e.path().extension().and_then(|x| x.to_str()).unwrap_or("");
1935 let yaml = matches!(ext, "yml" | "yaml") && rel != Path::new(crate::profiles::PROFILES_FILE);
1936 if yaml || in_reports || rel.starts_with(MACROS_DIR) || rel.starts_with(LOOKUPS_DIR) {
1937 d.sources.push(rel.clone());
1938 }
1939 if rel.starts_with(LOOKUPS_DIR) {
1940 d.lookups.push(rel);
1941 continue;
1942 }
1943 match ext {
1944 "yml" | "yaml"
1946 if rel != Path::new(PROJECT_FILE) && rel != Path::new(crate::profiles::PROFILES_FILE) =>
1947 {
1948 d.yaml.push(e.path().to_path_buf())
1949 }
1950 "sql" if rel.starts_with(MACROS_DIR) => d.macros.push(rel),
1951 "sql" if in_reports => d.sql.push(rel),
1952 _ => {}
1953 }
1954 }
1955 d
1956 }
1957
1958 fn classify(
1959 &mut self,
1960 yf: Rc<YamlFile>,
1961 fragments: &mut Vec<Fragment>,
1962 plugins: &mut Vec<(Rc<YamlFile>, Mapping)>,
1963 sets: &mut Vec<Rc<YamlFile>>,
1964 schedules: &mut Vec<Rc<YamlFile>>,
1965 timings: &mut Vec<Rc<YamlFile>>,
1966 ) {
1967 let in_reports = yf.display.starts_with(REPORTS_DIR);
1968 let timings_file = yf.display.file_name() == Some(std::ffi::OsStr::new(TIMINGS_FILE));
1969 match &yf.value {
1970 Value::Null => {}
1971 Value::Array(items)
1972 if !items.is_empty()
1973 && items
1974 .iter()
1975 .all(|i| ["name", "select", "report"].iter().any(|k| i.get(k).is_some())) =>
1976 {
1977 schedules.push(yf.clone());
1978 }
1979 Value::Object(m) => {
1980 let pl = pick(&yf.value, PLUGIN_KEYS);
1981 if !pl.is_empty() {
1982 plugins.push((yf.clone(), pl));
1983 }
1984 let dependency_file = packages::DEPENDENCY_FILES
1986 .iter()
1987 .any(|f| yf.display == Path::new(f));
1988 if !dependency_file && m.contains_key("packages") {
1989 self.diags.error(
1990 Code::MisplacedPackages,
1991 Some(yf.display.clone()),
1992 yf.line_of("packages", None),
1993 "`packages:` goes in dependencies.yml or packages.yml at the project root",
1994 );
1995 }
1996 if dependency_file {
1997 for k in m.keys().map(String::as_str) {
1998 if OLD_PLUGIN_KEYS.contains(&k) {
1999 self.old_plugin_key(&yf, k);
2000 }
2001 }
2002 }
2003 let has_sources = m.contains_key(SOURCES_KEY);
2004 let rest: Mapping = m
2005 .iter()
2006 .filter(|(k, _)| !is_one_of(k, PLUGIN_KEYS) && k.as_str() != "packages")
2007 .filter(|(k, _)| k.as_str() != SOURCES_KEY)
2008 .filter(|(k, _)| !(has_sources && k.as_str() == "version"))
2010 .filter(|(k, _)| !(dependency_file && is_one_of(k, OLD_PLUGIN_KEYS)))
2011 .map(|(k, v)| (k.clone(), v.clone()))
2012 .collect();
2013 if rest.is_empty() {
2014 return;
2015 }
2016 let looks_like_report = rest.contains_key("queries") || rest.contains_key("name");
2017 if !in_reports && (timings_file || is_timing_registry(&rest)) {
2018 timings.push(yf.clone());
2019 } else if in_reports || looks_like_report {
2020 self.report_fragment(yf.clone(), rest, fragments);
2021 } else if is_set_registry(&rest) {
2022 sets.push(yf.clone());
2023 } else {
2024 self.diags.warning(
2025 Code::UnrecognizedYaml,
2026 Some(yf.display.clone()),
2027 None,
2028 "not recognised as report, Set, plugin or schedule config; ignored",
2029 );
2030 }
2031 }
2032 _ => self.diags.warning(
2033 Code::UnrecognizedYaml,
2034 Some(yf.display.clone()),
2035 None,
2036 "not recognised as report, Set, plugin or schedule config; ignored",
2037 ),
2038 }
2039 }
2040
2041 fn report_fragment(&mut self, yf: Rc<YamlFile>, map: Mapping, out: &mut Vec<Fragment>) {
2042 for k in map.keys().map(String::as_str) {
2043 if !REPORT_KEYS.contains(&k) {
2044 self.diags.error(
2045 Code::UnknownKey,
2046 Some(yf.display.clone()),
2047 key_line(&yf, k),
2048 format!("unknown report key `{k}`"),
2049 );
2050 }
2051 }
2052 let typed = match de::from_node::<Loose<ReportFile>>(&yf.node) {
2053 Ok(Loose::Ok(r)) => r,
2054 Ok(Loose::Bad(_)) => return,
2055 Err(e) => {
2056 self.diags
2057 .error(Code::InvalidReport, Some(yf.display.clone()), e.line, e.message);
2058 return;
2059 }
2060 };
2061 let folder = folder_segments(yf.display.parent().unwrap_or(Path::new("")));
2062 let (name, explicit_name) = match &typed.name {
2063 Some(de::Located {
2064 value: Loose::Ok(s), ..
2065 }) if !s.is_empty() => (s.clone(), true),
2066 Some(n) => {
2067 self.diags.error(
2068 Code::InvalidField,
2069 Some(yf.display.clone()),
2070 n.line(),
2071 "`name` must be a non-empty string",
2072 );
2073 return;
2074 }
2075 None => match folder.last() {
2076 Some(f) => (f.clone(), false),
2077 None => {
2078 self.diags.error(
2079 Code::MissingField,
2080 Some(yf.display.clone()),
2081 None,
2082 "a report outside a report folder needs an explicit `name:`",
2083 );
2084 return;
2085 }
2086 },
2087 };
2088 out.push(Fragment {
2089 file: yf,
2090 name,
2091 explicit_name,
2092 folder,
2093 map,
2094 typed,
2095 });
2096 }
2097
2098 fn parse_sources(&mut self, files: &[Rc<YamlFile>]) -> BTreeMap<String, SourceDef> {
2103 let mut out: BTreeMap<String, SourceDef> = BTreeMap::new();
2104 for yf in files {
2105 let file = Some(yf.display.clone());
2106 let top = key_line(yf, SOURCES_KEY);
2107 let plugin_names = match yf.node.get(SOURCES_KEY).map(|n| &n.kind) {
2108 Some(crate::config::node::Kind::Seq(items)) => items.iter().all(|i| i.as_str().is_some()),
2109 _ => false,
2110 };
2111 if plugin_names {
2112 self.diags.error(
2114 Code::MovedPluginDeclaration,
2115 file,
2116 top,
2117 "`sources:` declares tables now (dbt's format); list plugin packages under `plugins:` instead (e.g. `plugins: [duckdb]`)",
2118 );
2119 continue;
2120 }
2121 let items = match de::from_node::<Loose<config::sources::SourcesFile>>(&yf.node) {
2122 Ok(Loose::Ok(f)) => f.sources.map(|s| s.value),
2123 _ => None,
2124 };
2125 let items = match items {
2126 Some(Loose::Ok(Some(items))) => items,
2127 Some(Loose::Ok(None)) => continue,
2128 _ => {
2129 self.diags.error(
2130 Code::InvalidSource,
2131 file,
2132 top,
2133 "`sources` must be a list of sources, as in dbt: `- name: sales` with `tables:`",
2134 );
2135 continue;
2136 }
2137 };
2138 let mut unsupported: BTreeSet<String> = BTreeSet::new();
2139 for item in items {
2140 if let Some(src) = self.source_def(yf, item, &mut unsupported) {
2141 if let Some(prev) = out.get(&src.name) {
2142 self.diags.error(
2143 Code::DuplicateSource,
2144 file.clone(),
2145 src.line,
2146 format!(
2147 "source `{}` is also declared in {}; source names must be unique",
2148 src.name,
2149 prev.file.display()
2150 ),
2151 );
2152 continue;
2153 }
2154 out.insert(src.name.clone(), src);
2155 }
2156 }
2157 if !unsupported.is_empty() {
2158 self.diags.warning(
2159 Code::SourceKeyNotSupported,
2160 file,
2161 top,
2162 format!(
2163 "dbt source keys DRE doesn't support yet are ignored: {}",
2164 unsupported.into_iter().collect::<Vec<_>>().join(", ")
2165 ),
2166 );
2167 }
2168 }
2169 out
2170 }
2171
2172 fn source_def(
2173 &mut self,
2174 yf: &YamlFile,
2175 item: de::Located<Loose<config::sources::Source>>,
2176 unsupported: &mut BTreeSet<String>,
2177 ) -> Option<SourceDef> {
2178 let file = Some(yf.display.clone());
2179 let top = key_line(yf, SOURCES_KEY);
2180 let item_line = item.line().or(top);
2181 let Loose::Ok(m) = item.value else {
2182 self.diags.error(
2183 Code::InvalidSource,
2184 file,
2185 top,
2186 "each source must be a map with `name:` and `tables:`",
2187 );
2188 return None;
2189 };
2190 let Some(name) = m
2191 .name
2192 .as_ref()
2193 .and_then(Loose::ok)
2194 .filter(|n| !n.is_empty())
2195 .cloned()
2196 else {
2197 self.diags
2198 .error(Code::InvalidSource, file, top, "every source needs a `name`");
2199 return None;
2200 };
2201 let line = item_line;
2202 let ctx = format!("source `{name}`");
2203 let mut ok = self.source_keys(
2204 &yf.display,
2205 &m.unknown,
2206 &m.dbt_keys(),
2207 &ctx,
2208 SOURCE_KEYS,
2209 "source",
2210 unsupported,
2211 );
2212 let mut text = |s: &mut Self, k: &str, v: &Option<Loose<de::Text>>| -> Option<String> {
2213 match v {
2214 None => None,
2215 Some(Loose::Ok(t)) => Some(t.0.clone()),
2216 Some(Loose::Bad(_)) => {
2217 s.diags.error(
2218 Code::InvalidSource,
2219 file.clone(),
2220 line,
2221 format!("{ctx}: `{k}` must be a string"),
2222 );
2223 ok = false;
2224 None
2225 }
2226 }
2227 };
2228 let description = text(self, "description", &m.description);
2229 let database = text(self, "database", &m.database);
2230 let schema = text(self, "schema", &m.schema);
2231 let profile = text(self, "profile", &m.profile);
2232 let quoting = self.quoting(&yf.display, m.quoting, &ctx, line);
2233 let tags = self.source_tags(&yf.display, m.tags, &ctx, line);
2234 let meta = self.source_meta(&yf.display, m.meta, &ctx, line);
2235 let mut tables = Vec::new();
2236 match m.tables {
2237 None | Some(Loose::Ok(None)) => {}
2238 Some(Loose::Ok(Some(ts))) => {
2239 let mut names = BTreeSet::new();
2241 for t in &ts {
2242 let Some(n) = t.value.ok().and_then(|t| t.name.as_ref()).and_then(Loose::ok) else {
2243 continue;
2244 };
2245 if !names.insert(n.clone()) {
2246 self.diags.error(
2247 Code::DuplicateSourceTable,
2248 file.clone(),
2249 t.line().or(line),
2250 format!("{ctx} declares table `{n}` twice"),
2251 );
2252 ok = false;
2253 }
2254 }
2255 for t in ts {
2256 if let Some(t) = self.source_table(yf, &name, t, line, unsupported)
2257 && !tables.iter().any(|x: &SourceTableDef| x.name == t.name)
2258 {
2259 tables.push(t);
2260 }
2261 }
2262 }
2263 Some(Loose::Bad(_)) => {
2264 self.diags.error(
2265 Code::InvalidSource,
2266 file.clone(),
2267 line,
2268 format!("{ctx}: `tables` must be a list"),
2269 );
2270 ok = false;
2271 }
2272 }
2273 ok.then(|| SourceDef {
2274 name: name.to_string(),
2275 description,
2276 database,
2277 schema,
2278 profile,
2279 quoting,
2280 tags,
2281 meta,
2282 tables,
2283 file: yf.display.clone(),
2284 line,
2285 })
2286 }
2287
2288 fn source_table(
2289 &mut self,
2290 yf: &YamlFile,
2291 source: &str,
2292 item: de::Located<Loose<config::sources::Table>>,
2293 source_line: Option<usize>,
2294 unsupported: &mut BTreeSet<String>,
2295 ) -> Option<SourceTableDef> {
2296 let file = Some(yf.display.clone());
2297 let item_line = item.line();
2298 let Loose::Ok(m) = item.value else {
2299 self.diags.error(
2300 Code::InvalidSource,
2301 file,
2302 source_line,
2303 format!("source `{source}`: each table must be a map with a `name`"),
2304 );
2305 return None;
2306 };
2307 let Some(name) = m
2308 .name
2309 .as_ref()
2310 .and_then(Loose::ok)
2311 .filter(|n| !n.is_empty())
2312 .cloned()
2313 else {
2314 self.diags.error(
2315 Code::InvalidSource,
2316 file,
2317 source_line,
2318 format!("source `{source}`: every table needs a `name`"),
2319 );
2320 return None;
2321 };
2322 let line = item_line.or(source_line);
2323 let ctx = format!("source `{source}`, table `{name}`");
2324 let mut ok = self.source_keys(
2325 &yf.display,
2326 &m.unknown,
2327 &m.dbt_keys(),
2328 &ctx,
2329 SOURCE_TABLE_KEYS,
2330 "table",
2331 unsupported,
2332 );
2333 let mut text = |s: &mut Self, k: &str, v: &Option<Loose<de::Text>>| -> Option<String> {
2334 match v {
2335 None => None,
2336 Some(Loose::Ok(t)) => Some(t.0.clone()),
2337 Some(Loose::Bad(_)) => {
2338 s.diags.error(
2339 Code::InvalidSource,
2340 file.clone(),
2341 line,
2342 format!("{ctx}: `{k}` must be a string"),
2343 );
2344 ok = false;
2345 None
2346 }
2347 }
2348 };
2349 let identifier = text(self, "identifier", &m.identifier);
2350 let description = text(self, "description", &m.description);
2351 let quoting = self.quoting(&yf.display, m.quoting, &ctx, line);
2352 let tags = self.source_tags(&yf.display, m.tags, &ctx, line);
2353 let meta = self.source_meta(&yf.display, m.meta, &ctx, line);
2354 let mut columns: Vec<SourceColumn> = Vec::new();
2355 match m.columns {
2356 None | Some(Loose::Ok(None)) => {}
2357 Some(Loose::Ok(Some(cs))) => {
2358 for c in cs {
2359 let Loose::Ok(cm) = c else {
2360 self.diags.error(
2361 Code::InvalidSource,
2362 file.clone(),
2363 line,
2364 format!("{ctx}: each column must be a map with a `name`"),
2365 );
2366 ok = false;
2367 continue;
2368 };
2369 let Some(cname) = cm
2370 .name
2371 .as_ref()
2372 .and_then(Loose::ok)
2373 .filter(|n| !n.is_empty())
2374 .cloned()
2375 else {
2376 self.diags.error(
2377 Code::InvalidSource,
2378 file.clone(),
2379 line,
2380 format!("{ctx}: every column needs a `name`"),
2381 );
2382 ok = false;
2383 continue;
2384 };
2385 let cctx = format!("{ctx}, column `{cname}`");
2386 ok &= self.source_keys(
2387 &yf.display,
2388 &cm.unknown,
2389 &cm.dbt_keys(),
2390 &cctx,
2391 SOURCE_COLUMN_KEYS,
2392 "column",
2393 unsupported,
2394 );
2395 if columns.iter().any(|c| c.name.eq_ignore_ascii_case(&cname)) {
2396 self.diags.error(
2397 Code::DuplicateSourceColumn,
2398 file.clone(),
2399 line,
2400 format!("{ctx} declares column `{cname}` twice"),
2401 );
2402 ok = false;
2403 continue;
2404 }
2405 let s = |v: &Option<Loose<de::Text>>| v.as_ref().and_then(Loose::ok).map(|t| t.0.clone());
2406 columns.push(SourceColumn {
2407 name: cname,
2408 description: s(&cm.description),
2409 data_type: s(&cm.data_type),
2410 });
2411 }
2412 }
2413 Some(Loose::Bad(_)) => {
2414 self.diags.error(
2415 Code::InvalidSource,
2416 file.clone(),
2417 line,
2418 format!("{ctx}: `columns` must be a list"),
2419 );
2420 ok = false;
2421 }
2422 }
2423 ok.then(|| SourceTableDef {
2424 name,
2425 identifier,
2426 description,
2427 quoting,
2428 tags,
2429 meta,
2430 columns,
2431 })
2432 }
2433
2434 #[allow(clippy::too_many_arguments)]
2437 fn source_keys(
2438 &mut self,
2439 file: &Path,
2440 unknown: &de::UnknownKeys,
2441 dbt: &[&str],
2442 ctx: &str,
2443 known: &[&str],
2444 level: &str,
2445 unsupported: &mut BTreeSet<String>,
2446 ) -> bool {
2447 for k in dbt {
2448 unsupported.insert(format!("{level} `{k}`"));
2449 }
2450 for k in &unknown.0 {
2451 self.diags.error(
2452 Code::UnknownKey,
2453 Some(file.to_path_buf()),
2454 Some(k.line),
2455 format!(
2456 "{ctx}: unknown key `{}`; a {level} takes {}",
2457 k.name,
2458 known.join(", ")
2459 ),
2460 );
2461 }
2462 unknown.0.is_empty()
2463 }
2464
2465 fn quoting(
2466 &mut self,
2467 file: &Path,
2468 v: Option<Loose<config::sources::Quoting>>,
2469 ctx: &str,
2470 line: Option<usize>,
2471 ) -> QuotingDef {
2472 let mut q = QuotingDef::default();
2473 let Some(v) = v else { return q };
2474 let bad = |s: &mut Self, msg: String| {
2475 s.diags.error(
2476 Code::InvalidSource,
2477 Some(file.to_path_buf()),
2478 line,
2479 format!("{ctx}: {msg}"),
2480 )
2481 };
2482 let Loose::Ok(qm) = v else {
2483 bad(
2484 self,
2485 "`quoting` must be a map of `database`, `schema` and `identifier` to true or false".into(),
2486 );
2487 return q;
2488 };
2489 for (k, v, slot) in [
2490 ("database", qm.database, &mut q.database),
2491 ("schema", qm.schema, &mut q.schema),
2492 ("identifier", qm.identifier, &mut q.identifier),
2493 ] {
2494 match v {
2495 None => {}
2496 Some(Loose::Ok(b)) => *slot = Some(b),
2497 Some(Loose::Bad(_)) => bad(self, format!("`quoting.{k}` must be true or false")),
2498 }
2499 }
2500 for (k, v) in qm.unknown.0 {
2501 if v.is_boolean() {
2502 bad(
2503 self,
2504 format!(
2505 "unknown `quoting` key `{}`; use `database`, `schema` or `identifier`",
2506 k.value
2507 ),
2508 );
2509 } else {
2510 bad(self, format!("`quoting.{}` must be true or false", k.value));
2511 }
2512 }
2513 q
2514 }
2515
2516 fn source_tags(
2517 &mut self,
2518 file: &Path,
2519 v: Option<Loose<config::sources::Tags>>,
2520 ctx: &str,
2521 line: Option<usize>,
2522 ) -> Vec<String> {
2523 match v {
2524 None => Vec::new(),
2525 Some(Loose::Ok(config::sources::Tags(de::OneOf::A(t)))) => vec![t],
2526 Some(Loose::Ok(config::sources::Tags(de::OneOf::B(l)))) => l,
2527 Some(Loose::Bad(_)) => {
2528 self.diags.error(
2529 Code::InvalidSource,
2530 Some(file.to_path_buf()),
2531 line,
2532 format!("{ctx}: `tags` must be a list of strings"),
2533 );
2534 Vec::new()
2535 }
2536 }
2537 }
2538
2539 fn source_meta(
2540 &mut self,
2541 file: &Path,
2542 v: Option<Loose<config::sources::Meta>>,
2543 ctx: &str,
2544 line: Option<usize>,
2545 ) -> JsonMap<String, Json> {
2546 match v {
2547 None => JsonMap::new(),
2548 Some(Loose::Ok(m)) => m.0,
2549 Some(Loose::Bad(_)) => {
2550 self.diags.error(
2551 Code::InvalidSource,
2552 Some(file.to_path_buf()),
2553 line,
2554 format!("{ctx}: `meta` must be a map"),
2555 );
2556 JsonMap::new()
2557 }
2558 }
2559 }
2560
2561 fn parse_pass(&mut self, project: &mut Project, used: &mut Usage) {
2567 let inputs = crate::parse::Inputs {
2568 target: project.target_name.clone(),
2569 cli_vars: self.opts.vars.clone(),
2570 date: self.opts.date,
2571 scheduled_at: self.opts.scheduled_at,
2572 timezone: self.opts.timezone.clone(),
2573 schedule: None,
2574 started_at: None,
2575 };
2576 project.inputs = inputs.clone();
2577 let failed: BTreeSet<PathBuf> = self
2578 .diags
2579 .iter()
2580 .filter(|d| d.severity == crate::Severity::Error)
2581 .filter_map(|d| d.file.clone())
2582 .collect();
2583 let mut results = Vec::new();
2584 for (ri, r) in project.reports.iter().enumerate() {
2585 for (bi, b) in r.bindings.iter().enumerate() {
2586 let parsed = crate::parse::binding(project, r, b, &b.vars, &inputs);
2587 results.push((ri, bi, parsed));
2588 }
2589 }
2590 for (ri, bi, parsed) in results {
2591 for p in &parsed.errors {
2592 if failed.contains(&p.file) {
2593 continue;
2594 }
2595 if p.code == crate::parse::PARSE_FAILED {
2599 let msg = crate::Diagnostic {
2600 severity: crate::Severity::Error,
2601 code: p.code,
2602 message: p.message.clone(),
2603 file: Some(p.file.clone()),
2604 line: p.line,
2605 plugin: None,
2606 };
2607 let errs = project
2608 .parse_errors
2609 .entry(project.reports[ri].name.clone())
2610 .or_default();
2611 if !errs.contains(&msg) {
2612 errs.push(msg);
2613 }
2614 continue;
2615 }
2616 self.diags
2617 .error(p.code, Some(p.file.clone()), p.line, p.message.clone());
2618 }
2619 for p in &parsed.warnings {
2620 self.diags
2621 .warning(p.code, Some(p.file.clone()), p.line, p.message.clone());
2622 }
2623 let file = Some(project.reports[ri].file.clone());
2624 if let Some(i) = &parsed.inherited {
2625 used.connection(i, file.clone(), None);
2626 }
2627 for q in &parsed.queries {
2628 if let Some(c) = &q.connection {
2629 used.connection(c, file.clone(), None);
2630 }
2631 }
2632 for d in parsed.destinations.iter().flatten() {
2633 used.destination(d, file.clone(), None);
2634 }
2635 project.reports[ri].bindings[bi].parsed = Some(std::sync::Arc::new(parsed));
2636 }
2637 }
2638
2639 fn parse_sets(&mut self, files: &[Rc<YamlFile>]) -> BTreeMap<String, SetDef> {
2642 let mut out = BTreeMap::new();
2643 let mut seen: BTreeMap<String, PathBuf> = BTreeMap::new();
2644 for yf in files {
2645 let Ok(Loose::Ok(sets)) = de::from_node::<Loose<config::schedule::SetsFile>>(&yf.node) else {
2646 continue;
2647 };
2648 for (k, v) in sets.0.0 {
2649 let line = k.line();
2650 let name = k.value;
2651 if let Some(prev) = seen.get(&name) {
2652 self.diags.error(
2653 Code::DuplicateSet,
2654 Some(yf.display.clone()),
2655 line,
2656 format!("Set `{name}` is also declared in {}", prev.display()),
2657 );
2658 continue;
2659 }
2660 seen.insert(name.clone(), yf.display.clone());
2661 let v = match v {
2663 Loose::Ok(Some(v)) => Some(v),
2664 _ => None,
2665 };
2666 let profile = v
2667 .as_ref()
2668 .and_then(|v| v.profile.as_ref())
2669 .and_then(Loose::ok)
2670 .cloned();
2671 if v.as_ref().is_some_and(|v| v.profile.is_some()) && profile.is_none() {
2672 self.diags.error(
2673 Code::InvalidField,
2674 Some(yf.display.clone()),
2675 line,
2676 format!("Set `{name}`: `profile` must be a string"),
2677 );
2678 }
2679 let locale = match v.as_ref().and_then(|v| v.locale.as_ref()) {
2680 None => None,
2681 Some(l) => self.locale_str(
2682 l.ok().map(String::as_str),
2683 &yf.display,
2684 line,
2685 &format!("Set `{name}`: `locale`"),
2686 ),
2687 };
2688 let vars = match v.as_ref().and_then(|v| v.vars.as_ref()) {
2689 Some(Loose::Ok(m)) => m.clone(),
2690 None => JsonMap::new(),
2691 Some(Loose::Bad(_)) => {
2692 self.diags.error(
2693 Code::InvalidField,
2694 Some(yf.display.clone()),
2695 line,
2696 format!("Set `{name}`: `vars` must be a map"),
2697 );
2698 JsonMap::new()
2699 }
2700 };
2701 out.insert(
2702 name,
2703 SetDef {
2704 profile,
2705 vars,
2706 locale,
2707 file: yf.display.clone(),
2708 line,
2709 },
2710 );
2711 }
2712 }
2713 out
2714 }
2715
2716 fn check_refs(
2721 &mut self,
2722 sql: &[PathBuf],
2723 macros: &[PathBuf],
2724 index: &BTreeMap<String, Vec<PathBuf>>,
2725 lookups: &BTreeMap<String, Lookup>,
2726 ) -> BTreeSet<String> {
2727 let mut names = BTreeSet::new();
2728 let mut graph: BTreeMap<String, Vec<(String, usize)>> = BTreeMap::new();
2730 for file in sql.iter().chain(macros) {
2731 let Ok(src) = std::fs::read_to_string(self.root.join(file)) else {
2732 continue;
2733 };
2734 let is_sql = !file.starts_with(MACROS_DIR);
2735 for (name, line) in preflight::refs(&src) {
2736 if index.contains_key(&name) {
2737 if is_sql {
2738 let stem = file.file_stem().unwrap().to_string_lossy().to_string();
2739 graph.entry(stem).or_default().push((name.clone(), line));
2740 }
2741 names.insert(name);
2742 } else if !lookups.contains_key(&name) {
2743 self.diags.error(
2744 Code::UnknownRef,
2745 Some(file.clone()),
2746 Some(line),
2747 format!("`ref('{name}')`: there's no `{name}.sql` under reports/ and no lookup `{name}` under lookups/"),
2748 );
2749 }
2750 }
2751 }
2752 let mut reported: BTreeSet<Vec<String>> = BTreeSet::new();
2754 for start in graph.keys() {
2755 let mut path = vec![start.clone()];
2756 let mut stack = vec![graph[start].iter()];
2757 while let Some(it) = stack.last_mut() {
2758 let Some((next, line)) = it.next() else {
2759 stack.pop();
2760 path.pop();
2761 continue;
2762 };
2763 if let Some(i) = path.iter().position(|n| n == next) {
2764 let mut cycle = path[i..].to_vec();
2765 let mut key = cycle.clone();
2766 key.sort();
2767 if reported.insert(key) {
2768 cycle.push(next.clone());
2769 let file = index[path.last().unwrap()][0].clone();
2770 self.diags.error(
2771 Code::RefCycle,
2772 Some(file),
2773 Some(*line),
2774 format!("`ref()` cycle: {}", cycle.join(" → ")),
2775 );
2776 }
2777 continue;
2778 }
2779 if let Some(edges) = graph.get(next) {
2780 path.push(next.clone());
2781 stack.push(edges.iter());
2782 }
2783 }
2784 }
2785 names
2786 }
2787
2788 fn index_sql(&mut self, sql: &[PathBuf]) -> BTreeMap<String, Vec<PathBuf>> {
2789 let mut index: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
2790 for p in sql {
2791 let stem = p.file_stem().unwrap().to_string_lossy().to_string();
2792 index.entry(stem).or_default().push(p.clone());
2793 }
2794 for (name, paths) in &index {
2795 if paths.len() > 1 {
2796 self.diags.error(
2797 Code::DuplicateSqlName,
2798 Some(paths[1].clone()),
2799 None,
2800 format!(
2801 "`{name}.sql` exists more than once: {}; .sql basenames must be unique project-wide",
2802 paths
2803 .iter()
2804 .map(|p| p.display().to_string())
2805 .collect::<Vec<_>>()
2806 .join(", ")
2807 ),
2808 );
2809 }
2810 }
2811 index
2812 }
2813
2814 fn merge_reports(&mut self, fragments: Vec<Fragment>) -> Vec<RawReport> {
2817 let mut by_name: BTreeMap<String, Vec<Fragment>> = BTreeMap::new();
2818 for f in fragments {
2819 by_name.entry(f.name.clone()).or_default().push(f);
2820 }
2821 let mut out = Vec::new();
2822 for (name, frags) in by_name {
2823 let definers: Vec<usize> = (0..frags.len())
2824 .filter(|i| frags[*i].map.contains_key("queries"))
2825 .collect();
2826 if definers.len() > 1 {
2827 let second = &frags[definers[1]].file;
2828 self.diags.error(
2829 Code::DuplicateReportName,
2830 Some(second.display.clone()),
2831 key_line(second, "queries"),
2832 format!(
2833 "report `{name}` is declared in both {} and {}; report names must be unique project-wide",
2834 frags[definers[0]].file.display.display(),
2835 second.display.display()
2836 ),
2837 );
2838 continue;
2839 }
2840 let Some(&def) = definers.first() else {
2841 for f in &frags {
2842 let msg = if f.explicit_name {
2843 format!(
2844 "report `{name}` has no `queries`; a config fragment must name a report that declares `queries:`"
2845 )
2846 } else {
2847 format!("report `{name}` has no `queries`")
2848 };
2849 self.diags
2850 .error(Code::MissingQueries, Some(f.file.display.clone()), None, msg);
2851 }
2852 continue;
2853 };
2854 let mut keys: BTreeMap<String, Located<Value>> = BTreeMap::new();
2855 let mut owner: BTreeMap<String, usize> = BTreeMap::new();
2856 let mut lines: BTreeMap<String, Option<usize>> = BTreeMap::new();
2857 for (i, f) in frags.iter().enumerate() {
2858 for (k, v) in &f.map {
2859 let k = k.as_str();
2860 if k == "name" || !REPORT_KEYS.contains(&k) {
2861 continue;
2862 }
2863 if let Some(prev) = keys.get(k) {
2864 self.diags.error(
2865 Code::ConflictingDeclaration,
2866 Some(f.file.display.clone()),
2867 key_line(&f.file, k),
2868 format!(
2869 "`{k}` for report `{name}` is declared in both {} and {}",
2870 prev.file.display.display(),
2871 f.file.display.display()
2872 ),
2873 );
2874 continue;
2875 }
2876 keys.insert(
2877 k.to_string(),
2878 Located {
2879 value: v.clone(),
2880 file: f.file.clone(),
2881 },
2882 );
2883 owner.insert(k.to_string(), i);
2884 lines.insert(k.to_string(), key_line(&f.file, k));
2885 }
2886 }
2887 let file = frags[def].file.clone();
2888 let folder = frags[def].folder.clone();
2889 let mut typed = ReportFile::default();
2890 for (i, f) in frags.into_iter().enumerate() {
2891 let owns = |k: &str| owner.get(k) == Some(&i);
2892 let ReportFile {
2893 name: _,
2894 description,
2895 meta,
2896 output: _,
2897 plugins: _,
2898 sources: _,
2899 tags,
2900 queries,
2901 profile,
2902 default_set,
2903 vars,
2904 var_schema,
2905 timezone,
2906 locale,
2907 sets,
2908 schedule,
2909 unknown: _,
2910 } = f.typed;
2911 if owns("description") {
2912 typed.description = description;
2913 }
2914 if owns("meta") {
2915 typed.meta = meta;
2916 }
2917 if owns("tags") {
2918 typed.tags = tags;
2919 }
2920 if owns("queries") {
2921 typed.queries = queries;
2922 }
2923 if owns("profile") {
2924 typed.profile = profile;
2925 }
2926 if owns("default_set") {
2927 typed.default_set = default_set;
2928 }
2929 if owns("vars") {
2930 typed.vars = vars;
2931 }
2932 if owns("var_schema") {
2933 typed.var_schema = var_schema;
2934 }
2935 if owns("timezone") {
2936 typed.timezone = timezone;
2937 }
2938 if owns("locale") {
2939 typed.locale = locale;
2940 }
2941 if owns("sets") {
2942 typed.sets = sets;
2943 }
2944 if owns("schedule") {
2945 typed.schedule = schedule;
2946 }
2947 }
2948 out.push(RawReport {
2949 name,
2950 file,
2951 folder,
2952 keys,
2953 lines,
2954 typed,
2955 });
2956 }
2957 out
2958 }
2959
2960 fn resolve_queries(
2961 &mut self,
2962 r: &RawReport,
2963 index: &BTreeMap<String, Vec<PathBuf>>,
2964 referenced: &mut BTreeSet<String>,
2965 ) -> Vec<QueryEntry> {
2966 let Some(q) = &r.typed.queries else {
2967 return Vec::new();
2968 };
2969 let file = r
2970 .keys
2971 .get("queries")
2972 .map_or_else(|| r.file.display.clone(), |k| k.file.display.clone());
2973 let Loose::Ok(items) = &q.value else {
2974 self.diags.error(
2975 Code::InvalidField,
2976 Some(file),
2977 q.line(),
2978 format!("report `{}`: `queries` must be a list", r.name),
2979 );
2980 return Vec::new();
2981 };
2982 if items.is_empty() {
2983 self.diags.error(
2984 Code::MissingQueries,
2985 Some(file.clone()),
2986 q.line(),
2987 format!("report `{}` has an empty `queries` list", r.name),
2988 );
2989 }
2990 let mut out = Vec::new();
2991 for item in items {
2992 if let Some(e) = self.query_entry(&r.name, item, &file, index) {
2993 referenced.insert(e.query.clone());
2994 out.push(e);
2995 } else if let Some(n) = query_item_name(item) {
2996 referenced.insert(n.to_string());
2997 }
2998 }
2999 out
3000 }
3001
3002 fn query_entry(
3003 &mut self,
3004 report: &str,
3005 item: &QueryItem,
3006 file: &Path,
3007 index: &BTreeMap<String, Vec<PathBuf>>,
3008 ) -> Option<QueryEntry> {
3009 let file = Some(file.to_path_buf());
3010 let line = item.line();
3011 let (name, m) = match &item.value {
3012 Loose::Ok(de::OneOf::A(s)) => (s.clone(), None),
3013 Loose::Ok(de::OneOf::B(m)) => match &m.query {
3014 Some(Loose::Ok(s)) => (s.clone(), Some(m)),
3015 _ => {
3016 self.diags.error(
3017 Code::InvalidField,
3018 file,
3019 line,
3020 format!("report `{report}`: a `queries` map entry needs a `query:` name"),
3021 );
3022 return None;
3023 }
3024 },
3025 Loose::Bad(_) => {
3026 self.diags.error(
3027 Code::InvalidField,
3028 file,
3029 line,
3030 format!("report `{report}`: `queries` entries must be names or maps"),
3031 );
3032 return None;
3033 }
3034 };
3035 if name.ends_with(".sql") || name.contains('/') || name.contains('\\') {
3036 self.diags.error(
3037 Code::InvalidQueryName,
3038 file,
3039 line,
3040 format!(
3041 "report `{report}`: query `{name}` must be a bare .sql basename, with no path and no extension"
3042 ),
3043 );
3044 return None;
3045 }
3046 let mut e = QueryEntry {
3047 query: name.clone(),
3048 path: PathBuf::new(),
3049 profile: None,
3050 tab: true,
3051 tab_name: None,
3052 anchor: None,
3053 header: None,
3054 columns: BTreeMap::new(),
3055 autofit: None,
3056 style: None,
3057 on_empty: None,
3058 secret_columns: Vec::new(),
3059 };
3060 if let Some(m) = m {
3061 for k in &m.unknown.0 {
3062 self.diags.error(
3063 Code::UnknownKey,
3064 file.clone(),
3065 Some(k.line),
3066 format!("report `{report}`: unknown key `{}` on query `{name}`", k.name),
3067 );
3068 }
3069 match &m.profile {
3070 None => {}
3071 Some(Loose::Ok(p)) if !p.trim().is_empty() => e.profile = Some(p.clone()),
3072 Some(_) => self.diags.error(
3073 Code::InvalidField,
3074 file.clone(),
3075 line,
3076 format!("report `{report}`: `profile` of `{name}` must be a connection name"),
3077 ),
3078 }
3079 e.tab_name = match &m.tab_name {
3080 None => None,
3081 Some(Loose::Ok(s)) => Some(s.clone()),
3082 Some(Loose::Bad(f)) if f.kind == "a list" => {
3083 self.diags.error(
3084 Code::InvalidField,
3085 file.clone(),
3086 line,
3087 format!("report `{report}`: {}", one_tab_per_file(&name)),
3088 );
3089 None
3090 }
3091 Some(Loose::Bad(_)) => {
3092 self.diags.error(
3093 Code::InvalidField,
3094 file.clone(),
3095 line,
3096 format!("report `{report}`: `tab_name` of `{name}` must be a string"),
3097 );
3098 None
3099 }
3100 };
3101 match &m.tab {
3102 None => {}
3103 Some(Loose::Ok(b)) => e.tab = *b,
3104 Some(Loose::Bad(_)) => self.diags.error(
3105 Code::InvalidField,
3106 file.clone(),
3107 line,
3108 format!("report `{report}`: `tab` of `{name}` must be true or false"),
3109 ),
3110 }
3111 if !e.tab && e.tab_name.is_some() {
3112 self.diags.error(
3113 Code::InvalidField,
3114 file.clone(),
3115 line,
3116 format!("report `{report}`: `{name}` has `tab: false`, so its `tab_name` would never be used; remove one of them"),
3117 );
3118 }
3119 if let Some(a) = &m.anchor {
3120 match a {
3121 Loose::Ok(s) if options::is_cell(s) => e.anchor = Some(s.clone()),
3122 _ => self.diags.error(
3123 Code::InvalidCell,
3124 file.clone(),
3125 line,
3126 format!("report `{report}`: `anchor` of `{name}` must be a cell reference like `A1`"),
3127 ),
3128 }
3129 }
3130 if let Some(h) = &m.header {
3131 match h {
3132 Loose::Ok(b) => e.header = Some(*b),
3133 Loose::Bad(_) => self.diags.error(
3134 Code::InvalidField,
3135 file.clone(),
3136 line,
3137 format!("report `{report}`: `header` of `{name}` must be true or false"),
3138 ),
3139 }
3140 }
3141 match &m.on_empty {
3142 None => {}
3143 Some(Loose::Ok(v)) => e.on_empty = Some(*v),
3144 Some(Loose::Bad(_)) => self.diags.error(
3145 Code::InvalidField,
3146 file.clone(),
3147 line,
3148 format!(
3149 "report `{report}`: `on_empty` of `{name}` must be one of {}",
3150 crate::no_data::OnEmpty::NAMES.join(", ")
3151 ),
3152 ),
3153 }
3154 match &m.secret_columns {
3155 None => {}
3156 Some(Loose::Ok(v)) => e.secret_columns = v.clone(),
3157 Some(Loose::Bad(_)) => self.diags.error(
3158 Code::InvalidField,
3159 file.clone(),
3160 line,
3161 format!("report `{report}`: `secret_columns` of `{name}` must be a list of column names"),
3162 ),
3163 }
3164 if let Some(a) = &m.autofit {
3165 match a {
3166 Loose::Ok(b) => e.autofit = Some(*b),
3167 Loose::Bad(_) => self.diags.error(
3168 Code::InvalidField,
3169 file.clone(),
3170 line,
3171 format!("report `{report}`: `autofit` of `{name}` must be true or false"),
3172 ),
3173 }
3174 }
3175 if let Some(st) = &m.style {
3176 let (style, errs) = dre_protocol::style::parse_sheet(st);
3177 for err in errs {
3178 self.diags.error(
3179 Code::InvalidField,
3180 file.clone(),
3181 line,
3182 format!("report `{report}`: query `{name}`: `style`: {err}"),
3183 );
3184 }
3185 e.style = Some(style);
3186 }
3187 if let Some(c) = &m.columns {
3188 let (columns, errs) = dre_protocol::options::parse_columns(c);
3189 for err in errs {
3190 self.diags.error(
3191 Code::InvalidField,
3192 file.clone(),
3193 line,
3194 format!("report `{report}`: query `{name}`: {err}"),
3195 );
3196 }
3197 e.columns = columns;
3198 }
3199 }
3200 match index.get(&name) {
3201 Some(paths) => {
3202 e.path = paths[0].clone();
3203 Some(e)
3204 }
3205 None => {
3206 self.diags.error(
3207 Code::UnknownQuery,
3208 file,
3209 line,
3210 format!("report `{report}`: query `{name}` doesn't match any .sql file under reports/"),
3211 );
3212 None
3213 }
3214 }
3215 }
3216
3217 fn resolve_managed(
3220 &mut self,
3221 r: &RawReport,
3222 queries: Vec<QueryEntry>,
3223 project: &Project,
3224 folders: &BTreeMap<Vec<String>, FolderCfg>,
3225 used: &mut Usage,
3226 ) -> Option<Report> {
3227 let name = r.name.clone();
3228 let layers = folder_layers(folders, &r.folder);
3229 let key = |k: &str| r.keys.get(k);
3230 let located = |k: &str| {
3231 r.keys
3232 .get(k)
3233 .map(|l| (l.file.display.clone(), r.lines.get(k).copied().flatten()))
3234 };
3235 let typed = &r.typed;
3236 let var_schema = match &typed.var_schema {
3237 None => BTreeMap::new(),
3238 Some(de::Located {
3239 value: Loose::Ok(schema),
3240 ..
3241 }) => schema.clone(),
3242 Some(value) => {
3243 let (file, _) = located("var_schema").unwrap();
3244 self.diags.error(Code::InvalidField, Some(file), value.line(),
3245 "`var_schema` must map variable names to declarations with a supported type, default, allowed values, description, required flag, pattern and max_length");
3246 BTreeMap::new()
3247 }
3248 };
3249 for (name, definition) in &var_schema {
3250 for problem in definition.problems(name) {
3251 self.diags
3252 .error(Code::InvalidField, Some(r.file.display.clone()), None, problem);
3253 }
3254 if let Some(value) = &definition.default
3255 && let Err(error) = definition.check(name, value)
3256 {
3257 self.diags
3258 .error(Code::InvalidField, Some(r.file.display.clone()), None, error);
3259 }
3260 if name.trim().is_empty() {
3261 self.diags.error(
3262 Code::InvalidField,
3263 Some(r.file.display.clone()),
3264 None,
3265 "variable names must be non-empty",
3266 );
3267 }
3268 }
3269 let description = self.typed_string(
3270 &located("description").map_or_else(|| r.file.display.clone(), |(f, _)| f),
3271 typed.description.clone(),
3272 "description",
3273 );
3274 let meta_file = located("meta").map_or_else(|| r.file.display.clone(), |(f, _)| f);
3275 let meta = self.typed_metadata(&meta_file, typed.meta.clone(), "meta");
3276 let mut meta_layers = metadata_layers(project, &layers);
3277 if typed.meta.is_some() {
3278 meta_layers.push(MetaLayer {
3279 file: crate::slash(&meta_file).to_string_lossy().into_owned(),
3280 key: "meta".into(),
3281 meta: meta.clone(),
3282 });
3283 }
3284
3285 let mut tags: Vec<String> = layers.iter().flat_map(|l| l.tags.clone()).collect();
3286 if let Some(t) = &typed.tags {
3287 match &t.value {
3288 Loose::Ok(l) => tags.extend(l.iter().cloned()),
3289 Loose::Bad(_) => {
3290 let (f, l) = located("tags").unwrap();
3291 self.diags.error(
3292 Code::InvalidField,
3293 Some(f),
3294 l,
3295 format!("report `{name}`: `tags` must be a list of strings"),
3296 );
3297 }
3298 }
3299 }
3300 dedup(&mut tags);
3301
3302 if key("profile").is_some() && key("sets").is_some() {
3303 let (f, l) = located("sets").unwrap();
3304 self.diags.error(
3305 Code::ProfileAndSets,
3306 Some(f),
3307 l,
3308 format!("report `{name}` declares both `profile:` and `sets:`; use one or the other"),
3309 );
3310 }
3311
3312 let report_profile = match &typed.profile {
3313 Some(p) => match &p.value {
3314 Loose::Ok(s) => {
3315 let (f, l) = located("profile").unwrap();
3316 used.connection(s, Some(f), l);
3317 Some(s.to_string())
3318 }
3319 Loose::Bad(_) => {
3320 let (f, l) = located("profile").unwrap();
3321 self.diags.error(
3322 Code::InvalidField,
3323 Some(f),
3324 l,
3325 format!("report `{name}`: `profile` must be a string"),
3326 );
3327 None
3328 }
3329 },
3330 None => None,
3331 };
3332 let folder_profile = layers
3333 .iter()
3334 .rev()
3335 .find_map(|l| l.profile.as_ref().map(|p| p.0.clone()));
3336 let report_timezone = match &typed.timezone {
3337 Some(t) => {
3338 let (f, l) = located("timezone").unwrap();
3339 self.timezone_str(
3340 t.value.ok().map(String::as_str),
3341 &f,
3342 l,
3343 &format!("report `{name}`: `timezone`"),
3344 )
3345 }
3346 None => None,
3347 };
3348 let timezone = report_timezone
3349 .or_else(|| layers.iter().rev().find_map(|l| l.timezone.clone()))
3350 .or_else(|| project.timezone.clone());
3351 let report_locale = match &typed.locale {
3352 Some(t) => {
3353 let (f, l) = located("locale").unwrap();
3354 self.locale_str(
3355 t.value.ok().map(String::as_str),
3356 &f,
3357 l,
3358 &format!("report `{name}`: `locale`"),
3359 )
3360 }
3361 None => None,
3362 };
3363 let locale = report_locale
3364 .or_else(|| layers.iter().rev().find_map(|l| l.locale.clone()))
3365 .or_else(|| project.locale.clone());
3366 let profile_at = if report_profile.is_some() {
3367 located("profile").map(|(file, line)| ProfileAt {
3368 file,
3369 line,
3370 key: "`profile`".into(),
3371 })
3372 } else {
3373 inherited_profile_at(&layers, project)
3374 };
3375 let base_profile = report_profile
3376 .or(folder_profile)
3377 .or(project.default_profile.clone());
3378
3379 let mut vars = project.vars.clone();
3380 for l in &layers {
3381 if let Some(v) = &l.vars {
3382 vars.extend(v.clone());
3383 }
3384 }
3385 if let Some(v) = &typed.vars {
3386 match &v.value {
3387 Loose::Ok(m) => vars.extend(m.clone()),
3388 Loose::Bad(_) => {
3389 let (f, l) = located("vars").unwrap();
3390 self.diags.error(
3391 Code::InvalidField,
3392 Some(f),
3393 l,
3394 format!("report `{name}`: `vars` must be a map"),
3395 );
3396 }
3397 }
3398 }
3399
3400 let mut output = builtin_output();
3401 for (name, definition) in &var_schema {
3402 if let Some(value) = &definition.default {
3403 vars.entry(name.clone()).or_insert_with(|| value.clone());
3404 }
3405 }
3406 let mut merge_problems = Vec::new();
3407 if let Some(o) = project_default_output(project) {
3408 merge_problems.extend(merge_output(&mut output, &o));
3409 }
3410 for l in &layers {
3411 if let Some(o) = &l.output {
3412 merge_problems.extend(merge_output(&mut output, o));
3413 }
3414 }
3415 for p in merge_problems {
3416 self.diags.error(
3417 Code::InvalidField,
3418 Some(r.file.display.clone()),
3419 None,
3420 format!("report `{name}`: folder config: {p}"),
3421 );
3422 }
3423 let mut outputs = vec![output];
3424 if let Some(o) = key("output") {
3425 for p in layer_outputs(&mut outputs, &o.value) {
3426 let (f, l) = located("output").unwrap();
3427 self.diags
3428 .error(Code::InvalidField, Some(f), l, format!("report `{name}`: {p}"));
3429 }
3430 }
3431
3432 if typed.schedule.is_some() {
3433 let (f, l) = located("schedule").unwrap();
3434 self.moved_to_schedules(&f, l, &format!("report `{name}`: `schedule`"));
3435 }
3436
3437 let default_set = typed.default_set.as_ref().and_then(|d| d.value.ok().cloned());
3438
3439 let base = BindingBase {
3440 profile: base_profile,
3441 profile_at,
3442 vars,
3443 outputs,
3444 locale,
3445 };
3446 let has_sets = key("sets").is_some();
3447 let report_base = self.silent_binding(&name, &base, &queries, &r.file.display);
3448 let mut bindings = Vec::new();
3449 if let (Some(s), Some(at)) = (&typed.sets, key("sets")) {
3450 bindings = self.resolve_sets(&name, s, &at.file.display, &queries, &base, project, used);
3451 let declared: Vec<&str> = bindings.iter().filter_map(|b| b.set.as_deref()).collect();
3452 if let Some(d) = &default_set
3453 && !declared.contains(&d.as_str())
3454 {
3455 let (f, l) = located("default_set").unwrap();
3456 self.diags.error(
3457 Code::UnknownDefaultSet,
3458 Some(f),
3459 l,
3460 format!("report `{name}`: `default_set` `{d}` isn't one of the report's `sets:`"),
3461 );
3462 }
3463 } else {
3464 if default_set.is_some() {
3465 let (f, l) = located("default_set").unwrap();
3466 self.diags.error(
3467 Code::UnknownDefaultSet,
3468 Some(f),
3469 l,
3470 format!("report `{name}`: `default_set` needs a `sets:` list"),
3471 );
3472 }
3473 let b = self.finish_binding(
3474 &name,
3475 None,
3476 &base,
3477 None,
3478 queries.clone(),
3479 r.file.display.clone(),
3480 used,
3481 );
3482 bindings.push(b);
3483 }
3484
3485 Some(Report {
3486 name,
3487 var_schema,
3488 description,
3489 meta,
3490 meta_layers,
3491 managed: true,
3492 file: r.file.display.clone(),
3493 folder: r.folder.clone(),
3494 tags,
3495 queries,
3496 default_set,
3497 timezone,
3498 has_sets,
3499 bindings,
3500 base: report_base,
3501 })
3502 }
3503
3504 #[allow(clippy::too_many_arguments)]
3505 fn resolve_sets(
3506 &mut self,
3507 report: &str,
3508 sets: &de::Located<Loose<Vec<SetItem>>>,
3509 display: &Path,
3510 queries: &[QueryEntry],
3511 base: &BindingBase,
3512 project: &Project,
3513 used: &mut Usage,
3514 ) -> Vec<Binding> {
3515 let file = Some(display.to_path_buf());
3516 let sets_line = sets.line();
3517 let Loose::Ok(items) = &sets.value else {
3518 self.diags.error(
3519 Code::InvalidField,
3520 file,
3521 sets_line,
3522 format!("report `{report}`: `sets` must be a list"),
3523 );
3524 return Vec::new();
3525 };
3526 let mut seen = BTreeSet::new();
3527 let mut out = Vec::new();
3528 for item in items {
3529 let line = item.line();
3530 let (name, inline): (String, Option<&SetEntry>) = match &item.value {
3531 Loose::Ok(de::OneOf::A(s)) => (s.clone(), None),
3532 Loose::Ok(de::OneOf::B(m)) => match &m.name {
3533 Some(Loose::Ok(n)) => (n.clone(), Some(m)),
3534 _ => {
3535 self.diags.error(
3536 Code::InvalidField,
3537 file.clone(),
3538 line,
3539 format!("report `{report}`: a `sets:` map entry needs a `name:`"),
3540 );
3541 continue;
3542 }
3543 },
3544 Loose::Bad(_) => {
3545 self.diags.error(
3546 Code::InvalidField,
3547 file.clone(),
3548 line,
3549 format!("report `{report}`: `sets:` entries must be names or maps"),
3550 );
3551 continue;
3552 }
3553 };
3554 if !seen.insert(name.clone()) {
3555 self.diags.error(
3556 Code::DuplicateSet,
3557 file.clone(),
3558 line,
3559 format!("report `{report}` lists Set `{name}` more than once"),
3560 );
3561 continue;
3562 }
3563 let registry = project.sets.get(&name);
3564 if inline.is_none() && registry.is_none() {
3565 self.diags.error(
3566 Code::UnknownSet,
3567 file.clone(),
3568 line,
3569 format!("report `{report}`: Set `{name}` isn't declared in sets.yml"),
3570 );
3571 continue;
3572 }
3573 let mut b = BindingBase {
3574 profile: base.profile.clone(),
3575 profile_at: base.profile_at.clone(),
3576 vars: base.vars.clone(),
3577 outputs: base.outputs.clone(),
3578 locale: base.locale.clone(),
3579 };
3580 if let Some(reg) = registry {
3581 if let Some(p) = ®.profile {
3582 b.profile = Some(p.clone());
3583 b.profile_at = Some(ProfileAt {
3584 file: reg.file.clone(),
3585 line: reg.line,
3586 key: format!("`profile` of Set `{name}`"),
3587 });
3588 used.connection(p, None, None);
3589 }
3590 b.vars.extend(reg.vars.clone());
3591 if reg.locale.is_some() {
3592 b.locale = reg.locale.clone();
3593 }
3594 }
3595 let mut qs = queries.to_vec();
3596 let mut tab_names: Option<Mapping> = None;
3597 if let Some(m) = inline {
3598 let ctx = format!("report `{report}`, Set `{name}`");
3599 for k in &m.unknown.0 {
3600 self.diags.error(
3601 Code::UnknownKey,
3602 file.clone(),
3603 Some(k.line),
3604 format!("{ctx}: unknown key `{}`", k.name),
3605 );
3606 }
3607 if let Some(p) = &m.profile {
3608 let pline = p.line();
3609 match &p.value {
3610 Loose::Ok(p) => {
3611 b.profile = Some(p.to_string());
3612 b.profile_at = Some(ProfileAt {
3613 file: display.to_path_buf(),
3614 line: pline,
3615 key: format!("`profile` of Set `{name}`"),
3616 });
3617 used.connection(p, file.clone(), pline);
3618 }
3619 Loose::Bad(_) => self.diags.error(
3620 Code::InvalidField,
3621 file.clone(),
3622 pline,
3623 format!("{ctx}: `profile` must be a string"),
3624 ),
3625 }
3626 }
3627 match &m.vars {
3628 Some(Loose::Ok(v)) => b.vars.extend(v.clone()),
3629 None => {}
3630 Some(Loose::Bad(_)) => self.diags.error(
3631 Code::InvalidField,
3632 file.clone(),
3633 line,
3634 format!("{ctx}: `vars` must be a map"),
3635 ),
3636 }
3637 if let Some(o) = &m.output {
3638 for p in layer_outputs(&mut b.outputs, o) {
3639 self.diags
3640 .error(Code::InvalidField, file.clone(), line, format!("{ctx}: {p}"));
3641 }
3642 }
3643 if m.schedule.is_some() {
3644 self.moved_to_schedules(display, line, &format!("{ctx}: `schedule`"));
3645 }
3646 if let Some(l) = &m.locale
3647 && let Some(l) = self.locale_str(
3648 l.ok().map(String::as_str),
3649 display,
3650 line,
3651 &format!("{ctx}: `locale`"),
3652 )
3653 {
3654 b.locale = Some(l);
3655 }
3656 let listed: Vec<&str> = queries.iter().map(|q| q.query.as_str()).collect();
3657 match (&m.exclude, &m.queries) {
3658 (Some(_), Some(_)) => self.diags.error(
3659 Code::ExcludeAndQueries,
3660 file.clone(),
3661 line,
3662 format!("{ctx}: use either `exclude:` or `queries:`, not both"),
3663 ),
3664 (Some(ex), None) => match &ex.value {
3665 Loose::Ok(names) => {
3666 for q in names {
3667 if !listed.contains(&q.as_str()) {
3668 self.diags.error(
3669 Code::UnknownQuery,
3670 file.clone(),
3671 ex.line(),
3672 format!("{ctx}: `exclude` names `{q}`, which isn't in the report's `queries:`"),
3673 );
3674 }
3675 }
3676 qs.retain(|q| !names.contains(&q.query));
3677 }
3678 Loose::Bad(_) => self.diags.error(
3679 Code::InvalidField,
3680 file.clone(),
3681 line,
3682 format!("{ctx}: `exclude` must be a list of query names"),
3683 ),
3684 },
3685 (None, Some(ov)) => match &ov.value {
3686 Loose::Ok(items) => {
3687 let mut sub = Vec::new();
3688 for it in items {
3689 let (qn, settings) = match it {
3690 Loose::Ok(de::OneOf::A(s)) => (s.clone(), None),
3691 Loose::Ok(de::OneOf::B(e)) => {
3692 match e.query.as_ref().and_then(Loose::ok) {
3693 Some(q) => (q.clone(), Some(e)),
3694 None => continue,
3695 }
3696 }
3697 Loose::Bad(_) => continue,
3698 };
3699 match queries.iter().find(|q| q.query == qn) {
3700 Some(q) => {
3701 let mut q = q.clone();
3702 if let Some(e) = settings {
3703 match &e.tab_name {
3704 None => {}
3705 Some(Loose::Ok(s)) => q.tab_name = Some(s.clone()),
3706 Some(Loose::Bad(_)) => self.diags.error(
3707 Code::InvalidField,
3708 file.clone(),
3709 line,
3710 format!("{ctx}: {}", one_tab_per_file(&qn)),
3711 ),
3712 }
3713 if let Some(Loose::Ok(b)) = &e.tab {
3714 q.tab = *b;
3715 }
3716 match &e.on_empty {
3717 None => {}
3718 Some(Loose::Ok(v)) => q.on_empty = Some(*v),
3719 Some(Loose::Bad(_)) => self.diags.error(
3720 Code::InvalidField,
3721 file.clone(),
3722 line,
3723 format!(
3724 "{ctx}: `on_empty` of `{qn}` must be one of {}",
3725 crate::no_data::OnEmpty::NAMES.join(", ")
3726 ),
3727 ),
3728 }
3729 match &e.secret_columns {
3730 None => {}
3731 Some(Loose::Ok(v)) => {
3732 for c in v {
3733 if !q.secret_columns.contains(c) {
3734 q.secret_columns.push(c.clone());
3735 }
3736 }
3737 }
3738 Some(Loose::Bad(_)) => self.diags.error(
3739 Code::InvalidField,
3740 file.clone(),
3741 line,
3742 format!("{ctx}: `secret_columns` of `{qn}` must be a list of column names"),
3743 ),
3744 }
3745 }
3746 sub.push(q);
3747 }
3748 None => self.diags.error(
3749 Code::UnknownQuery,
3750 file.clone(),
3751 ov.line(),
3752 format!("{ctx}: `queries` names `{qn}`, which isn't in the report's `queries:`"),
3753 ),
3754 }
3755 }
3756 qs = sub;
3757 }
3758 Loose::Bad(_) => self.diags.error(
3759 Code::InvalidField,
3760 file.clone(),
3761 line,
3762 format!("{ctx}: `queries` must be a list"),
3763 ),
3764 },
3765 (None, None) => {}
3766 }
3767 tab_names = match &m.tab_names {
3768 None => None,
3769 Some(Loose::Ok(t)) => Some(t.clone()),
3770 Some(Loose::Bad(_)) => {
3771 self.diags.error(
3772 Code::InvalidField,
3773 file.clone(),
3774 line,
3775 format!("{ctx}: `tab_names` must be a map of query name to tab name"),
3776 );
3777 None
3778 }
3779 };
3780 }
3781 let bind = self.finish_binding(
3782 report,
3783 Some(name),
3784 &b,
3785 tab_names.as_ref(),
3786 qs,
3787 display.to_path_buf(),
3788 used,
3789 );
3790 out.push(bind);
3791 }
3792 out
3793 }
3794
3795 fn silent_binding(
3797 &mut self,
3798 report: &str,
3799 base: &BindingBase,
3800 queries: &[QueryEntry],
3801 file: &Path,
3802 ) -> Binding {
3803 let saved = std::mem::take(&mut self.diags);
3804 let mut scratch = Usage::default();
3805 let b = self.finish_binding(
3806 report,
3807 None,
3808 base,
3809 None,
3810 queries.to_vec(),
3811 file.to_path_buf(),
3812 &mut scratch,
3813 );
3814 self.diags = saved;
3815 b
3816 }
3817
3818 #[allow(clippy::too_many_arguments)]
3819 fn finish_binding(
3820 &mut self,
3821 report: &str,
3822 set: Option<String>,
3823 base: &BindingBase,
3824 tab_names: Option<&Mapping>,
3825 mut queries: Vec<QueryEntry>,
3826 file: PathBuf,
3827 used: &mut Usage,
3828 ) -> Binding {
3829 let ctx = match &set {
3830 Some(s) => format!("report `{report}`, Set `{s}`"),
3831 None => format!("report `{report}`"),
3832 };
3833 if let Some(t) = tab_names {
3834 for (k, v) in t {
3835 let q = k.as_str();
3836 match queries.iter_mut().find(|e| e.query == q) {
3837 Some(e) => match v {
3838 Value::String(s) => e.tab_name = Some(s.clone()),
3839 _ => self.diags.error(
3840 Code::InvalidField,
3841 Some(file.clone()),
3842 None,
3843 format!("{ctx}: {}", one_tab_per_file(q)),
3844 ),
3845 },
3846 None => self.diags.error(
3847 Code::UnknownQuery,
3848 Some(file.clone()),
3849 None,
3850 format!("{ctx}: `tab_names` names `{q}`, which isn't one of this Binding's queries"),
3851 ),
3852 }
3853 }
3854 }
3855 let outputs = self.typed_outputs(&base.outputs, &ctx, &file, &queries, used);
3856 if let Some(p) = &base.profile {
3857 used.connection(p, None, None);
3858 }
3859 Binding {
3860 set,
3861 profile: base.profile.clone(),
3862 vars: base.vars.clone(),
3863 queries: std::mem::take(&mut queries),
3864 locale: base.locale.clone(),
3865 outputs,
3866 schedules: Vec::new(),
3867 parsed: None,
3868 profile_at: base.profile_at.clone(),
3869 }
3870 }
3871
3872 fn typed_outputs(
3875 &mut self,
3876 maps: &[Mapping],
3877 ctx: &str,
3878 file: &Path,
3879 queries: &[QueryEntry],
3880 used: &mut Usage,
3881 ) -> Vec<Output> {
3882 let at = Some(file.to_path_buf());
3883 let several = maps.len() > 1;
3884 let mut outputs = Vec::new();
3885 for (i, m) in maps.iter().enumerate() {
3886 let octx = if several {
3887 format!(
3888 "{ctx}, {}",
3889 output_label(m.get("name").and_then(Value::as_str), i)
3890 )
3891 } else {
3892 ctx.to_string()
3893 };
3894 let subset: Vec<QueryEntry> = match m.get("queries") {
3895 None | Some(Value::Null) => queries.to_vec(),
3896 Some(v) => {
3897 match string_list(v) {
3898 Some(names) => {
3899 for n in names.iter().filter(|n| !queries.iter().any(|q| &&q.query == n)) {
3900 self.diags.error(
3901 Code::UnknownQuery,
3902 at.clone(),
3903 None,
3904 format!("{octx}: `queries` names `{n}`, which isn't one of this Binding's queries"),
3905 );
3906 }
3907 queries
3908 .iter()
3909 .filter(|q| names.contains(&q.query))
3910 .cloned()
3911 .collect()
3912 }
3913 None => {
3914 self.diags.error(
3915 Code::InvalidField,
3916 at.clone(),
3917 None,
3918 format!("{octx}: `queries` must be a list of query names"),
3919 );
3920 queries.to_vec()
3921 }
3922 }
3923 }
3924 };
3925 outputs.push(self.typed_output(m, &octx, file, &subset, used));
3926 }
3927 let mut seen = BTreeSet::new();
3928 for o in &outputs {
3929 if let Some(n) = &o.name
3930 && !seen.insert(n.clone())
3931 {
3932 self.diags.error(
3933 Code::DuplicateOutput,
3934 at.clone(),
3935 None,
3936 format!("{ctx}: output `{n}` is declared twice; output names must be unique"),
3937 );
3938 }
3939 }
3940 let mut exts = BTreeSet::new();
3942 for o in outputs.iter().filter(|o| o.name.is_none()) {
3943 let ext = o.extension.clone().unwrap_or_else(|| o.format.clone());
3944 if !exts.insert(ext) {
3945 self.diags.error(
3946 Code::DuplicateOutput,
3947 at.clone(),
3948 None,
3949 format!(
3950 "{ctx}: two unnamed `{}` outputs would write the same file; give each output a `name:`",
3951 o.format
3952 ),
3953 );
3954 }
3955 }
3956 for q in queries.iter().filter(|q| !q.columns.is_empty()) {
3957 let formats: Vec<&str> = outputs
3958 .iter()
3959 .filter(|o| o.feeds(&q.query))
3960 .map(|o| o.format.as_str())
3961 .collect();
3962 if !formats.is_empty() && !formats.contains(&"xlsx") {
3963 self.diags.error(
3964 Code::InvalidField,
3965 at.clone(),
3966 None,
3967 format!(
3968 "{ctx}: `columns` on query `{}` only applies to the xlsx format",
3969 q.query
3970 ),
3971 );
3972 }
3973 }
3974 self.check_password_lists(&outputs, ctx, file, queries);
3975 self.check_zip_names(&outputs, ctx, file, queries);
3976 for (i, o) in outputs.iter().enumerate() {
3977 let octx = if several {
3978 format!("{ctx}, {}", o.label(i))
3979 } else {
3980 ctx.to_string()
3981 };
3982 self.check_output_templates(o, &outputs, &octx, file, queries);
3983 self.check_password_columns(o, &octx, file, queries);
3984 for d in o
3985 .destinations
3986 .iter()
3987 .filter(|d| d.zip.is_some() && o.is_message())
3988 {
3989 self.diags.error(
3990 Code::InvalidDestinationOption,
3991 at.clone(),
3992 None,
3993 format!(
3994 "{octx}: destination `{}`: a message can't be zipped; zip the files on a file output's entry, and attach that output's files to the message instead",
3995 d.profile
3996 ),
3997 );
3998 }
3999 for d in o.destinations.iter().filter(|d| !d.attach.is_empty()) {
4000 for a in &d.attach {
4001 let problem = match outputs.iter().find(|x| x.name.as_deref() == Some(a.as_str())) {
4002 _ if !o.is_message() && d.zip.is_none() => Some(
4003 "`attach` only applies to a message output, or to an entry with `zip:` (a pack)"
4004 .to_string(),
4005 ),
4006 None => Some(format!("no output of this report is named `{a}`")),
4007 Some(x) if std::ptr::eq(x, o) => Some(format!("`{a}` is this output itself")),
4008 Some(x) if x.is_message() && d.zip.is_some() => Some(format!(
4009 "`{a}` is a message, which can't go in a zip; attach file outputs"
4010 )),
4011 Some(x) if x.is_message() => Some(format!(
4012 "`{a}` is a message; attach file outputs (its `.md` is linked with `outputs.{a}.location`)"
4013 )),
4014 Some(x) => match (&o.for_each, &x.for_each) {
4015 (None, Some(_)) => Some(format!(
4016 "`{a}` makes a file per row; attach it from a message with the same `for_each`, so each row's message gets that row's file"
4017 )),
4018 (Some(m), Some(f)) if m.query != f.query || m.key != f.key => Some(format!(
4019 "`{a}` makes a file per row of `{}` by `{}`, but this message is per row of `{}` by `{}`; give them the same `for_each` driver and key so each row's message gets its own file",
4020 f.query,
4021 f.key.join(", "),
4022 m.query,
4023 m.key.join(", ")
4024 )),
4025 _ => None,
4026 },
4027 };
4028 if let Some(p) = problem {
4029 self.diags.error(
4030 Code::InvalidDestinationOption,
4031 at.clone(),
4032 None,
4033 format!("{octx}: destination `{}`: `attach: {a}`: {p}", d.profile),
4034 );
4035 }
4036 }
4037 }
4038 }
4039 for q in queries.iter().filter(|q| q.tab) {
4040 if !outputs.iter().any(|o| o.feeds(&q.query)) {
4041 self.diags.warning(
4042 Code::UnusedQuery,
4043 at.clone(),
4044 None,
4045 format!(
4046 "{ctx}: query `{}` feeds no output; add it to an output's `queries:`, or give it `tab: false` if it only prepares later queries",
4047 q.query
4048 ),
4049 );
4050 }
4051 }
4052 outputs
4053 }
4054
4055 fn check_zip_names(&mut self, outputs: &[Output], ctx: &str, file: &Path, queries: &[QueryEntry]) {
4061 let report = file
4062 .file_stem()
4063 .map(|s| s.to_string_lossy().into_owned())
4064 .unwrap_or_default();
4065 let files_of = |o: &Output| -> Option<Vec<String>> {
4066 if o.for_each.is_some() || o.is_message() {
4067 return None;
4068 }
4069 let ext = o.extension.clone().unwrap_or_else(|| {
4070 match o.format.as_str() {
4071 "delimited" | "fixed_width" => "txt",
4072 f => f,
4073 }
4074 .to_string()
4075 });
4076 let literal = o
4077 .destinations
4078 .iter()
4079 .filter(|d| d.zip.is_none())
4080 .find_map(|d| d.path.as_deref())
4081 .and_then(|p| p.rsplit(['/', '\\']).next())
4082 .filter(|n| !n.is_empty());
4083 let base = match literal {
4084 Some(n) if crate::preflight::is_templated(n) => return None,
4085 Some(n) => n.to_string(),
4086 None => {
4087 let stem = o.name.clone().unwrap_or_else(|| report.clone());
4088 if ext.is_empty() {
4089 stem
4090 } else {
4091 format!("{stem}.{ext}")
4092 }
4093 }
4094 };
4095 let single_table = matches!(o.format.as_str(), "csv" | "delimited" | "fixed_width" | "parquet");
4096 let tabs: Vec<String> = queries
4097 .iter()
4098 .filter(|q| q.tab && o.feeds(&q.query))
4099 .map(|q| q.tab_name.clone().unwrap_or_else(|| q.query.clone()))
4100 .collect();
4101 if !single_table || o.template.is_some() || tabs.len() <= 1 {
4102 return Some(vec![base]);
4103 }
4104 let (stem, ext) = match base.rfind('.') {
4105 Some(i) if i > 0 => (&base[..i], &base[i..]),
4106 _ => (base.as_str(), ""),
4107 };
4108 Some(tabs.iter().map(|t| format!("{stem}_{t}{ext}")).collect())
4109 };
4110 for (i, o) in outputs.iter().enumerate() {
4111 for d in o.destinations.iter().filter(|d| d.zip.is_some()) {
4112 let Some(mut names) = files_of(o) else { continue };
4113 let mut known = true;
4114 for a in &d.attach {
4115 match outputs
4116 .iter()
4117 .find(|x| x.name.as_deref() == Some(a.as_str()))
4118 .and_then(files_of)
4119 {
4120 Some(n) => names.extend(n),
4121 None => known = false,
4122 }
4123 }
4124 if !known {
4125 continue;
4126 }
4127 let mut seen = std::collections::BTreeSet::new();
4128 for n in &names {
4129 if !seen.insert(n.as_str()) {
4130 self.diags.error(
4131 Code::InvalidDestinationOption,
4132 Some(file.to_path_buf()),
4133 None,
4134 format!(
4135 "{ctx}, {}: destination `{}`: two files named `{n}` would go into the zip (it's flat, and nothing is renamed); give the outputs different `name:`s",
4136 o.label(i),
4137 d.profile
4138 ),
4139 );
4140 break;
4141 }
4142 }
4143 }
4144 }
4145 }
4146
4147 fn check_password_lists(&mut self, outputs: &[Output], ctx: &str, file: &Path, queries: &[QueryEntry]) {
4150 let protected = |o: &Output| o.options.get("password").is_some();
4151 let mut locked: Vec<&str> = Vec::new();
4152 for o in outputs {
4153 for d in &o.destinations {
4154 if d.zip.as_ref().is_some_and(|z| z.password.is_some()) {
4155 locked.push(&d.profile);
4156 }
4157 }
4158 let carries_protected = protected(o)
4159 || (o.is_message()
4160 && o.destinations.iter().any(|d| {
4161 d.attach.iter().any(|a| {
4162 outputs
4163 .iter()
4164 .any(|x| x.name.as_deref() == Some(a.as_str()) && protected(x))
4165 })
4166 }));
4167 if carries_protected {
4168 locked.extend(o.destinations.iter().map(|d| d.profile.as_str()));
4169 }
4170 }
4171 for o in outputs.iter().filter(|o| !o.is_message() && !protected(o)) {
4172 let secret = queries
4173 .iter()
4174 .filter(|q| !q.secret_columns.is_empty() && o.feeds(&q.query))
4175 .map(|q| q.query.as_str())
4176 .collect::<Vec<_>>();
4177 if secret.is_empty() {
4178 continue;
4179 }
4180 for d in o
4181 .destinations
4182 .iter()
4183 .filter(|d| d.zip.as_ref().is_none_or(|z| z.password.is_none()))
4184 {
4185 if d.profile != crate::profiles::LOCAL_TYPE && locked.contains(&d.profile.as_str()) {
4187 self.diags.warning(
4188 Code::InvalidOutputOption,
4189 Some(file.to_path_buf()),
4190 None,
4191 format!(
4192 "{ctx}: output `{}` holds secret columns of `{}` and goes to destination `{}`, which also receives the password-protected files: send the password list somewhere else",
4193 o.name.as_deref().unwrap_or(&o.format),
4194 secret.join("`, `"),
4195 d.profile
4196 ),
4197 );
4198 }
4199 }
4200 }
4201 }
4202
4203 fn check_password_columns(&mut self, o: &Output, ctx: &str, file: &Path, queries: &[QueryEntry]) {
4206 static EACH: std::sync::LazyLock<regex::Regex> =
4207 std::sync::LazyLock::new(|| regex::Regex::new(r"\beach\.([A-Za-z_][A-Za-z0-9_]*)").unwrap());
4208 static RESULTS: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
4210 regex::Regex::new(r"\bresults\.([A-Za-z_][A-Za-z0-9_]*)(?:\.(?:first|last|rows\[\d+\]))?(?:\.([A-Za-z_][A-Za-z0-9_]*))?").unwrap()
4211 });
4212 static VAR: std::sync::LazyLock<regex::Regex> =
4213 std::sync::LazyLock::new(|| regex::Regex::new(r"\bvar\s*\(").unwrap());
4214 let secret_of = |q: &str| -> Vec<&String> {
4215 queries
4216 .iter()
4217 .filter(|e| e.query == q)
4218 .flat_map(|e| &e.secret_columns)
4219 .collect()
4220 };
4221 let warn = |s: &mut Self, msg: String| {
4222 s.diags.warning(
4223 Code::InvalidOutputOption,
4224 Some(file.to_path_buf()),
4225 None,
4226 format!("{ctx}: {msg}"),
4227 )
4228 };
4229 let mut passwords: Vec<(&str, &str)> = Vec::new();
4230 if let Some(pw) = o.options.get("password").and_then(Json::as_str) {
4231 passwords.push(("`password`", pw));
4232 }
4233 for d in &o.destinations {
4234 if let Some(pw) = d.zip.as_ref().and_then(|z| z.password.as_deref()) {
4235 passwords.push(("`zip.password`", pw));
4236 }
4237 }
4238 for (what, pw) in passwords {
4239 if pw.is_empty() {
4240 continue;
4241 }
4242 if !crate::preflight::is_templated(pw) {
4243 warn(
4244 self,
4245 format!(
4246 "{what} is written in the YAML, where anyone who reads the project sees it; read it with `env_var()`, or from a query column in `secret_columns`"
4247 ),
4248 );
4249 continue;
4250 }
4251 if VAR.is_match(pw) {
4252 warn(
4253 self,
4254 format!(
4255 "{what} reads `var()`: vars show in the run's log and results; read it with `env_var()`, or from a query column in `secret_columns`"
4256 ),
4257 );
4258 }
4259 if let Some(fe) = &o.for_each {
4260 let secret = secret_of(&fe.query);
4261 for c in EACH.captures_iter(pw) {
4262 let col = &c[1];
4263 if !secret.iter().any(|s| s.as_str() == col) {
4264 warn(
4265 self,
4266 format!(
4267 "{what} reads `each.{col}`, which isn't in `secret_columns` of query `{}`: its values would show in logs and results; add `secret_columns: [{col}]` to the query entry",
4268 fe.query
4269 ),
4270 );
4271 }
4272 }
4273 }
4274 for c in RESULTS.captures_iter(pw) {
4275 let q = &c[1];
4276 let secret = secret_of(q);
4277 let col = c
4278 .get(2)
4279 .map(|m| m.as_str())
4280 .filter(|c| !matches!(*c, "value" | "row_count"));
4281 let covered = match col {
4282 Some(col) => secret.iter().any(|s| s.as_str() == col),
4283 None => !secret.is_empty(),
4284 };
4285 if !covered {
4286 let which = col.map_or_else(|| "its password column".to_string(), |c| format!("`{c}`"));
4287 warn(
4288 self,
4289 format!(
4290 "{what} reads `results.{q}`, but {which} isn't in `secret_columns` of query `{q}`: its values would show in logs and results; list it in the query entry's `secret_columns`"
4291 ),
4292 );
4293 }
4294 }
4295 }
4296 if let Some(fe) = &o.for_each {
4298 let secret = secret_of(&fe.query);
4299 let mut texts: Vec<&str> = ["text", "title"]
4300 .iter()
4301 .filter_map(|k| o.options.get(*k).and_then(Json::as_str))
4302 .collect();
4303 texts.extend(o.destinations.iter().filter_map(|d| d.path.as_deref()));
4304 let mut seen = Vec::new();
4305 for t in texts {
4306 for c in EACH.captures_iter(t) {
4307 let col = c[1].to_string();
4308 let lower = col.to_ascii_lowercase();
4309 let looks_secret = ["password", "passwd", "pwd", "passcode", "pin", "secret"]
4310 .iter()
4311 .any(|w| {
4312 lower == *w
4313 || lower.starts_with(&format!("{w}_"))
4314 || lower.ends_with(&format!("_{w}"))
4315 });
4316 if looks_secret && !secret.iter().any(|s| **s == col) && !seen.contains(&col) {
4317 warn(
4318 self,
4319 format!(
4320 "`each.{col}` looks like a password but isn't in `secret_columns` of query `{}`: its values would show in logs and results; add `secret_columns: [{col}]` to the query entry",
4321 fe.query
4322 ),
4323 );
4324 seen.push(col);
4325 }
4326 }
4327 }
4328 }
4329 }
4330
4331 fn check_output_templates(
4332 &mut self,
4333 o: &Output,
4334 all: &[Output],
4335 ctx: &str,
4336 file: &Path,
4337 queries: &[QueryEntry],
4338 ) {
4339 let at = Some(file.to_path_buf());
4340 let err = |s: &mut Self, msg: String| {
4341 s.diags.error(
4342 Code::InvalidOutputOption,
4343 at.clone(),
4344 None,
4345 format!("{ctx}: {msg}"),
4346 )
4347 };
4348 let mut templates: Vec<(String, String)> = Vec::new();
4350 if let Some(w) = &o.when {
4351 templates.push(("`when`".into(), format!("{{% if {w} %}}{{% endif %}}")));
4352 }
4353 if o.is_message() {
4354 for k in o.options.keys() {
4355 if !crate::message::MESSAGE_KEYS.contains(&k.as_str()) {
4356 err(
4357 self,
4358 format!(
4359 "`{k}` isn't an option of the `message` format; its options are {}",
4360 crate::message::MESSAGE_KEYS.join(", ")
4361 ),
4362 );
4363 }
4364 }
4365 if o.options.contains_key("text") && o.options.contains_key("file") {
4366 err(self, "use either `text:` or `file:`, not both".into());
4367 }
4368 for k in ["text", "title"] {
4369 match o.options.get(k) {
4370 None => {}
4371 Some(Json::String(t)) => templates.push((format!("`{k}`"), t.clone())),
4372 Some(_) => err(self, format!("`{k}` must be a string")),
4373 }
4374 }
4375 match o.options.get("file") {
4376 None => {}
4377 Some(Json::String(f)) => match find_template(&self.root, f).map(std::fs::read_to_string) {
4378 Some(Ok(t)) => templates.push((format!("`file` {f}"), t)),
4379 Some(Err(e)) => err(self, format!("can't read message file `{f}`: {e}")),
4380 None => err(
4381 self,
4382 format!(
4383 "message file `{f}` doesn't exist (looked in the project root and templates/)"
4384 ),
4385 ),
4386 },
4387 Some(_) => err(self, "`file` must be a path (a string)".into()),
4388 }
4389 match o.options.get("max_rows") {
4390 None => {}
4391 Some(v) if v.as_u64().is_some_and(|n| n > 0) => {}
4392 Some(_) => err(self, "`max_rows` must be a positive whole number".into()),
4393 }
4394 }
4395 let fed: Vec<&str> = queries
4396 .iter()
4397 .filter(|q| q.tab && o.feeds(&q.query))
4398 .map(|q| q.query.as_str())
4399 .collect();
4400 for (what, src) in &templates {
4401 if let Err((line, msg)) = preflight::check_syntax("output", src) {
4402 let line = line.map(|l| format!(" (line {l})")).unwrap_or_default();
4403 err(self, format!("{what} doesn't compile{line}: {msg}"));
4404 continue;
4405 }
4406 for (name, _) in preflight::attributes(src, "results") {
4407 if !fed.contains(&name.as_str()) {
4408 err(
4409 self,
4410 format!(
4411 "{what} reads `results.{name}`, but `{name}` isn't one of this output's queries ({})",
4412 if fed.is_empty() {
4413 "none".to_string()
4414 } else {
4415 fed.join(", ")
4416 }
4417 ),
4418 );
4419 }
4420 }
4421 if !o.is_message() && !preflight::attributes(src, "outputs").is_empty() {
4422 err(
4423 self,
4424 format!(
4425 "{what} reads `outputs.*`, but a file output's `when` is decided before anything is delivered; use `outputs.*` in a message output"
4426 ),
4427 );
4428 continue;
4429 }
4430 for (name, _) in preflight::attributes(src, "outputs") {
4431 let other = all.iter().find(|x| x.name.as_deref() == Some(name.as_str()));
4432 match other {
4433 None => err(
4434 self,
4435 format!(
4436 "{what} reads `outputs.{name}`, but no output of this report is named `{name}`"
4437 ),
4438 ),
4439 Some(x) if std::ptr::eq(x, o) => err(
4440 self,
4441 format!("{what} reads `outputs.{name}`, which is this output itself"),
4442 ),
4443 Some(_) => {}
4444 }
4445 }
4446 }
4447 }
4448
4449 fn typed_output(
4451 &mut self,
4452 m: &Mapping,
4453 ctx: &str,
4454 file: &Path,
4455 queries: &[QueryEntry],
4456 used: &mut Usage,
4457 ) -> Output {
4458 let file_path = file.to_path_buf();
4459 let file = Some(file_path.clone());
4460 let node = crate::yaml::to_node(&Value::Object(m.clone()));
4461 let o: OutputConfig = match de::from_node(&node) {
4462 Ok(o) => o,
4463 Err(e) => unreachable!("an output map reads as an output: {e}"),
4465 };
4466 let format = o
4467 .format
4468 .as_ref()
4469 .and_then(Loose::ok)
4470 .cloned()
4471 .unwrap_or_else(|| "csv".to_string());
4472 if format != MESSAGE_FORMAT {
4474 used.format(&format, ctx, &file_path);
4475 }
4476 let mut opts: JsonMap<String, Json> = o.options.0.into_iter().map(|(k, v)| (k.value, v)).collect();
4477 if let Some(Value::Object(d)) = self.format_options.get(format.as_str()) {
4479 for (k, v) in d {
4480 if !OUTPUT_SHARED_KEYS.contains(&k.as_str()) {
4481 opts.entry(k.clone()).or_insert_with(|| v.clone());
4482 }
4483 }
4484 }
4485 let destinations = match o.destination {
4486 None => Vec::new(),
4487 Some(Loose::Ok(de::OneOf::A(d))) => {
4488 self.typed_destination(d, ctx, &file, used).into_iter().collect()
4489 }
4490 Some(Loose::Ok(de::OneOf::B(list))) if list.is_empty() => {
4491 self.diags.error(
4492 Code::InvalidField,
4493 file.clone(),
4494 None,
4495 format!(
4496 "{ctx}: `output.destination` is an empty list; name at least one destination, or remove it to keep the output in target/"
4497 ),
4498 );
4499 Vec::new()
4500 }
4501 Some(Loose::Ok(de::OneOf::B(list))) => {
4502 let mut out = Vec::new();
4503 for (i, d) in list.into_iter().enumerate() {
4504 match d {
4505 Loose::Ok(d) => out.extend(self.typed_destination(d, ctx, &file, used)),
4506 Loose::Bad(_) => self.diags.error(
4507 Code::InvalidField,
4508 file.clone(),
4509 None,
4510 format!("{ctx}: `output.destination` entry {} must be a map", i + 1),
4511 ),
4512 }
4513 }
4514 out
4515 }
4516 Some(Loose::Bad(_)) => {
4517 self.diags.error(
4518 Code::InvalidField,
4519 file.clone(),
4520 None,
4521 format!("{ctx}: `output.destination` must be a map or a list of maps"),
4522 );
4523 Vec::new()
4524 }
4525 };
4526 let template = match o.template {
4527 None => None,
4528 Some(t) => {
4529 if !TEMPLATE_FORMATS.contains(&format.as_str()) {
4530 self.diags.error(
4531 Code::InvalidOutputOption,
4532 file.clone(),
4533 None,
4534 format!("{ctx}: `output.template` only applies to the xlsx, html and pdf formats"),
4535 );
4536 }
4537 let t = self.typed_template(t, ctx, &file, queries);
4538 if format != "xlsx" && t.as_ref().is_some_and(|t| !t.bindings.is_empty()) {
4539 self.diags.error(
4540 Code::InvalidTemplate,
4541 file.clone(),
4542 None,
4543 format!(
4544 "{ctx}: `output.template.bindings` is for xlsx templates; an {format} template reads `results.<query>` itself"
4545 ),
4546 );
4547 }
4548 t
4549 }
4550 };
4551 let extension = match o.extension {
4552 de::Maybe::Absent => None,
4553 de::Maybe::Given(Loose::Ok(None | Some(de::OneOf::B(false)))) => Some(String::new()),
4554 de::Maybe::Given(Loose::Ok(Some(de::OneOf::A(e)))) => {
4555 let e = e.strip_prefix('.').unwrap_or(&e).to_string();
4556 if e.contains(['/', '\\']) || e.chars().any(char::is_whitespace) {
4557 self.diags.error(
4558 Code::InvalidOutputOption,
4559 file.clone(),
4560 None,
4561 format!(
4562 "{ctx}: `extension` must be a file extension like `aba` (no path, no spaces)"
4563 ),
4564 );
4565 }
4566 Some(e)
4567 }
4568 de::Maybe::Given(_) => {
4569 self.diags.error(
4570 Code::InvalidOutputOption,
4571 file.clone(),
4572 None,
4573 format!("{ctx}: `extension` must be a string (`aba`), or `\"\"`/`false` for none"),
4574 );
4575 None
4576 }
4577 };
4578 let name = match o.name {
4579 None => None,
4580 Some(Loose::Ok(n)) if is_identifier(&n) => Some(n),
4581 Some(_) => {
4582 self.diags.error(
4583 Code::InvalidField,
4584 file.clone(),
4585 None,
4586 format!("{ctx}: output `name` must be an identifier (letters, digits, `_`)"),
4587 );
4588 None
4589 }
4590 };
4591 let when = match o.when {
4592 None => None,
4593 Some(Loose::Ok(de::OneOf::A(w))) => Some(w),
4594 Some(Loose::Ok(de::OneOf::B(b))) => Some(b.to_string()),
4595 Some(Loose::Bad(_)) => {
4596 self.diags.error(
4597 Code::InvalidField,
4598 file.clone(),
4599 None,
4600 format!("{ctx}: `when` must be a Jinja expression (a string)"),
4601 );
4602 None
4603 }
4604 };
4605 let entries: &[QueryEntry] = queries;
4606 let queries = match &o.queries {
4607 Some(Loose::Ok(_)) => Some(queries.iter().map(|q| q.query.clone()).collect()),
4608 _ => None,
4609 };
4610 if extension.is_some() && format == "xlsx" {
4611 self.diags.error(
4612 Code::InvalidOutputOption,
4613 file.clone(),
4614 None,
4615 format!("{ctx}: `extension` doesn't apply to xlsx (Excel only opens .xlsx workbooks)"),
4616 );
4617 }
4618 let on_empty = match o.on_empty {
4619 None => None,
4620 Some(Loose::Ok(v)) => Some(v),
4621 Some(Loose::Bad(_)) => {
4622 self.diags.error(
4623 Code::InvalidOutputOption,
4624 file.clone(),
4625 None,
4626 format!(
4627 "{ctx}: `on_empty` must be one of {}",
4628 crate::no_data::OnEmpty::NAMES.join(", ")
4629 ),
4630 );
4631 None
4632 }
4633 };
4634 if format == MESSAGE_FORMAT || crate::document::FORMATS.contains(&format.as_str()) {
4636 for q in entries.iter().filter(|q| {
4637 q.on_empty == Some(crate::no_data::OnEmpty::Skip) && output_feeds(&o.queries, &q.query)
4638 }) {
4639 self.diags.warning(
4640 Code::InvalidOutputOption,
4641 file.clone(),
4642 None,
4643 format!(
4644 "{ctx}: query `{}` has `on_empty: skip`, which has no effect on a {format} output (its template decides what to show); use `on_empty` on the output, or `results.{}.row_count` in the template",
4645 q.query, q.query
4646 ),
4647 );
4648 }
4649 }
4650 let for_each = match o.for_each {
4651 None | Some(Loose::Ok(de::OneOf::B(false))) => None,
4652 Some(Loose::Ok(de::OneOf::B(true))) | Some(Loose::Bad(_)) => {
4653 self.diags.error(
4654 Code::InvalidOutputOption,
4655 file.clone(),
4656 None,
4657 format!("{ctx}: `for_each` must be a map with `query` and `key`, or `false` to turn an inherited one off"),
4658 );
4659 None
4660 }
4661 Some(Loose::Ok(de::OneOf::A(f))) => {
4662 self.typed_for_each(f, ctx, &file, entries, &format, &destinations)
4663 }
4664 };
4665 let file_name = match o.file_name {
4666 None => None,
4667 Some(Loose::Ok(s)) if for_each.is_some() => Some(s),
4668 Some(Loose::Ok(_)) => {
4669 self.diags.error(
4670 Code::InvalidOutputOption,
4671 file.clone(),
4672 None,
4673 format!("{ctx}: `file_name` names a per-row output's files; it needs `for_each`"),
4674 );
4675 None
4676 }
4677 Some(Loose::Bad(_)) => {
4678 self.diags.error(
4679 Code::InvalidOutputOption,
4680 file.clone(),
4681 None,
4682 format!("{ctx}: `file_name` must be a string"),
4683 );
4684 None
4685 }
4686 };
4687 Output {
4688 name,
4689 format,
4690 queries,
4691 when,
4692 options: opts,
4693 destinations,
4694 template,
4695 extension,
4696 on_empty,
4697 for_each,
4698 file_name,
4699 }
4700 }
4701
4702 fn typed_for_each(
4705 &mut self,
4706 f: config::report::ForEach,
4707 ctx: &str,
4708 file: &Option<PathBuf>,
4709 queries: &[QueryEntry],
4710 format: &str,
4711 destinations: &[Destination],
4712 ) -> Option<ForEach> {
4713 let err = |s: &mut Self, msg: String| {
4714 s.diags.error(
4715 Code::InvalidOutputOption,
4716 file.clone(),
4717 None,
4718 format!("{ctx}: {msg}"),
4719 );
4720 };
4721 let names: Vec<&str> = queries
4722 .iter()
4723 .filter(|q| q.tab)
4724 .map(|q| q.query.as_str())
4725 .collect();
4726 let query = match f.query {
4727 Some(Loose::Ok(q)) if names.contains(&q.as_str()) => q,
4728 Some(Loose::Ok(q)) => {
4729 err(
4730 self,
4731 format!(
4732 "`for_each.query` `{q}` isn't a query of this report that returns rows (its queries: {})",
4733 names.join(", ")
4734 ),
4735 );
4736 return None;
4737 }
4738 _ => {
4739 err(
4740 self,
4741 "`for_each` needs `query:`, the driver query (one row per copy of the output)".into(),
4742 );
4743 return None;
4744 }
4745 };
4746 let key = match f.key {
4747 Some(Loose::Ok(de::OneOf::A(k))) if !k.trim().is_empty() => vec![k],
4748 Some(Loose::Ok(de::OneOf::B(list)))
4749 if !list.is_empty() && list.iter().all(|k| !k.trim().is_empty()) =>
4750 {
4751 list
4752 }
4753 _ => {
4754 err(
4755 self,
4756 "`for_each` needs `key:`, the column (or list of columns) that identifies a row".into(),
4757 );
4758 return None;
4759 }
4760 };
4761 for k in &key {
4762 let lower = k.to_lowercase();
4763 if ["email", "mail", "name", "phone", "mobile"]
4764 .iter()
4765 .any(|w| lower.contains(w))
4766 {
4767 self.diags.warning(
4768 Code::InvalidOutputOption,
4769 file.clone(),
4770 None,
4771 format!(
4772 "{ctx}: the `for_each` key `{k}` looks like personal data; keys are never masked (they name files and appear in logs), so use an opaque id such as a member number"
4773 ),
4774 );
4775 }
4776 }
4777 let positive = |s: &mut Self, v: Option<Loose<u64>>, what: &str| -> Option<u64> {
4778 match v {
4779 None => None,
4780 Some(Loose::Ok(n)) if n > 0 => Some(n),
4781 Some(_) => {
4782 err(
4783 s,
4784 format!("`for_each.{what}` must be a whole number of 1 or more"),
4785 );
4786 None
4787 }
4788 }
4789 };
4790 let concurrency = positive(self, f.concurrency, "concurrency").map(|n| n as usize);
4791 let max_population_rows = positive(self, f.max_population_rows, "max_population_rows")
4792 .unwrap_or(DEFAULT_MAX_POPULATION_ROWS);
4793 let flag = |s: &mut Self, v: Option<Loose<bool>>, what: &str| -> Option<bool> {
4794 match v {
4795 None => None,
4796 Some(Loose::Ok(b)) => Some(b),
4797 Some(Loose::Bad(_)) => {
4798 err(s, format!("`for_each.{what}` must be true or false"));
4799 None
4800 }
4801 }
4802 };
4803 let keep_results = flag(self, f.keep_results, "keep_results").unwrap_or(true);
4804 let mask_row_values = flag(self, f.mask_row_values, "mask_row_values");
4805 let on_row_error = match f.on_row_error {
4806 None => RowErrors::default(),
4807 Some(Loose::Ok(v)) => v,
4808 Some(Loose::Bad(_)) => {
4809 err(
4810 self,
4811 "`for_each.on_row_error` must be `continue` or `fail`".into(),
4812 );
4813 RowErrors::default()
4814 }
4815 };
4816 let on_orphan_rows = match f.on_orphan_rows {
4817 None => OrphanRows::default(),
4818 Some(Loose::Ok(v)) => v,
4819 Some(Loose::Bad(_)) => {
4820 err(self, "`for_each.on_orphan_rows` must be `warn` or `fail`".into());
4821 OrphanRows::default()
4822 }
4823 };
4824 for d in destinations {
4825 if let Some(p) = &d.path
4826 && !mentions_each(p)
4827 {
4828 err(
4829 self,
4830 format!(
4831 "destination `{}`: a per-row output's `path` must differ per row, but `{p}` doesn't use `each.*`; every row would overwrite the last (add e.g. `{{{{ each.{} }}}}`)",
4832 d.profile, key[0]
4833 ),
4834 );
4835 }
4836 }
4837 let _ = format;
4838 Some(ForEach {
4839 query,
4840 key,
4841 concurrency,
4842 on_row_error,
4843 on_orphan_rows,
4844 max_population_rows,
4845 keep_results,
4846 mask_row_values,
4847 })
4848 }
4849
4850 fn typed_destination(
4852 &mut self,
4853 d: config::report::Destination,
4854 ctx: &str,
4855 file: &Option<PathBuf>,
4856 used: &mut Usage,
4857 ) -> Option<Destination> {
4858 let Some(Loose::Ok(p)) = d.profile else {
4859 self.diags.error(
4860 Code::InvalidField,
4861 file.clone(),
4862 None,
4863 format!("{ctx}: `output.destination` needs a `profile:` naming a profiles.yml entry"),
4864 );
4865 return None;
4866 };
4867 let path = match d.path {
4868 None => None,
4869 Some(Loose::Ok(s)) => Some(s),
4870 Some(Loose::Bad(_)) => {
4871 self.diags.error(
4872 Code::InvalidField,
4873 file.clone(),
4874 None,
4875 format!("{ctx}: destination `{p}`: `path` must be a string"),
4876 );
4877 None
4878 }
4879 };
4880 used.destination(&p, file.clone(), None);
4881 let options = d.options.0.into_iter().map(|(k, v)| (k.value, v)).collect();
4882 let attach = match d.attach {
4883 None => Vec::new(),
4884 Some(Loose::Ok(de::OneOf::A(s))) => vec![s],
4885 Some(Loose::Ok(de::OneOf::B(list))) => list,
4886 Some(Loose::Bad(_)) => {
4887 self.diags.error(
4888 Code::InvalidField,
4889 file.clone(),
4890 None,
4891 format!("{ctx}: destination `{p}`: `attach` must be an output name or a list of them"),
4892 );
4893 Vec::new()
4894 }
4895 };
4896 let hold = match d.hold {
4897 None => false,
4898 Some(Loose::Ok(b)) => b,
4899 Some(Loose::Bad(_)) => {
4900 self.diags.error(
4901 Code::InvalidField,
4902 file.clone(),
4903 None,
4904 format!("{ctx}: destination `{p}`: `hold` must be true or false"),
4905 );
4906 false
4907 }
4908 };
4909 let mut rate = match d.rate {
4910 None => None,
4911 Some(Loose::Ok(s)) => match crate::rate::Rate::parse(&s) {
4912 Ok(r) => Some(r),
4913 Err(e) => {
4914 self.diags.error(
4915 Code::InvalidField,
4916 file.clone(),
4917 None,
4918 format!("{ctx}: destination `{p}`: `rate`: {e}"),
4919 );
4920 None
4921 }
4922 },
4923 Some(Loose::Bad(_)) => {
4924 self.diags.error(
4925 Code::InvalidField,
4926 file.clone(),
4927 None,
4928 format!("{ctx}: destination `{p}`: `rate` must be a count and a unit, like `100/minute`"),
4929 );
4930 None
4931 }
4932 };
4933 match (d.burst, rate.as_mut()) {
4934 (None, _) => {}
4935 (Some(Loose::Ok(n)), Some(r)) if n > 0 => r.burst = n,
4936 (Some(Loose::Ok(_)), None) => self.diags.error(
4937 Code::InvalidField,
4938 file.clone(),
4939 None,
4940 format!("{ctx}: destination `{p}`: `burst` needs a `rate`"),
4941 ),
4942 (Some(_), _) => self.diags.error(
4943 Code::InvalidField,
4944 file.clone(),
4945 None,
4946 format!("{ctx}: destination `{p}`: `burst` must be a whole number of 1 or more"),
4947 ),
4948 }
4949 let zip = match d.zip {
4950 None | Some(Loose::Ok(de::OneOf::A(false))) => None,
4951 Some(Loose::Ok(de::OneOf::A(true))) => Some(crate::zip_pack::ZipSpec {
4952 password: None,
4953 encryption: Default::default(),
4954 }),
4955 Some(Loose::Ok(de::OneOf::B(z))) => {
4956 for k in &z.unknown.0 {
4957 self.diags.error(
4958 Code::UnknownKey,
4959 file.clone(),
4960 Some(k.line),
4961 format!("{ctx}: destination `{p}`: unknown key `{}` in `zip`; expected `password`, `encryption`", k.name),
4962 );
4963 }
4964 let password = match z.password {
4965 None => None,
4966 Some(Loose::Ok(s)) if s.is_empty() => {
4967 self.diags.error(
4968 Code::InvalidField,
4969 file.clone(),
4970 None,
4971 format!("{ctx}: destination `{p}`: `zip.password` is empty; an empty password would leave the zip unencrypted: set one, or use `zip: true`"),
4972 );
4973 None
4974 }
4975 Some(Loose::Ok(s)) => Some(s),
4976 Some(Loose::Bad(_)) => {
4977 self.diags.error(
4978 Code::InvalidField,
4979 file.clone(),
4980 None,
4981 format!("{ctx}: destination `{p}`: `zip.password` must be a string"),
4982 );
4983 None
4984 }
4985 };
4986 let encryption = match z.encryption {
4987 None => Default::default(),
4988 Some(Loose::Ok(e)) => e,
4989 Some(Loose::Bad(_)) => {
4990 self.diags.error(
4991 Code::InvalidField,
4992 file.clone(),
4993 None,
4994 format!(
4995 "{ctx}: destination `{p}`: `zip.encryption` must be one of {}",
4996 crate::zip_pack::Encryption::NAMES.join(", ")
4997 ),
4998 );
4999 Default::default()
5000 }
5001 };
5002 if encryption == crate::zip_pack::Encryption::Zipcrypto {
5003 self.diags.warning(
5004 Code::InvalidField,
5005 file.clone(),
5006 None,
5007 format!("{ctx}: destination `{p}`: `encryption: zipcrypto` is weak (it can be cracked); use it only for recipients who can't open AES-256 zips (Windows Explorer, macOS Archive Utility)"),
5008 );
5009 if password.is_none() {
5010 self.diags.warning(
5011 Code::InvalidField,
5012 file.clone(),
5013 None,
5014 format!("{ctx}: destination `{p}`: `zip.encryption` has no effect without a `password`"),
5015 );
5016 }
5017 }
5018 Some(crate::zip_pack::ZipSpec { password, encryption })
5019 }
5020 Some(Loose::Bad(_)) => {
5021 self.diags.error(
5022 Code::InvalidField,
5023 file.clone(),
5024 None,
5025 format!(
5026 "{ctx}: destination `{p}`: `zip` must be true, false or `{{password, encryption}}`"
5027 ),
5028 );
5029 None
5030 }
5031 };
5032 if zip.is_some()
5033 && let Some(path) = &path
5034 && !crate::preflight::is_templated(path.rsplit(['/', '\\']).next().unwrap_or(path))
5035 && let Err(e) = crate::zip_pack::zip_name(Some(path), "")
5036 {
5037 self.diags.error(
5038 Code::InvalidField,
5039 file.clone(),
5040 None,
5041 format!("{ctx}: destination `{p}`: {e}"),
5042 );
5043 }
5044 Some(Destination {
5045 profile: p,
5046 path,
5047 options,
5048 attach,
5049 hold,
5050 rate,
5051 zip,
5052 })
5053 }
5054
5055 fn typed_template(
5056 &mut self,
5057 t: Loose<config::report::Template>,
5058 ctx: &str,
5059 file: &Option<PathBuf>,
5060 queries: &[QueryEntry],
5061 ) -> Option<Template> {
5062 let err = |s: &mut Self, msg: String| {
5063 s.diags
5064 .error(Code::InvalidTemplate, file.clone(), None, format!("{ctx}: {msg}"))
5065 };
5066 let Some(Loose::Ok(tf)) = t.ok().and_then(|t| t.file.clone()) else {
5067 err(self, "`output.template` needs a `file:`".into());
5068 return None;
5069 };
5070 let Loose::Ok(t) = t else {
5071 unreachable!("checked above")
5072 };
5073 let mut bindings = Vec::new();
5074 let items = match t.bindings {
5075 None => Vec::new(),
5076 Some(Loose::Ok(s)) => s,
5077 Some(Loose::Bad(_)) => {
5078 err(self, "`output.template.bindings` must be a list".into());
5079 Vec::new()
5080 }
5081 };
5082 let names: Vec<&str> = queries.iter().map(|q| q.query.as_str()).collect();
5083 for (i, b) in items.into_iter().enumerate() {
5084 let n = i + 1;
5085 let Loose::Ok(m) = b else {
5086 err(self, format!("template binding {n} must be a map"));
5087 continue;
5088 };
5089 let s = |v: &Option<Loose<String>>| v.as_ref().and_then(Loose::ok).cloned();
5090 for k in &m.unknown.0 {
5091 err(self, format!("template binding {n} has unknown key `{}`", k.name));
5092 }
5093 let Some(sheet) = s(&m.sheet) else {
5094 err(self, format!("template binding {n} needs a `sheet:`"));
5095 continue;
5096 };
5097 let tb = TemplateBinding {
5098 sheet,
5099 query: s(&m.query),
5100 result_index: m.result_index.as_ref().and_then(Loose::ok).map(|x| *x as usize),
5101 anchor: s(&m.anchor),
5102 header: m.header.as_ref().and_then(Loose::ok).copied(),
5103 columns: m.columns.as_ref().and_then(Loose::ok).cloned(),
5104 cell: s(&m.cell),
5105 value: s(&m.value),
5106 column: s(&m.column),
5107 };
5108 if let Some(q) = &tb.query
5109 && !names.contains(&q.as_str())
5110 {
5111 err(
5112 self,
5113 format!(
5114 "template binding {n} uses query `{q}`, which isn't one of this Binding's queries"
5115 ),
5116 );
5117 }
5118 if let Some(ri) = &m.result_index
5119 && ri.ok().is_none_or(|x| *x == 0)
5120 {
5121 err(
5122 self,
5123 format!("template binding {n}: `result_index` must be 1 or more"),
5124 );
5125 }
5126 match &tb.cell {
5127 Some(c) => {
5128 if !options::is_cell(c) {
5129 err(
5130 self,
5131 format!("template binding {n}: `cell` `{c}` isn't a valid cell reference"),
5132 );
5133 }
5134 let by_value = tb.value.is_some();
5135 let by_query = tb.query.is_some() && tb.column.is_some();
5136 if by_value == by_query || (tb.query.is_some() != tb.column.is_some()) {
5137 err(
5138 self,
5139 format!(
5140 "template binding {n}: a single-cell binding needs exactly one of `value` or `query` + `column`"
5141 ),
5142 );
5143 }
5144 if tb.anchor.is_some() || tb.columns.is_some() {
5145 err(
5146 self,
5147 format!(
5148 "template binding {n}: `anchor`/`columns` apply to table blocks, not single cells"
5149 ),
5150 );
5151 }
5152 }
5153 None => {
5154 if tb.query.is_none() {
5155 err(
5156 self,
5157 format!(
5158 "template binding {n}: a table block needs a `query` (or use `cell` for a single cell)"
5159 ),
5160 );
5161 }
5162 if let Some(a) = &tb.anchor
5163 && !options::is_cell(a)
5164 {
5165 err(
5166 self,
5167 format!("template binding {n}: `anchor` `{a}` isn't a valid cell reference"),
5168 );
5169 }
5170 if tb.value.is_some() || tb.column.is_some() {
5171 err(
5172 self,
5173 format!(
5174 "template binding {n}: `value`/`column` apply to single-cell bindings (add `cell`)"
5175 ),
5176 );
5177 }
5178 }
5179 }
5180 bindings.push(tb);
5181 }
5182 Some(Template { file: tf, bindings })
5183 }
5184
5185 fn resolve_unmanaged(
5188 &mut self,
5189 name: &str,
5190 path: &Path,
5191 project: &Project,
5192 folders: &BTreeMap<Vec<String>, FolderCfg>,
5193 used: &mut Usage,
5194 ) -> Report {
5195 let folder = folder_segments(path.parent().unwrap_or(Path::new("")));
5196 let layers = folder_layers(folders, &folder);
5197 let mut tags: Vec<String> = layers.iter().flat_map(|l| l.tags.clone()).collect();
5198 dedup(&mut tags);
5199 let profile = layers
5200 .iter()
5201 .rev()
5202 .find_map(|l| l.profile.as_ref().map(|p| p.0.clone()))
5203 .or(project.default_profile.clone());
5204 let mut vars = project.vars.clone();
5205 for l in &layers {
5206 if let Some(v) = &l.vars {
5207 vars.extend(v.clone());
5208 }
5209 }
5210 let mut output = builtin_output();
5211 let mut merge_problems = Vec::new();
5212 if let Some(o) = project_default_output(project) {
5213 merge_problems.extend(merge_output(&mut output, &o));
5214 }
5215 for l in &layers {
5216 if let Some(o) = &l.output {
5217 merge_problems.extend(merge_output(&mut output, o));
5218 }
5219 }
5220 for p in merge_problems {
5221 self.diags.error(
5222 Code::InvalidField,
5223 Some(path.to_path_buf()),
5224 None,
5225 format!("folder config: {p}"),
5226 );
5227 }
5228 self.diags.warning(
5229 Code::UnmanagedReport,
5230 Some(path.to_path_buf()),
5231 None,
5232 format!(
5233 "`{name}` is an unmanaged report (no YAML lists it in `queries:`); unmanaged reports are for quick tests — add a YAML to make it a managed report"
5234 ),
5235 );
5236 self.check_unmanaged_sql(name, path);
5237 let query = QueryEntry {
5238 query: name.to_string(),
5239 path: path.to_path_buf(),
5240 profile: None,
5241 tab: true,
5242 tab_name: None,
5243 anchor: None,
5244 header: None,
5245 columns: BTreeMap::new(),
5246 autofit: None,
5247 style: None,
5248 on_empty: None,
5249 secret_columns: Vec::new(),
5250 };
5251 let base = BindingBase {
5252 profile: profile.clone(),
5253 profile_at: inherited_profile_at(&layers, project),
5254 vars,
5255 outputs: vec![output],
5256 locale: layers
5257 .iter()
5258 .rev()
5259 .find_map(|l| l.locale.clone())
5260 .or_else(|| project.locale.clone()),
5261 };
5262 if let Some(p) = &profile {
5263 used.connection(p, None, None);
5264 }
5265 let b = self.finish_binding(
5266 name,
5267 None,
5268 &base,
5269 None,
5270 vec![query.clone()],
5271 path.to_path_buf(),
5272 used,
5273 );
5274 Report {
5275 name: name.to_string(),
5276 description: None,
5277 meta: JsonMap::new(),
5278 meta_layers: metadata_layers(project, &layers),
5279 var_schema: BTreeMap::new(),
5280 managed: false,
5281 file: path.to_path_buf(),
5282 folder,
5283 tags,
5284 queries: vec![query],
5285 default_set: None,
5286 timezone: layers
5287 .iter()
5288 .rev()
5289 .find_map(|l| l.timezone.clone())
5290 .or_else(|| project.timezone.clone()),
5291 has_sets: false,
5292 base: b.clone(),
5293 bindings: vec![b],
5294 }
5295 }
5296
5297 fn check_unmanaged_sql(&mut self, name: &str, path: &Path) {
5299 let Ok(text) = std::fs::read_to_string(self.root.join(path)) else {
5300 return;
5301 };
5302 let head = sqlsplit::strip_leading_comments(&text);
5303 if head.starts_with("{{") || head.starts_with("{%") || head.starts_with("{#") {
5304 return;
5305 }
5306 for st in sqlsplit::split(&text) {
5307 if !sqlsplit::classify(&st.text).is_read_only_safe() {
5308 let body = sqlsplit::strip_leading_comments(&st.text);
5309 let line = st.line + st.text[..st.text.len() - body.len()].matches('\n').count();
5310 self.diags.error(
5311 Code::UnmanagedSideEffect,
5312 Some(path.to_path_buf()),
5313 Some(line),
5314 format!(
5315 "unmanaged report `{name}` may only run SELECT/WITH or CREATE [OR REPLACE] TEMP|TEMPORARY TABLE|VIEW, but found `{}`; rewrite the statement, or give the report a YAML to declare it",
5316 dre_protocol::util::summarize(body, 60)
5317 ),
5318 );
5319 }
5320 }
5321 }
5322
5323 fn moved_to_schedules(&mut self, file: &Path, line: Option<usize>, ctx: &str) {
5327 self.diags.error(
5328 Code::ScheduleMoved,
5329 Some(file.to_path_buf()),
5330 line,
5331 format!(
5332 "{ctx} is no longer supported; declare schedules in schedules.yml as named entries (`name`, `report:`/`select:`, `cron`/`every`/`rrule`, optional `vars`)"
5333 ),
5334 );
5335 }
5336
5337 fn parse_timings(&mut self, files: &[Rc<YamlFile>]) -> (BTreeMap<String, Timing>, BTreeSet<String>) {
5340 let mut out = BTreeMap::new();
5341 let mut broken = BTreeSet::new();
5342 let mut seen: BTreeMap<String, String> = BTreeMap::new();
5343 for yf in files {
5344 let Ok(Loose::Ok(timings)) = de::from_node::<Loose<config::schedule::TimingsFile>>(&yf.node)
5345 else {
5346 continue;
5347 };
5348 for (k, v) in timings.0.0 {
5349 let file = Some(yf.display.clone());
5350 let line = k.line();
5351 let name = k.value;
5352 let mut ok = true;
5353 if !is_identifier(&name) {
5354 self.diags.error(
5355 Code::InvalidTiming,
5356 file.clone(),
5357 line,
5358 format!(
5359 "timing name `{name}` must be letters, digits and `_`, not starting with a digit"
5360 ),
5361 );
5362 ok = false;
5363 }
5364 if let Some(prev) = seen.get(&name) {
5365 self.diags.error(
5366 Code::DuplicateTimingName,
5367 file.clone(),
5368 line,
5369 format!("timing `{name}` is already declared at {prev}; timing names must be unique"),
5370 );
5371 continue;
5372 }
5373 seen.insert(
5374 name.clone(),
5375 format!("{}:{}", yf.display.display(), line.unwrap_or(0)),
5376 );
5377 let Loose::Ok(t) = v else {
5378 self.diags.error(
5379 Code::InvalidTiming,
5380 file.clone(),
5381 line,
5382 format!("timing `{name}` must be a map, e.g. `{{cron: \"0 6 1 * *\", timezone: Australia/Sydney}}`"),
5383 );
5384 broken.insert(name);
5385 continue;
5386 };
5387 for k in &t.unknown.0 {
5388 self.diags.error(
5389 Code::InvalidTiming,
5390 file.clone(),
5391 line,
5392 format!("timing `{name}`: unknown key `{}`", k.name),
5393 );
5394 ok = false;
5395 }
5396 let block = t.block();
5397 let shape = schedule::validate_block(&block, "a timing", "`cron`, `every` or `rrule`");
5398 for e in &shape {
5399 self.diags.error(
5400 Code::InvalidTiming,
5401 file.clone(),
5402 line,
5403 format!("timing `{name}`: {e}"),
5404 );
5405 ok = false;
5406 }
5407 let timezone = match &t.timezone {
5408 None => None,
5409 Some(v) => {
5410 let tz = self.timezone_str(
5411 v.ok().map(String::as_str),
5412 &yf.display,
5413 line,
5414 &format!("timing `{name}`: `timezone`"),
5415 );
5416 ok &= tz.is_some();
5417 tz
5418 }
5419 };
5420 if shape.is_empty() {
5421 for (code, msg) in schedule::strictness(&block) {
5422 self.diags
5423 .error(code, file.clone(), line, format!("timing `{name}`: {msg}"));
5424 }
5425 if let Some(msg) = schedule::no_time(&block) {
5426 self.diags.warning(
5427 Code::ScheduleNoTime,
5428 file.clone(),
5429 line,
5430 format!("timing `{name}`: {msg}"),
5431 );
5432 }
5433 }
5434 if ok {
5435 out.insert(
5436 name,
5437 Timing {
5438 schedule: block,
5439 timezone,
5440 location: (yf.display.clone(), line),
5441 },
5442 );
5443 } else {
5444 broken.insert(name);
5445 }
5446 }
5447 }
5448 (out, broken)
5449 }
5450
5451 fn parse_schedules(
5452 &mut self,
5453 files: &[Rc<YamlFile>],
5454 project: &Project,
5455 broken_timings: &BTreeSet<String>,
5456 ) -> Vec<ScheduleEntry> {
5457 let mut out = Vec::new();
5458 let mut used_timings = BTreeSet::new();
5459 let mut seen: BTreeMap<String, String> = BTreeMap::new();
5460 for yf in files {
5461 let Ok(Loose::Ok(items)) = de::from_node::<Loose<config::schedule::SchedulesFile>>(&yf.node)
5462 else {
5463 continue;
5464 };
5465 for item in &items.0 {
5466 let Loose::Ok(m) = &item.value else { continue };
5468 let line = item.line();
5469 let file = Some(yf.display.clone());
5470 let s = |v: &Option<Loose<String>>| v.as_ref().and_then(Loose::ok).cloned();
5471 let (select, report, set) = (s(&m.select), s(&m.report), s(&m.set));
5472 let mut ok = true;
5473 let name = match &m.name {
5474 Some(Loose::Ok(n)) if is_identifier(n) => {
5475 if let Some(prev) = seen.get(n) {
5476 self.diags.error(
5477 Code::DuplicateScheduleName,
5478 file.clone(),
5479 line,
5480 format!("schedule `{n}` is already declared at {prev}; schedule names must be unique"),
5481 );
5482 ok = false;
5483 }
5484 seen.insert(
5485 n.clone(),
5486 format!("{}:{}", yf.display.display(), line.unwrap_or(0)),
5487 );
5488 n.clone()
5489 }
5490 Some(Loose::Ok(n)) => {
5491 self.diags.error(
5492 Code::InvalidSchedule,
5493 file.clone(),
5494 line,
5495 format!("schedule name `{n}` must be letters, digits and `_`, not starting with a digit"),
5496 );
5497 ok = false;
5498 n.clone()
5499 }
5500 _ => {
5501 self.diags.error(
5502 Code::InvalidSchedule,
5503 file.clone(),
5504 line,
5505 "every schedule needs a `name`",
5506 );
5507 ok = false;
5508 String::new()
5509 }
5510 };
5511 let vars = match &m.vars {
5512 None => JsonMap::new(),
5513 Some(Loose::Ok(v)) => v.clone(),
5514 Some(Loose::Bad(_)) => {
5515 self.diags.error(
5516 Code::InvalidSchedule,
5517 file.clone(),
5518 line,
5519 format!("schedule `{name}`: `vars` must be a map"),
5520 );
5521 ok = false;
5522 JsonMap::new()
5523 }
5524 };
5525 for k in &m.unknown.0 {
5526 self.diags.error(
5527 Code::InvalidSchedule,
5528 file.clone(),
5529 line,
5530 format!("unknown schedule key `{}`", k.name),
5531 );
5532 }
5533 let sched = m.block();
5534 let timing = match &m.timing {
5535 None => None,
5536 Some(Loose::Ok(t)) => {
5537 used_timings.insert(t.clone());
5538 Some(t.clone())
5539 }
5540 Some(Loose::Bad(_)) => {
5541 self.diags.error(
5542 Code::InvalidSchedule,
5543 file.clone(),
5544 line,
5545 format!(
5546 "schedule `{name}`: `timing` must be the name of a timing in timings.yml"
5547 ),
5548 );
5549 ok = false;
5550 None
5551 }
5552 };
5553 let mut resolved = None;
5554 let shape = if let Some(t) = &timing {
5555 let mut errs = Vec::new();
5556 let own: Vec<String> = m.timing_keys().into_iter().map(|k| format!("`{k}`")).collect();
5557 if !own.is_empty() {
5558 let keys = match own.split_last() {
5559 Some((last, [])) => last.clone(),
5560 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
5561 None => String::new(),
5562 };
5563 errs.push(format!(
5564 "schedule `{name}` uses timing `{t}`, so it can't set {keys} too; change the timing, or give the schedule its own timing instead"
5565 ));
5566 }
5567 match project.timings.get(t) {
5568 Some(def) => resolved = Some(def.schedule.clone()),
5569 None if broken_timings.contains(t) => ok = false,
5570 None => {
5571 let names: Vec<&str> = project.timings.keys().map(String::as_str).collect();
5572 let valid = if names.is_empty() {
5573 "the project has no timings.yml entries".to_string()
5574 } else {
5575 format!("valid names: {}", names.join(", "))
5576 };
5577 self.diags.error(
5578 Code::UnknownTiming,
5579 file.clone(),
5580 line,
5581 format!("schedule `{name}`: no timing `{t}`; {valid}"),
5582 );
5583 ok = false;
5584 }
5585 }
5586 errs
5587 } else {
5588 schedule::validate_block(&sched, "a schedule", "`timing`, `cron`, `every` or `rrule`")
5589 };
5590 if shape.is_empty() && timing.is_none() {
5591 let block = &sched;
5592 for (code, msg) in schedule::strictness(block) {
5593 self.diags
5594 .error(code, file.clone(), line, format!("schedule `{name}`: {msg}"));
5595 }
5596 if let Some(msg) = schedule::no_time(block) {
5597 self.diags.warning(
5598 Code::ScheduleNoTime,
5599 file.clone(),
5600 line,
5601 format!("schedule `{name}`: {msg}"),
5602 );
5603 }
5604 }
5605 for e in shape {
5606 self.diags.error(Code::InvalidSchedule, file.clone(), line, e);
5607 ok = false;
5608 }
5609 if select.is_some() && report.is_some() {
5610 self.diags.error(
5611 Code::InvalidSchedule,
5612 file.clone(),
5613 line,
5614 "use either `select:` or `report:`, not both",
5615 );
5616 ok = false;
5617 }
5618 if select.is_some() && set.is_some() {
5619 self.diags.error(
5620 Code::InvalidSchedule,
5621 file.clone(),
5622 line,
5623 "`set:` only applies with `report:`",
5624 );
5625 ok = false;
5626 }
5627 if let Some(sel) = &select {
5628 match selector::resolve(project, sel) {
5629 Ok(r) if r.is_empty() => {
5630 self.diags.error(
5631 Code::SelectorMatchesNothing,
5632 file.clone(),
5633 line,
5634 format!("selector `{sel}` matches no report"),
5635 );
5636 ok = false;
5637 }
5638 Ok(_) => {}
5639 Err(e) => {
5640 self.diags.error(e.code(), file.clone(), line, e.to_string());
5641 ok = false;
5642 }
5643 }
5644 }
5645 if let Some(rn) = &report {
5646 match project.report(rn) {
5647 None => {
5648 self.diags.error(
5649 Code::SelectorMatchesNothing,
5650 file.clone(),
5651 line,
5652 format!("report `{rn}` doesn't exist"),
5653 );
5654 ok = false;
5655 }
5656 Some(r) => {
5657 if let Some(sn) = &set
5658 && r.binding(sn).is_none()
5659 {
5660 self.diags.error(
5661 Code::SelectorMatchesNothing,
5662 file.clone(),
5663 line,
5664 format!("report `{rn}` has no Set `{sn}`"),
5665 );
5666 ok = false;
5667 }
5668 }
5669 }
5670 }
5671 if select.is_none() && report.is_none() {
5672 self.diags.error(
5673 Code::InvalidSchedule,
5674 file.clone(),
5675 line,
5676 format!("schedule `{name}` needs `report:` (optionally with `set:`) or `select:`"),
5677 );
5678 ok = false;
5679 }
5680 let timezone = match (&m.timezone, &timing) {
5681 (None, Some(t)) => project.timings.get(t).and_then(|d| d.timezone.clone()),
5682 (None, None) => None,
5683 (Some(v), _) => {
5684 let t = self.timezone_str(
5685 v.value.ok().map(String::as_str),
5686 &yf.display,
5687 line,
5688 &format!("schedule `{name}`: `timezone`"),
5689 );
5690 ok &= t.is_some();
5691 t
5692 }
5693 };
5694 let enabled = match &m.enabled {
5695 None => true,
5696 Some(Loose::Ok(b)) => *b,
5697 Some(Loose::Bad(_)) => {
5698 self.diags.error(
5699 Code::InvalidSchedule,
5700 file.clone(),
5701 line,
5702 format!("schedule `{name}`: `enabled` must be true or false"),
5703 );
5704 ok = false;
5705 true
5706 }
5707 };
5708 if ok {
5709 out.push(ScheduleEntry {
5710 name,
5711 select,
5712 report,
5713 set,
5714 schedule: resolved.unwrap_or(sched),
5715 timing,
5716 enabled,
5717 vars,
5718 timezone,
5719 location: (yf.display.clone(), line),
5720 });
5721 }
5722 }
5723 }
5724 for (name, t) in &project.timings {
5725 if !used_timings.contains(name) {
5726 self.diags.warning(
5727 Code::UnusedTiming,
5728 Some(t.location.0.clone()),
5729 t.location.1,
5730 format!("timing `{name}` isn't used by any schedule"),
5731 );
5732 }
5733 }
5734 out
5735 }
5736
5737 fn check_schedule_paths(&mut self, project: &Project) {
5741 let date = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
5742 for report in &project.reports {
5743 for b in report.bindings.iter().filter(|b| b.schedules.len() > 1) {
5744 let paths: Vec<&String> = b.destinations().filter_map(|d| d.path.as_ref()).collect();
5745 if paths.is_empty() {
5746 continue;
5747 }
5748 let mut rendered: Vec<(&String, Vec<String>)> = Vec::new();
5749 for name in &b.schedules {
5750 let Some(e) = project.schedules.iter().find(|e| &e.name == name) else {
5751 continue;
5752 };
5753 let mut vars = b.vars.clone();
5754 vars.extend(e.vars.clone());
5755 let Ok(r) = crate::render::Renderer::new(crate::render::RendererConfig {
5756 root: &project.root,
5757 macros: &project.macros,
5758 context: crate::render::RunContext {
5759 report: report.name.clone(),
5760 set: b.set.clone(),
5761 target: project.target_name.clone(),
5762 schedule: Some(name.clone()),
5763 date,
5764 now: chrono::Utc::now(),
5765 scheduled_at: None,
5766 calendar: crate::dates::Calendar::default(),
5767 locale: Default::default(),
5768 },
5769 vars,
5770 cli_vars: self.opts.vars.clone(),
5771 runner: None,
5772 connections: None,
5773 mode: crate::render::Mode::Run,
5774 connection: None,
5775 source_type: String::new(),
5776 sources: None,
5777 run_query_max_rows: project.run_query_max_rows,
5778 sql: project.sql.clone(),
5779 lookups: project.lookups.clone(),
5780 lookup_inline_max_rows: project.lookup_inline_max_rows,
5781 packages: project.packages.clone(),
5782 project_name: project.name.clone(),
5783 dispatch: project.dispatch.clone(),
5784 }) else {
5785 continue;
5786 };
5787 let out: Result<Vec<String>, _> =
5788 paths.iter().map(|p| r.render(&report.file, p)).collect();
5789 if let Ok(out) = out {
5790 rendered.push((name, out));
5791 }
5792 }
5793 for (i, (a, pa)) in rendered.iter().enumerate() {
5794 for (bn, pb) in &rendered[i + 1..] {
5795 if let Some(same) = pa.iter().find(|p| pb.contains(p)) {
5796 self.diags.warning(
5797 Code::SchedulePathClash,
5798 Some(report.file.clone()),
5799 None,
5800 format!(
5801 "schedules `{a}` and `{bn}` both run report `{}`{} and deliver to `{same}`; the second overwrites the first — put a schedule var or `run.schedule` in the path",
5802 report.name,
5803 b.set.as_ref().map(|s| format!(", Set `{s}`,")).unwrap_or_default()
5804 ),
5805 );
5806 }
5807 }
5808 }
5809 }
5810 }
5811 }
5812
5813 fn apply_schedules(&mut self, project: &mut Project) {
5817 let entries = project.schedules.clone();
5818 for e in &entries {
5819 let reports: Vec<String> = match (&e.select, &e.report) {
5820 (Some(sel), _) => selector::resolve(project, sel)
5821 .map(|r| r.into_iter().map(|r| r.name.clone()).collect())
5822 .unwrap_or_default(),
5823 (_, Some(r)) => vec![r.clone()],
5824 _ => Vec::new(),
5825 };
5826 for report in project.reports.iter_mut().filter(|r| reports.contains(&r.name)) {
5827 for b in &mut report.bindings {
5828 if e.set.is_none() || e.set == b.set {
5829 b.schedules.push(e.name.clone());
5830 }
5831 }
5832 }
5833 }
5834 self.check_schedule_paths(project);
5835 self.check_schedule_timezones(project);
5836 }
5837
5838 fn check_schedule_timezones(&mut self, project: &Project) {
5841 for e in project.schedules.iter().filter(|e| e.timezone.is_none()) {
5842 let fires = crate::occurrences::firing_tz(project, e);
5843 let mut seen = BTreeSet::new();
5844 for (report, _) in crate::occurrences::bindings(project, &e.name) {
5845 let Some(r) = project.report(report) else { continue };
5846 let renders = r
5847 .timezone
5848 .as_ref()
5849 .and_then(|t| crate::dates::parse_tz(t).ok())
5850 .unwrap_or(chrono_tz::Tz::UTC);
5851 if renders != fires && seen.insert(report) {
5852 self.diags.warning(
5853 Code::ScheduleTimezoneMismatch,
5854 Some(e.location.0.clone()),
5855 e.location.1,
5856 format!(
5857 "schedule `{}` fires in {} but report `{report}` renders in {}, so its run date is {}'s date at the firing time; set `timezone:` on the schedule to fire and render in one timezone",
5858 e.name,
5859 fires.name(),
5860 renders.name(),
5861 renders.name()
5862 ),
5863 );
5864 }
5865 }
5866 }
5867 }
5868
5869 fn check_profiles(&mut self, project: &Project, used: &Usage) {
5872 let profiles = &project.profiles;
5873 if used.connections.is_empty() && used.destinations.is_empty() {
5874 return;
5875 }
5876 if !profiles.exists() {
5877 self.diags.error(
5878 Code::ProfilesMissing,
5879 None,
5880 None,
5881 if profiles.found_by == "~/.dre" {
5882 format!(
5883 "the project references profiles, but no profiles.yml was found in the project directory or at {}",
5884 profiles.path.display()
5885 )
5886 } else {
5887 format!(
5888 "the project references profiles, but no profiles.yml was found at {} (from {})",
5889 profiles.path.display(),
5890 profiles.found_by
5891 )
5892 },
5893 );
5894 return;
5895 }
5896 for (role, refs) in [
5897 (Role::Connection, &used.connections),
5898 (Role::Destination, &used.destinations),
5899 ] {
5900 for (name, (file, line)) in refs {
5901 if role == Role::Destination && name == LOCAL_TYPE {
5902 continue;
5903 }
5904 if !profiles.declares(role, name) {
5905 self.diags.error(
5906 Code::UnknownProfile,
5907 file.clone(),
5908 *line,
5909 format!(
5910 "{} profile `{name}` isn't defined under `{}:` in {}",
5911 role.as_str(),
5912 profiles.section_key(role),
5913 profiles.path.display()
5914 ),
5915 );
5916 }
5917 }
5918 }
5919 }
5920
5921 fn plugin_entry(
5923 &mut self,
5924 m: &config::dependencies::PluginEntry,
5925 file: &Path,
5926 line: Option<usize>,
5927 ) -> Option<(String, Option<Value>, PluginSource)> {
5928 let file = Some(file.to_path_buf());
5929 let name = m.name.ok().cloned().unwrap_or_default();
5930 let err = |s: &mut Self, msg: String| {
5931 s.diags.error(
5932 Code::InvalidPluginDeclaration,
5933 file.clone(),
5934 line,
5935 format!("plugin package `{name}`: {msg}"),
5936 );
5937 };
5938 if name.is_empty() {
5939 err(self, "`name` must be a non-empty string".into());
5940 return None;
5941 }
5942 if let Some(k) = m.unknown.0.first() {
5943 err(
5944 self,
5945 format!(
5946 "unknown key `{}`; use `name`, `version` and one of `github`, `local`, `registry`",
5947 k.name
5948 ),
5949 );
5950 return None;
5951 }
5952 let given: Vec<(&str, &str)> = [
5953 ("github", &m.github),
5954 ("local", &m.local),
5955 ("registry", &m.registry),
5956 ]
5957 .into_iter()
5958 .filter_map(|(k, v)| v.as_ref().map(|v| (k, v.as_str().unwrap_or(""))))
5959 .collect();
5960 let source = match given.as_slice() {
5961 [] => PluginSource::Default,
5962 [(k, "")] => {
5963 err(self, format!("`{k}` must be a non-empty string"));
5964 return None;
5965 }
5966 [("github", r)] => {
5967 let ok = r.split('/').count() == 2 && r.split('/').all(|p| !p.is_empty());
5968 if !ok {
5969 err(self, format!("`github: {r}` must be `owner/repo`"));
5970 return None;
5971 }
5972 PluginSource::Github(r.to_string())
5973 }
5974 [("local", p)] => {
5975 if m.version.is_some() {
5976 err(
5977 self,
5978 "a `local` package has no `version`: it's used as it is".into(),
5979 );
5980 return None;
5981 }
5982 PluginSource::Local(p.to_string())
5983 }
5984 [(_, u)] => PluginSource::Registry(u.to_string()),
5985 _ => {
5986 err(self, "give only one of `github`, `local` and `registry`".into());
5987 return None;
5988 }
5989 };
5990 Some((name, m.version.clone(), source))
5991 }
5992
5993 fn check_plugins(&mut self, decls: &[(Rc<YamlFile>, Mapping)], project: &mut Project, used: &Usage) {
5996 struct Decl {
5997 req: semver::VersionReq,
5998 raw: String,
5999 file: PathBuf,
6000 source: PluginSource,
6001 }
6002 let mut by_package: BTreeMap<String, Vec<Decl>> = BTreeMap::new();
6003 for (yf, _) in decls {
6004 {
6005 let Ok(config::dependencies::PluginsKey { plugins: Some(v) }) = de::from_node(&yf.node)
6006 else {
6007 continue;
6008 };
6009 let line = v.line();
6010 let file = Some(yf.display.clone());
6011 let entries: Vec<(String, Option<Value>, PluginSource, Option<usize>)> = match v.value {
6012 Loose::Ok(de::OneOf::A(items)) => items
6013 .into_iter()
6014 .filter_map(|i| {
6015 let iline = i.line();
6016 match i.value {
6017 Loose::Ok(de::OneOf::A(name)) => Some((name.0, None, PluginSource::Default, iline)),
6018 Loose::Ok(de::OneOf::B(de::OneOf::B(entry))) => self
6019 .plugin_entry(&entry, &yf.display, iline)
6020 .map(|(n, c, s)| (n, c, s, iline)),
6021 Loose::Ok(de::OneOf::B(de::OneOf::A(pin))) => Some((
6022 pin.name.value,
6023 Some(pin.version.clone()),
6024 PluginSource::Default,
6025 iline,
6026 )),
6027 Loose::Bad(_) => {
6028 self.diags.error(
6029 Code::InvalidPluginDeclaration,
6030 file.clone(),
6031 iline,
6032 "each `plugins` entry is a package name, `name: \"<version>\"`, or a map with `name:` and one of `github:`, `local:`, `registry:`",
6033 );
6034 None
6035 }
6036 }
6037 })
6038 .collect(),
6039 Loose::Ok(de::OneOf::B(m)) => m
6040 .0
6041 .into_iter()
6042 .map(|(k, v)| {
6043 let kline = k.line();
6044 (k.value, Some(v), PluginSource::Default, kline)
6045 })
6046 .collect(),
6047 Loose::Bad(f) if f.kind == "nothing" => Vec::new(),
6048 Loose::Bad(_) => {
6049 self.diags.error(
6050 Code::InvalidPluginDeclaration,
6051 file.clone(),
6052 line,
6053 "`plugins` must be a list like `- duckdb: \">=1.0\"`",
6054 );
6055 continue;
6056 }
6057 };
6058 for (name, c, source, eline) in entries {
6059 if !dre_protocol::valid_name(&name) {
6060 self.diags.error(
6061 Code::InvalidPluginDeclaration,
6062 file.clone(),
6063 eline,
6064 format!(
6065 "plugin package `{name}`: a package name is lowercase letters, digits and `_`"
6066 ),
6067 );
6068 continue;
6069 }
6070 let raw = match &c {
6071 None | Some(Value::Null) => "*".to_string(),
6072 Some(v) => crate::yaml::scalar_str(v).unwrap_or_default(),
6073 };
6074 match semver::VersionReq::parse(&raw) {
6075 Ok(req) => by_package.entry(name).or_default().push(Decl {
6076 req,
6077 raw,
6078 file: yf.display.clone(),
6079 source,
6080 }),
6081 Err(e) => self.diags.error(
6082 Code::InvalidVersionConstraint,
6083 file.clone(),
6084 eline,
6085 format!("plugin package `{name}`: invalid version constraint `{raw}`: {e}"),
6086 ),
6087 }
6088 }
6089 }
6090 }
6091 let mut out = Vec::new();
6092 for (name, ds) in &by_package {
6093 let source = ds
6095 .iter()
6096 .map(|d| &d.source)
6097 .find(|s| !s.is_default())
6098 .cloned()
6099 .unwrap_or_default();
6100 if let Some(other) = ds.iter().find(|d| !d.source.is_default() && d.source != source) {
6101 self.diags.error(
6102 Code::ConflictingPluginSources,
6103 Some(other.file.clone()),
6104 None,
6105 format!(
6106 "plugin package `{name}` is declared with two sources: {} and {}",
6107 source.lock_key().unwrap_or_default(),
6108 other.source.lock_key().unwrap_or_default()
6109 ),
6110 );
6111 project.plugins_incomplete = true;
6112 continue;
6113 }
6114 let reqs: Vec<&semver::VersionReq> = ds.iter().map(|d| &d.req).collect();
6115 if !constraints::compatible(&reqs) {
6116 for (i, a) in ds.iter().enumerate() {
6117 for b in &ds[i + 1..] {
6118 if !constraints::compatible(&[&a.req, &b.req]) {
6119 self.diags.error(
6120 Code::ConflictingPluginConstraints,
6121 Some(b.file.clone()),
6122 None,
6123 format!(
6124 "plugin package `{name}` is declared `{}` in {} and `{}` in {}; no version satisfies both",
6125 a.raw,
6126 a.file.display(),
6127 b.raw,
6128 b.file.display()
6129 ),
6130 );
6131 }
6132 }
6133 }
6134 project.plugins_incomplete = true;
6135 continue;
6136 }
6137 let combined = constraints::combine(&reqs);
6138 let mut files: Vec<PathBuf> = ds.iter().map(|d| d.file.clone()).collect();
6139 files.dedup();
6140 out.push(PluginRequirement {
6141 name: name.clone(),
6142 version: combined.to_string(),
6143 declared_in: files,
6144 source,
6145 });
6146 }
6147 project.plugins = out;
6148
6149 let mut uses = Vec::new();
6150 let profiles = &project.profiles;
6151 for (role, kind, refs) in [
6152 (Role::Connection, PluginKind::Source, &used.connections),
6153 (Role::Destination, PluginKind::Destination, &used.destinations),
6154 ] {
6155 for name in refs.keys() {
6156 let Some(p) = profiles.get(role, name) else {
6157 continue;
6158 };
6159 let role_name = role.as_str();
6160 for out_ in p.targets.values() {
6161 if kind == PluginKind::Destination && out_.kind == LOCAL_TYPE {
6162 continue;
6163 }
6164 let id = PluginId::new(kind, out_.kind.clone());
6165 if uses.iter().any(|u: &PluginUse| u.plugin == id) {
6166 continue;
6167 }
6168 uses.push(PluginUse {
6169 plugin: id,
6170 file: profiles.file.clone(),
6171 line: profiles.line_of(role, name),
6172 what: format!("`type: {}` used by {role_name} profile `{name}`", out_.kind),
6173 });
6174 }
6175 }
6176 }
6177 for (fmt, (ctx, file)) in &used.formats {
6178 let note = if fmt == "csv" {
6179 " (csv is the built-in default output)"
6180 } else {
6181 ""
6182 };
6183 uses.push(PluginUse {
6184 plugin: PluginId::new(PluginKind::Format, fmt.clone()),
6185 file: Some(file.clone()),
6186 line: None,
6187 what: format!("format `{fmt}` is used by {ctx}{note}"),
6188 });
6189 }
6190 project.plugin_uses = uses;
6191 }
6192
6193 fn old_plugin_key(&mut self, yf: &YamlFile, key: &str) {
6195 self.diags.error(
6196 Code::MovedPluginDeclaration,
6197 Some(yf.display.clone()),
6198 yf.line_of(key, None),
6199 format!(
6200 "`{key}:` no longer declares plugins: list plugin packages under `plugins:` instead (e.g. `plugins: [duckdb, object_store]`)"
6201 ),
6202 );
6203 }
6204
6205 fn preflight(&mut self, project: &Project, _used: &Usage) {
6208 let cli_owned: Vec<String> = self.opts.vars.keys().cloned().collect();
6209 let cli: BTreeSet<&str> = cli_owned.iter().map(String::as_str).collect();
6210 let mut sources: BTreeMap<PathBuf, String> = BTreeMap::new();
6211 let mut read = |root: &Path, p: &Path| -> Option<String> {
6212 if let Some(s) = sources.get(p) {
6213 return Some(s.clone());
6214 }
6215 let s = std::fs::read_to_string(root.join(p)).ok()?;
6216 sources.insert(p.to_path_buf(), s.clone());
6217 Some(s)
6218 };
6219 let mut checked: BTreeSet<PathBuf> = BTreeSet::new();
6220 let root = self.root.clone();
6221 let mut defs: BTreeMap<String, (PathBuf, preflight::MacroDef)> = BTreeMap::new();
6223 for m in &project.macros {
6224 if let Some(src) = read(&root, m) {
6225 self.check_template_text(m, &src, 0, None, &cli);
6226 checked.insert(m.clone());
6227 for d in preflight::macro_defs(&src) {
6228 defs.insert(d.name.clone(), (m.clone(), d));
6229 }
6230 }
6231 }
6232 for r in &project.reports {
6233 for b in &r.bindings {
6234 for error in crate::variables::check_given_values(&r.var_schema, &b.vars, &self.opts.vars) {
6235 self.diags
6236 .error(Code::InvalidField, Some(r.file.clone()), None, error);
6237 }
6238 let ctx = match &b.set {
6239 Some(s) => format!("report `{}`, Set `{s}`", r.name),
6240 None => format!("report `{}`", r.name),
6241 };
6242 let mut called: Vec<String> = Vec::new();
6243 let mut refs: Vec<String> = Vec::new();
6244 for q in &b.queries {
6245 let Some(src) = read(&root, &q.path) else { continue };
6246 let first = checked.insert(q.path.clone());
6247 self.check_vars(&q.path, &src, 0, &b.vars, &cli, &ctx);
6248 if first {
6249 self.check_template_text(&q.path, &src, 0, None, &cli);
6250 }
6251 called.extend(preflight::called_names(&src));
6252 refs.extend(preflight::refs(&src).into_iter().map(|(n, _)| n));
6253 }
6254 let mut seen_refs = BTreeSet::new();
6256 while let Some(name) = refs.pop() {
6257 let Some(path) = project.sql.get(&name) else {
6258 continue;
6259 };
6260 if !seen_refs.insert(name) {
6261 continue;
6262 }
6263 let Some(src) = read(&root, path) else { continue };
6264 self.check_vars(path, &src, 0, &b.vars, &cli, &ctx);
6265 if checked.insert(path.clone()) {
6266 self.check_template_text(path, &src, 0, None, &cli);
6267 }
6268 called.extend(preflight::called_names(&src));
6269 refs.extend(preflight::refs(&src).into_iter().map(|(n, _)| n));
6270 }
6271 let mut seen = BTreeSet::new();
6273 while let Some(name) = called.pop() {
6274 let Some((file, def)) = defs.get(&name) else {
6275 continue;
6276 };
6277 if !seen.insert(name) {
6278 continue;
6279 }
6280 self.check_vars(file, &def.body, def.line_offset, &b.vars, &cli, &ctx);
6281 called.extend(preflight::called_names(&def.body));
6282 }
6283 let mut values: Vec<String> = Vec::new();
6285 for d in b.destinations() {
6286 values.extend(d.path.clone());
6287 values.extend(d.options.values().flat_map(json_strings));
6288 }
6289 for t in b.outputs.iter().filter_map(|o| o.template.as_ref()) {
6290 values.extend(t.bindings.iter().filter_map(|tb| tb.value.clone()));
6291 }
6292 for v in values.iter().filter(|v| preflight::is_templated(v)) {
6293 let yf = std::fs::read_to_string(root.join(&r.file)).unwrap_or_default();
6294 let line = yf.lines().position(|l| l.contains(v.as_str())).map(|i| i + 1);
6295 self.check_template_text(&r.file, v, line.map_or(0, |l| l - 1), line, &cli);
6296 self.check_vars(&r.file, v, line.map_or(0, |l| l - 1), &b.vars, &cli, &ctx);
6297 }
6298 }
6299 }
6300 }
6301
6302 fn check_template_text(
6304 &mut self,
6305 file: &Path,
6306 src: &str,
6307 line_offset: usize,
6308 fixed_line: Option<usize>,
6309 _cli: &BTreeSet<&str>,
6310 ) {
6311 let f = Some(file.to_path_buf());
6312 if let Err((line, msg)) = preflight::check_syntax(&file.to_string_lossy(), src) {
6313 let line = fixed_line.or(line.map(|l| l + line_offset));
6314 self.diags.error(
6315 Code::JinjaSyntax,
6316 f.clone(),
6317 line,
6318 format!("Jinja syntax error: {msg}"),
6319 );
6320 return;
6321 }
6322 for c in preflight::calls(src)
6323 .into_iter()
6324 .filter(|c| c.func == "env_var" && !c.has_default)
6325 {
6326 if std::env::var_os(&c.name).is_none() {
6327 self.diags.error(
6328 Code::UnsetEnvVar,
6329 f.clone(),
6330 Some(fixed_line.unwrap_or(c.line + line_offset)),
6331 format!(
6332 "`env_var('{}')`: environment variable `{}` is not set and no default is given",
6333 c.name, c.name
6334 ),
6335 );
6336 }
6337 }
6338 for (name, instead, line) in preflight::removed_names(src) {
6339 self.diags.error(
6340 Code::RemovedTemplateName,
6341 f.clone(),
6342 Some(fixed_line.unwrap_or(line + line_offset)),
6343 format!("`{name}` was removed in DRE 0.2: use {instead}"),
6344 );
6345 }
6346 for (r, line) in preflight::unknown_run_refs(src) {
6347 self.diags.error(
6348 Code::UnknownRunAttribute,
6349 f.clone(),
6350 Some(fixed_line.unwrap_or(line + line_offset)),
6351 format!(
6352 "`{r}` isn't part of the run context; known: run.report, run.set, run.target, run.schedule, run.date (a date: .prev_month, .month_start, .yyyymmdd, ...), run.now, run.scheduled_at, run.timezone, run.date_format(...)"
6353 ),
6354 );
6355 }
6356 }
6357
6358 fn check_vars(
6359 &mut self,
6360 file: &Path,
6361 src: &str,
6362 line_offset: usize,
6363 vars: &JsonMap<String, Json>,
6364 cli: &BTreeSet<&str>,
6365 ctx: &str,
6366 ) {
6367 for c in preflight::calls(src)
6368 .into_iter()
6369 .filter(|c| c.func == "var" && !c.has_default)
6370 {
6371 if !vars.contains_key(&c.name) && !cli.contains(c.name.as_str()) {
6372 self.diags.error(
6373 Code::UnresolvedVar,
6374 Some(file.to_path_buf()),
6375 Some(c.line + line_offset),
6376 format!(
6377 "`var('{}')` has no value for {ctx} (checked --var, Set, report, folder and project vars) and no default",
6378 c.name
6379 ),
6380 );
6381 }
6382 }
6383 }
6384
6385 fn check_template_files(&mut self, project: &Project) {
6386 let mut seen = BTreeSet::new();
6387 for r in &project.reports {
6388 for (format, t) in r.bindings.iter().flat_map(|b| {
6389 b.outputs
6390 .iter()
6391 .filter_map(|o| Some((o.format.as_str(), o.template.as_ref()?)))
6392 }) {
6393 if !seen.insert((r.name.clone(), t.file.clone(), format!("{:?}", t.bindings))) {
6394 continue;
6395 }
6396 let path = match resolve_template(&self.root, &r.file, &t.file) {
6397 Ok(p) => p,
6398 Err(e) => {
6399 self.diags.error(
6400 Code::MissingTemplate,
6401 Some(r.file.clone()),
6402 None,
6403 format!("report `{}`: {e}", r.name),
6404 );
6405 continue;
6406 }
6407 };
6408 if format != "xlsx" {
6409 continue;
6410 }
6411 match template_sheets(&path) {
6412 Ok(sheets) => {
6413 for tb in &t.bindings {
6414 if !sheets.contains(&tb.sheet) {
6415 self.diags.error(
6416 Code::InvalidTemplate,
6417 Some(r.file.clone()),
6418 None,
6419 format!(
6420 "report `{}`: template `{}` has no sheet `{}` (sheets: {})",
6421 r.name,
6422 t.file,
6423 tb.sheet,
6424 sheets.join(", ")
6425 ),
6426 );
6427 }
6428 }
6429 }
6430 Err(e) => self.diags.error(
6431 Code::InvalidTemplate,
6432 Some(r.file.clone()),
6433 None,
6434 format!("report `{}`: can't read template `{}`: {e}", r.name, t.file),
6435 ),
6436 }
6437 }
6438 }
6439 }
6440}
6441
6442pub fn resolve_template(root: &Path, yaml: &Path, file: &str) -> Result<PathBuf, String> {
6445 let yaml = root.join(yaml);
6446 let from = yaml.parent().unwrap_or(root);
6447 crate::template_file::resolve(root, from, file)
6448}
6449
6450pub fn find_template(root: &Path, file: &str) -> Option<PathBuf> {
6454 [root.join(file), root.join("templates").join(file)]
6455 .into_iter()
6456 .find(|p| p.is_file())
6457}
6458
6459pub fn output_label(name: Option<&str>, index: usize) -> String {
6461 match name {
6462 Some(n) => format!("output `{n}`"),
6463 None => format!("output {}", index + 1),
6464 }
6465}
6466
6467pub fn template_sheets(path: &Path) -> Result<Vec<String>, String> {
6468 use calamine::Reader;
6469 let wb: calamine::Xlsx<_> =
6470 calamine::open_workbook(path).map_err(|e: calamine::XlsxError| e.to_string())?;
6471 Ok(wb.sheet_names().to_vec())
6472}
6473
6474struct RawReport {
6475 name: String,
6476 file: Rc<YamlFile>,
6477 folder: Vec<String>,
6478 keys: BTreeMap<String, Located<Value>>,
6480 lines: BTreeMap<String, Option<usize>>,
6482 typed: ReportFile,
6484}
6485
6486struct BindingBase {
6487 profile: Option<String>,
6488 profile_at: Option<ProfileAt>,
6489 vars: JsonMap<String, Json>,
6490 outputs: Vec<Mapping>,
6492 locale: Option<String>,
6493}
6494
6495#[derive(Default)]
6497struct Usage {
6498 connections: BTreeMap<String, (Option<PathBuf>, Option<usize>)>,
6499 destinations: BTreeMap<String, (Option<PathBuf>, Option<usize>)>,
6500 formats: BTreeMap<String, (String, PathBuf)>,
6502}
6503
6504impl Usage {
6505 fn connection(&mut self, p: &str, file: Option<PathBuf>, line: Option<usize>) {
6508 if preflight::is_templated(p) {
6509 return;
6510 }
6511 let e = self.connections.entry(p.to_string()).or_insert((None, None));
6512 if e.0.is_none() {
6513 *e = (file, line);
6514 }
6515 }
6516 fn destination(&mut self, p: &str, file: Option<PathBuf>, line: Option<usize>) {
6517 if preflight::is_templated(p) {
6518 return;
6519 }
6520 let e = self.destinations.entry(p.to_string()).or_insert((None, None));
6521 if e.0.is_none() {
6522 *e = (file, line);
6523 }
6524 }
6525 fn format(&mut self, f: &str, ctx: &str, file: &Path) {
6526 self.formats
6527 .entry(f.to_string())
6528 .or_insert_with(|| (ctx.to_string(), file.to_path_buf()));
6529 }
6530}
6531
6532fn builtin_output() -> Mapping {
6537 let mut m = Mapping::new();
6538 m.insert("format".into(), Value::String("csv".into()));
6539 m
6540}
6541
6542fn project_default_output(p: &Project) -> Option<Mapping> {
6543 let yf = YamlFile::parse(
6544 std::fs::read_to_string(p.root.join(PROJECT_FILE)).ok()?,
6545 PathBuf::from(PROJECT_FILE),
6546 &mut Diagnostics::default(),
6547 )?;
6548 yf.value.get("default_output").and_then(Value::as_object).cloned()
6549}
6550
6551pub fn layer_outputs(base: &mut Vec<Mapping>, over: &Value) -> Vec<String> {
6558 let mut problems = Vec::new();
6559 match over {
6560 Value::Array(list) if list.is_empty() => problems.push(
6561 "`output` is an empty list; give at least one output, or remove it to use the inherited one"
6562 .into(),
6563 ),
6564 Value::Array(list) => {
6565 let mut outs = Vec::new();
6566 for (i, entry) in list.iter().enumerate() {
6567 match entry.as_object() {
6568 Some(m) => {
6569 let mut o = builtin_output();
6570 problems.extend(merge_output(&mut o, m));
6571 outs.push(o);
6572 }
6573 None => problems.push(format!("`output` entry {} must be a map", i + 1)),
6574 }
6575 }
6576 if problems.is_empty() {
6577 *base = outs;
6578 }
6579 }
6580 Value::Object(m) => {
6581 let name_of = |o: &Mapping| o.get("name").and_then(Value::as_str).map(str::to_string);
6582 let target = match m.get("name").and_then(Value::as_str) {
6583 Some(n) => match base.iter().position(|o| name_of(o).as_deref() == Some(n)) {
6584 Some(i) => Some(i),
6585 None if base.len() == 1 && name_of(&base[0]).is_none() => Some(0),
6586 None => {
6587 let names: Vec<String> = base
6588 .iter()
6589 .filter_map(name_of)
6590 .map(|n| format!("`{n}`"))
6591 .collect();
6592 problems.push(format!(
6593 "no inherited output is named `{n}`{}; override the full `output:` list to add one",
6594 if names.is_empty() {
6595 String::new()
6596 } else {
6597 format!(" (they're {})", names.join(", "))
6598 }
6599 ));
6600 None
6601 }
6602 },
6603 None if base.len() == 1 => Some(0),
6604 None => {
6605 problems.push(format!(
6606 "this overrides `output` with no `name:`, but it inherits {} outputs, so it's unclear which one to change; give the `name:` of the output to change, or override the full list",
6607 base.len()
6608 ));
6609 None
6610 }
6611 };
6612 if let Some(i) = target {
6613 problems.extend(merge_output(&mut base[i], m));
6614 }
6615 }
6616 _ => problems.push("`output` must be a map or a list of maps".into()),
6617 }
6618 problems
6619}
6620
6621pub fn merge_output(base: &mut Mapping, over: &Mapping) -> Option<String> {
6632 if let Some(f) = over.get("format")
6633 && base.get("format") != Some(f)
6634 {
6635 base.retain(|k, _| is_one_of(k, FORMAT_INDEPENDENT_KEYS));
6636 }
6637 let mut problem = None;
6638 for (k, v) in over {
6639 if k.as_str() == "destination"
6640 && let Value::Object(o) = v
6641 {
6642 let profile = |m: &Mapping| m.get("profile").cloned();
6643 match base.get_mut(k) {
6644 Some(Value::Array(list)) if o.get("profile").is_none() && list.len() > 1 => {
6645 problem = Some(format!(
6646 "this overrides `destination` with no `profile:`, but it inherits {} destinations, so it's unclear which one to change; override the full list instead",
6647 list.len()
6648 ));
6649 continue;
6650 }
6651 Some(Value::Array(list))
6652 if list.len() == 1
6653 && list[0]
6654 .as_object()
6655 .is_some_and(|b| o.get("profile").is_none() || profile(b) == profile(o)) =>
6656 {
6657 let mut b = list[0].as_object().cloned().unwrap_or_default();
6658 b.extend(o.clone());
6659 base.insert(k.clone(), Value::Object(b));
6660 continue;
6661 }
6662 Some(Value::Object(b)) if o.get("profile").is_none() || profile(b) == profile(o) => {
6663 b.extend(o.clone());
6664 continue;
6665 }
6666 _ => {}
6667 }
6668 }
6669 base.insert(k.clone(), v.clone());
6670 }
6671 problem
6672}
6673
6674fn inherited_profile_at(layers: &[&FolderCfg], project: &Project) -> Option<ProfileAt> {
6677 if let Some((_, line)) = layers.iter().rev().find_map(|l| l.profile.as_ref()) {
6678 return Some(ProfileAt {
6679 file: PathBuf::from(PROJECT_FILE),
6680 line: *line,
6681 key: "`+profile`".into(),
6682 });
6683 }
6684 project.default_profile.as_ref().map(|_| ProfileAt {
6685 file: PathBuf::from(PROJECT_FILE),
6686 line: project.default_profile_line,
6687 key: "`default_profile`".into(),
6688 })
6689}
6690
6691fn metadata_layers(project: &Project, folders: &[&FolderCfg]) -> Vec<MetaLayer> {
6692 let mut layers = Vec::new();
6693 if !project.meta.is_empty() {
6694 layers.push(MetaLayer {
6695 file: PROJECT_FILE.into(),
6696 key: "meta".into(),
6697 meta: project.meta.clone(),
6698 });
6699 }
6700 layers.extend(folders.iter().filter_map(|f| f.meta.clone()));
6701 layers
6702}
6703
6704fn folder_layers<'a>(folders: &'a BTreeMap<Vec<String>, FolderCfg>, folder: &[String]) -> Vec<&'a FolderCfg> {
6705 (0..=folder.len())
6706 .filter_map(|n| folders.get(&folder[..n].to_vec()))
6707 .collect()
6708}
6709
6710fn folder_segments(rel: &Path) -> Vec<String> {
6711 rel.components()
6712 .skip(1)
6713 .map(|c| c.as_os_str().to_string_lossy().to_string())
6714 .collect()
6715}
6716
6717pub fn dotted(path: &[String]) -> String {
6718 path.join(".")
6719}
6720
6721fn pick(v: &Value, keys: &[&str]) -> Mapping {
6722 v.as_object()
6723 .map(|m| {
6724 m.iter()
6725 .filter(|(k, _)| is_one_of(k, keys))
6726 .map(|(k, v)| (k.clone(), v.clone()))
6727 .collect()
6728 })
6729 .unwrap_or_default()
6730}
6731
6732fn json_strings(v: &Json) -> Vec<String> {
6734 match v {
6735 Json::String(s) => vec![s.clone()],
6736 Json::Array(a) => a.iter().flat_map(json_strings).collect(),
6737 Json::Object(o) => o.values().flat_map(json_strings).collect(),
6738 _ => Vec::new(),
6739 }
6740}
6741
6742fn is_one_of(k: &str, keys: &[&str]) -> bool {
6743 keys.contains(&k)
6744}
6745
6746fn is_timing_registry(m: &Mapping) -> bool {
6748 !m.is_empty()
6749 && m.values().all(|v| {
6750 v.as_object()
6751 .is_some_and(|t| ["cron", "every", "rrule"].iter().any(|k| t.contains_key(*k)))
6752 })
6753}
6754
6755fn is_set_registry(m: &Mapping) -> bool {
6758 m.values().any(Value::is_object)
6759 && m.values().all(|v| {
6760 v.is_null()
6761 || v.as_object()
6762 .is_some_and(|e| e.keys().all(|k| is_one_of(k, &["profile", "vars", "locale"])))
6763 })
6764}
6765
6766fn string_list(v: &Value) -> Option<Vec<String>> {
6767 v.as_array()?
6768 .iter()
6769 .map(|i| i.as_str().map(str::to_string))
6770 .collect()
6771}
6772
6773fn query_item_name(item: &QueryItem) -> Option<&str> {
6775 match &item.value {
6776 Loose::Ok(de::OneOf::A(s)) => Some(s),
6777 Loose::Ok(de::OneOf::B(e)) => e.query.as_ref().and_then(Loose::ok).map(String::as_str),
6778 Loose::Bad(_) => None,
6779 }
6780}
6781
6782fn entry_name(v: &Value) -> Option<String> {
6783 match v {
6784 Value::String(s) => Some(s.clone()),
6785 Value::Object(m) => m.get("query").and_then(Value::as_str).map(str::to_string),
6786 _ => None,
6787 }
6788}
6789
6790fn dedup(v: &mut Vec<String>) {
6791 let mut seen = BTreeSet::new();
6792 v.retain(|t| seen.insert(t.clone()));
6793}
6794
6795fn is_identifier(s: &str) -> bool {
6797 !s.is_empty()
6798 && !s.starts_with(|c: char| c.is_ascii_digit())
6799 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
6800}