1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
//! The command line, in DuckDB's spelling.
//!
//! DuckDB's shell descends from SQLite's, which means single dash long options, a positional
//! argument that is the database rather than a script, and a second positional argument that is
//! SQL. None of that is what a Rust program would choose and all of it is what a script written
//! against `duckdb` expects, so it is what this parses.
use std::path::PathBuf;
use crate::format::Format;
/// One thing to run before the shell reads its input.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
/// SQL, or a dot command, given on the command line.
Sql(String),
/// A file of them.
File(PathBuf),
}
/// What the shell was asked to do.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
/// Open a database and run.
Run(Box<Options>),
/// Print the version and stop.
Version,
/// Print the usage and stop.
Help,
/// Print the build configuration and stop.
Config,
/// The command line does not make sense, and this says why.
Wrong(String),
}
/// Everything the command line can set.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Options {
/// The database to open. `:memory:` until there is a storage format to open a file with.
pub database: String,
/// What to run before reading input, in the order it was given.
pub commands: Vec<Command>,
/// Whether to stop after the commands rather than reading input.
pub stop_after_commands: bool,
/// Set by `-interactive` and `-batch`, which override the guess made from whether input is a
/// terminal.
pub interactive: Option<bool>,
/// Print each statement before running it.
pub echo: bool,
/// Stop at the first error even when reading a script.
pub bail: bool,
/// Open without allowing writes.
pub readonly: bool,
/// What `--set name=value` asked for, in the order it was given.
///
/// Kept apart from [`Options::commands`] rather than pushed in as SQL, because these run
/// before everything else whatever position they were written in. A flag that configures the
/// engine and a flag that runs a query are two different things, and a benchmark script that
/// puts its `--set` at the end of the line means the same thing as one that puts it first.
pub sets: Vec<String>,
/// Where `--metrics` writes what each statement reported about itself, one document per line.
///
/// One per line rather than one array, because a statement's document is written the moment
/// that statement finishes and a run that dies half way through still leaves the ones that did
/// finish. A harness reading this wants the line for the query it timed, and a file it has to
/// read to the end before it can parse any of it is a file it cannot get that from.
pub metrics: Option<PathBuf>,
/// Print the row at a time fall through table on the way out.
///
/// Separate from [`Options::metrics`] because it answers a different question and is counted
/// differently. The document says which operator in this query paid for a fall through, and it
/// is written per statement. This says which pair of physical forms a kernel had no
/// specialization for, and the counters behind it are process wide, so it is one table for the
/// run rather than one per statement.
pub fallbacks: bool,
/// How results are printed, and everything that goes with it.
pub settings: crate::format::Settings,
}
impl Default for Options {
fn default() -> Self {
Self {
database: ":memory:".to_string(),
commands: Vec::new(),
stop_after_commands: false,
interactive: None,
echo: false,
bail: false,
readonly: false,
sets: Vec::new(),
metrics: None,
fallbacks: false,
settings: crate::format::Settings::default(),
}
}
}
/// Reads the command line.
///
/// Unknown options are an error rather than a positional argument. SQLite treats an unrecognized
/// dash argument as a filename and DuckDB inherits that, which turns a typo into a database called
/// `-csvv`, so this is one of the few places the shell deliberately does not copy the behaviour.
pub fn parse(arguments: &[String]) -> Action {
let mut options = Options::default();
let mut positional = 0;
let mut at = 0;
while at < arguments.len() {
let argument = arguments[at].as_str();
at += 1;
let mut next = |name: &str| -> Result<String, String> {
if at < arguments.len() {
let value = arguments[at].clone();
at += 1;
Ok(value)
} else {
Err(format!("{name} wants a value"))
}
};
match argument {
"-version" | "--version" | "-V" => return Action::Version,
"-h" | "-help" | "--help" => return Action::Help,
"--print-config" => return Action::Config,
"-c" | "-s" | "--command" => match next(argument) {
Ok(sql) => {
options.commands.push(Command::Sql(sql));
options.stop_after_commands = true;
}
Err(why) => return Action::Wrong(why),
},
"-cmd" => match next(argument) {
Ok(sql) => options.commands.push(Command::Sql(sql)),
Err(why) => return Action::Wrong(why),
},
"-f" | "-file" => match next(argument) {
Ok(path) => {
options.commands.push(Command::File(PathBuf::from(path)));
options.stop_after_commands = true;
}
Err(why) => return Action::Wrong(why),
},
"-init" => match next(argument) {
Ok(path) => options.commands.push(Command::File(PathBuf::from(path))),
Err(why) => return Action::Wrong(why),
},
// Two dashes, like `--print-config`, because DuckDB has no flag of this name and the
// single dash forms in this list are the ones a script written against `duckdb`
// already uses. A name that is ours should look like it.
"--set" => match next(argument) {
Ok(pair) => match pair.split_once('=') {
Some(_) => options.sets.push(pair),
None => {
return Action::Wrong(format!("--set is written name=value, not {pair}"));
}
},
Err(why) => return Action::Wrong(why),
},
// Two dashes for the same reason `--set` has two: DuckDB has no flag of this name.
"--metrics" => match next(argument) {
Ok(path) => options.metrics = Some(PathBuf::from(path)),
Err(why) => return Action::Wrong(why),
},
// Two dashes for the same reason the two above have them.
"--fallbacks" => options.fallbacks = true,
"-separator" => match next(argument) {
Ok(value) => options.settings.separator = value,
Err(why) => return Action::Wrong(why),
},
"-newline" => match next(argument) {
Ok(value) => options.settings.newline = value,
Err(why) => return Action::Wrong(why),
},
"-nullvalue" => match next(argument) {
Ok(value) => options.settings.nullvalue = value,
Err(why) => return Action::Wrong(why),
},
"-header" => options.settings.header = true,
"-noheader" => options.settings.header = false,
"-echo" => options.echo = true,
"-bail" => options.bail = true,
"-readonly" => options.readonly = true,
"-interactive" => options.interactive = Some(true),
"-batch" => options.interactive = Some(false),
"-no-stdin" => options.stop_after_commands = true,
"-no-init" | "-unsigned" | "-unredacted" | "-safe" => {}
other if other.starts_with('-') => {
match Format::from_flag(other.trim_start_matches('-')) {
Some(format) => options.settings.set_format_flag(format),
None => return Action::Wrong(format!("unknown option {other}")),
}
}
// The first one is the database and every one after it is SQL, however many there are.
// There is no count to get wrong: `duckdb a.db "SELECT 1" extra` does not complain
// about the third argument, it runs it, and says the table `extra` does not exist.
// Per #246.
other => {
positional += 1;
if positional == 1 {
options.database = other.to_string();
} else {
options.commands.push(Command::Sql(other.to_string()));
options.stop_after_commands = true;
}
}
}
}
Action::Run(Box::new(options))
}
#[cfg(test)]
mod tests {
use super::{Action, Command, parse};
use crate::format::Format;
fn options(arguments: &[&str]) -> super::Options {
let owned: Vec<String> = arguments.iter().map(|text| (*text).to_string()).collect();
match parse(&owned) {
Action::Run(options) => *options,
other => panic!("expected a run, got {other:?}"),
}
}
#[test]
fn nothing_means_an_interactive_memory_database() {
let parsed = options(&[]);
assert_eq!(parsed.database, ":memory:");
assert!(parsed.commands.is_empty());
assert!(!parsed.stop_after_commands);
}
#[test]
fn a_command_runs_and_stops() {
let parsed = options(&["-c", "SELECT 1"]);
assert_eq!(parsed.commands, vec![Command::Sql("SELECT 1".to_string())]);
assert!(parsed.stop_after_commands);
}
#[test]
fn commands_keep_their_order() {
let parsed = options(&["-c", "one", "-c", "two"]);
assert_eq!(
parsed.commands,
vec![Command::Sql("one".to_string()), Command::Sql("two".to_string())]
);
}
#[test]
fn cmd_runs_first_and_does_not_stop() {
let parsed = options(&["-cmd", ".mode csv"]);
assert!(!parsed.stop_after_commands);
}
#[test]
fn the_first_positional_is_the_database_and_the_second_is_sql() {
let parsed = options(&["shop.db", "SELECT 1"]);
assert_eq!(parsed.database, "shop.db");
assert_eq!(parsed.commands, vec![Command::Sql("SELECT 1".to_string())]);
assert!(parsed.stop_after_commands);
}
/// Every positional after the first is another statement, in the order they were written.
///
/// DuckDB has no limit here and no error for the count, so neither does this. Per #246.
#[test]
fn every_positional_after_the_database_is_another_statement() {
let parsed = options(&["shop.db", "SELECT 1", "SELECT 2", "SELECT 3"]);
assert_eq!(parsed.database, "shop.db");
assert_eq!(
parsed.commands,
vec![
Command::Sql("SELECT 1".to_string()),
Command::Sql("SELECT 2".to_string()),
Command::Sql("SELECT 3".to_string()),
]
);
assert!(parsed.stop_after_commands);
}
#[test]
fn a_mode_flag_sets_the_mode_and_its_separator() {
let parsed = options(&["-csv"]);
assert_eq!(parsed.settings.format, Format::Csv);
assert_eq!(parsed.settings.separator, ",");
}
/// The row separator is the one thing the csv flag does not set, which is DuckDB's behaviour.
///
/// `duckdb -csv` writes `\n` at the end of a row and `duckdb -cmd ".mode csv"` writes `\r\n`,
/// on the same build in the same run, and `tests/shell.rs` holds both captures. This is the
/// parse side of it.
#[test]
fn a_mode_flag_leaves_the_row_separator_where_it_was_and_the_dot_command_does_not() {
assert_eq!(options(&["-csv"]).settings.newline, "\n");
assert_eq!(options(&["-csv", "-newline", ";"]).settings.newline, ";");
}
/// What each flag sets, against `duckdb v2.0.0-dev84237` read out of `.show`.
///
/// The separators are given first so that a flag which leaves one alone can be told apart from
/// one that sets it to the value it already had. Per #239.
#[test]
fn each_mode_flag_sets_the_separators_that_flag_sets_and_no_others() {
let given = |flag: &str| {
let parsed = options(&["-separator", ";", "-newline", "@", flag]);
(parsed.settings.separator, parsed.settings.newline)
};
assert_eq!(given("-ascii"), ("\u{1f}".to_string(), "\u{1e}".to_string()));
assert_eq!(given("-csv"), (",".to_string(), "@".to_string()));
let neither = [
"-box",
"-column",
"-html",
"-json",
"-jsonlines",
"-line",
"-list",
"-markdown",
"-quote",
"-table",
];
for flag in neither {
assert_eq!(given(flag), (";".to_string(), "@".to_string()), "{flag}");
}
}
/// The four modes that are not flags, per #238.
///
/// Each of them is still a mode, so `.mode tabs` works and `-tabs` does not, which is what the
/// binary does. The aliases are not flags either.
#[test]
fn a_mode_that_duckdb_has_no_flag_for_is_an_error_here_too() {
for flag in ["-duckbox", "-insert", "-tabs", "-trash", "-lines", "-tsv", "-ndjson"] {
assert!(matches!(parse(&[flag.to_string()]), Action::Wrong(_)), "{flag}");
}
}
#[test]
fn a_separator_given_after_the_mode_wins() {
let parsed = options(&["-csv", "-separator", ";"]);
assert_eq!(parsed.settings.separator, ";");
}
#[test]
fn every_set_flag_is_kept_in_order_and_apart_from_the_sql() {
let parsed = options(&["--set", "hash.table=unchained", "-c", "SELECT 1", "--set", "x=y"]);
assert_eq!(parsed.sets, ["hash.table=unchained", "x=y"]);
assert_eq!(parsed.commands, [Command::Sql("SELECT 1".to_string())]);
}
#[test]
fn a_set_flag_without_a_value_says_how_it_is_written() {
assert!(matches!(
parse(&["--set".to_string(), "hash.table".to_string()]),
Action::Wrong(why) if why.contains("name=value")
));
assert!(matches!(parse(&["--set".to_string()]), Action::Wrong(_)));
}
#[test]
fn the_fallbacks_flag_takes_no_value_and_is_off_unless_asked_for() {
let parsed = options(&["--fallbacks", "-c", "SELECT 1"]);
assert!(parsed.fallbacks);
assert_eq!(parsed.commands, [Command::Sql("SELECT 1".to_string())]);
assert!(!options(&["-c", "SELECT 1"]).fallbacks);
}
#[test]
fn the_metrics_flag_names_the_file_the_documents_go_to() {
let parsed = options(&["--metrics", "run.json", "-c", "SELECT 1"]);
assert_eq!(parsed.metrics, Some(std::path::PathBuf::from("run.json")));
assert_eq!(parsed.commands, [Command::Sql("SELECT 1".to_string())]);
assert!(matches!(parse(&["--metrics".to_string()]), Action::Wrong(_)));
}
#[test]
fn nothing_is_written_unless_the_metrics_flag_asks_for_it() {
assert_eq!(options(&["-c", "SELECT 1"]).metrics, None);
}
#[test]
fn an_unknown_option_is_an_error_rather_than_a_filename() {
assert!(matches!(parse(&["-csvv".to_string()]), Action::Wrong(_)));
}
#[test]
fn an_option_missing_its_value_says_so() {
assert!(matches!(parse(&["-c".to_string()]), Action::Wrong(_)));
}
#[test]
fn version_and_help_win_wherever_they_appear() {
assert!(matches!(parse(&["-csv".to_string(), "-version".to_string()]), Action::Version));
assert!(matches!(parse(&["-help".to_string()]), Action::Help));
}
}