1use std::collections::BTreeMap;
35
36use anyhow::{Context, anyhow, bail, ensure};
37use mz_repr::GlobalId;
38
39use crate::script::{BuildSpec, ColumnSpec, Command, ConfigSetting, ExportSpec, ImportSpec};
40
41pub enum Item {
44 Verbatim(String),
46 Stanza(Stanza),
48}
49
50pub struct Stanza {
52 pub input: String,
54 pub expected: String,
56 pub command: Command,
58}
59
60pub fn parse_file(content: &str) -> anyhow::Result<Vec<Item>> {
63 let lines: Vec<&str> = content.lines().collect();
64 let mut items = Vec::new();
65 let mut i = 0;
66 while i < lines.len() {
67 let line = lines[i];
68 if line.trim().is_empty() || line.starts_with('#') {
70 items.push(Item::Verbatim(line.to_string()));
71 i += 1;
72 continue;
73 }
74 let start = i;
76 while i < lines.len() && lines[i] != "----" {
77 i += 1;
78 }
79 ensure!(
80 i < lines.len(),
81 "stanza starting at line {} has no `----` separator",
82 start + 1
83 );
84 let input = lines[start..i].join("\n");
85 i += 1; let exp_start = i;
88 while i < lines.len() && !lines[i].trim().is_empty() {
89 i += 1;
90 }
91 let expected = lines[exp_start..i].join("\n");
92 let command = parse_command(&input)
93 .with_context(|| format!("parsing stanza at line {}", start + 1))?;
94 items.push(Item::Stanza(Stanza {
95 input,
96 expected,
97 command,
98 }));
99 }
100 Ok(items)
101}
102
103pub fn rewrite(items: &[Item], actuals: &[String]) -> String {
107 let mut out = String::new();
108 let mut next = 0;
109 for item in items {
110 match item {
111 Item::Verbatim(line) => {
112 out.push_str(line);
113 out.push('\n');
114 }
115 Item::Stanza(stanza) => {
116 let actual = &actuals[next];
117 next += 1;
118 out.push_str(&stanza.input);
119 out.push_str("\n----\n");
120 out.push_str(actual);
121 if !actual.is_empty() {
122 out.push('\n');
123 }
124 }
125 }
126 }
127 out
128}
129
130struct Line {
132 indent: usize,
133 text: String,
134}
135
136fn lex(block: &str) -> Vec<Line> {
138 block
139 .lines()
140 .filter_map(|raw| {
141 let trimmed = raw.trim_end();
142 if trimmed.trim().is_empty() {
143 return None;
144 }
145 let indent = trimmed.len() - trimmed.trim_start().len();
146 Some(Line {
147 indent,
148 text: trimmed.trim_start().to_string(),
149 })
150 })
151 .collect()
152}
153
154fn group(lines: &[Line]) -> anyhow::Result<Vec<(&Line, &[Line])>> {
157 if lines.is_empty() {
158 return Ok(vec![]);
159 }
160 let base = lines.iter().map(|l| l.indent).min().expect("non-empty");
161 let mut groups = Vec::new();
162 let mut i = 0;
163 while i < lines.len() {
164 ensure!(
165 lines[i].indent == base,
166 "inconsistent indentation: `{}`",
167 lines[i].text
168 );
169 let start = i + 1;
170 let mut j = start;
171 while j < lines.len() && lines[j].indent > base {
172 j += 1;
173 }
174 groups.push((&lines[i], &lines[start..j]));
175 i = j;
176 }
177 Ok(groups)
178}
179
180fn body_text(lines: &[Line]) -> String {
183 if lines.is_empty() {
184 return String::new();
185 }
186 let base = lines.iter().map(|l| l.indent).min().expect("non-empty");
187 lines
188 .iter()
189 .map(|l| format!("{}{}", " ".repeat(l.indent - base), l.text))
190 .collect::<Vec<_>>()
191 .join("\n")
192}
193
194fn tokenize(s: &str) -> anyhow::Result<Vec<String>> {
197 let mut tokens = Vec::new();
198 let mut cur = String::new();
199 let mut depth = 0i32;
200 let mut in_quote = false;
201 for c in s.chars() {
202 match c {
203 '"' => {
204 in_quote = !in_quote;
205 cur.push(c);
206 }
207 '[' if !in_quote => {
208 depth += 1;
209 cur.push(c);
210 }
211 ']' if !in_quote => {
212 depth -= 1;
213 cur.push(c);
214 }
215 c if c.is_whitespace() && !in_quote && depth == 0 => {
216 if !cur.is_empty() {
217 tokens.push(std::mem::take(&mut cur));
218 }
219 }
220 c => cur.push(c),
221 }
222 }
223 ensure!(!in_quote, "unterminated string in `{s}`");
224 ensure!(depth == 0, "unterminated list in `{s}`");
225 if !cur.is_empty() {
226 tokens.push(cur);
227 }
228 Ok(tokens)
229}
230
231fn parse_header(header: &str) -> anyhow::Result<(String, BTreeMap<String, String>, Vec<String>)> {
233 let mut tokens = tokenize(header)?.into_iter();
234 let verb = tokens.next().context("empty command")?;
235 let mut args = BTreeMap::new();
236 let mut flags = Vec::new();
237 for token in tokens {
238 match token.split_once('=') {
239 Some((key, value)) => {
240 args.insert(key.to_string(), value.to_string());
241 }
242 None => flags.push(token),
243 }
244 }
245 Ok((verb, args, flags))
246}
247
248fn req<'a>(args: &'a BTreeMap<String, String>, key: &str) -> anyhow::Result<&'a str> {
249 args.get(key)
250 .map(String::as_str)
251 .ok_or_else(|| anyhow!("missing argument `{key}`"))
252}
253
254fn req_u64(args: &BTreeMap<String, String>, key: &str) -> anyhow::Result<u64> {
255 req(args, key)?
256 .parse()
257 .with_context(|| format!("argument `{key}` is not an integer"))
258}
259
260fn opt_u64(args: &BTreeMap<String, String>, key: &str) -> anyhow::Result<Option<u64>> {
261 args.get(key)
262 .map(|v| {
263 v.parse()
264 .with_context(|| format!("argument `{key}` is not an integer"))
265 })
266 .transpose()
267}
268
269fn opt_usize(args: &BTreeMap<String, String>, key: &str) -> anyhow::Result<Option<usize>> {
270 args.get(key)
271 .map(|v| {
272 v.parse()
273 .with_context(|| format!("argument `{key}` is not an integer"))
274 })
275 .transpose()
276}
277
278fn parse_id(s: &str) -> anyhow::Result<GlobalId> {
282 if s.bytes().all(|b| b.is_ascii_digit()) {
283 let id: u64 = s.parse().with_context(|| format!("bad id `{s}`"))?;
284 Ok(GlobalId::User(id))
285 } else {
286 s.parse().with_context(|| format!("bad id `{s}`"))
287 }
288}
289
290fn req_id(args: &BTreeMap<String, String>, key: &str) -> anyhow::Result<GlobalId> {
291 parse_id(req(args, key)?)
292}
293
294fn opt_string(args: &BTreeMap<String, String>, key: &str) -> Option<String> {
295 args.get(key).cloned()
296}
297
298fn parse_usize_list(s: &str) -> anyhow::Result<Vec<usize>> {
300 let inner = s
301 .strip_prefix('[')
302 .and_then(|s| s.strip_suffix(']'))
303 .ok_or_else(|| anyhow!("expected a list like `[0,1]`, got `{s}`"))?;
304 if inner.trim().is_empty() {
305 return Ok(vec![]);
306 }
307 inner
308 .split(',')
309 .map(|part| {
310 part.trim()
311 .parse()
312 .with_context(|| format!("bad list element `{part}`"))
313 })
314 .collect()
315}
316
317fn parse_export(args: &BTreeMap<String, String>) -> anyhow::Result<ExportSpec> {
320 let on_id = req_id(args, "on")?;
321 Ok(
322 match args.get("kind").map(String::as_str).unwrap_or("index") {
323 "index" => ExportSpec::Index {
324 index_id: req_id(args, "index")?,
325 on_id,
326 key: parse_usize_list(req(args, "key")?)?,
327 },
328 "materialized-view" => ExportSpec::MaterializedView {
329 sink_id: req_id(args, "sink")?,
330 on_id,
331 shard: req(args, "shard")?.to_string(),
332 schema: opt_string(args, "schema"),
333 },
334 "subscribe" => ExportSpec::Subscribe {
335 sink_id: req_id(args, "sink")?,
336 on_id,
337 schema: opt_string(args, "schema"),
338 up_to: opt_u64(args, "up-to")?,
339 },
340 "copy-to" => bail!("copy-to export is not implemented"),
341 other => bail!("unknown export kind `{other}`"),
342 },
343 )
344}
345
346fn rows_from_body(body: &[Line]) -> anyhow::Result<Vec<Vec<String>>> {
349 body.iter().map(|l| tokenize(&l.text)).collect()
350}
351
352fn settings_from_body(body: &[Line]) -> anyhow::Result<Vec<ConfigSetting>> {
354 body.iter()
355 .map(|l| {
356 let tokens = tokenize(&l.text)?;
357 ensure!(
358 tokens.len() == 3,
359 "config setting needs `name type value`, got `{}`",
360 l.text
361 );
362 Ok(ConfigSetting {
363 name: tokens[0].clone(),
364 ty: tokens[1].clone(),
365 value: tokens[2].clone(),
366 })
367 })
368 .collect()
369}
370
371fn columns_from_body(body: &[Line]) -> anyhow::Result<Vec<ColumnSpec>> {
373 body.iter()
374 .map(|l| {
375 let tokens = tokenize(&l.text)?;
376 ensure!(
377 tokens.len() >= 2,
378 "column needs `name type [nullable]`, got `{}`",
379 l.text
380 );
381 Ok(ColumnSpec {
382 name: tokens[0].clone(),
383 ty: tokens[1].clone(),
384 nullable: tokens.get(2).is_some_and(|t| t == "nullable"),
385 })
386 })
387 .collect()
388}
389
390fn parse_create_dataflow(
393 args: &BTreeMap<String, String>,
394 flags: &[String],
395 body: &[Line],
396) -> anyhow::Result<Command> {
397 let name = opt_string(args, "name");
398 let as_of = req_u64(args, "as-of")?;
399 let optimize = flags.iter().any(|f| f == "optimize");
400 let mut imports = Vec::new();
401 let mut builds = Vec::new();
402 let mut exports = Vec::new();
403 for (header, sub_body) in group(body)? {
404 let (verb, args, _flags) = parse_header(&header.text)?;
405 match verb.as_str() {
406 "import" => {
407 if let Some(index_id) = args.get("index") {
408 imports.push(ImportSpec::Index {
409 index_id: parse_id(index_id)
410 .with_context(|| format!("bad index id `{index_id}`"))?,
411 });
412 } else {
413 imports.push(ImportSpec::Source {
414 id: req_id(&args, "source")?,
415 shard: req(&args, "shard")?.to_string(),
416 schema: opt_string(&args, "schema"),
417 upper: req_u64(&args, "upper")?,
418 });
419 }
420 }
421 "build" => {
422 ensure!(!sub_body.is_empty(), "`build` needs a MIR body");
423 builds.push(BuildSpec {
424 id: req_id(&args, "id")?,
425 expr: body_text(sub_body),
426 });
427 }
428 "export" => exports.push(parse_export(&args)?),
429 other => bail!("unknown `create-dataflow` sub-command `{other}`"),
430 }
431 }
432 Ok(Command::CreateDataflow {
433 name,
434 imports,
435 builds,
436 exports,
437 as_of,
438 optimize,
439 })
440}
441
442fn parse_command(input: &str) -> anyhow::Result<Command> {
444 let lines = lex(input);
445 let (header, body) = lines.split_first().context("empty command")?;
446 ensure!(header.indent == 0, "directive must not be indented");
447 let (verb, args, flags) = parse_header(&header.text)?;
448 let command = match verb.as_str() {
449 "define-schema" => Command::DefineSchema {
450 name: req(&args, "name")?.to_string(),
451 columns: columns_from_body(body)?,
452 },
453 "write-single-ts" => Command::WriteSingleTs {
454 shard: req(&args, "shard")?.to_string(),
455 schema: opt_string(&args, "schema"),
456 ts: req_u64(&args, "ts")?,
457 count: req_u64(&args, "count")?,
458 start: opt_u64(&args, "start")?.unwrap_or(0),
459 row_bytes: opt_usize(&args, "row-bytes")?,
460 },
461 "write-spread" => Command::WriteSpread {
462 shard: req(&args, "shard")?.to_string(),
463 schema: opt_string(&args, "schema"),
464 count: req_u64(&args, "count")?,
465 n_ts: req_u64(&args, "n-ts")?,
466 start: opt_u64(&args, "start")?.unwrap_or(0),
467 row_bytes: opt_usize(&args, "row-bytes")?,
468 },
469 "write-rows" => Command::WriteRows {
470 shard: req(&args, "shard")?.to_string(),
471 schema: opt_string(&args, "schema"),
472 ts: req_u64(&args, "ts")?,
473 rows: rows_from_body(body)?,
474 },
475 "define-index" => Command::DefineIndex {
476 source_id: req_id(&args, "source")?,
477 index_id: req_id(&args, "index")?,
478 shard: req(&args, "shard")?.to_string(),
479 schema: opt_string(&args, "schema"),
480 key: parse_usize_list(req(&args, "key")?)?,
481 as_of: req_u64(&args, "as-of")?,
482 upper: req_u64(&args, "upper")?,
483 },
484 "schedule" => Command::Schedule {
485 id: req_id(&args, "id")?,
486 },
487 "allow-compaction" => Command::AllowCompaction {
488 id: req_id(&args, "id")?,
489 frontier: req_u64(&args, "frontier")?,
490 },
491 "allow-writes" => Command::AllowWrites {
492 id: req_id(&args, "id")?,
493 },
494 "await-frontier" => Command::AwaitFrontier {
495 id: req_id(&args, "id")?,
496 ts: req_u64(&args, "ts")?,
497 timeout_secs: opt_u64(&args, "timeout-secs")?,
498 allow_timeout: flags.iter().any(|f| f == "allow-timeout"),
499 },
500 "count" => Command::Count {
501 id: req_id(&args, "id")?,
502 ts: req_u64(&args, "ts")?,
503 },
504 "peek" => Command::Peek {
505 id: req_id(&args, "id")?,
506 schema: opt_string(&args, "schema"),
507 ts: req_u64(&args, "ts")?,
508 },
509 "await-subscribe" => Command::AwaitSubscribe {
510 id: req_id(&args, "id")?,
511 up_to: req_u64(&args, "up-to")?,
512 timeout_secs: opt_u64(&args, "timeout-secs")?,
513 },
514 "create-dataflow" => parse_create_dataflow(&args, &flags, body)?,
515 "create-instance" => Command::CreateInstance {
516 expiration_offset: opt_string(&args, "expiration-offset"),
517 arrangement_dictionary_compression: args
518 .get("arrangement-dictionary-compression")
519 .map(|v| v.parse())
520 .transpose()
521 .context("argument `arrangement-dictionary-compression` is not a bool")?
522 .unwrap_or(false),
523 initial_config: settings_from_body(body)?,
525 },
526 "update-configuration" => Command::UpdateConfiguration {
527 updates: settings_from_body(body)?,
528 },
529 "reconnect" => Command::Reconnect,
530 "initialization-complete" => Command::InitializationComplete,
531 other => bail!("unknown command `{other}`"),
532 };
533 Ok(command)
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 #[mz_ore::test]
542 fn parses_simple_command() {
543 let cmd = parse_command("write-single-ts shard=data ts=0 count=5000").unwrap();
544 assert_eq!(
545 cmd,
546 Command::WriteSingleTs {
547 shard: "data".to_string(),
548 schema: None,
549 ts: 0,
550 count: 5000,
551 start: 0,
552 row_bytes: None,
553 }
554 );
555 }
556
557 #[mz_ore::test]
559 fn parses_flag_and_list() {
560 let cmd =
561 parse_command("await-frontier id=1001 ts=1 timeout-secs=3 allow-timeout").unwrap();
562 assert_eq!(
563 cmd,
564 Command::AwaitFrontier {
565 id: GlobalId::User(1001),
566 ts: 1,
567 timeout_secs: Some(3),
568 allow_timeout: true,
569 }
570 );
571 let cmd =
572 parse_command("define-index source=1000 index=1001 shard=d key=[0] as-of=0 upper=1")
573 .unwrap();
574 assert_eq!(
575 cmd,
576 Command::DefineIndex {
577 source_id: GlobalId::User(1000),
578 index_id: GlobalId::User(1001),
579 shard: "d".to_string(),
580 schema: None,
581 key: vec![0],
582 as_of: 0,
583 upper: 1,
584 }
585 );
586 }
587
588 #[mz_ore::test]
591 fn parses_prefixed_ids() {
592 assert_eq!(
593 parse_command("schedule id=1001").unwrap(),
594 Command::Schedule {
595 id: GlobalId::User(1001)
596 }
597 );
598 assert_eq!(
599 parse_command("schedule id=u1001").unwrap(),
600 Command::Schedule {
601 id: GlobalId::User(1001)
602 }
603 );
604 assert_eq!(
605 parse_command("schedule id=t7").unwrap(),
606 Command::Schedule {
607 id: GlobalId::Transient(7)
608 }
609 );
610 assert_eq!(
611 parse_command("schedule id=s42").unwrap(),
612 Command::Schedule {
613 id: GlobalId::System(42)
614 }
615 );
616 assert!(parse_command("schedule id=bogus").is_err());
617 }
618
619 #[mz_ore::test]
621 fn parses_bodies() {
622 let cmd = parse_command(
623 "define-schema name=events\n key bigint\n flag boolean\n label text nullable",
624 )
625 .unwrap();
626 assert_eq!(
627 cmd,
628 Command::DefineSchema {
629 name: "events".to_string(),
630 columns: vec![
631 ColumnSpec {
632 name: "key".to_string(),
633 ty: "bigint".to_string(),
634 nullable: false
635 },
636 ColumnSpec {
637 name: "flag".to_string(),
638 ty: "boolean".to_string(),
639 nullable: false
640 },
641 ColumnSpec {
642 name: "label".to_string(),
643 ty: "text".to_string(),
644 nullable: true
645 },
646 ],
647 }
648 );
649
650 let cmd = parse_command(
651 "write-rows shard=ev schema=events ts=1\n 1000 true alpha\n 1001 false null",
652 )
653 .unwrap();
654 assert_eq!(
655 cmd,
656 Command::WriteRows {
657 shard: "ev".to_string(),
658 schema: Some("events".to_string()),
659 ts: 1,
660 rows: vec![
661 vec!["1000".to_string(), "true".to_string(), "alpha".to_string()],
662 vec!["1001".to_string(), "false".to_string(), "null".to_string()],
663 ],
664 }
665 );
666 }
667
668 #[mz_ore::test]
672 fn parses_create_dataflow_with_mir() {
673 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]";
674 let cmd = parse_command(input).unwrap();
675 assert_eq!(
676 cmd,
677 Command::CreateDataflow {
678 name: Some("count".to_string()),
679 imports: vec![ImportSpec::Index {
680 index_id: GlobalId::User(1001)
681 }],
682 builds: vec![BuildSpec {
683 id: GlobalId::User(2000),
684 expr: "Reduce aggregates=[count(*)]\n Get u1000".to_string(),
685 }],
686 exports: vec![ExportSpec::Index {
687 index_id: GlobalId::User(2001),
688 on_id: GlobalId::User(2000),
689 key: vec![0]
690 }],
691 as_of: 0,
692 optimize: false,
693 }
694 );
695
696 let optimized = parse_command(
698 "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]",
699 )
700 .unwrap();
701 assert!(matches!(
702 optimized,
703 Command::CreateDataflow { optimize: true, .. }
704 ));
705 }
706
707 #[mz_ore::test]
710 fn parses_sink_export_kinds() {
711 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";
712 let Command::CreateDataflow { exports, .. } = parse_command(mv).unwrap() else {
713 panic!("expected create-dataflow");
714 };
715 assert_eq!(
716 exports,
717 vec![ExportSpec::MaterializedView {
718 sink_id: GlobalId::User(2001),
719 on_id: GlobalId::User(2000),
720 shard: "out".to_string(),
721 schema: Some("kv".to_string()),
722 }]
723 );
724
725 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";
726 let Command::CreateDataflow { exports, .. } = parse_command(sub).unwrap() else {
727 panic!("expected create-dataflow");
728 };
729 assert_eq!(
730 exports,
731 vec![ExportSpec::Subscribe {
732 sink_id: GlobalId::User(2001),
733 on_id: GlobalId::User(2000),
734 schema: None,
735 up_to: Some(2),
736 }]
737 );
738
739 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";
741 assert!(parse_command(copy).is_err());
742 }
743
744 #[mz_ore::test]
747 fn parses_handshake_config() {
748 assert_eq!(
749 parse_command("create-instance").unwrap(),
750 Command::CreateInstance {
751 expiration_offset: None,
752 arrangement_dictionary_compression: false,
753 initial_config: vec![],
754 }
755 );
756 assert_eq!(
757 parse_command(
758 "create-instance expiration-offset=30s arrangement-dictionary-compression=true"
759 )
760 .unwrap(),
761 Command::CreateInstance {
762 expiration_offset: Some("30s".to_string()),
763 arrangement_dictionary_compression: true,
764 initial_config: vec![],
765 }
766 );
767 assert_eq!(
769 parse_command("create-instance\n enable_my_flag bool true").unwrap(),
770 Command::CreateInstance {
771 expiration_offset: None,
772 arrangement_dictionary_compression: false,
773 initial_config: vec![ConfigSetting {
774 name: "enable_my_flag".to_string(),
775 ty: "bool".to_string(),
776 value: "true".to_string(),
777 }],
778 }
779 );
780
781 assert_eq!(
782 parse_command("update-configuration").unwrap(),
783 Command::UpdateConfiguration { updates: vec![] }
784 );
785 assert_eq!(
786 parse_command("update-configuration\n enable_my_flag bool true\n my_dur duration 1s")
787 .unwrap(),
788 Command::UpdateConfiguration {
789 updates: vec![
790 ConfigSetting {
791 name: "enable_my_flag".to_string(),
792 ty: "bool".to_string(),
793 value: "true".to_string(),
794 },
795 ConfigSetting {
796 name: "my_dur".to_string(),
797 ty: "duration".to_string(),
798 value: "1s".to_string(),
799 },
800 ],
801 }
802 );
803 }
804
805 #[mz_ore::test]
807 fn parses_await_subscribe() {
808 let cmd = parse_command("await-subscribe id=2001 up-to=2 timeout-secs=5").unwrap();
809 assert_eq!(
810 cmd,
811 Command::AwaitSubscribe {
812 id: GlobalId::User(2001),
813 up_to: 2,
814 timeout_secs: Some(5),
815 }
816 );
817 }
818
819 #[mz_ore::test]
822 fn parses_and_rewrites_file() {
823 let content =
824 "# a comment\nschedule id=1001\n----\nok\n\ncount id=1001 ts=5\n----\n10000\n";
825 let items = parse_file(content).unwrap();
826 let stanzas: Vec<_> = items
827 .iter()
828 .filter_map(|i| match i {
829 Item::Stanza(s) => Some(s),
830 Item::Verbatim(_) => None,
831 })
832 .collect();
833 assert_eq!(stanzas.len(), 2);
834 assert_eq!(
835 stanzas[0].command,
836 Command::Schedule {
837 id: GlobalId::User(1001)
838 }
839 );
840 assert_eq!(stanzas[0].expected, "ok");
841 assert_eq!(stanzas[1].expected, "10000");
842
843 let actuals = vec!["ok".to_string(), "10000".to_string()];
845 assert_eq!(rewrite(&items, &actuals), content);
846 }
847}