Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! Render an AST back to 4.9 `q=` syntax, so a rewritten query can travel
3 : //! on as a query string. `parse_q(&node.to_string())` yields `node` again,
4 : //! with one limit the grammar itself imposes: the operand of `patternOp` /
5 : //! `notPatternOp` is a `RegExp`, not a `quotedStr`, so it is written back
6 : //! verbatim between quotes and a pattern whose own text carries a `\"`
7 : //! cannot be spelled as a Query Term at all.
8 :
9 : use crate::{CmpOp, Link, QNode, QPath, QValue};
10 : use std::fmt;
11 :
12 : impl fmt::Display for QNode {
13 134 : fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14 134 : match self {
15 : // `;` binds tighter than `|`: an Or inside an And needs the
16 : // parentheses back, nothing else does.
17 36 : QNode::And(items) => join(f, items, ";", |n| matches!(n, QNode::Or(_))),
18 26 : QNode::Or(items) => join(f, items, "|", |n| matches!(n, QNode::Or(_))),
19 : // 4.9: a `RegExp` operand is not a `quotedStr`, so it is not
20 : // escaped as one — the backslashes in it are the pattern's.
21 78 : QNode::Cmp { path, op, value } => match (op, value) {
22 8 : (CmpOp::Pattern | CmpOp::NotPattern, QValue::Str(s)) => {
23 8 : write!(f, "{path}{op}\"{s}\"")
24 : }
25 70 : _ => write!(f, "{path}{op}{value}"),
26 : },
27 26 : QNode::Exists { path, negated } => {
28 26 : if *negated {
29 2 : f.write_str("!")?;
30 24 : }
31 26 : write!(f, "{path}")
32 : }
33 : }
34 134 : }
35 : }
36 :
37 30 : fn join(
38 30 : f: &mut fmt::Formatter<'_>,
39 30 : items: &[QNode],
40 30 : sep: &str,
41 30 : parens: impl Fn(&QNode) -> bool,
42 30 : ) -> fmt::Result {
43 62 : for (i, n) in items.iter().enumerate() {
44 62 : if i > 0 {
45 32 : f.write_str(sep)?;
46 30 : }
47 62 : if parens(n) {
48 6 : write!(f, "({n})")?;
49 : } else {
50 56 : write!(f, "{n}")?;
51 : }
52 : }
53 30 : Ok(())
54 30 : }
55 :
56 : impl fmt::Display for QPath {
57 104 : fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 104 : for Link { attr, types } in &self.links {
59 6 : write!(f, "{attr}{{")?;
60 6 : if !types.is_empty() {
61 2 : write!(f, "{}:", types.join(","))?;
62 4 : }
63 : }
64 104 : f.write_str(&self.path.join("."))?;
65 104 : if let Some(b) = &self.bracket {
66 6 : write!(f, "[{}]", b.join("."))?;
67 98 : }
68 104 : for _ in &self.links {
69 6 : f.write_str("}")?;
70 : }
71 104 : Ok(())
72 104 : }
73 : }
74 :
75 : impl fmt::Display for CmpOp {
76 78 : fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 78 : f.write_str(match self {
78 36 : CmpOp::Eq => "==",
79 2 : CmpOp::Ne => "!=",
80 12 : CmpOp::Gt => ">",
81 2 : CmpOp::Ge => ">=",
82 18 : CmpOp::Lt => "<",
83 0 : CmpOp::Le => "<=",
84 4 : CmpOp::Pattern => "~=",
85 4 : CmpOp::NotPattern => "!~=",
86 : })
87 78 : }
88 : }
89 :
90 : impl fmt::Display for QValue {
91 78 : fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 78 : match self {
93 : // 4.9 `quotedStr = String`: the RFC 8259 escaping the parser
94 : // decodes, put back. Always quoted — the parser reads an unquoted
95 : // date into Str too, and the quoted form parses back the same.
96 34 : QValue::Str(s) => write!(f, "{}", serde_json::Value::String(s.clone())),
97 38 : QValue::Num(n) => write!(f, "{n}"),
98 2 : QValue::Bool(b) => write!(f, "{b}"),
99 2 : QValue::List(items) => {
100 4 : for (i, v) in items.iter().enumerate() {
101 4 : if i > 0 {
102 2 : f.write_str(",")?;
103 2 : }
104 4 : write!(f, "{v}")?;
105 : }
106 2 : Ok(())
107 : }
108 2 : QValue::Range(lo, hi) => write!(f, "{lo}..{hi}"),
109 : }
110 78 : }
111 : }
112 :
113 : #[cfg(test)]
114 : mod tests {
115 : use crate::{parse_q, QNode, QPath, QValue};
116 :
117 : /// Every grammar shape survives parse → render → parse unchanged.
118 : #[test]
119 2 : fn render_round_trips_through_the_parser() {
120 32 : for q in [
121 2 : r#"brandName=="Mercedes""#,
122 2 : "speed>=5;heading<90",
123 2 : r#"speed>25|name~="^m""#,
124 2 : r#"(speed>25|heading>100);route=="550","551""#,
125 2 : "speed==10..40",
126 2 : "!heading;speed>1",
127 2 : "ref{Vehicle,Car:speed}>3",
128 2 : "a{b{c}}==1",
129 2 : r#"label[en]=="x""#,
130 2 : r#"address[city]=="Paris""#,
131 2 : "label[*]!=\"y\"",
132 2 : "x!~=\"^y\"",
133 2 : "t==2020-01-01T00:00:00Z",
134 2 : "flag==true",
135 2 : "a;b|c;d",
136 2 : "a|(b;c)|d",
137 2 : ] {
138 32 : let node = parse_q(q).expect(q);
139 32 : let rendered = node.to_string();
140 32 : let again = parse_q(&rendered).unwrap_or_else(|e| panic!("{q} → {rendered}: {e}"));
141 32 : assert_eq!(again, node, "{q} → {rendered}");
142 : }
143 2 : }
144 :
145 : /// A tree built by hand (an Or nested in an And, which the parser only
146 : /// produces through parentheses) renders with the parentheses restored.
147 : #[test]
148 2 : fn nested_or_inside_and_gets_its_parentheses_back() {
149 2 : let node = QNode::And(vec![
150 2 : QNode::Or(vec![
151 2 : QNode::Exists {
152 2 : path: QPath::dotted(vec!["a".into()]),
153 2 : negated: false,
154 2 : },
155 2 : QNode::Exists {
156 2 : path: QPath::dotted(vec!["b".into()]),
157 2 : negated: false,
158 2 : },
159 2 : ]),
160 2 : QNode::Cmp {
161 2 : path: QPath::dotted(vec!["owner".into()]),
162 2 : op: crate::CmpOp::Eq,
163 2 : value: QValue::Str("t1".into()),
164 2 : },
165 2 : ]);
166 2 : assert_eq!(node.to_string(), r#"(a|b);owner=="t1""#);
167 2 : assert_eq!(parse_q(&node.to_string()).expect("parses"), node);
168 2 : }
169 :
170 : /// 4.9 `quotedStr = String`: a value carrying the RFC 8259 escapes is
171 : /// written back escaped, so the rendered Query Term parses to the value
172 : /// it was rendered from rather than to a truncated one.
173 : #[test]
174 2 : fn an_escaped_string_survives_the_round_trip() {
175 16 : for q in [
176 2 : r#"a=="say \"hi\"""#,
177 2 : r#"a=="back\\slash""#,
178 2 : r#"a=="line\nbreak""#,
179 2 : r#"a=="semi;colon""#,
180 2 : r#"a=="pipe|bar""#,
181 2 : r#"a=="comma,list""#,
182 2 : r#"a=="paren)close""#,
183 2 : r#"a=="dots..range""#,
184 2 : ] {
185 16 : let node = parse_q(q).expect(q);
186 16 : let rendered = node.to_string();
187 16 : let again = parse_q(&rendered).unwrap_or_else(|e| panic!("{q} → {rendered}: {e}"));
188 16 : assert_eq!(again, node, "{q} → {rendered}");
189 : }
190 2 : }
191 :
192 : /// The operand of `patternOp`/`notPatternOp` is a `RegExp`, not a
193 : /// `quotedStr`: its backslashes belong to the pattern and are neither
194 : /// decoded on the way in nor escaped on the way out.
195 : #[test]
196 2 : fn a_regexp_operand_keeps_its_own_backslashes() {
197 4 : for q in [r#"a~="^\d+$""#, r#"a!~="^[a-z]\.[0-9]{2}$""#] {
198 4 : let node = parse_q(q).expect(q);
199 4 : let QNode::Cmp { value, .. } = &node else {
200 0 : panic!("{q}: expected a comparison")
201 : };
202 4 : let QValue::Str(pattern) = value else {
203 0 : panic!("{q}: expected a string operand")
204 : };
205 4 : assert!(
206 4 : pattern.contains('\\'),
207 : "{q}: the regex kept its backslash: {pattern:?}"
208 : );
209 4 : assert_eq!(parse_q(&node.to_string()).expect("re-parses"), node, "{q}");
210 : }
211 2 : }
212 :
213 : #[test]
214 2 : fn the_ast_serializes() {
215 2 : let node = parse_q("speed>3").expect("parse");
216 2 : let json = serde_json::to_value(&node).expect("serialize");
217 2 : assert_eq!(json["Cmp"]["op"], "Gt", "{json}");
218 2 : assert_eq!(json["Cmp"]["path"]["path"][0], "speed", "{json}");
219 2 : }
220 : }
|