LCOV - code coverage report
Current view: top level - antares-ql/src - sql.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 96.6 % 319 308
Test Date: 2026-09-21 10:31:06 Functions: 95.9 % 49 47

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

Generated by: LCOV version 2.0-1