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

Generated by: LCOV version 2.0-1