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