Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! NGSI-LD `q=` (CIM 009 clause 4.9) compiled to SQL jsonpath.
3 : //!
4 : //! Strategy is Scorpio's, proven against the ETSI suite: the predicate
5 : //! becomes `entity @? $n::jsonpath` over the stored expanded document (the
6 : //! operator spelling of `jsonb_path_exists`, because only the operator form
7 : //! matches the GIN `jsonb_path_ops` index). One rule is absolute here — **the
8 : //! jsonpath travels as a bind, never
9 : //! as SQL text**. Nothing a client typed is ever concatenated into a
10 : //! statement; the compiler emits `$n` placeholders and hands the paths back
11 : //! as a separate list.
12 : //!
13 : //! The compiler is deliberately partial. It returns `None` for any shape it
14 : //! cannot reproduce EXACTLY as `eval::eval_q` would evaluate it, and the
15 : //! caller then falls back to fetching the rows the other predicates select
16 : //! and filtering them in memory. A wrong row is a compliance bug; a slow
17 : //! query is a benchmark item.
18 : //!
19 : //! Stored document shape (what the paths address): attribute keys are
20 : //! expanded IRIs, each holding an ARRAY of instances, each instance carrying
21 : //! its comparable value under one of `value`/`object`/`languageMap`/`vocab`/
22 : //! `json`/`valueList`/`objectList`.
23 : //!
24 : //! The AST holds TERMS, not IRIs — `eval` expands them against the request
25 : //! `@context` at evaluation time. The compiler therefore takes the same
26 : //! expander as a closure rather than depending on `antares-jsonld`: one
27 : //! function, no crate edge, and the two paths cannot disagree about what a
28 : //! term means because they are handed the same one.
29 :
30 : use crate::{CmpOp, QNode, QValue};
31 :
32 : /// The comparable-value members, in `eval::comparable_value` order. That
33 : /// function returns the FIRST present member; we OR over all of them, which
34 : /// is identical for valid NGSI-LD (an attribute instance carries exactly one)
35 : /// and only diverges for a document that is already invalid.
36 : const VALUE_KEYS: &[&str] = &[
37 : "value",
38 : "object",
39 : "languageMap",
40 : "vocab",
41 : "json",
42 : "valueList",
43 : "objectList",
44 : ];
45 :
46 : /// The members whose value a client may write as a JSON-LD typed value
47 : /// (annex C.6) — `eval::push_target` unwraps exactly these. A JsonProperty
48 : /// is deliberately absent: the core `@context` types its `json` member
49 : /// `@json`, so an `@value` inside it is data.
50 : const TYPED_KEYS: &[&str] = &["value", "valueList"];
51 :
52 : /// The jsonpath arms one comparable-value OR expands to: every member, plus
53 : /// the typed-value step of the members that can carry one.
54 : const ARMS: usize = VALUE_KEYS.len() + TYPED_KEYS.len();
55 :
56 : /// A compiled SQL fragment: a boolean expression carrying `$n` placeholders
57 : /// numbered from the `first_bind` its compiler was given, plus the texts
58 : /// those placeholders bind to, in order. What a bind MEANS belongs to the
59 : /// compiler that produced it -- a jsonpath for `q=`, a regex for `scopeQ`, a
60 : /// timeproperty name and a boundary for a temporal range -- but the shape
61 : /// and the numbering contract are one, which is what lets a caller
62 : /// concatenate fragments from different compilers into a single statement.
63 : pub struct CompiledSql {
64 : /// The boolean SQL expression, with `$n` placeholders.
65 : pub sql: String,
66 : /// The texts the placeholders bind to, in order.
67 : pub binds: Vec<String>,
68 : }
69 :
70 : /// Compile `node` into a SQL predicate over column `col` (a `jsonb`).
71 : /// `first_bind` is the 1-based number of the next free placeholder.
72 : /// `None` = this expression is outside the exact subset; filter in memory.
73 135 : pub fn compile_q(
74 135 : node: &QNode,
75 135 : col: &str,
76 135 : first_bind: usize,
77 135 : expand: &dyn Fn(&str) -> String,
78 135 : ) -> Option<CompiledSql> {
79 135 : let mut binds = Vec::new();
80 135 : let sql = emit(node, col, first_bind, expand, &mut binds)?;
81 81 : Some(CompiledSql { sql, binds })
82 135 : }
83 :
84 : /// One 4.9 leaf over a SINGLE stored attribute instance (a temporal
85 : /// `attr_instances.data` object): identical member/operator semantics to the
86 : /// entity-doc path, but the jsonpath is rooted at the instance (`$."value"`)
87 : /// instead of navigating `$."IRI"[*]`. Same exact-or-refuse contract —
88 : /// `cmp = None` is the existence form.
89 300 : pub fn compile_instance_leaf(
90 300 : cmp: Option<(CmpOp, &QValue)>,
91 300 : col: &str,
92 300 : first: usize,
93 300 : ) -> Option<CompiledSql> {
94 300 : let mut binds = Vec::new();
95 300 : let sql = value_or("$", cmp, col, first, &mut binds)?;
96 218 : Some(CompiledSql { sql, binds })
97 300 : }
98 :
99 187 : fn emit(
100 187 : node: &QNode,
101 187 : col: &str,
102 187 : first: usize,
103 187 : expand: &dyn Fn(&str) -> String,
104 187 : binds: &mut Vec<String>,
105 187 : ) -> Option<String> {
106 187 : match node {
107 15 : QNode::And(items) => join(items, " AND ", col, first, expand, binds),
108 16 : QNode::Or(items) => join(items, " OR ", col, first, expand, binds),
109 8 : QNode::Exists { path, negated } => {
110 : // 4.9 linked-entity hops and trailing brackets are outside the
111 : // exact SQL subset — fall back to in-memory eval_q.
112 8 : if !path.links.is_empty() || path.bracket.is_some() {
113 0 : return None;
114 8 : }
115 8 : let p = path_expr(&path.path, expand)?;
116 8 : let sql = value_or(&p, None, col, first, binds)?;
117 8 : Some(if *negated {
118 5 : format!("NOT ({sql})")
119 : } else {
120 3 : sql
121 : })
122 : }
123 148 : QNode::Cmp { path, op, value } => {
124 148 : if !path.links.is_empty() || path.bracket.is_some() {
125 0 : return None;
126 148 : }
127 148 : let p = path_expr(&path.path, expand)?;
128 138 : value_or(&p, Some((*op, value)), col, first, binds)
129 : }
130 : }
131 187 : }
132 :
133 : /// `first` is the ORIGINAL placeholder offset throughout; the running count
134 : /// is `binds.len()` alone. Adding the offset again per level is how you get
135 : /// two predicates pointing at the same `$n`.
136 31 : fn join(
137 31 : items: &[QNode],
138 31 : sep: &str,
139 31 : col: &str,
140 31 : first: usize,
141 31 : expand: &dyn Fn(&str) -> String,
142 31 : binds: &mut Vec<String>,
143 31 : ) -> Option<String> {
144 31 : if items.is_empty() {
145 4 : return None; // `()` is not a predicate; the parser cannot build one,
146 : // but this entry point is public
147 27 : }
148 27 : let mut parts = Vec::with_capacity(items.len());
149 52 : for it in items {
150 52 : parts.push(emit(it, col, first, expand, binds)?);
151 : }
152 15 : Some(format!("({})", parts.join(sep)))
153 31 : }
154 :
155 : /// One `jsonb_path_exists` per comparable-value member, OR'd — the SQL
156 : /// spelling of "whichever member this instance carries".
157 446 : fn value_or(
158 446 : prefix: &str,
159 446 : cmp: Option<(CmpOp, &QValue)>,
160 446 : col: &str,
161 446 : first: usize,
162 446 : binds: &mut Vec<String>,
163 446 : ) -> Option<String> {
164 446 : let filter = match cmp {
165 430 : Some((op, v)) => Some(cmp_filter(op, v)?),
166 16 : None => None,
167 : };
168 324 : Some(value_or_filter(
169 324 : prefix,
170 324 : filter.as_deref(),
171 324 : col,
172 324 : first,
173 324 : binds,
174 324 : ))
175 446 : }
176 :
177 : /// The member-OR with a PRE-BUILT jsonpath filter — shared with the
178 : /// qprefilter's extension leaves (`!=` as NOT-of-Eq).
179 388 : pub fn value_or_filter(
180 388 : prefix: &str,
181 388 : filter: Option<&str>,
182 388 : col: &str,
183 388 : first: usize,
184 388 : binds: &mut Vec<String>,
185 388 : ) -> String {
186 388 : let mut parts = Vec::with_capacity(ARMS);
187 388 : let steps = VALUE_KEYS
188 388 : .iter()
189 2716 : .map(|k| format!(".\"{k}\""))
190 : // C.6: a value written as a JSON-LD typed value carries it under
191 : // `@value`, and `eval::untyped` compares that member — one more
192 : // step for the members that can hold one.
193 776 : .chain(TYPED_KEYS.iter().map(|k| format!(".\"{k}\".\"@value\"")));
194 3492 : for step in steps {
195 : // lax mode (the default) auto-unwraps arrays at every step, which is
196 : // exactly `eval::compare`'s "any element of an array value matches".
197 3492 : let jp = match filter {
198 3348 : Some(f) => format!("{prefix}{step}{f}"),
199 144 : None => format!("{prefix}{step}"),
200 : };
201 : // the OPERATOR form of jsonb_path_exists: identical lax semantics,
202 : // but the planner can match `@?` against the GIN jsonb_path_ops
203 : // index — the function form never uses it
204 3492 : parts.push(format!("{col} @? ${}::jsonpath", first + binds.len()));
205 3492 : binds.push(jp);
206 : }
207 388 : format!("({})", parts.join(" OR "))
208 388 : }
209 :
210 : /// The equality jsonpath filter for `want` — the building block of the
211 : /// qprefilter's NOT-of-Eq `!=` leaf. `None` for shapes Eq itself refuses
212 : /// (string-endpoint ranges, exponent numbers …).
213 40 : pub fn eq_filter(want: &QValue) -> Option<String> {
214 40 : cmp_filter(CmpOp::Eq, want)
215 40 : }
216 :
217 : /// The jsonpath filter for a `[lang]` leaf — same operator table as any
218 : /// other member (ordering-on-string / patterns / `!=` refuse as usual).
219 38 : pub fn lang_filter(op: CmpOp, want: &QValue) -> Option<String> {
220 38 : cmp_filter(op, want)
221 38 : }
222 :
223 : /// Dotted q path → jsonpath prefix addressing the instance objects.
224 : /// Exact only while every segment is an attribute step (`attr.sub.subsub`);
225 : /// `eval::collect` falls back to navigating INTO a value object when a
226 : /// segment is not a sub-attribute, and that ambiguity is not reproducible in
227 : /// one jsonpath — so those queries stay in-memory.
228 156 : fn path_expr(path: &[String], expand: &dyn Fn(&str) -> String) -> Option<String> {
229 : // Only single-segment paths are unambiguous. For a longer one `eval::
230 : // collect` picks between a sub-attribute step and navigation INTO the
231 : // value object based on what the DOCUMENT happens to hold — a per-row
232 : // decision no single jsonpath reproduces. Refuse rather than guess.
233 156 : if path.len() != 1 {
234 8 : return None;
235 148 : }
236 148 : let key = expand(path.first()?);
237 148 : if key.contains('\0') {
238 2 : return None; // see `literal`
239 146 : }
240 146 : Some(format!("$.{}[*]", quoted(&key)))
241 156 : }
242 :
243 508 : fn cmp_filter(op: CmpOp, want: &QValue) -> Option<String> {
244 : // 4.9 ValueList / Range (CompEqualityValue). Only `==` compiles:
245 : // - Eq+List p.90 is "identical to ANY of the list values" — an OR of
246 : // equality filters, existential like jsonpath's lax arrays. Exact.
247 : // - Eq+Range p.90 is a closed interval — exact for numbers; string
248 : // endpoints would order through the database collation (see the
249 : // ordering note below), so those stay in memory.
250 : // - Ne+List / Ne+Range inherit every `!=` caveat (type-mismatch matches,
251 : // universal quantification over arrays) — declined with it.
252 508 : match want {
253 32 : QValue::List(vals) => {
254 32 : if op != CmpOp::Eq {
255 2 : return None;
256 30 : }
257 30 : let mut parts = Vec::with_capacity(vals.len());
258 60 : for v in vals {
259 60 : parts.push(format!("@ == {}", literal(v)?));
260 : }
261 30 : return Some(format!(" ? ({})", parts.join(" || ")));
262 : }
263 22 : QValue::Range(lo, hi) => {
264 22 : if op != CmpOp::Eq {
265 2 : return None;
266 20 : }
267 20 : let (QValue::Num(a), QValue::Num(b)) = (lo.as_ref(), hi.as_ref()) else {
268 2 : return None;
269 : };
270 18 : let (a, b) = (literal(&QValue::Num(*a))?, literal(&QValue::Num(*b))?);
271 18 : return Some(format!(" ? (@ >= {a} && @ <= {b})"));
272 : }
273 454 : _ => {}
274 : }
275 : // Ordering against a STRING is left to the evaluator: `eval::compare`
276 : // orders with Rust's byte-wise `str` comparison, while jsonpath orders
277 : // through the database collation. They agree on ASCII and can disagree
278 : // elsewhere, and disagreeing HERE drops a matching row (a compliance
279 : // bug), not merely a fast row (a benchmark item).
280 454 : if matches!(want, QValue::Str(_)) && matches!(op, CmpOp::Gt | CmpOp::Ge | CmpOp::Lt | CmpOp::Le)
281 : {
282 37 : return None;
283 417 : }
284 417 : let lit = literal(want)?;
285 409 : Some(match op {
286 175 : CmpOp::Eq => format!(" ? (@ == {lit})"),
287 : // 4.9 p.92: "If the data type of the target value and the data type of
288 : // the Query Term value are different, then they shall be considered
289 : // unequal" — a type mismatch MATCHES `!=`. PostgreSQL jsonpath compares
290 : // across types as `unknown`, so `@ != lit` silently DROPS exactly those
291 : // rows, and 4.9 p.91 additionally requires every element of an array to
292 : // differ (jsonpath quantifies existentially). Neither is reproducible
293 : // here, so per this module's contract — decline rather than narrow
294 : // wrongly — `!=` is left to the in-memory evaluator.
295 8 : CmpOp::Ne => return None,
296 132 : CmpOp::Gt => format!(" ? (@ > {lit})"),
297 14 : CmpOp::Ge => format!(" ? (@ >= {lit})"),
298 17 : CmpOp::Lt => format!(" ? (@ < {lit})"),
299 0 : CmpOp::Le => format!(" ? (@ <= {lit})"),
300 : // ~= / !~= are regexes over strings; jsonpath's like_regex is
301 : // POSIX-ish and does not match Rust's `regex` crate on every pattern,
302 : // so both are left to the in-memory evaluator.
303 63 : CmpOp::Pattern | CmpOp::NotPattern => return None,
304 : })
305 508 : }
306 :
307 521 : fn literal(v: &QValue) -> Option<String> {
308 227 : Some(match v {
309 : // a jsonpath value is NUL-terminated text in the database, so a NUL
310 : // has no representation inside a string literal: the path would fail
311 : // to parse instead of filtering. Refuse, like any other shape outside
312 : // the subset, and let the evaluator compare it.
313 227 : QValue::Str(s) if s.contains('\0') => return None,
314 225 : QValue::Str(s) => jsonpath_string(s),
315 18 : QValue::Bool(b) => b.to_string(),
316 276 : QValue::Num(n) => {
317 276 : if !n.is_finite() {
318 6 : return None;
319 270 : }
320 : // 4.9 numbers are parsed to f64, but jsonb holds exact `numeric`:
321 : // past 2^53 the f64 the query carries is no longer the integer the
322 : // client wrote, so the compiled predicate would refuse rows
323 : // `eval` (which compares f64 to f64 on BOTH sides) keeps. Leave
324 : // those to the evaluator rather than narrow wrongly.
325 270 : if n.abs() >= 9_007_199_254_740_992.0 {
326 6 : return None;
327 264 : }
328 : // shortest round-trip form; jsonpath numbers are JSON numbers
329 264 : let s = n.to_string();
330 264 : if s.contains(['e', 'E']) {
331 0 : return None; // exponent forms differ across parsers
332 264 : }
333 264 : s
334 : }
335 : // composite values never render as one literal — cmp_filter unfolds
336 : // them (Eq) or declines (everything else) before reaching here
337 0 : QValue::List(_) | QValue::Range(..) => return None,
338 : })
339 521 : }
340 :
341 : /// A jsonpath member name: always double-quoted, because attribute keys are
342 : /// expanded IRIs full of `:` `/` `#` `.` that would otherwise be syntax.
343 170 : pub fn quoted(s: &str) -> String {
344 170 : jsonpath_string(s)
345 170 : }
346 :
347 399 : fn jsonpath_string(s: &str) -> String {
348 399 : let mut out = String::with_capacity(s.len() + 2);
349 399 : out.push('"');
350 7785 : for c in s.chars() {
351 7775 : match c {
352 4 : '"' => out.push_str("\\\""),
353 4 : '\\' => out.push_str("\\\\"),
354 2 : '\n' => out.push_str("\\n"),
355 0 : '\r' => out.push_str("\\r"),
356 0 : '\t' => out.push_str("\\t"),
357 7775 : c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
358 7775 : c => out.push(c),
359 : }
360 : }
361 399 : out.push('"');
362 399 : out
363 399 : }
364 :
365 : #[cfg(test)]
366 : mod tests {
367 : use super::*;
368 : use crate::{parse_q, QPath};
369 :
370 : /// the expander the API hands in, stubbed: term → default-context IRI
371 68 : fn ex(t: &str) -> String {
372 68 : format!("https://uri.etsi.org/ngsi-ld/default-context/{t}")
373 68 : }
374 :
375 56 : fn c(q: &str) -> Option<CompiledSql> {
376 56 : compile_q(&parse_q(q).expect("parse"), "entity", 2, &ex)
377 56 : }
378 :
379 : #[test]
380 2 : fn comparison_binds_the_jsonpath_and_never_splices_it() {
381 2 : let got = c("temperature>20").expect("compiles");
382 : // every placeholder is a bind; no client text in the SQL
383 2 : assert!(!got.sql.contains("temperature"), "sql: {}", got.sql);
384 2 : assert_eq!(got.binds.len(), ARMS);
385 2 : assert_eq!(
386 2 : got.binds[0],
387 : "$.\"https://uri.etsi.org/ngsi-ld/default-context/temperature\"[*].\"value\" ? (@ > 20)"
388 : );
389 : // C.6: the same term written as a JSON-LD typed value is an arm of
390 : // the same OR, so the pushdown keeps the rows `eval` keeps
391 2 : assert_eq!(
392 2 : got.binds[VALUE_KEYS.len()],
393 : "$.\"https://uri.etsi.org/ngsi-ld/default-context/temperature\"[*].\"value\".\"@value\" ? (@ > 20)"
394 : );
395 2 : assert!(
396 2 : got.sql.starts_with("(entity @? $2::jsonpath"),
397 : "sql: {}",
398 : got.sql
399 : );
400 2 : }
401 :
402 : #[test]
403 2 : fn placeholders_are_numbered_from_the_offset_and_stay_unique() {
404 2 : let got = c("a==1;b==2").expect("compiles");
405 2 : let n = ARMS;
406 2 : assert_eq!(got.binds.len(), 2 * n);
407 36 : for i in 0..2 * n {
408 36 : assert!(
409 36 : got.sql.contains(&format!("${}::jsonpath", i + 2)),
410 : "missing ${}",
411 0 : i + 2
412 : );
413 : }
414 2 : assert!(got.sql.contains(") AND ("));
415 2 : }
416 :
417 : /// jsonb holds `numeric`, the query holds an f64. Past 2^53 the two stop
418 : /// agreeing, and the pushdown would drop rows the evaluator keeps — so an
419 : /// integer that big compiles to nothing at all and the evaluator decides.
420 : #[test]
421 2 : fn an_integer_past_the_f64_grid_is_left_to_the_evaluator() {
422 2 : assert!(
423 2 : c("n==9007199254740993").is_none(),
424 : "a value the f64 cannot hold must not narrow the match set"
425 : );
426 2 : assert!(c("n>9007199254740993").is_none());
427 2 : assert!(c("n==-9007199254740993").is_none());
428 : // the boundary itself is exact, and everything under it still compiles
429 2 : assert!(c("n==9007199254740991").is_some());
430 2 : assert!(c("n==0").is_some());
431 2 : assert!(c("n>20.5").is_some());
432 2 : }
433 :
434 : #[test]
435 2 : fn or_and_negated_existence() {
436 2 : assert!(c("a==1|b==2").expect("or").sql.contains(") OR ("));
437 2 : let neg = c("!a").expect("negated exists");
438 2 : assert!(neg.sql.starts_with("NOT ("));
439 2 : }
440 :
441 : #[test]
442 2 : fn expanded_iri_keys_are_quoted_so_slashes_and_colons_are_not_syntax() {
443 2 : let got = c("t==5").expect("compiles");
444 2 : assert!(
445 2 : got.binds[0].starts_with("$.\"https://uri.etsi.org/ngsi-ld/default-context/t\"[*]"),
446 : "{}",
447 0 : got.binds[0]
448 : );
449 2 : }
450 :
451 : #[test]
452 2 : fn quoting_escapes_what_would_break_the_jsonpath() {
453 2 : assert_eq!(jsonpath_string("a\"b\\c"), "\"a\\\"b\\\\c\"");
454 2 : assert_eq!(jsonpath_string("l\n"), "\"l\\n\"");
455 2 : }
456 :
457 : #[test]
458 2 : fn unsupported_shapes_refuse_instead_of_guessing() {
459 : // dotted path: sub-attribute vs value navigation is ambiguous
460 2 : assert!(c("address.city==\"Bonn\"").is_none());
461 : // ~= is a regex dialect mismatch
462 2 : assert!(c("name~=\"^ab\"").is_none());
463 : // ordering on strings: Rust byte-wise vs database collation
464 2 : assert!(c("name>\"m\"").is_none());
465 2 : assert!(c("name<=\"m\"").is_none());
466 : // ... but equality on strings is collation-free, so it compiles
467 2 : assert!(c("name==\"m\"").is_some());
468 2 : assert!(c("n>=3").is_some(), "numeric ordering is unambiguous");
469 2 : }
470 :
471 : /// A refused member must refuse the WHOLE expression. Emitting only the
472 : /// members that compiled would turn `a AND b` into `a` (still a superset,
473 : /// but the caller would page on it) and `a OR b` into `a` — a predicate
474 : /// that is plausible and wrong.
475 : #[test]
476 2 : fn one_refused_member_refuses_the_whole_expression() {
477 2 : assert!(
478 2 : c(r#"a==1;b~="x""#).is_none(),
479 : "AND must not drop a conjunct"
480 : );
481 2 : assert!(c(r#"a==1|b~="x""#).is_none(), "OR must not drop a branch");
482 2 : assert!(c(r#"b~="x"|a==1"#).is_none(), "…in either position");
483 2 : assert!(c(r#"(a==1|b!=2);c==3"#).is_none(), "…nor when nested");
484 : // and the members that DID compile leave no trace behind them
485 2 : assert!(c(r#"a==1;a==2;b~="x""#).is_none());
486 : // an empty junction has no predicate either — `()` would be a syntax
487 : // error, and this entry point is public
488 2 : assert!(compile_q(&QNode::And(Vec::new()), "entity", 1, &ex).is_none());
489 2 : assert!(compile_q(&QNode::Or(Vec::new()), "entity", 1, &ex).is_none());
490 2 : }
491 :
492 : /// Everything a client typed — the term, the value, and any SQL syntax
493 : /// hidden in either — reaches the statement as `$n` and nothing else.
494 : #[test]
495 2 : fn client_text_never_reaches_the_statement() {
496 2 : let hostile = r#"'; DROP TABLE entities; --"#;
497 2 : let node = QNode::Cmp {
498 2 : path: QPath::dotted(vec![hostile.to_owned()]),
499 2 : op: CmpOp::Eq,
500 2 : value: QValue::Str(format!("{hostile}\\\"$1")),
501 2 : };
502 2 : let got = compile_q(&node, "entity", 1, &|t| t.to_owned()).expect("compiles");
503 10 : for needle in ["DROP", "TABLE", "--", "'", "entities"] {
504 10 : assert!(
505 10 : !got.sql.contains(needle),
506 : "{needle:?} leaked into the sql: {}",
507 : got.sql
508 : );
509 : }
510 : // the sql is placeholders and compiler constants only
511 2 : let skeleton: Vec<String> = (1..=ARMS)
512 18 : .map(|n| format!("entity @? ${n}::jsonpath"))
513 2 : .collect();
514 2 : assert_eq!(got.sql, format!("({})", skeleton.join(" OR ")));
515 : // …and the bind escapes what would otherwise end the jsonpath string
516 2 : assert!(got.binds[0].contains("\\\\\\\"$1"), "{}", got.binds[0]);
517 2 : }
518 :
519 : /// A NUL has no representation inside a jsonpath string literal, so the
520 : /// path would fail to parse in the database instead of filtering. Refuse
521 : /// it like any other shape outside the subset.
522 : #[test]
523 2 : fn a_nul_in_a_term_or_a_value_is_left_to_the_evaluator() {
524 2 : let leaf = |t: &str, v: &str| QNode::Cmp {
525 6 : path: QPath::dotted(vec![t.to_owned()]),
526 6 : op: CmpOp::Eq,
527 6 : value: QValue::Str(v.to_owned()),
528 6 : };
529 6 : let id = |t: &str| t.to_owned();
530 2 : assert!(compile_q(&leaf("a\0b", "x"), "entity", 1, &id).is_none());
531 2 : assert!(compile_q(&leaf("a", "x\0y"), "entity", 1, &id).is_none());
532 2 : assert!(compile_q(&leaf("a", "x"), "entity", 1, &id).is_some());
533 2 : }
534 :
535 : /// The temporal per-instance leaf: same operator table, rooted at the
536 : /// instance object rather than at `$."IRI"[*]`.
537 : #[test]
538 2 : fn instance_leaf_roots_at_the_instance_and_numbers_from_first() {
539 2 : let want = QValue::Num(25.0);
540 2 : let got = compile_instance_leaf(Some((CmpOp::Gt, &want)), "qi.data", 7).expect("compiles");
541 2 : assert_eq!(got.binds.len(), ARMS);
542 2 : assert_eq!(got.binds[0], "$.\"value\" ? (@ > 25)");
543 2 : assert!(
544 2 : got.sql.starts_with("(qi.data @? $7::jsonpath"),
545 : "{}",
546 : got.sql
547 : );
548 2 : assert!(got.sql.contains(&format!("${}", 7 + ARMS - 1)));
549 2 : assert!(!got.sql.contains(&format!("${}", 7 + ARMS)));
550 : // existence form
551 2 : assert_eq!(
552 2 : compile_instance_leaf(None, "qi.data", 1)
553 2 : .expect("exists")
554 2 : .binds[0],
555 : "$.\"value\""
556 : );
557 : // and the same refusals
558 2 : assert!(compile_instance_leaf(Some((CmpOp::Ne, &want)), "qi.data", 1).is_none());
559 2 : let s = QValue::Str("m".to_owned());
560 2 : assert!(compile_instance_leaf(Some((CmpOp::Lt, &s)), "qi.data", 1).is_none());
561 2 : assert!(compile_instance_leaf(Some((CmpOp::Pattern, &s)), "qi.data", 1).is_none());
562 2 : }
563 :
564 : /// A number that cannot round-trip as a JSON literal is not compiled to
565 : /// one — `literal` is the last gate before the jsonpath text.
566 : #[test]
567 2 : fn non_finite_numbers_never_become_a_jsonpath_literal() {
568 2 : assert!(literal(&QValue::Num(f64::NAN)).is_none());
569 2 : assert!(literal(&QValue::Num(f64::INFINITY)).is_none());
570 2 : assert!(literal(&QValue::Num(f64::NEG_INFINITY)).is_none());
571 2 : assert_eq!(literal(&QValue::Num(-0.5)).expect("finite"), "-0.5");
572 2 : }
573 :
574 : #[test]
575 2 : fn value_list_and_range_compile_for_eq_and_decline_for_ne() {
576 : // Eq+List: an OR of equality filters — existential, exact
577 2 : let got = c(r#"color=="black","red""#).expect("compiles");
578 2 : assert!(
579 2 : got.binds[0].ends_with(r#" ? (@ == "black" || @ == "red")"#),
580 : "{}",
581 0 : got.binds[0]
582 : );
583 : // Eq+Range on numbers: closed interval
584 2 : let got = c("t==10..20").expect("compiles");
585 2 : assert!(
586 2 : got.binds[0].ends_with(" ? (@ >= 10 && @ <= 20)"),
587 : "{}",
588 0 : got.binds[0]
589 : );
590 : // Ne inherits the != caveats (type mismatch, array quantification)
591 2 : assert!(c(r#"color!="black","red""#).is_none());
592 2 : assert!(c("t!=10..20").is_none());
593 : // string-endpoint ranges order through the collation — in-memory
594 2 : assert!(c(r#"name=="a".."m""#).is_none());
595 : // !~= is a regex — dialect mismatch, in-memory
596 2 : assert!(c(r#"name!~="^ab""#).is_none());
597 2 : }
598 : }
|