Skip to main content

dre_core/config/
report.rs

1//! The shape of a report YAML file under `reports/` (one fragment of a report: a report may be
2//! split over several files, merged by name).
3//!
4//! Values the loader checks itself, to say what's wrong in DRE's words, are [`Loose`]. Outputs and
5//! Sets are read where they're used.
6
7use schemars::JsonSchema;
8use serde::Deserialize;
9use serde::de::IgnoredAny;
10
11use super::de::{Located, Loose, Map, Maybe, OneOf, UnknownKeys};
12
13type JsonMap = serde_json::Map<String, serde_json::Value>;
14
15/// One `queries:` item: a query's name, or a query with settings.
16pub type QueryItem = Located<Loose<OneOf<String, QueryEntry>>>;
17
18/// A report: SQL queries plus an output, in a YAML file under `reports/`.
19#[derive(Default, Deserialize, JsonSchema)]
20#[schemars(title = "DRE report", deny_unknown_fields)]
21pub struct ReportFile {
22    /// The report's name. Default: the name of the folder the YAML file is in. Report names are unique across the project.
23    #[schemars(length(min = 1))]
24    pub name: Option<Located<Loose<String>>>,
25    /// What this report answers, including its business definitions and units.
26    pub description: Option<Located<Loose<String>>>,
27    /// Metadata for external tools. DRE checks its shape, not the meaning of its keys.
28    pub meta: Option<Located<Loose<JsonMap>>>,
29    /// Tags to select the report with, `-s tag:<tag>`.
30    pub tags: Option<Located<Loose<Vec<String>>>>,
31    /// The `.sql` files whose results make the report's tabs, in the order they run, on one database session.
32    #[serde(default)]
33    #[schemars(schema_with = "queries")]
34    pub queries: Option<Located<Loose<Vec<QueryItem>>>>,
35    /// One output, or a list of outputs formatted from the same run of the queries (file outputs are delivered before messages). A list replaces the inherited output; a map changes it.
36    #[serde(default)]
37    #[schemars(schema_with = "output_or_list")]
38    pub output: Option<IgnoredAny>,
39    /// The connection (in `profiles.yml`) the queries run on, unless a query's own `profile:` or a source's says otherwise. Default: the folder's `+profile`, then `default_profile`. Can't be combined with `sets`. May use Jinja with `var()`, `env_var()`, `run.*` and `target.name`.
40    pub profile: Option<Located<Loose<String>>>,
41    /// The Sets the report can run as, by name (declared in `sets.yml`) or declared here.
42    pub sets: Option<Located<Loose<Vec<SetItem>>>>,
43    /// The Set a plain `dre run` uses. Must be one of `sets`.
44    pub default_set: Option<Located<Loose<String>>>,
45    /// Variables, read in SQL and YAML with `var('name')`. Values can be strings, numbers, booleans, lists or maps.
46    pub vars: Option<Located<Loose<JsonMap>>>,
47    /// Optional declarations for variables: type, default, allowed values, description, required, and for strings a pattern and max_length.
48    /// Kept separate from `vars` so a map-valued variable remains a plain value.
49    pub var_schema: Option<Located<Loose<crate::variables::Definitions>>>,
50    /// The timezone `run.date` and `run.now` use, an IANA name such as `Australia/Sydney`. Default: UTC.
51    pub timezone: Option<Located<Loose<String>>>,
52    /// The locale this report's number filters format for (`de-DE`). Default: the folder's `+locale`, then the project's `locale`, then `en`.
53    pub locale: Option<Located<Loose<String>>>,
54    /// The plugin packages this project uses. DRE installs them on demand into `dre_deps/` and pins them in `dre.lock`. May be written in any project YAML file; `dependencies.yml` is the usual place.
55    #[serde(default)]
56    #[schemars(schema_with = "super::dependencies::plugins")]
57    pub plugins: Option<IgnoredAny>,
58    /// dbt-style source declarations (see the sources schema). May be written in any project YAML file.
59    #[serde(default)]
60    #[schemars(schema_with = "super::project::sources_ref")]
61    pub sources: Option<IgnoredAny>,
62    /// Removed: schedules live in schedules.yml. Read only to say so.
63    #[schemars(skip)]
64    pub schedule: Option<Located<IgnoredAny>>,
65    #[serde(rename = "$unknown", default)]
66    #[schemars(skip)]
67    pub unknown: UnknownKeys,
68}
69
70/// A query with settings, instead of just its name.
71#[derive(Deserialize, JsonSchema)]
72#[schemars(rename = "queryEntry", deny_unknown_fields)]
73pub struct QueryEntry {
74    /// The name of a `.sql` file under `reports/`, without folder or extension.
75    #[schemars(required)]
76    pub query: Option<Loose<String>>,
77    /// The connection this query runs on, over the report's, Set's, folder's and project's. Must agree with the `profile` of any source it uses. May use Jinja with `var()`, `env_var()`, `run.*` and `target.name`.
78    pub profile: Option<Loose<String>>,
79    /// `false` runs the file only for what it does (temp tables, `SET`s) and discards any result, so it gets no tab.
80    #[schemars(extend("default" = true))]
81    pub tab: Option<Loose<bool>>,
82    /// The tab (sheet) name. Default: the file's name. Not allowed with `tab: false`.
83    pub tab_name: Option<Loose<String>>,
84    /// Where the data starts on the sheet (xlsx only). Default: `A1`.
85    #[schemars(regex(pattern = "^[A-Za-z]{1,3}[0-9]+$"))]
86    pub anchor: Option<Loose<String>>,
87    /// Whether to write the column names as the first row (xlsx only). Default: the output's `header`.
88    pub header: Option<Loose<bool>>,
89    /// Per-column settings for this tab, by column name (xlsx only).
90    #[serde(default)]
91    #[schemars(schema_with = "columns")]
92    pub columns: Option<serde_json::Value>,
93    /// When this query returns no rows: `continue` keeps its empty tab or section, `skip` leaves its tab (or per-query file) out, `fail` fails the run. Wins over the output's `on_empty`. Default: the output decides once every query is empty.
94    pub on_empty: Option<Loose<crate::no_data::OnEmpty>>,
95    /// Columns whose values are secrets (a PDF or zip password): values of 4 or more characters are masked as `*****` in the console, logs, `run_results.json`, `rows.jsonl`, `--output-json` and previews. Never masked in delivered files or messages.
96    pub secret_columns: Option<Loose<Vec<String>>>,
97    /// Size this tab's columns from their content (xlsx only), over the output's `autofit`. Default: the output's, which is on.
98    pub autofit: Option<Loose<bool>>,
99    /// How this tab looks (xlsx only), over the output's `style`: `font`, `header`, `totals`, `banded_rows`, `borders`, and cell keys (`bold`, `fill`, ...) for every data cell. See the xlsx plugin page.
100    #[serde(default)]
101    #[schemars(schema_with = "sheet_style")]
102    pub style: Option<serde_json::Value>,
103    #[serde(rename = "$unknown", default)]
104    #[schemars(skip)]
105    pub unknown: UnknownKeys,
106}
107
108fn sheet_style(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
109    schemars::json_schema!({
110        "type": "object",
111        "description": "How an xlsx tab looks. Cell keys apply to every data cell; `header` and `totals` take cell keys for those rows.",
112        "properties": {
113            "header": {"type": "object", "description": "Cell keys for the header row (default: bold)."},
114            "totals": {"type": "object", "description": "Cell keys for the totals row (default: bold, a thin top border)."},
115            "banded_rows": {"oneOf": [{"type": "string", "pattern": "^#?[0-9A-Fa-f]{6}$"}, {"const": false}], "description": "The fill of every other data row, `\"#RRGGBB\"`, or `false`."},
116            "borders": {"enum": ["none", "thin", "medium"], "description": "A border around every cell of the table."},
117            "bold": {"type": "boolean", "description": "Bold text in every data cell."},
118            "italic": {"type": "boolean", "description": "Italic text in every data cell."},
119            "underline": {"type": "boolean", "description": "Underlined text in every data cell."},
120            "font": {"type": "object", "description": "The font: `{name: Calibri, size: 11}`.", "properties": {"name": {"type": "string", "description": "The font's name."}, "size": {"type": "number", "description": "The size in points."}}, "additionalProperties": false},
121            "font_color": {"type": "string", "pattern": "^#?[0-9A-Fa-f]{6}$", "description": "The text colour, `\"#RRGGBB\"`."},
122            "fill": {"type": "string", "pattern": "^#?[0-9A-Fa-f]{6}$", "description": "The background colour, `\"#RRGGBB\"`."},
123            "align": {"enum": ["left", "center", "right"], "description": "Horizontal alignment."},
124            "border": {"enum": ["none", "thin", "medium"], "description": "A border around each data cell."}
125        },
126        "additionalProperties": false
127    })
128}
129
130fn columns(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
131    schemars::json_schema!({
132        "type": "object",
133        "additionalProperties": {
134            "type": "object",
135            "description": "Settings for one column of an xlsx tab.",
136            "properties": {
137                "format": {"type": "string", "description": "The Excel number format of the column, e.g. `#,##0.00` or `dd/mm/yyyy`. See the xlsx column formats in the plugins reference."},
138                "formula": {"type": "string", "description": "An Excel formula for each row of this column; `{name}` stands for that column's cell on the same row, e.g. `=ROUND({qty}*{unit_price},2)`. The SQL selects a placeholder column where the formula goes."},
139                "total": {"type": "string", "description": "Puts a total under the column: one of `sum`, `count`, `average`, `min`, `max`, or a formula such as `=SUM({net:*})`."},
140                "style": {"type": "object", "description": "How the column's data cells look: `bold`, `italic`, `underline`, `font` (`{name, size}`), `font_color`, `fill` (`\"#RRGGBB\"`), `align` (`left`, `center`, `right`), `border` (`none`, `thin`, `medium`). Over the tab's and output's `style`."},
141                "width": {"oneOf": [{"const": "auto"}, {"type": "number", "exclusiveMinimum": 0, "maximum": 255}], "description": "The column's width: `auto` (sized from its content, at most 60 characters) or a number of characters. Over the tab's and output's `autofit`."}
142            },
143            "additionalProperties": false
144        },
145        "propertyNames": {"minLength": 1}
146    })
147}
148
149/// One `sets:` item: a Set's name (declared in `sets.yml`), or a Set declared here.
150pub type SetItem = Located<Loose<OneOf<String, SetEntry>>>;
151
152/// A Set declared in the report: a named variant of it.
153#[derive(Deserialize, JsonSchema)]
154#[schemars(rename = "setEntry", deny_unknown_fields)]
155pub struct SetEntry {
156    /// The Set's name. A report can also name Sets declared in `sets.yml`.
157    #[schemars(required)]
158    pub name: Option<Loose<String>>,
159    /// The connection this Set runs on. May use Jinja with `var()`, `env_var()`, `run.*` and `target.name`.
160    pub profile: Option<Located<Loose<String>>>,
161    /// Variables, read in SQL and YAML with `var('name')`. Values can be strings, numbers, booleans, lists or maps.
162    #[serde(default)]
163    #[schemars(schema_with = "any_map")]
164    pub vars: Option<Loose<JsonMap>>,
165    /// Queries to leave out of this Set, by name.
166    pub exclude: Option<Located<Loose<Vec<String>>>>,
167    /// Replaces the report's queries for this Set.
168    #[serde(default)]
169    #[schemars(schema_with = "set_queries")]
170    pub queries: Option<Located<Loose<Vec<SetQueryItem>>>>,
171    /// Renames tabs for this Set: query name to tab name.
172    #[serde(default)]
173    #[schemars(schema_with = "tab_names")]
174    pub tab_names: Option<Loose<JsonMap>>,
175    /// Output settings for this Set: a map changes the inherited output (with several, the one its `name:` names); a list replaces them all.
176    #[serde(default)]
177    #[schemars(schema_with = "output_or_list")]
178    pub output: Option<serde_json::Value>,
179    /// The locale for this Set's number filters (`fr-FR`), above the report's.
180    pub locale: Option<Loose<String>>,
181    /// Removed: schedules live in schedules.yml. Read only to say so.
182    #[schemars(skip)]
183    pub schedule: Option<IgnoredAny>,
184    #[serde(rename = "$unknown", default)]
185    #[schemars(skip)]
186    pub unknown: UnknownKeys,
187}
188
189/// One item of a Set's `queries:`: a query's name, or a query with settings.
190pub type SetQueryItem = Loose<OneOf<String, SetQuery>>;
191
192/// A query of a Set with its own settings.
193#[derive(Deserialize)]
194pub struct SetQuery {
195    pub query: Option<Loose<String>>,
196    pub tab: Option<Loose<bool>>,
197    pub tab_name: Option<Loose<String>>,
198    pub on_empty: Option<Loose<crate::no_data::OnEmpty>>,
199    pub secret_columns: Option<Loose<Vec<String>>>,
200}
201
202fn any_map(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
203    schemars::json_schema!({"type": "object", "additionalProperties": true})
204}
205
206fn tab_names(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
207    schemars::json_schema!({"type": "object", "additionalProperties": {"type": "string"}})
208}
209
210fn output_or_list(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
211    let output = g.subschema_for::<Output>();
212    schemars::json_schema!({
213        "oneOf": [output, {"type": "array", "minItems": 1, "items": output}]
214    })
215}
216
217fn set_queries(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
218    schemars::json_schema!({
219        "type": "array",
220        "items": {
221            "description": "A query of this Set, by name, or with its own settings.",
222            "oneOf": [
223                {"type": "string"},
224                {
225                    "type": "object",
226                    "description": "A query with settings.",
227                    "properties": {
228                        "query": {"type": "string", "description": "The query's name."},
229                        "tab": {"type": "boolean", "description": "Whether the query makes a tab."},
230                        "tab_name": {"type": "string", "description": "The tab name."},
231                        "on_empty": {"enum": ["continue", "skip", "fail"], "description": "When this query returns no rows: keep its empty tab, leave it out, or fail the run."},
232                        "secret_columns": {"type": "array", "items": {"type": "string"}, "description": "Columns whose values are masked in DRE's logs, results and previews, added to the query's own."}
233                    },
234                    "required": ["query"],
235                    "additionalProperties": true
236                }
237            ]
238        }
239    })
240}
241
242/// How a report's result is written and where it goes. Besides the keys below, each format takes its own options (for example `delimiter` for `delimited`, `columns` for `fixed_width`, `text` for `message`); they are documented with the formats.
243// Outputs are layered (the built-in default, `default_output`, each folder's `+output`, the
244// report's and a Set's `output`) and read once merged.
245#[derive(Deserialize, JsonSchema)]
246#[schemars(rename = "output", extend("additionalProperties" = true))]
247pub struct Output {
248    /// Names the output so other outputs and Set overrides can refer to it (`outputs.<name>` in a message, `attach:`). Also the default file name (`<name>.<ext>`). Unique within the report.
249    #[schemars(regex(pattern = "^[A-Za-z_][A-Za-z0-9_]*$"))]
250    pub name: Option<Loose<String>>,
251    /// The output format: `message` (built in: a short headline rendered from the results), or `csv`, `delimited`, `fixed_width`, `parquet` or `xlsx` (each a plugin). Default: `csv`, or the project's `default_output`.
252    pub format: Option<Loose<String>>,
253    /// Which of the report's queries this output formats. Default: all of them. Each query runs once, whatever the number of outputs.
254    #[schemars(length(min = 1))]
255    pub queries: Option<Loose<Vec<String>>>,
256    /// A Jinja expression over the results (`results.<query>.value < 0`); when it's false, the output is skipped and recorded as `skipped`.
257    #[serde(default)]
258    #[schemars(schema_with = "string_or_bool")]
259    pub when: Option<Loose<OneOf<String, bool>>>,
260    /// Where to deliver the file: one destination, or a list to deliver to several in one run. Default: the file stays in the target path.
261    #[serde(default)]
262    #[schemars(schema_with = "destination_or_list")]
263    pub destination: Option<Loose<OneOf<Destination, Vec<Loose<Destination>>>>>,
264    pub template: Option<Loose<Template>>,
265    /// One copy of the output per row of a driver query (`{query: members, key: member_id}`), each with its own slice of the results and the driver row as `each.*`; `false` turns an inherited one off. See docs/per-row-outputs.md.
266    #[serde(default)]
267    #[schemars(schema_with = "for_each_or_false")]
268    pub for_each: Option<Loose<OneOf<ForEach, bool>>>,
269    /// A per-row output's file name, with Jinja (`"{{ each.member_id }}_statement.pdf"`). Default: the row's key and the format's extension, under a folder named after the output.
270    pub file_name: Option<Loose<String>>,
271    /// When every query of the output returns no rows: `continue` (default) writes and delivers it empty, with a warning; `skip` writes and delivers nothing; `fail` fails the run. A query's own `on_empty` wins. See "When there's no data" in docs/building-reports.md.
272    pub on_empty: Option<Loose<crate::no_data::OnEmpty>>,
273    /// File extension for text formats (e.g. `aba`), instead of the format's own. `""` or `false` means none. Not for xlsx.
274    #[serde(default)]
275    #[schemars(schema_with = "extension")]
276    pub extension: Maybe<Loose<Option<OneOf<String, bool>>>>,
277    /// The format's own options.
278    #[serde(rename = "$unknown", default)]
279    #[schemars(skip)]
280    pub options: Map<serde_json::Value>,
281}
282
283/// Where a file is delivered: the name of a destination profile in `profiles.yml`, an optional `path`, and the options of that destination's plugin (e.g. `to`, `subject` and `body` for email). The built-in profile `local` copies the file to a local path.
284#[derive(Deserialize, JsonSchema)]
285#[schemars(rename = "destination", extend("additionalProperties" = true))]
286pub struct Destination {
287    /// The destination profile in `profiles.yml` (under `destinations:`) to deliver with. `local` is built in. It uses its entry for the run (`--target`, `DRE_TARGET`, else the profile's own `target:`, else `dev`); a missing entry is an error, and an entry `{deliver: false}` delivers nowhere. May use Jinja with `var()`, `env_var()`, `run.*` and `target.name`.
288    #[schemars(required)]
289    pub profile: Option<Loose<String>>,
290    /// Where to put the file: a path, or a URL such as `s3://bucket/key`, depending on the destination. Rendered with Jinja, so it can use `var()`, `run.*`, macros and `destination.*` (this destination's settings).
291    pub path: Option<Loose<String>>,
292    /// On a message output's entry, for a destination that takes messages and files (`slack`, `email`): other outputs of the report whose files go with the message.
293    #[serde(default)]
294    #[schemars(schema_with = "attach")]
295    pub attach: Option<Loose<OneOf<String, Vec<String>>>>,
296    /// Hold this delivery: the output is rendered and every other destination delivered, and this one waits for `dre release`. `dre run --hold` holds every destination.
297    pub hold: Option<Loose<bool>>,
298    /// At most this many deliveries to this destination per second, minute or hour (`100/minute`, `5/second`, `20000/hour`), spaced evenly: `60/minute` sends one a second. For `dre run` and `dre release`; `--rate` overrides it.
299    #[schemars(regex(pattern = "^\\s*[0-9_]+\\s*/\\s*[A-Za-z]+\\s*$"))]
300    pub rate: Option<Loose<String>>,
301    /// With `rate`: how many deliveries may go back to back while keeping the same average. Default: 1 (evenly spaced).
302    #[schemars(range(min = 1))]
303    pub burst: Option<Loose<u64>>,
304    /// Deliver one zip instead of loose files: the output's files plus every `attach:`ed output's, flat. `true` for no password, or `{password, encryption}`. The zip is named by `path` (which must end in `.zip`), else `<output>.zip`.
305    #[serde(default)]
306    #[schemars(schema_with = "zip_schema")]
307    pub zip: Option<Loose<OneOf<bool, ZipConfig>>>,
308    /// The destination plugin's options.
309    #[serde(rename = "$unknown", default)]
310    #[schemars(skip)]
311    pub options: Map<serde_json::Value>,
312}
313
314/// `zip:` with settings.
315#[derive(Deserialize)]
316pub struct ZipConfig {
317    pub password: Option<Loose<String>>,
318    pub encryption: Option<Loose<crate::zip_pack::Encryption>>,
319    #[serde(rename = "$unknown", default)]
320    pub unknown: UnknownKeys,
321}
322
323fn zip_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
324    schemars::json_schema!({
325        "oneOf": [
326            {"type": "boolean", "description": "`true`: zip the files, with no password."},
327            {
328                "type": "object",
329                "description": "Zip the files with a password.",
330                "properties": {
331                    "password": {"type": "string", "description": "The zip's password. Jinja: `env_var()`, `var()`, `each.*`. An empty password is an error."},
332                    "encryption": {"enum": ["aes256", "zipcrypto"], "description": "`aes256` (default): strong; opens in 7-Zip, WinRAR, Keka. `zipcrypto`: weak, but opens in Windows Explorer and macOS Archive Utility."}
333                },
334                "additionalProperties": false
335            }
336        ]
337    })
338}
339
340/// One copy of the output per row of a driver query (a per-row output): each copy gets only its row's slice of the results, and the driver row as `each.<column>` in every template of the output. `false` turns an inherited `for_each` off.
341#[derive(Deserialize, JsonSchema)]
342#[schemars(rename = "for_each", deny_unknown_fields)]
343pub struct ForEach {
344    /// The driver: a query of the report, one row per copy of the output. It isn't a tab or section of the output itself.
345    #[schemars(required)]
346    pub query: Option<Loose<String>>,
347    /// The column (or list of columns) that identifies a row, in the driver and in every query of the output. Use an opaque id, never an email or a name: keys appear in file names and logs.
348    #[schemars(required)]
349    #[serde(default)]
350    #[schemars(schema_with = "string_or_strings")]
351    pub key: Option<Loose<OneOf<String, Vec<String>>>>,
352    /// How many rows render and deliver at once. Default: 4. `--row-concurrency` and `DRE_ROW_CONCURRENCY` override it.
353    #[schemars(range(min = 1))]
354    pub concurrency: Option<Loose<u64>>,
355    /// `continue` (default): a failed row is recorded and the others carry on; the run ends failed. `fail`: render every row first, and deliver only if all rendered.
356    pub on_row_error: Option<Loose<crate::project::RowErrors>>,
357    /// Rows of a query whose key isn't in the driver: `warn` (default) or `fail`.
358    pub on_orphan_rows: Option<Loose<crate::project::OrphanRows>>,
359    /// The most rows across the output's queries before anything renders. Default: 5,000,000.
360    #[schemars(range(min = 1))]
361    pub max_population_rows: Option<Loose<u64>>,
362    /// Keep the run's query results in its run folder, for `--retry-failed`. Default: true.
363    pub keep_results: Option<Loose<bool>>,
364    /// Mask destination values rendered from `each.*` (addresses, paths) in the console, logs and results. Default: the project's `mask_row_values`, true.
365    pub mask_row_values: Option<Loose<bool>>,
366}
367
368fn string_or_strings(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
369    schemars::json_schema!({
370        "oneOf": [
371            {"type": "string"},
372            {"type": "array", "items": {"type": "string"}, "minItems": 1}
373        ]
374    })
375}
376
377fn for_each_or_false(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
378    let f = g.subschema_for::<ForEach>();
379    schemars::json_schema!({"oneOf": [f, {"const": false}]})
380}
381
382/// The template an xlsx output fills (a branded workbook, instead of a new one), or an html or pdf output renders (a Jinja HTML + CSS file; see the document plugin).
383#[derive(Deserialize, JsonSchema)]
384#[schemars(rename = "template", deny_unknown_fields)]
385pub struct Template {
386    /// The template file (`.xlsx` for xlsx, `.html` for html and pdf): a bare file name, found next to the report's YAML, then in each parent folder, then anywhere in the project (it must be unique); or a path with `/`, relative to the project root.
387    #[schemars(required)]
388    pub file: Option<Loose<String>>,
389    /// xlsx only: where each query's data goes in the template. Default: none, so the template is copied as it is.
390    pub bindings: Option<Loose<Vec<Loose<TemplateBinding>>>>,
391}
392
393/// One block of data in an xlsx template: a table of a query's result, or a single cell.
394#[derive(Deserialize, JsonSchema)]
395#[schemars(deny_unknown_fields, inline)]
396pub struct TemplateBinding {
397    /// The template sheet to write into.
398    #[schemars(required)]
399    pub sheet: Option<Loose<String>>,
400    /// The query whose result goes here. Must be one of the Binding's queries.
401    pub query: Option<Loose<String>>,
402    /// Which result set of the query to use, counting from 1. Default: the last.
403    #[schemars(range(min = 1))]
404    pub result_index: Option<Loose<u64>>,
405    /// The top-left cell of a table block. A table block needs a `query`.
406    #[schemars(regex(pattern = "^[A-Za-z]{1,3}[0-9]+$"))]
407    pub anchor: Option<Loose<String>>,
408    /// Whether to write the column names above the data in a table block.
409    pub header: Option<Loose<bool>>,
410    /// The columns of the query to write in a table block, in order. Default: all.
411    pub columns: Option<Loose<Vec<String>>>,
412    /// Makes this a single-cell binding: the cell to write. Needs exactly one of `value`, or `query` with `column`.
413    #[schemars(regex(pattern = "^[A-Za-z]{1,3}[0-9]+$"))]
414    pub cell: Option<Loose<String>>,
415    /// A fixed value (rendered with Jinja) for a single-cell binding.
416    pub value: Option<Loose<String>>,
417    /// The column of the query's first row to write into a single-cell binding.
418    pub column: Option<Loose<String>>,
419    #[serde(rename = "$unknown", default)]
420    #[schemars(skip)]
421    pub unknown: UnknownKeys,
422}
423
424fn string_or_bool(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
425    schemars::json_schema!({"type": ["string", "boolean"]})
426}
427
428fn extension(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
429    // `null` is a value here (no extension), not schemars' "optional".
430    schemars::json_schema!({"type": ["string", "boolean", "null"], "x-dre-null": true})
431}
432
433fn destination_or_list(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
434    let destination = g.subschema_for::<Destination>();
435    schemars::json_schema!({
436        "oneOf": [destination, {"type": "array", "minItems": 1, "items": destination}]
437    })
438}
439
440fn attach(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
441    schemars::json_schema!({
442        "oneOf": [
443            {"type": "string"},
444            {"type": "array", "items": {"type": "string"}, "minItems": 1}
445        ]
446    })
447}
448
449fn queries(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
450    let entry = g.subschema_for::<QueryEntry>();
451    schemars::json_schema!({
452        "type": "array",
453        "items": {
454            "oneOf": [
455                {"type": "string", "description": "The name of a `.sql` file under `reports/`, without extension."},
456                entry
457            ]
458        }
459    })
460}