Skip to main content

mz_clusterd_test_driver/
text.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! The text script format: a hand-writable, `datadriven`-style command file.
11//!
12//! A script is a sequence of stanzas, each a command and its expected output:
13//!
14//! ```text
15//! write-single-ts shard=data ts=0 count=5000
16//! ----
17//! wrote 5000
18//!
19//! count id=1001 ts=5
20//! ----
21//! 10000
22//! ```
23//!
24//! A stanza is a command (a directive line plus an optional indentation-structured
25//! body) up to a `----` separator, then the expected output up to a blank line. The
26//! `----` block is the assertion; `REWRITE=1` regenerates it (see [`crate::script`]).
27//! A `#` at column 0 is a comment; an indented `#0` is a column reference in MIR.
28//! Comments and blank lines are preserved across a rewrite.
29//!
30//! Output that itself contains blank lines (notably an `explain` plan render) uses
31//! the `datadriven` doubled-separator form: the directive, then `----`, then `----`,
32//! then the expected output, closed by a `----`/`----` pair. `REWRITE` emits this
33//! form automatically when the output contains a blank line.
34//!
35//! Command bodies are indentation-structured: `define-schema`/`write-rows`/`peek`
36//! carry rows or columns, and `define` carries `import`/`build`/`export`
37//! sub-commands, with a `build`'s MIR as its own deeper-indented sub-body.
38
39use std::collections::BTreeMap;
40
41use anyhow::{Context, anyhow, bail, ensure};
42use mz_repr::GlobalId;
43
44use crate::script::{
45    BuildSpec, ColumnSpec, Command, ConfigSetting, ExplainTarget, ExportSpec, ImportSpec,
46};
47
48/// One element of a parsed script file, retained so a `REWRITE` reproduces the
49/// file faithfully.
50pub enum Item {
51    /// A blank line or column-0 `#` comment, kept verbatim.
52    Verbatim(String),
53    /// A command and its expected output.
54    Stanza(Stanza),
55}
56
57/// A command stanza: the input block, the expected output, and the parsed command.
58pub struct Stanza {
59    /// The directive line plus body, verbatim (for rewrite).
60    pub input: String,
61    /// The expected output block (no trailing newline).
62    pub expected: String,
63    /// The parsed command.
64    pub command: Command,
65}
66
67/// Parse a script file into items: each command stanza plus the comments and blank
68/// lines around it.
69pub fn parse_file(content: &str) -> anyhow::Result<Vec<Item>> {
70    let lines: Vec<&str> = content.lines().collect();
71    let mut items = Vec::new();
72    let mut i = 0;
73    while i < lines.len() {
74        let line = lines[i];
75        // Blank lines and column-0 comments are preserved as-is.
76        if line.trim().is_empty() || line.starts_with('#') {
77            items.push(Item::Verbatim(line.to_string()));
78            i += 1;
79            continue;
80        }
81        // A stanza: slurp the input block up to the `----` separator.
82        let start = i;
83        while i < lines.len() && lines[i] != "----" {
84            i += 1;
85        }
86        ensure!(
87            i < lines.len(),
88            "stanza starting at line {} has no `----` separator",
89            start + 1
90        );
91        let input = lines[start..i].join("\n");
92        i += 1; // consume `----`
93        // A second `----` opens "blank-line mode" (the `datadriven` convention): the
94        // expected output may contain blank lines and runs until a closing `----`
95        // `----` pair, instead of ending at the first blank line. Used for the
96        // multi-object `explain` plan render.
97        let blank_mode = i < lines.len() && lines[i] == "----";
98        if blank_mode {
99            i += 1;
100        }
101        let exp_start = i;
102        let expected = if blank_mode {
103            while i < lines.len()
104                && !(lines[i] == "----" && i + 1 < lines.len() && lines[i + 1] == "----")
105            {
106                i += 1;
107            }
108            ensure!(
109                i < lines.len(),
110                "stanza starting at line {} has an unclosed `----`/`----` block",
111                start + 1
112            );
113            let expected = lines[exp_start..i].join("\n");
114            i += 2; // consume the closing `----` `----`
115            expected
116        } else {
117            // The expected output runs to the next blank line (or end of file).
118            while i < lines.len() && !lines[i].trim().is_empty() {
119                i += 1;
120            }
121            lines[exp_start..i].join("\n")
122        };
123        let command = parse_command(&input)
124            .with_context(|| format!("parsing stanza at line {}", start + 1))?;
125        items.push(Item::Stanza(Stanza {
126            input,
127            expected,
128            command,
129        }));
130    }
131    Ok(items)
132}
133
134/// Reproduce a script file with each stanza's expected output replaced by its
135/// actual output, for `REWRITE`. `actuals` has one entry per [`Item::Stanza`], in
136/// order.
137pub fn rewrite(items: &[Item], actuals: &[String]) -> String {
138    let mut out = String::new();
139    let mut next = 0;
140    for item in items {
141        match item {
142            Item::Verbatim(line) => {
143                out.push_str(line);
144                out.push('\n');
145            }
146            Item::Stanza(stanza) => {
147                let actual = &actuals[next];
148                next += 1;
149                out.push_str(&stanza.input);
150                if actual.contains("\n\n") {
151                    // Blank lines in the output need the doubled-`----` form, else the
152                    // first blank line would truncate the block on the next parse.
153                    out.push_str("\n----\n----\n");
154                    out.push_str(actual);
155                    out.push_str("\n----\n----\n");
156                } else {
157                    out.push_str("\n----\n");
158                    out.push_str(actual);
159                    if !actual.is_empty() {
160                        out.push('\n');
161                    }
162                }
163            }
164        }
165    }
166    out
167}
168
169/// A non-blank line of a command block, with its leading-space indentation.
170struct Line {
171    indent: usize,
172    text: String,
173}
174
175/// Split a command block into non-blank lines, recording each line's indentation.
176fn lex(block: &str) -> Vec<Line> {
177    block
178        .lines()
179        .filter_map(|raw| {
180            let trimmed = raw.trim_end();
181            if trimmed.trim().is_empty() {
182                return None;
183            }
184            let indent = trimmed.len() - trimmed.trim_start().len();
185            Some(Line {
186                indent,
187                text: trimmed.trim_start().to_string(),
188            })
189        })
190        .collect()
191}
192
193/// Split body lines into `(header, body)` groups: each header is a line at the
194/// minimum indentation, owning the following more-indented lines as its body.
195fn group(lines: &[Line]) -> anyhow::Result<Vec<(&Line, &[Line])>> {
196    if lines.is_empty() {
197        return Ok(vec![]);
198    }
199    let base = lines.iter().map(|l| l.indent).min().expect("non-empty");
200    let mut groups = Vec::new();
201    let mut i = 0;
202    while i < lines.len() {
203        ensure!(
204            lines[i].indent == base,
205            "inconsistent indentation: `{}`",
206            lines[i].text
207        );
208        let start = i + 1;
209        let mut j = start;
210        while j < lines.len() && lines[j].indent > base {
211            j += 1;
212        }
213        groups.push((&lines[i], &lines[start..j]));
214        i = j;
215    }
216    Ok(groups)
217}
218
219/// Render body lines as text, dedented to the body's minimum indentation so their
220/// relative structure (which MIR depends on) is preserved.
221fn body_text(lines: &[Line]) -> String {
222    if lines.is_empty() {
223        return String::new();
224    }
225    let base = lines.iter().map(|l| l.indent).min().expect("non-empty");
226    lines
227        .iter()
228        .map(|l| format!("{}{}", " ".repeat(l.indent - base), l.text))
229        .collect::<Vec<_>>()
230        .join("\n")
231}
232
233/// Split a header line into whitespace-separated tokens, keeping `"..."` strings
234/// and `[...]` lists intact.
235fn tokenize(s: &str) -> anyhow::Result<Vec<String>> {
236    let mut tokens = Vec::new();
237    let mut cur = String::new();
238    let mut depth = 0i32;
239    let mut in_quote = false;
240    for c in s.chars() {
241        match c {
242            '"' => {
243                in_quote = !in_quote;
244                cur.push(c);
245            }
246            '[' if !in_quote => {
247                depth += 1;
248                cur.push(c);
249            }
250            ']' if !in_quote => {
251                depth -= 1;
252                cur.push(c);
253            }
254            c if c.is_whitespace() && !in_quote && depth == 0 => {
255                if !cur.is_empty() {
256                    tokens.push(std::mem::take(&mut cur));
257                }
258            }
259            c => cur.push(c),
260        }
261    }
262    ensure!(!in_quote, "unterminated string in `{s}`");
263    ensure!(depth == 0, "unterminated list in `{s}`");
264    if !cur.is_empty() {
265        tokens.push(cur);
266    }
267    Ok(tokens)
268}
269
270/// Parse a header line into its verb, `key=value` arguments, and bare flags.
271fn parse_header(header: &str) -> anyhow::Result<(String, BTreeMap<String, String>, Vec<String>)> {
272    let mut tokens = tokenize(header)?.into_iter();
273    let verb = tokens.next().context("empty command")?;
274    let mut args = BTreeMap::new();
275    let mut flags = Vec::new();
276    for token in tokens {
277        match token.split_once('=') {
278            Some((key, value)) => {
279                args.insert(key.to_string(), value.to_string());
280            }
281            None => flags.push(token),
282        }
283    }
284    Ok((verb, args, flags))
285}
286
287fn req<'a>(args: &'a BTreeMap<String, String>, key: &str) -> anyhow::Result<&'a str> {
288    args.get(key)
289        .map(String::as_str)
290        .ok_or_else(|| anyhow!("missing argument `{key}`"))
291}
292
293fn req_u64(args: &BTreeMap<String, String>, key: &str) -> anyhow::Result<u64> {
294    req(args, key)?
295        .parse()
296        .with_context(|| format!("argument `{key}` is not an integer"))
297}
298
299fn opt_u64(args: &BTreeMap<String, String>, key: &str) -> anyhow::Result<Option<u64>> {
300    args.get(key)
301        .map(|v| {
302            v.parse()
303                .with_context(|| format!("argument `{key}` is not an integer"))
304        })
305        .transpose()
306}
307
308fn opt_usize(args: &BTreeMap<String, String>, key: &str) -> anyhow::Result<Option<usize>> {
309    args.get(key)
310        .map(|v| {
311            v.parse()
312                .with_context(|| format!("argument `{key}` is not an integer"))
313        })
314        .transpose()
315}
316
317/// Parse a global id argument: a bare `u64` (e.g. `1001`) is the user namespace,
318/// matching [`mz_repr::GlobalId`]'s `Display`/`FromStr` prefixes (`s`/`si`/`u`/`t`)
319/// for the other namespaces, e.g. `t7` is `GlobalId::Transient(7)`.
320fn parse_id(s: &str) -> anyhow::Result<GlobalId> {
321    if s.bytes().all(|b| b.is_ascii_digit()) {
322        let id: u64 = s.parse().with_context(|| format!("bad id `{s}`"))?;
323        Ok(GlobalId::User(id))
324    } else {
325        s.parse().with_context(|| format!("bad id `{s}`"))
326    }
327}
328
329fn req_id(args: &BTreeMap<String, String>, key: &str) -> anyhow::Result<GlobalId> {
330    parse_id(req(args, key)?)
331}
332
333fn opt_string(args: &BTreeMap<String, String>, key: &str) -> Option<String> {
334    args.get(key).cloned()
335}
336
337/// Parse a `[a,b,c]` list of `usize`s (`[]` is empty).
338fn parse_usize_list(s: &str) -> anyhow::Result<Vec<usize>> {
339    let inner = s
340        .strip_prefix('[')
341        .and_then(|s| s.strip_suffix(']'))
342        .ok_or_else(|| anyhow!("expected a list like `[0,1]`, got `{s}`"))?;
343    if inner.trim().is_empty() {
344        return Ok(vec![]);
345    }
346    inner
347        .split(',')
348        .map(|part| {
349            part.trim()
350                .parse()
351                .with_context(|| format!("bad list element `{part}`"))
352        })
353        .collect()
354}
355
356/// Parse an `export` sub-command into an [`ExportSpec`]. The `kind=` argument
357/// selects the variant (defaulting to `index`); each kind takes its own arguments.
358fn parse_export(args: &BTreeMap<String, String>) -> anyhow::Result<ExportSpec> {
359    let on_id = req_id(args, "on")?;
360    Ok(
361        match args.get("kind").map(String::as_str).unwrap_or("index") {
362            "index" => ExportSpec::Index {
363                index_id: req_id(args, "index")?,
364                on_id,
365                key: parse_usize_list(req(args, "key")?)?,
366            },
367            "materialized-view" => ExportSpec::MaterializedView {
368                sink_id: req_id(args, "sink")?,
369                on_id,
370                shard: req(args, "shard")?.to_string(),
371                schema: opt_string(args, "schema"),
372            },
373            "subscribe" => ExportSpec::Subscribe {
374                sink_id: req_id(args, "sink")?,
375                on_id,
376                schema: opt_string(args, "schema"),
377                up_to: opt_u64(args, "up-to")?,
378            },
379            "copy-to" => bail!("copy-to export is not implemented"),
380            other => bail!("unknown export kind `{other}`"),
381        },
382    )
383}
384
385/// Parse body lines as rows of raw space-separated value tokens (quotes intact).
386/// The tokens are typed against the schema server-side; see `cell_from_token`.
387fn rows_from_body(body: &[Line]) -> anyhow::Result<Vec<Vec<String>>> {
388    body.iter().map(|l| tokenize(&l.text)).collect()
389}
390
391/// Parse body lines as dyncfg settings: `name type value`.
392fn settings_from_body(body: &[Line]) -> anyhow::Result<Vec<ConfigSetting>> {
393    body.iter()
394        .map(|l| {
395            let tokens = tokenize(&l.text)?;
396            ensure!(
397                tokens.len() == 3,
398                "config setting needs `name type value`, got `{}`",
399                l.text
400            );
401            Ok(ConfigSetting {
402                name: tokens[0].clone(),
403                ty: tokens[1].clone(),
404                value: tokens[2].clone(),
405            })
406        })
407        .collect()
408}
409
410/// Parse body lines as column declarations: `name type [nullable]`.
411fn columns_from_body(body: &[Line]) -> anyhow::Result<Vec<ColumnSpec>> {
412    body.iter()
413        .map(|l| {
414            let tokens = tokenize(&l.text)?;
415            ensure!(
416                tokens.len() >= 2,
417                "column needs `name type [nullable]`, got `{}`",
418                l.text
419            );
420            Ok(ColumnSpec {
421                name: tokens[0].clone(),
422                ty: tokens[1].clone(),
423                nullable: tokens.get(2).is_some_and(|t| t == "nullable"),
424            })
425        })
426        .collect()
427}
428
429/// The parsed parts of a `create-dataflow` / `explain` body, shared by both verbs.
430struct DataflowBody {
431    name: Option<String>,
432    imports: Vec<ImportSpec>,
433    builds: Vec<BuildSpec>,
434    exports: Vec<ExportSpec>,
435    as_of: u64,
436    optimize: bool,
437}
438
439/// Parse a dataflow body of `import`/`build`/`export` sub-commands, shared by
440/// `create-dataflow` and `explain`. The directive's bare flags carry the
441/// dataflow-level options (`optimize`).
442fn parse_dataflow_body(
443    args: &BTreeMap<String, String>,
444    flags: &[String],
445    body: &[Line],
446) -> anyhow::Result<DataflowBody> {
447    let name = opt_string(args, "name");
448    let as_of = req_u64(args, "as-of")?;
449    let optimize = flags.iter().any(|f| f == "optimize");
450    let mut imports = Vec::new();
451    let mut builds = Vec::new();
452    let mut exports = Vec::new();
453    for (header, sub_body) in group(body)? {
454        let (verb, args, _flags) = parse_header(&header.text)?;
455        match verb.as_str() {
456            "import" => {
457                if let Some(index_id) = args.get("index") {
458                    imports.push(ImportSpec::Index {
459                        index_id: parse_id(index_id)
460                            .with_context(|| format!("bad index id `{index_id}`"))?,
461                    });
462                } else {
463                    imports.push(ImportSpec::Source {
464                        id: req_id(&args, "source")?,
465                        shard: req(&args, "shard")?.to_string(),
466                        schema: opt_string(&args, "schema"),
467                        upper: req_u64(&args, "upper")?,
468                    });
469                }
470            }
471            "build" => {
472                ensure!(!sub_body.is_empty(), "`build` needs a MIR body");
473                builds.push(BuildSpec {
474                    id: req_id(&args, "id")?,
475                    expr: body_text(sub_body),
476                });
477            }
478            "export" => exports.push(parse_export(&args)?),
479            other => bail!("unknown dataflow sub-command `{other}`"),
480        }
481    }
482    Ok(DataflowBody {
483        name,
484        imports,
485        builds,
486        exports,
487        as_of,
488        optimize,
489    })
490}
491
492/// Parse one command block (directive line plus indentation-structured body).
493fn parse_command(input: &str) -> anyhow::Result<Command> {
494    let lines = lex(input);
495    let (header, body) = lines.split_first().context("empty command")?;
496    ensure!(header.indent == 0, "directive must not be indented");
497    let (verb, args, flags) = parse_header(&header.text)?;
498    let command = match verb.as_str() {
499        "define-schema" => Command::DefineSchema {
500            name: req(&args, "name")?.to_string(),
501            columns: columns_from_body(body)?,
502        },
503        "write-single-ts" => Command::WriteSingleTs {
504            shard: req(&args, "shard")?.to_string(),
505            schema: opt_string(&args, "schema"),
506            ts: req_u64(&args, "ts")?,
507            count: req_u64(&args, "count")?,
508            start: opt_u64(&args, "start")?.unwrap_or(0),
509            row_bytes: opt_usize(&args, "row-bytes")?,
510        },
511        "write-spread" => Command::WriteSpread {
512            shard: req(&args, "shard")?.to_string(),
513            schema: opt_string(&args, "schema"),
514            count: req_u64(&args, "count")?,
515            n_ts: req_u64(&args, "n-ts")?,
516            start: opt_u64(&args, "start")?.unwrap_or(0),
517            row_bytes: opt_usize(&args, "row-bytes")?,
518        },
519        "write-rows" => Command::WriteRows {
520            shard: req(&args, "shard")?.to_string(),
521            schema: opt_string(&args, "schema"),
522            ts: req_u64(&args, "ts")?,
523            rows: rows_from_body(body)?,
524        },
525        "define-index" => Command::DefineIndex {
526            source_id: req_id(&args, "source")?,
527            index_id: req_id(&args, "index")?,
528            shard: req(&args, "shard")?.to_string(),
529            schema: opt_string(&args, "schema"),
530            key: parse_usize_list(req(&args, "key")?)?,
531            as_of: req_u64(&args, "as-of")?,
532            upper: req_u64(&args, "upper")?,
533        },
534        "schedule" => Command::Schedule {
535            id: req_id(&args, "id")?,
536        },
537        "allow-compaction" => Command::AllowCompaction {
538            id: req_id(&args, "id")?,
539            frontier: req_u64(&args, "frontier")?,
540        },
541        "allow-writes" => Command::AllowWrites {
542            id: req_id(&args, "id")?,
543        },
544        "await-frontier" => Command::AwaitFrontier {
545            id: req_id(&args, "id")?,
546            ts: req_u64(&args, "ts")?,
547            timeout_secs: opt_u64(&args, "timeout-secs")?,
548            allow_timeout: flags.iter().any(|f| f == "allow-timeout"),
549        },
550        "count" => Command::Count {
551            id: req_id(&args, "id")?,
552            ts: req_u64(&args, "ts")?,
553        },
554        "peek" => Command::Peek {
555            id: req_id(&args, "id")?,
556            schema: opt_string(&args, "schema"),
557            ts: req_u64(&args, "ts")?,
558        },
559        "await-subscribe" => Command::AwaitSubscribe {
560            id: req_id(&args, "id")?,
561            up_to: req_u64(&args, "up-to")?,
562            timeout_secs: opt_u64(&args, "timeout-secs")?,
563        },
564        "create-dataflow" => {
565            let DataflowBody {
566                name,
567                imports,
568                builds,
569                exports,
570                as_of,
571                optimize,
572            } = parse_dataflow_body(&args, &flags, body)?;
573            Command::CreateDataflow {
574                name,
575                imports,
576                builds,
577                exports,
578                as_of,
579                optimize,
580            }
581        }
582        "explain" => {
583            // `explain ref=<name>` renders a previously declared dataflow; otherwise
584            // the dataflow is given inline with the `create-dataflow` body.
585            let target = if let Some(reference) = opt_string(&args, "ref") {
586                ensure!(
587                    body.is_empty(),
588                    "`explain ref=...` takes no body; it renders the declared dataflow"
589                );
590                ExplainTarget::Reference { name: reference }
591            } else {
592                let DataflowBody {
593                    name,
594                    imports,
595                    builds,
596                    exports,
597                    as_of,
598                    optimize,
599                } = parse_dataflow_body(&args, &flags, body)?;
600                ExplainTarget::Inline {
601                    name,
602                    imports,
603                    builds,
604                    exports,
605                    as_of,
606                    optimize,
607                }
608            };
609            Command::Explain { target }
610        }
611        "create-instance" => Command::CreateInstance {
612            expiration_offset: opt_string(&args, "expiration-offset"),
613            arrangement_dictionary_compression: args
614                .get("arrangement-dictionary-compression")
615                .map(|v| v.parse())
616                .transpose()
617                .context("argument `arrangement-dictionary-compression` is not a bool")?
618                .unwrap_or(false),
619            // Same `name type value` body rows as `update-configuration`.
620            initial_config: settings_from_body(body)?,
621        },
622        "update-configuration" => Command::UpdateConfiguration {
623            updates: settings_from_body(body)?,
624        },
625        "reconnect" => Command::Reconnect,
626        "initialization-complete" => Command::InitializationComplete,
627        other => bail!("unknown command `{other}`"),
628    };
629    Ok(command)
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635
636    /// A simple command parses its args; absent optionals default.
637    #[mz_ore::test]
638    fn parses_simple_command() {
639        let cmd = parse_command("write-single-ts shard=data ts=0 count=5000").unwrap();
640        assert_eq!(
641            cmd,
642            Command::WriteSingleTs {
643                shard: "data".to_string(),
644                schema: None,
645                ts: 0,
646                count: 5000,
647                start: 0,
648                row_bytes: None,
649            }
650        );
651    }
652
653    /// A flag and a list argument parse.
654    #[mz_ore::test]
655    fn parses_flag_and_list() {
656        let cmd =
657            parse_command("await-frontier id=1001 ts=1 timeout-secs=3 allow-timeout").unwrap();
658        assert_eq!(
659            cmd,
660            Command::AwaitFrontier {
661                id: GlobalId::User(1001),
662                ts: 1,
663                timeout_secs: Some(3),
664                allow_timeout: true,
665            }
666        );
667        let cmd =
668            parse_command("define-index source=1000 index=1001 shard=d key=[0] as-of=0 upper=1")
669                .unwrap();
670        assert_eq!(
671            cmd,
672            Command::DefineIndex {
673                source_id: GlobalId::User(1000),
674                index_id: GlobalId::User(1001),
675                shard: "d".to_string(),
676                schema: None,
677                key: vec![0],
678                as_of: 0,
679                upper: 1,
680            }
681        );
682    }
683
684    /// An id argument accepts a bare number (user namespace) or an explicit
685    /// `s`/`si`/`u`/`t` prefix selecting the namespace directly.
686    #[mz_ore::test]
687    fn parses_prefixed_ids() {
688        assert_eq!(
689            parse_command("schedule id=1001").unwrap(),
690            Command::Schedule {
691                id: GlobalId::User(1001)
692            }
693        );
694        assert_eq!(
695            parse_command("schedule id=u1001").unwrap(),
696            Command::Schedule {
697                id: GlobalId::User(1001)
698            }
699        );
700        assert_eq!(
701            parse_command("schedule id=t7").unwrap(),
702            Command::Schedule {
703                id: GlobalId::Transient(7)
704            }
705        );
706        assert_eq!(
707            parse_command("schedule id=s42").unwrap(),
708            Command::Schedule {
709                id: GlobalId::System(42)
710            }
711        );
712        assert!(parse_command("schedule id=bogus").is_err());
713    }
714
715    /// `define-schema` and `write-rows` parse their indented bodies, typing values.
716    #[mz_ore::test]
717    fn parses_bodies() {
718        let cmd = parse_command(
719            "define-schema name=events\n  key bigint\n  flag boolean\n  label text nullable",
720        )
721        .unwrap();
722        assert_eq!(
723            cmd,
724            Command::DefineSchema {
725                name: "events".to_string(),
726                columns: vec![
727                    ColumnSpec {
728                        name: "key".to_string(),
729                        ty: "bigint".to_string(),
730                        nullable: false
731                    },
732                    ColumnSpec {
733                        name: "flag".to_string(),
734                        ty: "boolean".to_string(),
735                        nullable: false
736                    },
737                    ColumnSpec {
738                        name: "label".to_string(),
739                        ty: "text".to_string(),
740                        nullable: true
741                    },
742                ],
743            }
744        );
745
746        let cmd = parse_command(
747            "write-rows shard=ev schema=events ts=1\n  1000 true alpha\n  1001 false null",
748        )
749        .unwrap();
750        assert_eq!(
751            cmd,
752            Command::WriteRows {
753                shard: "ev".to_string(),
754                schema: Some("events".to_string()),
755                ts: 1,
756                rows: vec![
757                    vec!["1000".to_string(), "true".to_string(), "alpha".to_string()],
758                    vec!["1001".to_string(), "false".to_string(), "null".to_string()],
759                ],
760            }
761        );
762    }
763
764    /// `create-dataflow` parses its sub-commands, keeping a `build`'s MIR (its
765    /// deeper body) with relative indentation preserved; the export kind defaults
766    /// to `index`.
767    #[mz_ore::test]
768    fn parses_create_dataflow_with_mir() {
769        let input = "create-dataflow name=count as-of=0\n  import index=1001\n  build id=2000\n    Reduce aggregates=[count(*)]\n      Get u1000\n  export index=2001 on=2000 key=[0]";
770        let cmd = parse_command(input).unwrap();
771        assert_eq!(
772            cmd,
773            Command::CreateDataflow {
774                name: Some("count".to_string()),
775                imports: vec![ImportSpec::Index {
776                    index_id: GlobalId::User(1001)
777                }],
778                builds: vec![BuildSpec {
779                    id: GlobalId::User(2000),
780                    expr: "Reduce aggregates=[count(*)]\n  Get u1000".to_string(),
781                }],
782                exports: vec![ExportSpec::Index {
783                    index_id: GlobalId::User(2001),
784                    on_id: GlobalId::User(2000),
785                    key: vec![0]
786                }],
787                as_of: 0,
788                optimize: false,
789            }
790        );
791
792        // The `optimize` flag on the directive line is picked up.
793        let optimized = parse_command(
794            "create-dataflow name=j as-of=0 optimize\n  import source=1000 shard=l upper=1\n  build id=2000\n    Get u1000\n  export index=2001 on=2000 key=[0]",
795        )
796        .unwrap();
797        assert!(matches!(
798            optimized,
799            Command::CreateDataflow { optimize: true, .. }
800        ));
801    }
802
803    /// Inline `explain` shares the `create-dataflow` body grammar but parses into
804    /// `Command::Explain` with an `Inline` target carrying the same body.
805    #[mz_ore::test]
806    fn parses_explain_inline() {
807        let input = "explain name=j as-of=0 optimize\n  import source=1000 shard=l upper=1\n  import source=1001 shard=r upper=1\n  build id=2000\n    Join on=(#0 = #2)\n      Get u1000\n      Get u1001\n  export index=2001 on=2000 key=[0]";
808        let cmd = parse_command(input).unwrap();
809        assert_eq!(
810            cmd,
811            Command::Explain {
812                target: ExplainTarget::Inline {
813                    name: Some("j".to_string()),
814                    imports: vec![
815                        ImportSpec::Source {
816                            id: GlobalId::User(1000),
817                            shard: "l".to_string(),
818                            schema: None,
819                            upper: 1,
820                        },
821                        ImportSpec::Source {
822                            id: GlobalId::User(1001),
823                            shard: "r".to_string(),
824                            schema: None,
825                            upper: 1,
826                        },
827                    ],
828                    builds: vec![BuildSpec {
829                        id: GlobalId::User(2000),
830                        expr: "Join on=(#0 = #2)\n  Get u1000\n  Get u1001".to_string(),
831                    }],
832                    exports: vec![ExportSpec::Index {
833                        index_id: GlobalId::User(2001),
834                        on_id: GlobalId::User(2000),
835                        key: vec![0],
836                    }],
837                    as_of: 0,
838                    optimize: true,
839                }
840            }
841        );
842    }
843
844    /// `explain ref=<name>` parses into a `Reference` target and takes no body.
845    #[mz_ore::test]
846    fn parses_explain_ref() {
847        let cmd = parse_command("explain ref=join").unwrap();
848        assert_eq!(
849            cmd,
850            Command::Explain {
851                target: ExplainTarget::Reference {
852                    name: "join".to_string(),
853                },
854            }
855        );
856        // A body is rejected: the declared dataflow supplies it.
857        assert!(parse_command("explain ref=join\n  import source=1 shard=s upper=1").is_err());
858    }
859
860    /// `create-dataflow` parses the sink export kinds: a materialized-view sink with
861    /// a target shard, and a subscribe sink. The `copy-to` kind is rejected.
862    #[mz_ore::test]
863    fn parses_sink_export_kinds() {
864        let mv = "create-dataflow name=mv as-of=0\n  import source=1000 shard=r upper=1\n  build id=2000\n    Get u1000\n  export kind=materialized-view sink=2001 on=2000 shard=out schema=kv";
865        let Command::CreateDataflow { exports, .. } = parse_command(mv).unwrap() else {
866            panic!("expected create-dataflow");
867        };
868        assert_eq!(
869            exports,
870            vec![ExportSpec::MaterializedView {
871                sink_id: GlobalId::User(2001),
872                on_id: GlobalId::User(2000),
873                shard: "out".to_string(),
874                schema: Some("kv".to_string()),
875            }]
876        );
877
878        let sub = "create-dataflow name=sub as-of=0\n  import source=1000 shard=s upper=2\n  build id=2000\n    Get u1000\n  export kind=subscribe sink=2001 on=2000 up-to=2";
879        let Command::CreateDataflow { exports, .. } = parse_command(sub).unwrap() else {
880            panic!("expected create-dataflow");
881        };
882        assert_eq!(
883            exports,
884            vec![ExportSpec::Subscribe {
885                sink_id: GlobalId::User(2001),
886                on_id: GlobalId::User(2000),
887                schema: None,
888                up_to: Some(2),
889            }]
890        );
891
892        // copy-to is named but not implemented.
893        let copy = "create-dataflow name=c as-of=0\n  import source=1000 shard=s upper=1\n  build id=2000\n    Get u1000\n  export kind=copy-to sink=2001 on=2000";
894        assert!(parse_command(copy).is_err());
895    }
896
897    /// `create-instance` parses its optional knobs (defaulting), and
898    /// `update-configuration` parses a `name type value` table (empty when bodyless).
899    #[mz_ore::test]
900    fn parses_handshake_config() {
901        assert_eq!(
902            parse_command("create-instance").unwrap(),
903            Command::CreateInstance {
904                expiration_offset: None,
905                arrangement_dictionary_compression: false,
906                initial_config: vec![],
907            }
908        );
909        assert_eq!(
910            parse_command(
911                "create-instance expiration-offset=30s arrangement-dictionary-compression=true"
912            )
913            .unwrap(),
914            Command::CreateInstance {
915                expiration_offset: Some("30s".to_string()),
916                arrangement_dictionary_compression: true,
917                initial_config: vec![],
918            }
919        );
920        // The create-time snapshot is a `name type value` body, like `update-configuration`.
921        assert_eq!(
922            parse_command("create-instance\n  enable_my_flag bool true").unwrap(),
923            Command::CreateInstance {
924                expiration_offset: None,
925                arrangement_dictionary_compression: false,
926                initial_config: vec![ConfigSetting {
927                    name: "enable_my_flag".to_string(),
928                    ty: "bool".to_string(),
929                    value: "true".to_string(),
930                }],
931            }
932        );
933
934        assert_eq!(
935            parse_command("update-configuration").unwrap(),
936            Command::UpdateConfiguration { updates: vec![] }
937        );
938        assert_eq!(
939            parse_command("update-configuration\n  enable_my_flag bool true\n  my_dur duration 1s")
940                .unwrap(),
941            Command::UpdateConfiguration {
942                updates: vec![
943                    ConfigSetting {
944                        name: "enable_my_flag".to_string(),
945                        ty: "bool".to_string(),
946                        value: "true".to_string(),
947                    },
948                    ConfigSetting {
949                        name: "my_dur".to_string(),
950                        ty: "duration".to_string(),
951                        value: "1s".to_string(),
952                    },
953                ],
954            }
955        );
956    }
957
958    /// `await-subscribe` parses its arguments.
959    #[mz_ore::test]
960    fn parses_await_subscribe() {
961        let cmd = parse_command("await-subscribe id=2001 up-to=2 timeout-secs=5").unwrap();
962        assert_eq!(
963            cmd,
964            Command::AwaitSubscribe {
965                id: GlobalId::User(2001),
966                up_to: 2,
967                timeout_secs: Some(5),
968            }
969        );
970    }
971
972    /// A file splits into stanzas, preserving comments and blanks, and round-trips
973    /// through a rewrite when the actual output equals the expected.
974    #[mz_ore::test]
975    fn parses_and_rewrites_file() {
976        let content =
977            "# a comment\nschedule id=1001\n----\nok\n\ncount id=1001 ts=5\n----\n10000\n";
978        let items = parse_file(content).unwrap();
979        let stanzas: Vec<_> = items
980            .iter()
981            .filter_map(|i| match i {
982                Item::Stanza(s) => Some(s),
983                Item::Verbatim(_) => None,
984            })
985            .collect();
986        assert_eq!(stanzas.len(), 2);
987        assert_eq!(
988            stanzas[0].command,
989            Command::Schedule {
990                id: GlobalId::User(1001)
991            }
992        );
993        assert_eq!(stanzas[0].expected, "ok");
994        assert_eq!(stanzas[1].expected, "10000");
995
996        // Rewriting with the same outputs reproduces the file.
997        let actuals = vec!["ok".to_string(), "10000".to_string()];
998        assert_eq!(rewrite(&items, &actuals), content);
999    }
1000
1001    /// Output with blank lines uses the doubled-`----` form: it parses with the
1002    /// blanks intact, and rewriting an actual that contains a blank line emits that
1003    /// form (so it round-trips).
1004    #[mz_ore::test]
1005    fn blank_mode_round_trips() {
1006        let content = "explain name=j as-of=0\n----\n----\nu2001:\n  →Arrange (#0)\n\nu2000:\n  →Stream u1000\n----\n----\n";
1007        let items = parse_file(content).unwrap();
1008        let Item::Stanza(stanza) = &items[0] else {
1009            panic!("expected a stanza");
1010        };
1011        // The blank line between the two objects is preserved in the expected block.
1012        assert_eq!(
1013            stanza.expected,
1014            "u2001:\n  →Arrange (#0)\n\nu2000:\n  →Stream u1000"
1015        );
1016        // Rewriting with the same (blank-containing) output reproduces the file.
1017        assert_eq!(
1018            rewrite(&items, std::slice::from_ref(&stanza.expected)),
1019            content
1020        );
1021    }
1022}