LCOV - code coverage report
Current view: top level - antares-ql/src - sql.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.4 % 439 432
Test Date: 2026-11-02 13:21:27 Functions: 100.0 % 67 67

            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              : }
        

Generated by: LCOV version 2.0-1