1use 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
48pub enum Item {
51 Verbatim(String),
53 Stanza(Stanza),
55}
56
57pub struct Stanza {
59 pub input: String,
61 pub expected: String,
63 pub command: Command,
65}
66
67pub 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 if line.trim().is_empty() || line.starts_with('#') {
77 items.push(Item::Verbatim(line.to_string()));
78 i += 1;
79 continue;
80 }
81 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; 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; expected
116 } else {
117 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
134pub 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 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
169struct Line {
171 indent: usize,
172 text: String,
173}
174
175fn 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
193fn 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
219fn 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
233fn 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
270fn 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
317fn 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
337fn 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
356fn 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
385fn rows_from_body(body: &[Line]) -> anyhow::Result<Vec<Vec<String>>> {
388 body.iter().map(|l| tokenize(&l.text)).collect()
389}
390
391fn 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
410fn 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
429struct 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
439fn 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
492fn 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 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 assert!(parse_command("explain ref=join\n import source=1 shard=s upper=1").is_err());
858 }
859
860 #[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 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 #[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 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 #[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 #[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 let actuals = vec!["ok".to_string(), "10000".to_string()];
998 assert_eq!(rewrite(&items, &actuals), content);
999 }
1000
1001 #[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 assert_eq!(
1013 stanza.expected,
1014 "u2001:\n →Arrange (#0)\n\nu2000:\n →Stream u1000"
1015 );
1016 assert_eq!(
1018 rewrite(&items, std::slice::from_ref(&stanza.expected)),
1019 content
1020 );
1021 }
1022}