LCOV - code coverage report
Current view: top level - antares-sql/src/compile - qprefilter.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.1 % 324 318
Test Date: 2026-09-21 10:31:06 Functions: 97.8 % 46 45

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! 5.7.4.4 S2 — the values filter compiled to a SUPERSET SQL prefilter.
       3              : //!
       4              : //! The temporal store keeps one row per Attribute instance, so the entity
       5              : //! doc `eval::eval_q` sees does not exist SQL-side until the expensive
       6              : //! per-entity reconstruction has already happened. This module narrows the
       7              : //! ENTITY set before reconstruction instead: each q leaf the exact compiler
       8              : //! (`compile::q`) can reproduce becomes one windowed EXISTS over
       9              : //! `attr_instances`; every shape outside that subset becomes TRUE. The
      10              : //! structure rules keep the superset invariant total over the 4.9 grammar:
      11              : //!
      12              : //! * `And` — AND of the compiled members; an uncompilable member is TRUE and
      13              : //!   is simply dropped (dropping a conjunct only widens).
      14              : //! * `Or`  — OR of the compiled members, but ANY uncompilable branch makes
      15              : //!   the whole disjunction TRUE (a TRUE branch absorbs the OR).
      16              : //! * leaf  — `EXISTS(instance of that attr, inside the widened column
      17              : //!   window, satisfying the exact per-instance jsonpath)`. Per 5.7.4.4 the
      18              : //!   values filter is checked against "the Attribute instances resulting
      19              : //!   from the initial filtering performed by the temporal query", so the
      20              : //!   window belongs INSIDE the existence test.
      21              : //! * extension leaves (superset-only, see `instance_predicate`): `!=` as
      22              : //!   NOT-of-Eq, `[lang]`/`[*]` via the languageMap wildcard, string
      23              : //!   ordering via `COLLATE "C"` with array pass-through.
      24              : //! * negated existence, patterns, dotted/linked paths — TRUE (entity-level
      25              : //!   negation over per-instance rows is not superset-safe to push; regex
      26              : //!   dialects differ).
      27              : //!
      28              : //! `eval_q` remains the arbiter — the API always re-evaluates q on the rows
      29              : //! that come back, so a defect here can only fail to narrow, never narrow
      30              : //! wrongly. `None` from the top level means "no narrowing at all".
      31              : 
      32              : use antares_ql::{CmpOp, QNode, QPath, QValue};
      33              : 
      34              : use super::q::{compile_instance_leaf, CompiledSql};
      35              : use super::temporal::{column_range_bound, InstanceRange};
      36              : 
      37              : /// Compile `node` into a SQL predicate over the entity row aliased `entity`
      38              : /// (`temporal_entities m`). Placeholders are numbered from `first_bind`;
      39              : /// every bind is text (`$n::timestamptz` / `$n::jsonpath` casts in the SQL).
      40          186 : pub fn compile_prefilter(
      41          186 :     node: &QNode,
      42          186 :     range: Option<&InstanceRange<'_>>,
      43          186 :     entity: &str,
      44          186 :     first_bind: usize,
      45          186 :     expand: &dyn Fn(&str) -> String,
      46          186 : ) -> Option<CompiledSql> {
      47          186 :     let (sql, binds, _) = emit_prefilter(node, range, entity, first_bind, expand)?;
      48          160 :     Some(CompiledSql { sql, binds })
      49          186 : }
      50              : 
      51              : /// Did the whole filter compile EXACTLY — no member dropped, no branch
      52              : /// refused, every leaf a `Cmp` whose window carries the byte-exact text
      53              : /// predicate? An exact prefilter's entity verdict equals the evaluator's,
      54              : /// which is what makes SQL entity-paging with `q=` safe (the caller's gate).
      55              : /// Existence leaves are deliberately NOT exact: the evaluator's treatment of
      56              : /// deletion instances has no SQL twin yet.
      57          130 : pub fn prefilter_exact(
      58          130 :     node: &QNode,
      59          130 :     range: Option<&InstanceRange<'_>>,
      60          130 :     expand: &dyn Fn(&str) -> String,
      61          130 : ) -> bool {
      62          130 :     emit_prefilter(node, range, "m", 1, expand).is_some_and(|(_, _, exact)| exact)
      63          130 : }
      64              : 
      65              : /// Lower one `q=` node to a TEMPORAL PREFILTER: a predicate over the
      66              : /// instance table plus whether it is exact (`antares_ql::sql::emit` is the
      67              : /// other lowering -- a predicate over an entity's own jsonb column, which
      68              : /// is always exact or nothing). `first` is the ABSOLUTE number the member's
      69              : /// first bind will get; refused subtrees return `None` without having
      70              : /// committed any binds, so numbering stays dense.
      71          444 : fn emit_prefilter(
      72          444 :     node: &QNode,
      73          444 :     range: Option<&InstanceRange<'_>>,
      74          444 :     entity: &str,
      75          444 :     first: usize,
      76          444 :     expand: &dyn Fn(&str) -> String,
      77          444 : ) -> Option<(String, Vec<String>, bool)> {
      78          444 :     match node {
      79           32 :         QNode::And(items) => {
      80           32 :             let mut sqls = Vec::new();
      81           32 :             let mut binds = Vec::new();
      82           32 :             let mut exact = true;
      83           64 :             for it in items {
      84           44 :                 if let Some((s, b, e)) =
      85           64 :                     emit_prefilter(it, range, entity, first + binds.len(), expand)
      86           44 :                 {
      87           44 :                     sqls.push(s);
      88           44 :                     binds.extend(b);
      89           44 :                     exact &= e;
      90           44 :                 } else {
      91           20 :                     // a dropped conjunct only widens — but the result is no
      92           20 :                     // longer the evaluator's verdict
      93           20 :                     exact = false;
      94           20 :                 }
      95              :             }
      96           32 :             (!sqls.is_empty()).then(|| (format!("({})", sqls.join(" AND ")), binds, exact))
      97              :         }
      98           32 :         QNode::Or(items) => {
      99           32 :             let mut sqls = Vec::new();
     100           32 :             let mut binds = Vec::new();
     101           32 :             let mut exact = true;
     102           64 :             for it in items {
     103           64 :                 let (s, b, e) = emit_prefilter(it, range, entity, first + binds.len(), expand)?;
     104           50 :                 sqls.push(s);
     105           50 :                 binds.extend(b);
     106           50 :                 exact &= e;
     107              :             }
     108           18 :             (!sqls.is_empty()).then(|| (format!("({})", sqls.join(" OR ")), binds, exact))
     109              :         }
     110           16 :         QNode::Exists { path, negated } => {
     111           16 :             if *negated {
     112           10 :                 return None;
     113            6 :             }
     114            6 :             leaf(path, None, range, entity, first, expand)
     115              :         }
     116          364 :         QNode::Cmp { path, op, value } => {
     117          364 :             leaf(path, Some((*op, value)), range, entity, first, expand)
     118              :         }
     119              :     }
     120          444 : }
     121              : 
     122          370 : fn leaf(
     123          370 :     path: &QPath,
     124          370 :     cmp: Option<(CmpOp, &QValue)>,
     125          370 :     range: Option<&InstanceRange<'_>>,
     126          370 :     entity: &str,
     127          370 :     first: usize,
     128          370 :     expand: &dyn Fn(&str) -> String,
     129          370 : ) -> Option<(String, Vec<String>, bool)> {
     130              :     // links and dotted paths stay in memory (compile::q's ambiguity rules);
     131              :     // brackets are handled by the languageMap extension leaf below
     132          370 :     if !path.links.is_empty() || path.path.len() != 1 {
     133            2 :         return None;
     134          368 :     }
     135              :     // binds: [attr IRI, timeproperty + window time(s)…, jsonpath(s)…]
     136          368 :     let mut binds = vec![expand(path.path.first()?)];
     137          368 :     let mut window = String::new();
     138          368 :     let mut win_exact = range.is_none();
     139          368 :     if let Some(r) = range {
     140              :         // The canonically-keyed text predicate (the arbiter's own window
     141              :         // semantics), plus the widened column bound REUSING its time binds so
     142              :         // the (tenant, entity, attr, observed_at) btree still serves the
     143              :         // range. A shape the compiler refuses (unknown timerel, `between`
     144              :         // without an end) is refused by `column_range_bound` too, so there is
     145              :         // no widened bound to fall back on: the EXISTS then keeps the
     146              :         // attribute predicate alone — unwindowed, which only widens.
     147          364 :         if let Some(c) =
     148          364 :             crate::compile::temporal::compile_instance_range(r, "qi.data", first + binds.len())
     149              :         {
     150          364 :             let time_bind = first + binds.len() + 1;
     151          364 :             window = format!(" AND {}", c.sql);
     152          364 :             binds.extend(c.binds);
     153          364 :             if let Some(cb) = column_range_bound(r, "qi", time_bind) {
     154          364 :                 window.push_str(&format!(" AND {cb}"));
     155          364 :             }
     156          364 :             win_exact = true;
     157            0 :         }
     158            4 :     }
     159          368 :     let (inner, arm_exact) = instance_predicate(path, cmp, first, &mut binds)?;
     160          322 :     let sql = format!(
     161              :         "EXISTS (SELECT 1 FROM attr_instances qi \
     162              :          WHERE qi.tenant_id = {entity}.tenant_id AND qi.entity_id = {entity}.id \
     163              :          AND qi.attr_id = ${first}{window} AND {inner})"
     164              :     );
     165          322 :     Some((sql, binds, win_exact && arm_exact))
     166          370 : }
     167              : 
     168              : /// The per-instance predicate inside the EXISTS: the exact `compile::q`
     169              : /// leaf when possible, else one of the SUPERSET-ONLY extension leaves —
     170              : /// each may only widen relative to `eval_q`, never narrow:
     171              : ///
     172              : /// * `[lang]`/`[*]` — the value under ANY language (`languageMap.*`): a
     173              : ///   superset of the specific-tag semantics (case-insensitive BCP 47 tag
     174              : ///   matching stays in memory) and exactly `[*]`'s own meaning.
     175              : /// * `!=` — NOT of the existential-equality member-OR: 4.9 p.91's
     176              : ///   universal quantification over arrays and p.92's datatype-mismatch-
     177              : ///   matches both fall out of the negation; a deletion instance passes to
     178              : ///   the evaluator (which is why the arm is never exact).
     179              : /// * string ordering — `COLLATE "C"` byte comparison, the SQL spelling of
     180              : ///   the p.89 RFC 8259 code-unit SHALL; scalar strings compare exactly,
     181              : ///   array values pass through, non-string scalars are a datatype
     182              : ///   mismatch on both sides.
     183          368 : fn instance_predicate(
     184          368 :     path: &QPath,
     185          368 :     cmp: Option<(CmpOp, &QValue)>,
     186          368 :     first: usize,
     187          368 :     binds: &mut Vec<String>,
     188          368 : ) -> Option<(String, bool)> {
     189              :     use super::q;
     190          368 :     if let Some(bracket) = &path.bracket {
     191           38 :         let filter = match cmp {
     192           38 :             Some((op, v)) => Some(q::lang_filter(op, v)?),
     193            0 :             None => None,
     194              :         };
     195           38 :         let jp = match &filter {
     196           38 :             Some(f) => format!("$.\"languageMap\".*{f}"),
     197            0 :             None => "$.\"languageMap\".*".to_owned(),
     198              :         };
     199           38 :         let n = first + binds.len();
     200           38 :         binds.push(jp);
     201           38 :         let lang = format!("qi.data @? ${n}::jsonpath");
     202              :         // `[*]` is only ever the language wildcard. A NAMED bracket is
     203              :         // ambiguous by the 4.9 grammar — the same syntax addresses a
     204              :         // languageMap tag or a member of a compound Property value
     205              :         // (EXAMPLE 9/10/11) — and only the stored document decides which, so
     206              :         // the prefilter has to admit BOTH readings or it narrows away the
     207              :         // compound-value matches the evaluator would keep.
     208           38 :         if bracket.first().map(String::as_str) == Some("*") {
     209           14 :             return Some((lang, false));
     210           24 :         }
     211           24 :         let member: String = bracket
     212           24 :             .iter()
     213           24 :             .map(|s| format!(".{}", q::quoted(s)))
     214           24 :             .collect();
     215           24 :         let member_filter = match &filter {
     216           24 :             Some(f) => format!("{member}{f}"),
     217            0 :             None => member,
     218              :         };
     219              :         // value_or_filter numbers from `first + binds.len()` itself
     220           24 :         let members = q::value_or_filter("$", Some(&member_filter), "qi.data", first, binds);
     221           24 :         return Some((format!("({lang} OR {members})"), false));
     222          330 :     }
     223          324 :     if let Some((CmpOp::Ne, v)) = cmp {
     224           40 :         let f = q::eq_filter(v)?;
     225              :         // value_or_filter numbers as `first + binds.len()` itself — hand it
     226              :         // the leaf's base offset, not an already-advanced one
     227           40 :         let sql = q::value_or_filter("$", Some(&f), "qi.data", first, binds);
     228           40 :         return Some((format!("NOT {sql}"), false));
     229          290 :     }
     230          290 :     if let Some(l) = compile_instance_leaf(cmp, "qi.data", first + binds.len()) {
     231          214 :         binds.extend(l.binds);
     232              :         // existence leaves stay inexact: deletion-instance semantics differ
     233          214 :         return Some((l.sql, cmp.is_some()));
     234           76 :     }
     235           76 :     if let Some((op @ (CmpOp::Gt | CmpOp::Ge | CmpOp::Lt | CmpOp::Le), QValue::Str(sv))) = cmp {
     236           30 :         let o = match op {
     237           24 :             CmpOp::Gt => ">",
     238            6 :             CmpOp::Ge => ">=",
     239            0 :             CmpOp::Lt => "<",
     240            0 :             _ => "<=",
     241              :         };
     242           30 :         let mut parts = Vec::new();
     243          180 :         for key in [
     244           30 :             "value",
     245           30 :             "object",
     246           30 :             "vocab",
     247           30 :             "json",
     248           30 :             "valueList",
     249           30 :             "objectList",
     250          180 :         ] {
     251          180 :             let n = first + binds.len();
     252          180 :             binds.push(sv.clone());
     253          180 :             parts.push(format!(
     254          180 :                 "(jsonb_typeof(qi.data->'{key}') = 'string' \
     255          180 :                  AND (qi.data->>'{key}') COLLATE \"C\" {o} ${n}::text) \
     256          180 :                  OR jsonb_typeof(qi.data->'{key}') = 'array'"
     257          180 :             ));
     258          180 :         }
     259           30 :         return Some((format!("({})", parts.join(" OR ")), false));
     260           46 :     }
     261           46 :     None
     262          368 : }
     263              : 
     264              : #[cfg(test)]
     265              : mod tests {
     266              :     use super::*;
     267              :     use antares_ql::parse_q;
     268              : 
     269          114 :     fn ex(t: &str) -> String {
     270          114 :         format!("https://uri.etsi.org/ngsi-ld/default-context/{t}")
     271          114 :     }
     272              : 
     273           56 :     fn between() -> InstanceRange<'static> {
     274           56 :         InstanceRange {
     275           56 :             timerel: "between",
     276           56 :             time_at: "2026-03-01T00:00:00Z",
     277           56 :             end_time_at: Some("2026-03-02T00:00:00Z"),
     278           56 :             timeproperty: "observedAt",
     279           56 :         }
     280           56 :     }
     281              : 
     282           44 :     fn pf(q: &str) -> Option<CompiledSql> {
     283           44 :         let r = between();
     284           44 :         compile_prefilter(&parse_q(q).expect("parse"), Some(&r), "m", 3, &ex)
     285           44 :     }
     286              : 
     287              :     /// max `$n` referenced must equal first-1+binds.len(), all dense
     288           16 :     fn assert_dense(c: &CompiledSql, first: usize) {
     289          240 :         for n in first..first + c.binds.len() {
     290          240 :             assert!(c.sql.contains(&format!("${n}")), "missing ${n}: {}", c.sql);
     291              :         }
     292           16 :         assert!(
     293           16 :             !c.sql.contains(&format!("${}", first + c.binds.len())),
     294              :             "overshoot: {}",
     295              :             c.sql
     296              :         );
     297           16 :     }
     298              : 
     299              :     #[test]
     300            2 :     fn leaf_is_a_windowed_exists_with_the_iri_as_a_bind() {
     301            2 :         let c = pf("speed>25").expect("compiles");
     302            2 :         assert!(!c.sql.contains("speed"), "IRI must travel as a bind");
     303            2 :         assert_eq!(c.binds[0], ex("speed"));
     304            2 :         assert!(c.sql.contains("EXISTS (SELECT 1 FROM attr_instances qi"));
     305            2 :         assert!(c.sql.contains("qi.attr_id = $3"), "{}", c.sql);
     306              :         // byte-exact text window predicate INSIDE the existence test
     307              :         // (5.7.4.4 S2 — this is what makes a Cmp leaf EXACT), its binds
     308              :         // [timeproperty, timeAt, endTimeAt] at $4..$6…
     309            2 :         assert_eq!(c.binds[1], "observedAt");
     310            2 :         assert_eq!(c.binds[2], "2026-03-01T00:00:00Z");
     311            2 :         assert_eq!(c.binds[3], "2026-03-02T00:00:00Z");
     312              :         // …with the widened column bound REUSING the time binds
     313            2 :         assert!(
     314            2 :             c.sql
     315            2 :                 .contains("qi.observed_at >= $5::timestamptz - interval '48 hours'"),
     316              :             "{}",
     317              :             c.sql
     318              :         );
     319            2 :         assert!(
     320            2 :             c.sql
     321            2 :                 .contains("qi.observed_at < $6::timestamptz + interval '48 hours'"),
     322              :             "{}",
     323              :             c.sql
     324              :         );
     325              :         // the jsonpath leaf is rooted at the instance, not the entity doc
     326            2 :         assert!(c.binds[4].starts_with("$.\"value\""), "{}", c.binds[4]);
     327            2 :         assert_dense(&c, 3);
     328            2 :     }
     329              : 
     330              :     #[test]
     331            2 :     fn exactness_flags_the_pageable_subset() {
     332            2 :         let r = between();
     333           20 :         let e = |q: &str| prefilter_exact(&parse_q(q).expect("parse"), Some(&r), &ex);
     334              :         // every leaf a compiled Cmp with the text window → exact
     335            2 :         assert!(e("speed>25"));
     336            2 :         assert!(e("speed>=5;heading<90"));
     337            2 :         assert!(e("speed>25|heading>100"));
     338            2 :         assert!(e("speed==10..40"));
     339            2 :         assert!(e(r#"route=="550","551""#));
     340              :         // dropped conjunct / refused branch / existence / negation → inexact
     341            2 :         assert!(!e(r#"speed>25;name~="^x""#), "And drop widens");
     342            2 :         assert!(!e(r#"speed>25|name~="^x""#), "Or refusal is trivial");
     343            2 :         assert!(!e("speed"), "existence: deletion semantics differ");
     344            2 :         assert!(!e("!speed"));
     345            2 :         assert!(!e("speed!=10"));
     346              :         // no range at all still exact (nothing to window)
     347            2 :         assert!(prefilter_exact(
     348            2 :             &parse_q("speed>25").expect("parse"),
     349            2 :             None,
     350            2 :             &ex
     351              :         ));
     352            2 :     }
     353              : 
     354              :     #[test]
     355            2 :     fn and_drops_an_uncompilable_member_or_keeps_both() {
     356              :         // pattern leaf is outside the exact subset → dropped, one EXISTS left
     357            2 :         let c = pf(r#"speed>25;name~="^x""#).expect("compiles");
     358            2 :         assert_eq!(c.sql.matches("EXISTS").count(), 1, "{}", c.sql);
     359              :         // two compilable members: both EXISTS, AND'd, dense numbering
     360            2 :         let c = pf("speed>25;heading<90").expect("compiles");
     361            2 :         assert_eq!(c.sql.matches("EXISTS").count(), 2);
     362            2 :         assert!(c.sql.contains(" AND "), "{}", c.sql);
     363            2 :         assert_dense(&c, 3);
     364            2 :     }
     365              : 
     366              :     #[test]
     367            2 :     fn or_with_an_uncompilable_branch_is_trivial() {
     368              :         // a TRUE branch absorbs the OR — no prefilter at all
     369            2 :         assert!(pf(r#"speed>25|name~="^x""#).is_none());
     370              :         // both branches compile → OR of EXISTS
     371            2 :         let c = pf("speed>25|heading>100").expect("compiles");
     372            2 :         assert!(c.sql.contains(" OR "), "{}", c.sql);
     373            2 :         assert_eq!(c.sql.matches("EXISTS").count(), 2);
     374            2 :     }
     375              : 
     376              :     #[test]
     377            2 :     fn shapes_outside_the_exact_subset_are_trivial_not_wrong() {
     378            6 :         for q in [
     379            2 :             "!speed",        // negated existence: not superset-safe per-row
     380            2 :             r#"name~="^x""#, // regex dialect mismatch
     381            2 :             "a.b==1",        // dotted path ambiguity
     382            2 :         ] {
     383            6 :             assert!(pf(q).is_none(), "{q} must be trivial");
     384              :         }
     385            2 :     }
     386              : 
     387              :     #[test]
     388            2 :     fn extension_leaves_compile_superset_only() {
     389              :         // != — NOT of the existential-equality member-OR
     390            2 :         let c = pf("speed!=10").expect("compiles");
     391            2 :         assert!(c.sql.contains("AND NOT ("), "{}", c.sql);
     392            2 :         assert!(pf(r#"speed!="10",30"#).is_some(), "Ne+List");
     393            2 :         assert!(pf(r#"name!~="^x""#).is_none(), "!~= stays a regex refusal");
     394              :         // [lang]/[*] — the languageMap wildcard
     395            2 :         let c = pf(r#"label[en]=="hi""#).expect("compiles");
     396            2 :         assert!(
     397           10 :             c.binds.iter().any(|b| b.starts_with("$.\"languageMap\".*")),
     398              :             "{:?}",
     399              :             c.binds
     400              :         );
     401            2 :         assert!(pf(r#"label[*]=="hi""#).is_some());
     402              :         // string ordering — COLLATE "C" scalar compare + array pass-through
     403            2 :         let c = pf(r#"name>"m""#).expect("compiles");
     404            2 :         assert!(c.sql.contains("COLLATE \"C\" >"), "{}", c.sql);
     405            2 :         assert!(c.sql.contains("= 'array'"), "{}", c.sql);
     406              :         // all three are superset-only: never page-exact
     407            2 :         let r = between();
     408            6 :         for q in [r#"name>"m""#, "speed!=10", r#"label[en]=="hi""#] {
     409            6 :             assert!(
     410            6 :                 !prefilter_exact(&parse_q(q).expect("parse"), Some(&r), &ex),
     411              :                 "{q} must stay inexact"
     412              :             );
     413              :         }
     414            2 :     }
     415              : 
     416              :     /// 4.9 `ValuePath = DottedPath *1([DottedPath])`: a NAMED trailing
     417              :     /// bracket is either a languageMap tag or a member of a compound Property
     418              :     /// value (EXAMPLE 9/10/11), and only the document decides which. Matching
     419              :     /// the languageMap alone would narrow every compound-value match away —
     420              :     /// the prefilter has to admit both readings.
     421              :     #[test]
     422            2 :     fn a_named_bracket_admits_the_compound_value_member_too() {
     423            2 :         let c = pf(r#"brandName[brand]=="MB""#).expect("compiles");
     424            2 :         assert!(
     425           10 :             c.binds.iter().any(|b| b.starts_with("$.\"languageMap\".*")),
     426              :             "the languageMap reading is gone: {:?}",
     427              :             c.binds
     428              :         );
     429            2 :         assert!(
     430            2 :             c.binds
     431            2 :                 .iter()
     432           12 :                 .any(|b| b.starts_with("$.\"value\".\"brand\"")),
     433              :             "the compound-member reading is missing — matching entities are \
     434              :              narrowed away: {:?}",
     435              :             c.binds
     436              :         );
     437            2 :         assert_dense(&c, 3);
     438              :         // `[*]` has no member reading in the grammar: it stays the pure
     439              :         // languageMap wildcard, with no compound-value alternative bolted on.
     440            2 :         let star = pf(r#"label[*]=="hi""#).expect("compiles");
     441            2 :         assert!(
     442           10 :             !star.binds.iter().any(|b| b.contains("\"*\"")),
     443              :             "the wildcard was read as a member name: {:?}",
     444              :             star.binds
     445              :         );
     446            2 :         assert_dense(&star, 3);
     447            2 :     }
     448              : 
     449              :     /// Superset arithmetic one level down. A disjunction that refuses is a
     450              :     /// refused CONJUNCT, not a refused statement: dropping `(a|b)` from
     451              :     /// `(a|b);c` leaves `c`, which still admits every entity the evaluator
     452              :     /// would keep.
     453              :     #[test]
     454            2 :     fn a_refused_disjunction_drops_only_its_own_conjunct() {
     455            2 :         let c = pf(r#"(speed>25|name~="^x");heading<90"#).expect("compiles");
     456            2 :         assert_eq!(c.sql.matches("EXISTS").count(), 1, "{}", c.sql);
     457            2 :         assert_eq!(c.binds[0], ex("heading"), "the surviving conjunct");
     458            2 :         assert_dense(&c, 3);
     459            2 :         let r = between();
     460            2 :         assert!(!prefilter_exact(
     461            2 :             &parse_q(r#"(speed>25|name~="^x");heading<90"#).expect("parse"),
     462            2 :             Some(&r),
     463            2 :             &ex
     464            2 :         ));
     465              :         // nothing left to keep → no prefilter at all, never an empty predicate
     466            2 :         assert!(pf(r#"name~="^x";label!~="^y""#).is_none());
     467            2 :     }
     468              : 
     469              :     /// A conjunction that dropped a member is still a legal OR branch: it
     470              :     /// admits MORE than the branch it stands for, and a union of supersets is
     471              :     /// a superset. It must never be reported exact.
     472              :     #[test]
     473            2 :     fn a_widened_conjunction_inside_a_disjunction_still_widens() {
     474            2 :         let c = pf(r#"(speed>25;name~="^x")|heading>100"#).expect("compiles");
     475            2 :         assert_eq!(c.sql.matches("EXISTS").count(), 2, "{}", c.sql);
     476            2 :         assert!(c.sql.contains(" OR "), "{}", c.sql);
     477            2 :         assert_eq!(c.binds[0], ex("speed"));
     478            2 :         assert_dense(&c, 3);
     479            2 :         let r = between();
     480            2 :         assert!(!prefilter_exact(
     481            2 :             &parse_q(r#"(speed>25;name~="^x")|heading>100"#).expect("parse"),
     482            2 :             Some(&r),
     483            2 :             &ex
     484            2 :         ));
     485            2 :     }
     486              : 
     487              :     /// The attribute IRI, the stamps and every compared value are binds; the
     488              :     /// statement carries this module's own text and `$n` only.
     489              :     #[test]
     490            2 :     fn client_text_never_reaches_the_statement() {
     491            2 :         let node = QNode::Cmp {
     492            2 :             path: QPath::dotted(vec!["a' OR 1=1 --".to_owned()]),
     493            2 :             op: CmpOp::Eq,
     494            2 :             value: QValue::Str("'; DROP TABLE attr_instances; --".to_owned()),
     495            2 :         };
     496            2 :         let c = compile_prefilter(&node, Some(&between()), "m", 1, &|t| t.to_owned())
     497            2 :             .expect("compiles");
     498            8 :         for needle in ["DROP", "TABLE", "--", "OR 1=1"] {
     499            8 :             assert!(!c.sql.contains(needle), "{needle:?} leaked: {}", c.sql);
     500              :         }
     501            2 :         assert_eq!(c.binds[0], "a' OR 1=1 --");
     502            2 :         assert!(c.binds.last().expect("jsonpath").contains("DROP"));
     503            2 :         assert_dense(&c, 1);
     504            2 :     }
     505              : 
     506              :     #[test]
     507            2 :     fn existence_list_and_range_leaves_compile() {
     508            2 :         let c = pf("speed").expect("existence compiles");
     509            2 :         assert!(c.sql.contains("EXISTS"), "{}", c.sql);
     510            2 :         assert!(pf(r#"route=="550","551""#).is_some(), "Eq+List");
     511            2 :         assert!(pf("speed==10..40").is_some(), "Eq+Range");
     512            2 :     }
     513              : 
     514              :     #[test]
     515            2 :     fn window_is_omitted_when_no_range_or_no_column() {
     516            2 :         let ast = parse_q("speed>25").expect("parse");
     517            2 :         let c = compile_prefilter(&ast, None, "m", 1, &ex).expect("compiles");
     518            2 :         assert!(!c.sql.contains("observed_at"), "{}", c.sql);
     519            2 :         assert_dense(&c, 1);
     520              :         // deletedAt now HAS a column bound — NULL-tolerant, because
     521              :         // `deleted_at` is the one nullable column of the four
     522            2 :         let r = InstanceRange {
     523            2 :             timeproperty: "deletedAt",
     524            2 :             ..between()
     525            2 :         };
     526            2 :         let c = compile_prefilter(&ast, Some(&r), "m", 1, &ex).expect("compiles");
     527            2 :         assert!(
     528            2 :             c.sql.contains("qi.deleted_at IS NULL OR"),
     529              :             "old rows must pass to the text predicate: {}",
     530              :             c.sql
     531              :         );
     532            2 :     }
     533              : }
        

Generated by: LCOV version 2.0-1