1use common::fmt::EscapeIdent;
11use surrealdb_strand::TableName;
12use surrealdb_types::{SqlFormat, ToSql};
13
14use crate::expr::{Expr, Idiom};
15
16pub type BindingId = u32;
23
24#[derive(Clone, Debug, PartialEq, Eq, Hash)]
39pub struct MatchPlan {
40 pub bindings: Vec<BindingDef>,
42 pub stages: Vec<MatchStage>,
46 pub output: Option<MatchOutput>,
49}
50
51impl MatchPlan {
52 pub fn has_mutations(&self) -> bool {
56 self.stages.iter().any(|s| matches!(s, MatchStage::Mutate(_)))
57 }
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Hash)]
64pub enum MatchStage {
65 Read(MatchClausePlan),
67 Mutate(MutationStage),
69}
70
71#[derive(Clone, Debug, PartialEq, Eq, Hash)]
78pub enum MutationStage {
79 Update {
82 target: BindingId,
83 data: UpdateData,
84 },
85 Delete {
87 target: BindingId,
88 detach: DetachMode,
89 },
90 Insert(InsertStage),
92}
93
94#[derive(Clone, Debug, PartialEq, Eq, Hash)]
98pub enum UpdateData {
99 Set(Vec<(Idiom, Expr)>),
101 Unset(Vec<Idiom>),
103 Content(Expr),
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
112pub enum DetachMode {
113 Detach,
114 NoDetach,
115}
116
117#[derive(Clone, Debug, PartialEq, Eq, Hash)]
120pub struct InsertStage {
121 pub nodes: Vec<InsertNodePlan>,
122 pub edges: Vec<InsertEdgePlan>,
123}
124
125#[derive(Clone, Debug, PartialEq, Eq, Hash)]
127pub struct InsertNodePlan {
128 pub binding: BindingId,
130 pub label: TableName,
132 pub props: Expr,
134}
135
136#[derive(Clone, Debug, PartialEq, Eq, Hash)]
140pub struct InsertEdgePlan {
141 pub binding: BindingId,
143 pub label: TableName,
145 pub from: BindingId,
147 pub to: BindingId,
149 pub props: Expr,
151}
152
153#[derive(Clone, Debug, PartialEq, Eq, Hash)]
154pub struct BindingDef {
155 pub name: String,
156 pub kind: BindingKind,
157 pub user_named: bool,
159}
160
161#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
162pub enum BindingKind {
163 Node,
164 Edge,
165 EdgeGroup,
166 Path,
167}
168
169#[derive(Clone, Debug, PartialEq, Eq, Hash)]
170pub struct MatchClausePlan {
171 pub optional_group: Option<u32>,
184 pub patterns: Vec<PatternPlan>,
185 pub predicates: Vec<MatchPredicate>,
187}
188
189impl MatchClausePlan {
190 pub fn is_optional(&self) -> bool {
194 self.optional_group.is_some()
195 }
196}
197
198#[derive(Clone, Debug, PartialEq, Eq, Hash)]
199pub struct PatternPlan {
200 pub path_var: Option<BindingId>,
202 pub search: Option<PathPrefixPlan>,
208 pub start: NodeStep,
209 pub steps: Vec<(EdgeStep, NodeStep)>,
211}
212
213#[derive(Clone, Debug, PartialEq, Eq, Hash)]
214pub struct NodeStep {
215 pub binding: BindingId,
216 pub label: Option<TableName>,
217}
218
219#[derive(Clone, Debug, PartialEq, Eq, Hash)]
220pub struct EdgeStep {
221 pub binding: BindingId,
223 pub label: Option<TableName>,
224 pub direction: ExpandDirection,
225 pub quantifier: Option<EdgeQuantifier>,
226}
227
228#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
229pub enum ExpandDirection {
230 Out,
231 In,
232}
233
234#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
235pub struct EdgeQuantifier {
236 pub min: u32,
237 pub max: Option<u32>,
238}
239
240#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
244pub struct PathPrefixPlan {
245 pub search: PathSearch,
246 pub mode: PathMode,
247}
248
249#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
252pub enum PathSearch {
253 All,
255 Any {
257 count: u32,
258 },
259 AllShortest,
261 AnyShortest,
263 ShortestCounted {
265 count: u32,
266 },
267 ShortestGroups {
269 count: u32,
270 },
271}
272
273#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
278pub enum PathMode {
279 Walk,
280 Trail,
281 Simple,
282 Acyclic,
283}
284
285#[derive(Clone, Debug, PartialEq, Eq, Hash)]
286pub struct MatchPredicate {
287 pub expr: Expr,
288 pub deps: Vec<BindingId>,
290}
291
292#[derive(Clone, Debug, PartialEq, Eq, Hash)]
293pub struct MatchOutput {
294 pub columns: Vec<MatchColumn>,
296 pub distinct: bool,
297 pub group_by: Option<Vec<Expr>>,
305 pub order: Vec<MatchOrder>,
306 pub skip: Option<Expr>,
307 pub limit: Option<Expr>,
308}
309
310#[derive(Clone, Debug, PartialEq, Eq, Hash)]
311pub struct MatchColumn {
312 pub name: String,
313 pub expr: Expr,
314 pub hidden: bool,
320}
321
322#[derive(Clone, Debug, PartialEq, Eq, Hash)]
323pub struct MatchOrder {
324 pub expr: Expr,
325 pub ascending: bool,
326}
327
328impl MatchPlan {
329 pub fn binding(&self, id: BindingId) -> &BindingDef {
338 &self.bindings[id as usize]
339 }
340
341 fn binding_name(&self, id: BindingId) -> &str {
346 self.bindings.get(id as usize).map(|b| b.name.as_str()).unwrap_or("?")
347 }
348}
349
350impl EdgeQuantifier {
351 pub fn is_exact(&self) -> bool {
353 self.max == Some(self.min)
354 }
355}
356
357impl ExpandDirection {
358 pub fn reverse(self) -> Self {
360 match self {
361 ExpandDirection::Out => ExpandDirection::In,
362 ExpandDirection::In => ExpandDirection::Out,
363 }
364 }
365}
366
367impl ToSql for MatchPlan {
374 fn fmt_sql(&self, f: &mut String, _fmt: SqlFormat) {
375 for (i, stage) in self.stages.iter().enumerate() {
378 if i > 0 {
379 f.push(' ');
380 }
381 match stage {
382 MatchStage::Read(clause) => self.render_clause(f, clause),
383 MatchStage::Mutate(mutation) => self.render_mutation(f, mutation),
384 }
385 }
386
387 let Some(output) = self.output.as_ref() else {
388 return;
389 };
390
391 if !self.stages.is_empty() {
392 f.push(' ');
393 }
394 f.push_str("RETURN");
395 if output.distinct {
396 f.push_str(" DISTINCT");
397 }
398 for (i, column) in output.columns.iter().enumerate() {
399 if i > 0 {
400 f.push(',');
401 }
402 f.push(' ');
403 column.expr.fmt_sql(f, SqlFormat::SingleLine);
404 f.push_str(" AS ");
405 f.push_str(&column.name);
406 }
407
408 if let Some(keys) = output.group_by.as_ref()
411 && !keys.is_empty()
412 {
413 f.push_str(" GROUP BY");
414 for (i, key) in keys.iter().enumerate() {
415 if i > 0 {
416 f.push(',');
417 }
418 f.push(' ');
419 key.fmt_sql(f, SqlFormat::SingleLine);
420 }
421 }
422
423 if !output.order.is_empty() {
424 f.push_str(" ORDER BY");
425 for (i, order) in output.order.iter().enumerate() {
426 if i > 0 {
427 f.push(',');
428 }
429 f.push(' ');
430 order.expr.fmt_sql(f, SqlFormat::SingleLine);
431 f.push_str(if order.ascending {
432 " ASC"
433 } else {
434 " DESC"
435 });
436 }
437 }
438
439 if let Some(skip) = output.skip.as_ref() {
440 f.push_str(" SKIP ");
441 skip.fmt_sql(f, SqlFormat::SingleLine);
442 }
443 if let Some(limit) = output.limit.as_ref() {
444 f.push_str(" LIMIT ");
445 limit.fmt_sql(f, SqlFormat::SingleLine);
446 }
447 }
448}
449
450impl MatchPlan {
451 fn render_clause(&self, f: &mut String, clause: &MatchClausePlan) {
454 if clause.is_optional() {
455 f.push_str("OPTIONAL ");
456 }
457 f.push_str("MATCH ");
458 for (i, pattern) in clause.patterns.iter().enumerate() {
459 if i > 0 {
460 f.push_str(", ");
461 }
462 self.render_pattern(f, pattern);
463 }
464 if !clause.predicates.is_empty() {
465 f.push_str(" WHERE ");
466 for (i, predicate) in clause.predicates.iter().enumerate() {
467 if i > 0 {
468 f.push_str(" AND ");
469 }
470 predicate.expr.fmt_sql(f, SqlFormat::SingleLine);
471 }
472 }
473 }
474
475 fn render_mutation(&self, f: &mut String, stage: &MutationStage) {
479 match stage {
480 MutationStage::Update {
481 target,
482 data,
483 } => match data {
484 UpdateData::Set(assignments) => {
485 f.push_str("SET");
486 for (i, (place, value)) in assignments.iter().enumerate() {
487 if i > 0 {
488 f.push(',');
489 }
490 f.push(' ');
491 place.fmt_sql(f, SqlFormat::SingleLine);
492 f.push_str(" = ");
493 value.fmt_sql(f, SqlFormat::SingleLine);
494 }
495 }
496 UpdateData::Unset(fields) => {
497 f.push_str("REMOVE");
498 for (i, place) in fields.iter().enumerate() {
499 if i > 0 {
500 f.push(',');
501 }
502 f.push(' ');
503 place.fmt_sql(f, SqlFormat::SingleLine);
504 }
505 }
506 UpdateData::Content(expr) => {
507 f.push_str("SET ");
508 f.push_str(self.binding_name(*target));
509 f.push_str(" = ");
510 expr.fmt_sql(f, SqlFormat::SingleLine);
511 }
512 },
513 MutationStage::Delete {
514 target,
515 detach,
516 } => {
517 if matches!(detach, DetachMode::Detach) {
518 f.push_str("DETACH ");
519 }
520 f.push_str("DELETE ");
521 f.push_str(self.binding_name(*target));
522 }
523 MutationStage::Insert(stage) => {
524 f.push_str("INSERT");
525 for node in stage.nodes.iter() {
526 f.push_str(" (");
527 f.push_str(self.binding_name(node.binding));
528 f.push(':');
529 EscapeIdent(node.label.as_str()).fmt_sql(f, SqlFormat::SingleLine);
530 f.push(' ');
531 node.props.fmt_sql(f, SqlFormat::SingleLine);
532 f.push(')');
533 }
534 for edge in stage.edges.iter() {
535 f.push(' ');
536 f.push_str(self.binding_name(edge.from));
537 f.push_str("-[");
538 f.push_str(self.binding_name(edge.binding));
539 f.push(':');
540 EscapeIdent(edge.label.as_str()).fmt_sql(f, SqlFormat::SingleLine);
541 f.push_str("]->");
542 f.push_str(self.binding_name(edge.to));
543 }
544 }
545 }
546 }
547
548 fn render_pattern(&self, f: &mut String, pattern: &PatternPlan) {
550 if let Some(path_var) = pattern.path_var {
551 f.push_str(self.binding_name(path_var));
552 f.push_str(" = ");
553 }
554 if let Some(prefix) = pattern.search.as_ref() {
555 Self::render_prefix(f, prefix);
556 }
557 self.render_node(f, &pattern.start);
558 for (edge, node) in pattern.steps.iter() {
559 self.render_edge(f, edge);
560 self.render_node(f, node);
561 }
562 }
563
564 fn render_prefix(f: &mut String, prefix: &PathPrefixPlan) {
569 match prefix.search {
570 PathSearch::All => f.push_str("ALL"),
571 PathSearch::Any {
572 count,
573 } => {
574 f.push_str("ANY");
575 if count != 1 {
576 f.push(' ');
577 f.push_str(&count.to_string());
578 }
579 }
580 PathSearch::AllShortest => f.push_str("ALL SHORTEST"),
581 PathSearch::AnyShortest => f.push_str("ANY SHORTEST"),
582 PathSearch::ShortestCounted {
583 count,
584 } => {
585 f.push_str("SHORTEST ");
586 f.push_str(&count.to_string());
587 }
588 PathSearch::ShortestGroups {
589 count,
590 } => {
591 f.push_str("SHORTEST ");
592 f.push_str(&count.to_string());
593 f.push_str(if count == 1 {
594 " GROUP"
595 } else {
596 " GROUPS"
597 });
598 }
599 }
600 match prefix.mode {
601 PathMode::Walk => {}
602 PathMode::Trail => f.push_str(" TRAIL"),
603 PathMode::Simple => f.push_str(" SIMPLE"),
604 PathMode::Acyclic => f.push_str(" ACYCLIC"),
605 }
606 f.push(' ');
607 }
608
609 fn render_node(&self, f: &mut String, node: &NodeStep) {
611 f.push('(');
612 let def = self.bindings.get(node.binding as usize);
613 if let Some(def) = def
614 && def.user_named
615 {
616 f.push_str(&def.name);
617 }
618 if let Some(label) = node.label.as_ref() {
619 f.push(':');
620 EscapeIdent(label.as_str()).fmt_sql(f, SqlFormat::SingleLine);
621 }
622 f.push(')');
623 }
624
625 fn render_edge(&self, f: &mut String, edge: &EdgeStep) {
627 match edge.direction {
628 ExpandDirection::Out => f.push('-'),
629 ExpandDirection::In => f.push_str("<-"),
630 }
631 f.push('[');
632 let def = self.bindings.get(edge.binding as usize);
633 if let Some(def) = def
634 && def.user_named
635 {
636 f.push_str(&def.name);
637 }
638 if let Some(label) = edge.label.as_ref() {
639 f.push(':');
640 EscapeIdent(label.as_str()).fmt_sql(f, SqlFormat::SingleLine);
641 }
642 f.push(']');
643 match edge.direction {
644 ExpandDirection::Out => f.push_str("->"),
645 ExpandDirection::In => f.push('-'),
646 }
647 if let Some(quantifier) = edge.quantifier.as_ref() {
648 self.render_quantifier(f, quantifier);
649 }
650 }
651
652 fn render_quantifier(&self, f: &mut String, quantifier: &EdgeQuantifier) {
654 f.push('{');
655 if quantifier.is_exact() {
656 f.push_str(&quantifier.min.to_string());
657 } else {
658 f.push_str(&quantifier.min.to_string());
659 f.push(',');
660 if let Some(max) = quantifier.max {
661 f.push_str(&max.to_string());
662 }
663 }
664 f.push('}');
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use surrealdb_types::ToSql;
671
672 use super::*;
673 use crate::expr::{Expr, Idiom, Literal, Part};
674
675 fn stages_of(clauses: Vec<MatchClausePlan>) -> Vec<MatchStage> {
678 clauses.into_iter().map(MatchStage::Read).collect()
679 }
680
681 fn clause_mut(plan: &mut MatchPlan, i: usize) -> &mut MatchClausePlan {
683 match &mut plan.stages[i] {
684 MatchStage::Read(c) => c,
685 MatchStage::Mutate(_) => panic!("stage {i} is not a read clause"),
686 }
687 }
688
689 fn clause(plan: &MatchPlan, i: usize) -> &MatchClausePlan {
691 match &plan.stages[i] {
692 MatchStage::Read(c) => c,
693 MatchStage::Mutate(_) => panic!("stage {i} is not a read clause"),
694 }
695 }
696
697 fn field_path(binding: &str, field: &str) -> Expr {
699 Expr::Idiom(Idiom(vec![
700 Part::Field(binding.to_string().into()),
701 Part::Field(field.to_string().into()),
702 ]))
703 }
704
705 fn sample_plan() -> MatchPlan {
708 let bindings = vec![
709 BindingDef {
710 name: "a".to_string(),
711 kind: BindingKind::Node,
712 user_named: true,
713 },
714 BindingDef {
715 name: "k".to_string(),
716 kind: BindingKind::Edge,
717 user_named: true,
718 },
719 BindingDef {
720 name: "b".to_string(),
721 kind: BindingKind::Node,
722 user_named: true,
723 },
724 ];
725 let predicate = MatchPredicate {
726 expr: Expr::Binary {
727 left: Box::new(field_path("k", "since")),
728 op: crate::expr::BinaryOperator::MoreThan,
729 right: Box::new(Expr::Literal(Literal::Integer(2020))),
730 },
731 deps: vec![1],
732 };
733 let pattern = PatternPlan {
734 path_var: None,
735 search: None,
736 start: NodeStep {
737 binding: 0,
738 label: Some(TableName::new("person".to_string())),
739 },
740 steps: vec![(
741 EdgeStep {
742 binding: 1,
743 label: Some(TableName::new("knows".to_string())),
744 direction: ExpandDirection::Out,
745 quantifier: None,
746 },
747 NodeStep {
748 binding: 2,
749 label: Some(TableName::new("person".to_string())),
750 },
751 )],
752 };
753 MatchPlan {
754 bindings,
755 stages: stages_of(vec![MatchClausePlan {
756 optional_group: None,
757 patterns: vec![pattern],
758 predicates: vec![predicate],
759 }]),
760 output: Some(MatchOutput {
761 columns: vec![
762 MatchColumn {
763 name: "a_name".to_string(),
764 expr: field_path("a", "name"),
765 hidden: false,
766 },
767 MatchColumn {
768 name: "b_name".to_string(),
769 expr: field_path("b", "name"),
770 hidden: false,
771 },
772 ],
773 distinct: false,
774 group_by: None,
775 order: Vec::new(),
776 skip: None,
777 limit: None,
778 }),
779 }
780 }
781
782 #[test]
783 fn to_sql_renders_single_pattern() {
784 let plan = sample_plan();
785 assert_eq!(
786 plan.to_sql(),
787 "MATCH (a:person)-[k:knows]->(b:person) WHERE k.since > 2020 RETURN a.name AS \
788 a_name, b.name AS b_name"
789 );
790 }
791
792 #[test]
793 fn to_sql_renders_distinct_order_quantifier_path() {
794 let mut plan = sample_plan();
795 plan.bindings.push(BindingDef {
797 name: "p".to_string(),
798 kind: BindingKind::Path,
799 user_named: true,
800 });
801 plan.bindings[1].kind = BindingKind::EdgeGroup;
802 let path_id = (plan.bindings.len() - 1) as BindingId;
803 let clause = clause_mut(&mut plan, 0);
804 clause.patterns[0].path_var = Some(path_id);
805 clause.patterns[0].steps[0].0.quantifier = Some(EdgeQuantifier {
806 min: 1,
807 max: Some(3),
808 });
809 clause.predicates.clear();
810 let output = plan.output.as_mut().unwrap();
811 output.distinct = true;
812 output.order = vec![MatchOrder {
813 expr: field_path("a", "age"),
814 ascending: false,
815 }];
816 output.skip = Some(Expr::Literal(Literal::Integer(5)));
817 output.limit = Some(Expr::Literal(Literal::Integer(10)));
818
819 assert_eq!(
820 plan.to_sql(),
821 "MATCH p = (a:person)-[k:knows]->{1,3}(b:person) RETURN DISTINCT a.name AS a_name, \
822 b.name AS b_name ORDER BY a.age DESC SKIP 5 LIMIT 10"
823 );
824 }
825
826 #[test]
827 fn to_sql_renders_path_search_prefix() {
828 let mut plan = sample_plan();
829 plan.bindings[1].kind = BindingKind::EdgeGroup;
831 clause_mut(&mut plan, 0).patterns[0].steps[0].0.quantifier = Some(EdgeQuantifier {
832 min: 1,
833 max: None,
834 });
835 clause_mut(&mut plan, 0).patterns[0].search = Some(PathPrefixPlan {
836 search: PathSearch::AnyShortest,
837 mode: PathMode::Simple,
838 });
839 clause_mut(&mut plan, 0).predicates.clear();
840 plan.output.as_mut().unwrap().columns.truncate(1);
841 assert_eq!(
842 plan.to_sql(),
843 "MATCH ANY SHORTEST SIMPLE (a:person)-[k:knows]->{1,}(b:person) RETURN a.name AS a_name"
844 );
845 }
846
847 #[test]
848 fn to_sql_omits_default_search_prefix() {
849 let plan = sample_plan();
852 assert!(clause(&plan, 0).patterns[0].search.is_none());
853 let rendered = plan.to_sql();
854 assert!(!rendered.contains("SHORTEST"), "{rendered}");
855 assert!(!rendered.contains("ANY"), "{rendered}");
856 assert!(rendered.starts_with("MATCH (a:person)"), "{rendered}");
857 }
858
859 #[test]
860 fn to_sql_renders_shortest_group_count() {
861 let mut plan = sample_plan();
862 plan.bindings[1].kind = BindingKind::EdgeGroup;
863 clause_mut(&mut plan, 0).patterns[0].steps[0].0.quantifier = Some(EdgeQuantifier {
864 min: 1,
865 max: None,
866 });
867 clause_mut(&mut plan, 0).patterns[0].search = Some(PathPrefixPlan {
868 search: PathSearch::ShortestGroups {
869 count: 2,
870 },
871 mode: PathMode::Walk,
872 });
873 clause_mut(&mut plan, 0).predicates.clear();
874 plan.output.as_mut().unwrap().columns.truncate(1);
875 assert_eq!(
877 plan.to_sql(),
878 "MATCH SHORTEST 2 GROUPS (a:person)-[k:knows]->{1,}(b:person) RETURN a.name AS a_name"
879 );
880 }
881
882 #[test]
883 fn to_sql_renders_hidden_bindings_anonymously() {
884 let mut plan = sample_plan();
885 plan.bindings[1].user_named = false;
887 plan.bindings[1].name = "__e0".to_string();
888 clause_mut(&mut plan, 0).predicates.clear();
889 plan.output.as_mut().unwrap().columns.truncate(1);
890 assert_eq!(plan.to_sql(), "MATCH (a:person)-[:knows]->(b:person) RETURN a.name AS a_name");
891 }
892
893 #[test]
894 fn debug_does_not_panic() {
895 let plan = sample_plan();
896 let rendered = format!("{plan:?}");
897 assert!(rendered.contains("MatchPlan"));
898 }
899
900 #[test]
901 fn binding_accessor_returns_def() {
902 let plan = sample_plan();
903 assert_eq!(plan.binding(1).name, "k");
904 assert_eq!(plan.binding(1).kind, BindingKind::Edge);
905 }
906
907 #[test]
908 fn to_sql_tolerates_out_of_range_binding() {
909 let mut plan = sample_plan();
911 clause_mut(&mut plan, 0).patterns[0].start.binding = 99;
912 let rendered = plan.to_sql();
913 assert!(rendered.contains("(:person)"));
914 }
915
916 #[test]
917 fn revisioned_serialize_of_match_fails_loud() {
918 use revision::SerializeRevisioned;
925
926 let expr = Expr::Match(Box::new(sample_plan()));
927 let mut bytes = Vec::new();
928 let serialize = std::panic::AssertUnwindSafe(|| {
929 let _ = SerializeRevisioned::serialize_revisioned(&expr, &mut bytes);
930 });
931 let outcome = std::panic::catch_unwind(serialize);
932
933 if cfg!(debug_assertions) {
934 assert!(
936 outcome.is_err(),
937 "Expr::Match serialize_revisioned must panic in debug builds"
938 );
939 } else {
940 assert!(outcome.is_ok());
945 }
946 }
947 #[test]
955 fn to_sql_escapes_labels_that_collide_with_keywords() {
956 let plan = MatchPlan {
957 bindings: vec![BindingDef {
958 name: "a".to_string(),
959 kind: BindingKind::Node,
960 user_named: true,
961 }],
962 stages: stages_of(vec![MatchClausePlan {
963 optional_group: None,
964 patterns: vec![PatternPlan {
965 search: None,
966 path_var: None,
967 start: NodeStep {
968 binding: 0,
969 label: Some(TableName::new("select".to_string())),
970 },
971 steps: Vec::new(),
972 }],
973 predicates: Vec::new(),
974 }]),
975 output: None,
976 };
977
978 let rendered = plan.to_sql();
979 assert!(rendered.contains("`select`"), "a keyword label must be quoted, got: {rendered}");
980 }
981}