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