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`; no jsonb operator is leakproof,
7 : //! so under RLS it filters rows an index on the type, id or location found,
8 : //! and migration 0019 dropped the GIN that only a role bypassing RLS could
9 : //! use). One rule is absolute here — **the
10 : //! jsonpath travels as a bind, never
11 : //! as SQL text**. Nothing a client typed is ever concatenated into a
12 : //! statement; the compiler emits `$n` placeholders and hands the paths back
13 : //! as a separate list.
14 : //!
15 : //! The compiler is deliberately partial. It returns `None` for any shape it
16 : //! cannot reproduce EXACTLY as `eval::eval_q` would evaluate it, and the
17 : //! caller then falls back to fetching the rows the other predicates select
18 : //! and filtering them in memory. A wrong row is a compliance bug; a slow
19 : //! query is a benchmark item.
20 : //!
21 : //! Stored document shape (what the paths address): attribute keys are
22 : //! expanded IRIs, each holding an ARRAY of instances, each instance carrying
23 : //! its comparable value under one of `value`/`object`/`languageMap`/`vocab`/
24 : //! `json`/`valueList`/`objectList`.
25 : //!
26 : //! The AST holds TERMS, not IRIs — `eval` expands them against the request
27 : //! `@context` at evaluation time. The compiler therefore takes the same
28 : //! expander as a closure rather than depending on `antares-jsonld`: one
29 : //! function, no crate edge, and the two paths cannot disagree about what a
30 : //! term means because they are handed the same one.
31 :
32 : use crate::{CmpOp, QNode, QValue};
33 :
34 : /// The comparable-value members, in `eval::comparable_value` order. That
35 : /// function returns the FIRST present member; we OR over all of them, which
36 : /// is identical for valid NGSI-LD (an attribute instance carries exactly one)
37 : /// and only diverges for a document that is already invalid.
38 : const VALUE_KEYS: &[&str] = &[
39 : "value",
40 : "object",
41 : "languageMap",
42 : "vocab",
43 : "json",
44 : "valueList",
45 : "objectList",
46 : ];
47 :
48 : /// The members whose value a client may write as a JSON-LD typed value
49 : /// (annex C.6) — `eval::push_target` unwraps exactly these. A JsonProperty
50 : /// is deliberately absent: the core `@context` types its `json` member
51 : /// `@json`, so an `@value` inside it is data.
52 : const TYPED_KEYS: &[&str] = &["value", "valueList"];
53 :
54 : /// The jsonpath arms one comparable-value OR expands to: every member, plus
55 : /// the typed-value step of the members that can carry one.
56 : const ARMS: usize = VALUE_KEYS.len() + TYPED_KEYS.len();
57 :
58 : /// A compiled SQL fragment: a boolean expression carrying `$n` placeholders
59 : /// numbered from the `first_bind` its compiler was given, plus the texts
60 : /// those placeholders bind to, in order. What a bind MEANS belongs to the
61 : /// compiler that produced it -- a jsonpath for `q=`, a regex for `scopeQ`, a
62 : /// timeproperty name and a boundary for a temporal range -- but the shape
63 : /// and the numbering contract are one, which is what lets a caller
64 : /// concatenate fragments from different compilers into a single statement.
65 : pub struct CompiledSql {
66 : /// The boolean SQL expression, with `$n` placeholders.
67 : pub sql: String,
68 : /// The texts the placeholders bind to, in order.
69 : pub binds: Vec<String>,
70 : }
71 :
72 : /// Compile `node` into a SQL predicate over column `col` (a `jsonb`). `col`
73 : /// is SQL text, so it is `'static`: a column the caller names in its own
74 : /// code, never a string a request carried.
75 : /// `first_bind` is the 1-based number of the next free placeholder.
76 : /// `None` = this expression is outside the exact subset; filter in memory.
77 1396 : pub fn compile_q(
78 1396 : node: &QNode,
79 1396 : col: &'static str,
80 1396 : first_bind: usize,
81 1396 : expand: &dyn Fn(&str) -> String,
82 1396 : ) -> Option<CompiledSql> {
83 1396 : let mut binds = Vec::new();
84 1396 : let sql = emit(node, col, first_bind, expand, &mut binds)?;
85 1095 : Some(CompiledSql { sql, binds })
86 1396 : }
87 :
88 : /// One 4.9 leaf over a SINGLE stored attribute instance (a temporal
89 : /// `attr_instances.data` object): identical member/operator semantics to the
90 : /// entity-doc path, but the jsonpath is rooted at the instance (`$."value"`)
91 : /// instead of navigating `$."IRI"[*]`. Same exact-or-refuse contract —
92 : /// `cmp = None` is the existence form.
93 443 : pub fn compile_instance_leaf(
94 443 : cmp: Option<(CmpOp, &QValue)>,
95 443 : col: &'static str,
96 443 : first: usize,
97 443 : ) -> Option<CompiledSql> {
98 443 : let mut binds = Vec::new();
99 443 : let sql = value_or("$", cmp, col, first, &mut binds)?;
100 367 : Some(CompiledSql { sql, binds })
101 443 : }
102 :
103 3741 : fn emit(
104 3741 : node: &QNode,
105 3741 : col: &'static str,
106 3741 : first: usize,
107 3741 : expand: &dyn Fn(&str) -> String,
108 3741 : binds: &mut Vec<String>,
109 3741 : ) -> Option<String> {
110 3741 : match node {
111 616 : QNode::And(items) => join(items, " AND ", col, first, expand, binds),
112 475 : QNode::Or(items) => join(items, " OR ", col, first, expand, binds),
113 613 : QNode::Exists { path, negated } => {
114 : // 4.9 linked-entity hops and trailing brackets are outside the
115 : // exact SQL subset — fall back to in-memory eval_q.
116 613 : if !path.links.is_empty() || path.bracket.is_some() {
117 18 : return None;
118 595 : }
119 595 : let p = path_expr(&path.path, expand)?;
120 587 : let sql = value_or(&p, None, col, first, binds)?;
121 587 : Some(if *negated {
122 410 : format!("NOT ({sql})")
123 : } else {
124 177 : sql
125 : })
126 : }
127 2037 : QNode::Cmp { path, op, value } => {
128 2037 : if !path.links.is_empty() || path.bracket.is_some() {
129 87 : return None;
130 1950 : }
131 1950 : let p = path_expr(&path.path, expand)?;
132 1912 : value_or(&p, Some((*op, value)), col, first, binds)
133 : }
134 : }
135 3741 : }
136 :
137 : /// `first` is the ORIGINAL placeholder offset throughout; the running count
138 : /// is `binds.len()` alone. Adding the offset again per level is how you get
139 : /// two predicates pointing at the same `$n`.
140 1091 : fn join(
141 1091 : items: &[QNode],
142 1091 : sep: &str,
143 1091 : col: &'static str,
144 1091 : first: usize,
145 1091 : expand: &dyn Fn(&str) -> String,
146 1091 : binds: &mut Vec<String>,
147 1091 : ) -> Option<String> {
148 1091 : if items.is_empty() {
149 4 : return None; // `()` is not a predicate; the parser cannot build one,
150 : // but this entry point is public
151 1087 : }
152 1087 : let mut parts = Vec::with_capacity(items.len());
153 2345 : for it in items {
154 2345 : parts.push(emit(it, col, first, expand, binds)?);
155 : }
156 820 : Some(format!("({})", parts.join(sep)))
157 1091 : }
158 :
159 : /// One `jsonb_path_exists` per comparable-value member, OR'd — the SQL
160 : /// spelling of "whichever member this instance carries".
161 2942 : fn value_or(
162 2942 : prefix: &str,
163 2942 : cmp: Option<(CmpOp, &QValue)>,
164 2942 : col: &'static str,
165 2942 : first: usize,
166 2942 : binds: &mut Vec<String>,
167 2942 : ) -> Option<String> {
168 2942 : let filter = match cmp {
169 2347 : Some((op, v)) => Some(cmp_filter(op, v)?),
170 595 : None => None,
171 : };
172 : // `eval::kind_allows`: a Relationship's object only answers == and !=
173 2720 : let objects = !matches!(cmp, Some((CmpOp::Pattern, _)));
174 2720 : Some(value_or_filter(
175 2720 : prefix,
176 2720 : filter.as_deref(),
177 2720 : col,
178 2720 : first,
179 2720 : binds,
180 2720 : objects,
181 2720 : ))
182 2942 : }
183 :
184 : /// The member-OR with a PRE-BUILT jsonpath filter — shared with the
185 : /// qprefilter's extension leaves (`!=` as NOT-of-Eq). `objects = false`
186 : /// leaves out `object`/`objectList`, the members `~=` never reads.
187 3003 : pub fn value_or_filter(
188 3003 : prefix: &str,
189 3003 : filter: Option<&str>,
190 3003 : col: &'static str,
191 3003 : first: usize,
192 3003 : binds: &mut Vec<String>,
193 3003 : objects: bool,
194 3003 : ) -> String {
195 3003 : let mut parts = Vec::with_capacity(ARMS);
196 3003 : let steps = VALUE_KEYS
197 3003 : .iter()
198 21021 : .filter(|k| objects || !matches!(**k, "object" | "objectList"))
199 : // 4.9: a LanguageProperty's target value is a value of ANY of its
200 : // languageMap pairs (`eval::terminal`), so a compare reads the map's
201 : // values; existence keeps the member itself, as an empty map exists
202 20459 : .map(|k| match (*k, filter) {
203 20459 : ("languageMap", Some(_)) => ".\"languageMap\".*".to_owned(),
204 18051 : _ => format!(".\"{k}\""),
205 20459 : })
206 : // C.6: a value written as a JSON-LD typed value carries it under
207 : // `@value`, and `eval::untyped` compares that member — one more
208 : // step for the members that can hold one.
209 6006 : .chain(TYPED_KEYS.iter().map(|k| format!(".\"{k}\".\"@value\"")));
210 26465 : for step in steps {
211 : // lax mode (the default) auto-unwraps arrays at every step, which is
212 : // exactly `eval::compare`'s "any element of an array value matches".
213 26465 : let jp = match filter {
214 21110 : Some(f) => format!("{prefix}{step}{f}"),
215 5355 : None => format!("{prefix}{step}"),
216 : };
217 : // the OPERATOR form of jsonb_path_exists: identical lax semantics
218 26465 : parts.push(format!(
219 : "{col} @? ${}::jsonpath",
220 26465 : first.saturating_add(binds.len())
221 : ));
222 26465 : binds.push(jp);
223 : }
224 3003 : format!("({})", parts.join(" OR "))
225 3003 : }
226 :
227 : /// The equality jsonpath filter for `want` — the building block of the
228 : /// qprefilter's NOT-of-Eq `!=` leaf. `None` for shapes Eq itself refuses
229 : /// (string-endpoint ranges, exponent numbers …).
230 48 : pub fn eq_filter(want: &QValue) -> Option<String> {
231 48 : cmp_filter(CmpOp::Eq, want)
232 48 : }
233 :
234 : /// The jsonpath filter for a `[lang]` leaf — same operator table as any
235 : /// other member (ordering-on-string / patterns / `!=` refuse as usual).
236 154 : pub fn lang_filter(op: CmpOp, want: &QValue) -> Option<String> {
237 154 : cmp_filter(op, want)
238 154 : }
239 :
240 : /// Dotted q path → jsonpath prefix addressing the instance objects.
241 : /// Exact only while every segment is an attribute step (`attr.sub.subsub`);
242 : /// `eval::collect` falls back to navigating INTO a value object when a
243 : /// segment is not a sub-attribute, and that ambiguity is not reproducible in
244 : /// one jsonpath — so those queries stay in-memory.
245 2545 : fn path_expr(path: &[String], expand: &dyn Fn(&str) -> String) -> Option<String> {
246 : // Only single-segment paths are unambiguous. For a longer one `eval::
247 : // collect` picks between a sub-attribute step and navigation INTO the
248 : // value object based on what the DOCUMENT happens to hold — a per-row
249 : // decision no single jsonpath reproduces. Refuse rather than guess.
250 2545 : if path.len() != 1 {
251 44 : return None;
252 2501 : }
253 2501 : let key = expand(path.first()?);
254 2501 : if key.contains('\0') {
255 2 : return None; // see `literal`
256 2499 : }
257 2499 : Some(format!("$.{}[*]", quoted(&key)))
258 2545 : }
259 :
260 2549 : fn cmp_filter(op: CmpOp, want: &QValue) -> Option<String> {
261 : // 4.9 ValueList / Range (CompEqualityValue). Only `==` compiles:
262 : // - Eq+List p.90 is "identical to ANY of the list values" — an OR of
263 : // equality filters, existential like jsonpath's lax arrays. Exact.
264 : // - Eq+Range p.90 is a closed interval — exact for numbers; string
265 : // endpoints would order through the database collation (see the
266 : // ordering note below), so those stay in memory.
267 : // - Ne+List / Ne+Range inherit every `!=` caveat (type-mismatch matches,
268 : // universal quantification over arrays) — declined with it.
269 2549 : match want {
270 164 : QValue::List(vals) => {
271 164 : if op != CmpOp::Eq {
272 18 : return None;
273 146 : }
274 146 : let mut parts = Vec::with_capacity(vals.len());
275 292 : for v in vals {
276 292 : parts.push(format!("@ == {}", literal(v)?));
277 : }
278 146 : return Some(format!(" ? ({})", parts.join(" || ")));
279 : }
280 61 : QValue::Range(lo, hi) => {
281 61 : if op != CmpOp::Eq {
282 7 : return None;
283 54 : }
284 54 : let (QValue::Num(a), QValue::Num(b)) = (lo.as_ref(), hi.as_ref()) else {
285 4 : return None;
286 : };
287 50 : let (a, b) = (literal(&QValue::Num(*a))?, literal(&QValue::Num(*b))?);
288 50 : return Some(format!(" ? (@ >= {a} && @ <= {b})"));
289 : }
290 2324 : _ => {}
291 : }
292 : // Ordering against a STRING is left to the evaluator: `eval::compare`
293 : // orders with Rust's byte-wise `str` comparison, while jsonpath orders
294 : // through the database collation. They agree on ASCII and can disagree
295 : // elsewhere, and disagreeing HERE drops a matching row (a compliance
296 : // bug), not merely a fast row (a benchmark item).
297 2324 : if matches!(want, QValue::Str(_)) && matches!(op, CmpOp::Gt | CmpOp::Ge | CmpOp::Lt | CmpOp::Le)
298 : {
299 72 : return None;
300 2252 : }
301 2252 : let lit = literal(want)?;
302 2244 : Some(match op {
303 1150 : CmpOp::Eq => format!(" ? (@ == {lit})"),
304 : // 4.9 p.92: "If the data type of the target value and the data type of
305 : // the Query Term value are different, then they shall be considered
306 : // unequal" — a type mismatch MATCHES `!=`. PostgreSQL jsonpath compares
307 : // across types as `unknown`, so `@ != lit` silently DROPS exactly those
308 : // rows, and 4.9 p.91 additionally requires every element of an array to
309 : // differ (jsonpath quantifies existentially). Neither is reproducible
310 : // here, so per this module's contract — decline rather than narrow
311 : // wrongly — `!=` is left to the in-memory evaluator.
312 27 : CmpOp::Ne => return None,
313 241 : CmpOp::Gt => format!(" ? (@ > {lit})"),
314 261 : CmpOp::Ge => format!(" ? (@ >= {lit})"),
315 105 : CmpOp::Lt => format!(" ? (@ < {lit})"),
316 87 : CmpOp::Le => format!(" ? (@ <= {lit})"),
317 : // ~= is a regex over strings. jsonpath's `like_regex` is Postgres'
318 : // ARE dialect, which reads the `pushable_pattern` subset exactly as
319 : // the `regex` crate does; anything else stays in memory.
320 : // !~= needs every array element to miss (p.92), which lax jsonpath
321 : // quantifies existentially, so it always stays in memory.
322 320 : CmpOp::Pattern => match want {
323 320 : QValue::Str(s) if pushable_pattern(s) => {
324 281 : format!(" ? (@ like_regex {})", jsonpath_string(s))
325 : }
326 39 : _ => return None,
327 : },
328 53 : CmpOp::NotPattern => return None,
329 : })
330 2549 : }
331 :
332 2654 : fn literal(v: &QValue) -> Option<String> {
333 1361 : Some(match v {
334 : // a jsonpath value is NUL-terminated text in the database, so a NUL
335 : // has no representation inside a string literal: the path would fail
336 : // to parse instead of filtering. Refuse, like any other shape outside
337 : // the subset, and let the evaluator compare it.
338 1361 : QValue::Str(s) if s.contains('\0') => return None,
339 1359 : QValue::Str(s) => jsonpath_string(s),
340 183 : QValue::Bool(b) => b.to_string(),
341 1108 : QValue::Num(n) => {
342 1108 : if !n.is_finite() {
343 6 : return None;
344 1102 : }
345 : // 4.9 numbers are parsed to f64, but jsonb holds exact `numeric`:
346 : // past 2^53 the f64 the query carries is no longer the integer the
347 : // client wrote, so the compiled predicate would refuse rows
348 : // `eval` (which compares f64 to f64 on BOTH sides) keeps. Leave
349 : // those to the evaluator rather than narrow wrongly.
350 1102 : if n.abs() >= 9_007_199_254_740_992.0 {
351 6 : return None;
352 1096 : }
353 : // shortest round-trip form; jsonpath numbers are JSON numbers
354 1096 : let s = n.to_string();
355 1096 : if s.contains(['e', 'E']) {
356 0 : return None; // exponent forms differ across parsers
357 1096 : }
358 1096 : s
359 : }
360 : // composite values never render as one literal — cmp_filter unfolds
361 : // them (Eq) or declines (everything else) before reaching here
362 2 : QValue::List(_) | QValue::Range(..) => return None,
363 : })
364 2654 : }
365 :
366 : /// Longest `~=` pattern pushed to Postgres; a longer one stays in memory
367 : /// under the evaluator's own program caps (`crate::regex`).
368 : const MAX_PUSHED_PATTERN: usize = 128;
369 :
370 : /// Whether `~=`'s pattern means the same to Postgres `like_regex` and to the
371 : /// `regex` crate. Literals, `.`, `^`, `$`, `|`, groups, the
372 : /// quantifiers `*` `+` `?` (lazy too) and positive bracket lists of
373 : /// literals and ranges. Refused, so evaluated in memory: escapes (`\`:
374 : /// class shorthands and back-references differ, and a back-reference is
375 : /// what makes Postgres backtrack), `{m,n}` counts, `[^…]` (in like_regex it
376 : /// never matches a newline, in Rust it does), POSIX `[:…:]` classes, inline
377 : /// flags `(?…)`, a NUL, and every pattern Rust itself refuses.
378 : /// `like_regex` without flags keeps `.` off newlines and `^`/`$` at the
379 : /// ends of the string, which are the crate's defaults.
380 366 : fn pushable_pattern(p: &str) -> bool {
381 366 : if p.is_empty() || p.len() > MAX_PUSHED_PATTERN || crate::regex::compile(p).is_err() {
382 10 : return false;
383 356 : }
384 356 : let mut chars = p.chars().peekable();
385 1358 : while let Some(c) = chars.next() {
386 22 : match c {
387 37 : '\\' | '{' | '}' | '\0' => return false,
388 22 : '(' if chars.peek() == Some(&'?') => return false,
389 : '[' => {
390 : // a positive list of literals and `a-z` ranges, closed
391 48 : if matches!(chars.peek(), Some('^' | ']' | '[')) {
392 11 : return false;
393 37 : }
394 : loop {
395 150 : match chars.next() {
396 33 : Some(']') => break,
397 4 : Some('[' | '\\' | '\0') | None => return false,
398 113 : Some(_) => {}
399 : }
400 : }
401 : }
402 2 : ']' => return false,
403 969 : _ => {}
404 : }
405 : }
406 299 : true
407 366 : }
408 :
409 : /// A jsonpath member name: always double-quoted, because attribute keys are
410 : /// expanded IRIs full of `:` `/` `#` `.` that would otherwise be syntax.
411 2742 : pub fn quoted(s: &str) -> String {
412 2742 : jsonpath_string(s)
413 2742 : }
414 :
415 4386 : fn jsonpath_string(s: &str) -> String {
416 4386 : let mut out = String::with_capacity(s.len().saturating_add(2));
417 4386 : out.push('"');
418 124690 : for c in s.chars() {
419 124592 : match c {
420 6 : '"' => out.push_str("\\\""),
421 86 : '\\' => out.push_str("\\\\"),
422 2 : '\n' => out.push_str("\\n"),
423 2 : '\r' => out.push_str("\\r"),
424 2 : '\t' => out.push_str("\\t"),
425 124592 : c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
426 124590 : c => out.push(c),
427 : }
428 : }
429 4386 : out.push('"');
430 4386 : out
431 4386 : }
432 :
433 : #[cfg(test)]
434 : mod tests {
435 : use super::*;
436 : use crate::{parse_q, QPath};
437 :
438 : /// the expander the API hands in, stubbed: term → default-context IRI
439 88 : fn ex(t: &str) -> String {
440 88 : format!("https://uri.etsi.org/ngsi-ld/default-context/{t}")
441 88 : }
442 :
443 84 : fn c(q: &str) -> Option<CompiledSql> {
444 84 : compile_q(&parse_q(q).expect("parse"), "entity", 2, &ex)
445 84 : }
446 :
447 : #[test]
448 2 : fn comparison_binds_the_jsonpath_and_never_splices_it() {
449 2 : let got = c("temperature>20").expect("compiles");
450 : // every placeholder is a bind; no client text in the SQL
451 2 : assert!(!got.sql.contains("temperature"), "sql: {}", got.sql);
452 2 : assert_eq!(got.binds.len(), ARMS);
453 2 : assert_eq!(
454 2 : got.binds[0],
455 : "$.\"https://uri.etsi.org/ngsi-ld/default-context/temperature\"[*].\"value\" ? (@ > 20)"
456 : );
457 : // C.6: the same term written as a JSON-LD typed value is an arm of
458 : // the same OR, so the pushdown keeps the rows `eval` keeps
459 2 : assert_eq!(
460 2 : got.binds[VALUE_KEYS.len()],
461 : "$.\"https://uri.etsi.org/ngsi-ld/default-context/temperature\"[*].\"value\".\"@value\" ? (@ > 20)"
462 : );
463 2 : assert!(
464 2 : got.sql.starts_with("(entity @? $2::jsonpath"),
465 : "sql: {}",
466 : got.sql
467 : );
468 2 : }
469 :
470 : #[test]
471 2 : fn placeholders_are_numbered_from_the_offset_and_stay_unique() {
472 2 : let got = c("a==1;b==2").expect("compiles");
473 2 : let n = ARMS;
474 2 : assert_eq!(got.binds.len(), 2 * n);
475 36 : for i in 0..2 * n {
476 36 : assert!(
477 36 : got.sql.contains(&format!("${}::jsonpath", i + 2)),
478 : "missing ${}",
479 0 : i + 2
480 : );
481 : }
482 2 : assert!(got.sql.contains(") AND ("));
483 2 : }
484 :
485 : /// jsonb holds `numeric`, the query holds an f64. Past 2^53 the two stop
486 : /// agreeing, and the pushdown would drop rows the evaluator keeps — so an
487 : /// integer that big compiles to nothing at all and the evaluator decides.
488 : #[test]
489 2 : fn an_integer_past_the_f64_grid_is_left_to_the_evaluator() {
490 2 : assert!(
491 2 : c("n==9007199254740993").is_none(),
492 : "a value the f64 cannot hold must not narrow the match set"
493 : );
494 2 : assert!(c("n>9007199254740993").is_none());
495 2 : assert!(c("n==-9007199254740993").is_none());
496 : // the boundary itself is exact, and everything under it still compiles
497 2 : assert!(c("n==9007199254740991").is_some());
498 2 : assert!(c("n==0").is_some());
499 2 : assert!(c("n>20.5").is_some());
500 2 : }
501 :
502 : #[test]
503 2 : fn or_and_negated_existence() {
504 2 : assert!(c("a==1|b==2").expect("or").sql.contains(") OR ("));
505 2 : let neg = c("!a").expect("negated exists");
506 2 : assert!(neg.sql.starts_with("NOT ("));
507 2 : }
508 :
509 : #[test]
510 2 : fn expanded_iri_keys_are_quoted_so_slashes_and_colons_are_not_syntax() {
511 2 : let got = c("t==5").expect("compiles");
512 2 : assert!(
513 2 : got.binds[0].starts_with("$.\"https://uri.etsi.org/ngsi-ld/default-context/t\"[*]"),
514 : "{}",
515 0 : got.binds[0]
516 : );
517 2 : }
518 :
519 : #[test]
520 2 : fn quoting_escapes_what_would_break_the_jsonpath() {
521 2 : assert_eq!(jsonpath_string("a\"b\\c"), "\"a\\\"b\\\\c\"");
522 2 : assert_eq!(jsonpath_string("l\n"), "\"l\\n\"");
523 2 : }
524 :
525 : /// 4.9: a bare LanguageProperty name compares the values of its
526 : /// languageMap (as `eval::terminal` does), not the map object, which no
527 : /// scalar ever equals; existence still reads the member itself.
528 : #[test]
529 2 : fn a_language_map_compares_its_values() {
530 4 : let lm = |q: &str| {
531 4 : c(q).expect("compiles")
532 4 : .binds
533 4 : .into_iter()
534 12 : .find(|b| b.contains("languageMap"))
535 4 : .expect("languageMap arm")
536 4 : };
537 2 : assert_eq!(
538 2 : lm(r#"color=="red""#),
539 : "$.\"https://uri.etsi.org/ngsi-ld/default-context/color\"[*].\"languageMap\".* ? (@ == \"red\")"
540 : );
541 2 : assert_eq!(
542 2 : lm("color"),
543 : "$.\"https://uri.etsi.org/ngsi-ld/default-context/color\"[*].\"languageMap\""
544 : );
545 2 : }
546 :
547 : #[test]
548 2 : fn unsupported_shapes_refuse_instead_of_guessing() {
549 : // dotted path: sub-attribute vs value navigation is ambiguous
550 2 : assert!(c("address.city==\"Bonn\"").is_none());
551 : // ~= outside the subset both regex dialects read alike, and !~=
552 2 : assert!(c("name~=\"\\d\"").is_none());
553 2 : assert!(c("name!~=\"^ab\"").is_none());
554 2 : assert!(c("name~=\"^ab\"").is_some());
555 : // ordering on strings: Rust byte-wise vs database collation
556 2 : assert!(c("name>\"m\"").is_none());
557 2 : assert!(c("name<=\"m\"").is_none());
558 : // ... but equality on strings is collation-free, so it compiles
559 2 : assert!(c("name==\"m\"").is_some());
560 2 : assert!(c("n>=3").is_some(), "numeric ordering is unambiguous");
561 2 : }
562 :
563 : /// A refused member must refuse the WHOLE expression. Emitting only the
564 : /// members that compiled would turn `a AND b` into `a` (still a superset,
565 : /// but the caller would page on it) and `a OR b` into `a` — a predicate
566 : /// that is plausible and wrong.
567 : #[test]
568 2 : fn one_refused_member_refuses_the_whole_expression() {
569 2 : assert!(
570 2 : c(r#"a==1;b!~="x""#).is_none(),
571 : "AND must not drop a conjunct"
572 : );
573 2 : assert!(c(r#"a==1|b!~="x""#).is_none(), "OR must not drop a branch");
574 2 : assert!(c(r#"b!~="x"|a==1"#).is_none(), "…in either position");
575 2 : assert!(c(r#"(a==1|b!=2);c==3"#).is_none(), "…nor when nested");
576 : // and the members that DID compile leave no trace behind them
577 2 : assert!(c(r#"a==1;a==2;b!~="x""#).is_none());
578 : // an empty junction has no predicate either — `()` would be a syntax
579 : // error, and this entry point is public
580 2 : assert!(compile_q(&QNode::And(Vec::new()), "entity", 1, &ex).is_none());
581 2 : assert!(compile_q(&QNode::Or(Vec::new()), "entity", 1, &ex).is_none());
582 2 : }
583 :
584 : /// Everything a client typed — the term, the value, and any SQL syntax
585 : /// hidden in either — reaches the statement as `$n` and nothing else.
586 : #[test]
587 2 : fn client_text_never_reaches_the_statement() {
588 2 : let hostile = r#"'; DROP TABLE entities; --"#;
589 2 : let node = QNode::Cmp {
590 2 : path: QPath::dotted(vec![hostile.to_owned()]),
591 2 : op: CmpOp::Eq,
592 2 : value: QValue::Str(format!("{hostile}\\\"$1")),
593 2 : };
594 2 : let got = compile_q(&node, "entity", 1, &|t| t.to_owned()).expect("compiles");
595 10 : for needle in ["DROP", "TABLE", "--", "'", "entities"] {
596 10 : assert!(
597 10 : !got.sql.contains(needle),
598 : "{needle:?} leaked into the sql: {}",
599 : got.sql
600 : );
601 : }
602 : // the sql is placeholders and compiler constants only
603 2 : let skeleton: Vec<String> = (1..=ARMS)
604 18 : .map(|n| format!("entity @? ${n}::jsonpath"))
605 2 : .collect();
606 2 : assert_eq!(got.sql, format!("({})", skeleton.join(" OR ")));
607 : // …and the bind escapes what would otherwise end the jsonpath string
608 2 : assert!(got.binds[0].contains("\\\\\\\"$1"), "{}", got.binds[0]);
609 2 : }
610 :
611 : /// A NUL has no representation inside a jsonpath string literal, so the
612 : /// path would fail to parse in the database instead of filtering. Refuse
613 : /// it like any other shape outside the subset.
614 : #[test]
615 2 : fn a_nul_in_a_term_or_a_value_is_left_to_the_evaluator() {
616 2 : let leaf = |t: &str, v: &str| QNode::Cmp {
617 6 : path: QPath::dotted(vec![t.to_owned()]),
618 6 : op: CmpOp::Eq,
619 6 : value: QValue::Str(v.to_owned()),
620 6 : };
621 6 : let id = |t: &str| t.to_owned();
622 2 : assert!(compile_q(&leaf("a\0b", "x"), "entity", 1, &id).is_none());
623 2 : assert!(compile_q(&leaf("a", "x\0y"), "entity", 1, &id).is_none());
624 2 : assert!(compile_q(&leaf("a", "x"), "entity", 1, &id).is_some());
625 2 : }
626 :
627 : /// The temporal per-instance leaf: same operator table, rooted at the
628 : /// instance object rather than at `$."IRI"[*]`.
629 : #[test]
630 2 : fn instance_leaf_roots_at_the_instance_and_numbers_from_first() {
631 2 : let want = QValue::Num(25.0);
632 2 : let got = compile_instance_leaf(Some((CmpOp::Gt, &want)), "qi.data", 7).expect("compiles");
633 2 : assert_eq!(got.binds.len(), ARMS);
634 2 : assert_eq!(got.binds[0], "$.\"value\" ? (@ > 25)");
635 2 : assert!(
636 2 : got.sql.starts_with("(qi.data @? $7::jsonpath"),
637 : "{}",
638 : got.sql
639 : );
640 2 : assert!(got.sql.contains(&format!("${}", 7 + ARMS - 1)));
641 2 : assert!(!got.sql.contains(&format!("${}", 7 + ARMS)));
642 : // existence form
643 2 : assert_eq!(
644 2 : compile_instance_leaf(None, "qi.data", 1)
645 2 : .expect("exists")
646 2 : .binds[0],
647 : "$.\"value\""
648 : );
649 : // and the same refusals
650 2 : assert!(compile_instance_leaf(Some((CmpOp::Ne, &want)), "qi.data", 1).is_none());
651 2 : let s = QValue::Str("m".to_owned());
652 2 : assert!(compile_instance_leaf(Some((CmpOp::Lt, &s)), "qi.data", 1).is_none());
653 2 : assert!(compile_instance_leaf(Some((CmpOp::NotPattern, &s)), "qi.data", 1).is_none());
654 2 : let esc = QValue::Str("\\d".to_owned());
655 2 : assert!(compile_instance_leaf(Some((CmpOp::Pattern, &esc)), "qi.data", 1).is_none());
656 : // a pattern both dialects read alike compiles, Relationship members left out
657 2 : let got = compile_instance_leaf(Some((CmpOp::Pattern, &s)), "qi.data", 1).expect("pushed");
658 2 : assert!(
659 14 : got.binds.iter().all(|b| !b.contains("object")),
660 : "{:?}",
661 : got.binds
662 : );
663 2 : assert!(
664 2 : got.binds[0].ends_with("? (@ like_regex \"m\")"),
665 : "{:?}",
666 : got.binds
667 : );
668 2 : }
669 :
670 : /// only the subset Postgres ARE and the `regex` crate read alike
671 : /// is pushed; the rest stays with the evaluator.
672 : #[test]
673 2 : fn only_patterns_both_dialects_read_alike_are_pushed() {
674 18 : for p in [
675 2 : "^B",
676 2 : "erl",
677 2 : "^b.*n$",
678 2 : "(a|b)+",
679 2 : "[A-Z]erlin",
680 2 : "x y",
681 2 : "é.",
682 2 : "^[a-c0-9_-]+?$",
683 2 : "a|",
684 2 : ] {
685 18 : assert!(pushable_pattern(p), "{p}");
686 : }
687 28 : for p in [
688 2 : "",
689 2 : "\\d",
690 2 : "a{2}",
691 2 : "[^a]",
692 2 : "[[:alpha:]]",
693 2 : "(?i)b",
694 2 : "(a)\\1",
695 2 : "[a",
696 2 : "a]",
697 2 : "[a\\d]",
698 2 : "[a[b]]",
699 2 : "a\0",
700 2 : "(",
701 2 : &"a".repeat(MAX_PUSHED_PATTERN + 1),
702 2 : ] {
703 28 : assert!(!pushable_pattern(p), "{p}");
704 : }
705 2 : }
706 :
707 : /// A number that cannot round-trip as a JSON literal is not compiled to
708 : /// one — `literal` is the last gate before the jsonpath text.
709 : #[test]
710 2 : fn non_finite_numbers_never_become_a_jsonpath_literal() {
711 2 : assert!(literal(&QValue::Num(f64::NAN)).is_none());
712 2 : assert!(literal(&QValue::Num(f64::INFINITY)).is_none());
713 2 : assert!(literal(&QValue::Num(f64::NEG_INFINITY)).is_none());
714 2 : assert_eq!(literal(&QValue::Num(-0.5)).expect("finite"), "-0.5");
715 2 : }
716 :
717 : #[test]
718 2 : fn value_list_and_range_compile_for_eq_and_decline_for_ne() {
719 : // Eq+List: an OR of equality filters — existential, exact
720 2 : let got = c(r#"color=="black","red""#).expect("compiles");
721 2 : assert!(
722 2 : got.binds[0].ends_with(r#" ? (@ == "black" || @ == "red")"#),
723 : "{}",
724 0 : got.binds[0]
725 : );
726 : // Eq+Range on numbers: closed interval
727 2 : let got = c("t==10..20").expect("compiles");
728 2 : assert!(
729 2 : got.binds[0].ends_with(" ? (@ >= 10 && @ <= 20)"),
730 : "{}",
731 0 : got.binds[0]
732 : );
733 : // Ne inherits the != caveats (type mismatch, array quantification)
734 2 : assert!(c(r#"color!="black","red""#).is_none());
735 2 : assert!(c("t!=10..20").is_none());
736 : // string-endpoint ranges order through the collation — in-memory
737 2 : assert!(c(r#"name=="a".."m""#).is_none());
738 : // !~= is a regex — dialect mismatch, in-memory
739 2 : assert!(c(r#"name!~="^ab""#).is_none());
740 2 : }
741 :
742 : /// The equality and `[lang]` building blocks share Eq's table: a list
743 : /// is an OR, a numeric range a closed interval, a string range and an
744 : /// ordering on a list stay in memory.
745 : #[test]
746 2 : fn eq_and_lang_filters_follow_the_eq_table() {
747 8 : let n = |x: f64| Box::new(QValue::Num(x));
748 2 : let list = QValue::List(vec![QValue::Num(1.0), QValue::Num(2.0)]);
749 2 : assert!(eq_filter(&list).is_some_and(|f| f.contains("||")));
750 2 : assert!(eq_filter(&QValue::Range(n(1.0), n(2.0))).is_some_and(|f| f.contains(">=")));
751 4 : let s = |x: &str| Box::new(QValue::Str(x.into()));
752 2 : assert!(eq_filter(&QValue::Range(s("a"), s("b"))).is_none());
753 2 : assert!(lang_filter(CmpOp::Ne, &list).is_none());
754 2 : assert!(lang_filter(CmpOp::Ne, &QValue::Range(n(1.0), n(2.0))).is_none());
755 2 : assert_eq!(lang_filter(CmpOp::Eq, &list), eq_filter(&list));
756 2 : }
757 :
758 : /// Linked-entity hops and trailing brackets stay in memory, for an
759 : /// existence test as for a comparison; a control character in a
760 : /// literal is escaped, a space is not.
761 : #[test]
762 2 : fn hops_and_brackets_stay_in_memory_and_controls_are_escaped() {
763 8 : for q in [
764 2 : "sensor{humidity}==40",
765 2 : "sensor{humidity}",
766 2 : "a[b]==1",
767 2 : "a[b]",
768 2 : ] {
769 8 : assert!(c(q).is_none(), "{q}");
770 : }
771 2 : let got = c(r#"a=="x\u0001 y""#).expect("compiles");
772 2 : assert!(got.binds[0].contains(r#""x\u0001 y""#), "{}", got.binds[0]);
773 2 : }
774 :
775 : /// The edges of the subset: a bare existence term, `<` and `<=`, a boolean, a
776 : /// tab and a carriage return in a string, and the composite values `literal` never renders.
777 : #[test]
778 2 : fn existence_ordering_booleans_and_control_characters() {
779 10 : let bound = |q: &str| c(q).map(|got| got.binds[0].clone()).expect(q);
780 2 : assert!(
781 2 : bound("temperature").ends_with(r#"."value""#),
782 : "{}",
783 0 : bound("temperature")
784 : );
785 2 : assert!(bound("temperature<20").ends_with("? (@ < 20)"));
786 2 : assert!(bound("temperature<=20").ends_with("? (@ <= 20)"));
787 2 : assert!(bound("open==true").ends_with("? (@ == true)"));
788 2 : assert!(
789 2 : bound(r#"a=="x\ty\rz""#).contains(r#""x\ty\rz""#),
790 : "{}",
791 0 : bound(r#"a=="x\ty\rz""#)
792 : );
793 2 : assert_eq!(literal(&QValue::List(vec![QValue::Num(1.0)])), None);
794 2 : }
795 : }
|