Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! Temporal Query Language (CIM 009 clause 4.11) compiled to a
3 : //! per-instance SQL predicate over a jsonb instance object.
4 : //!
5 : //! Exactness by construction: `TemporalQ::instance_matches` (antares-api)
6 : //! compares CANONICAL keys — the trailing `Z` dropped and the 4.6.3 seconds
7 : //! fraction (`.` or `,`) zero-padded — so that equal instants written in
8 : //! different fraction forms hit the bounds exactly. The SQL builds the same
9 : //! key with `dt_key_sql` and compares it with `COLLATE "C"` (byte order,
10 : //! locale-proof) instead of casting to timestamptz (which would re-order
11 : //! mixed-offset forms and can raise on malformed values). The member must be
12 : //! string-typed, exactly as `Value::as_str` demands.
13 : //!
14 : //! The store PRUNES on this predicate, so it may never be stricter than the
15 : //! arbiter: a raw byte compare made `…00.000Z` sort after `…00Z` ('.' is
16 : //! 0x2E, 'Z' is 0x5A) and silently dropped instances 4.11 requires to be
17 : //! returned.
18 :
19 : /// The 4.11 window, as the API layer parsed it. `timerel` ∈
20 : /// before|after|between|any ("any" = bare timeproperty: presence filter).
21 : pub use antares_store::filter::InstanceRange;
22 :
23 : // The compiled range is the shared fragment shape; its bind 0 is always the
24 : // timeproperty name.
25 : use antares_ql::sql::CompiledSql;
26 :
27 : /// 4.6.3 DateTime → canonical lexicographic key, the SQL twin of the
28 : /// arbiter's `dt_key`: for a `Z`-terminated stamp of at least 19 characters,
29 : /// the `Z` is dropped and the optional seconds fraction (`.` or `,`
30 : /// separator) is zero-padded to six digits; anything else is compared as it
31 : /// stands. String order over the key is temporal order across spellings, so
32 : /// `…00Z`, `…00.000Z` and `…00,0Z` are one instant on both sides.
33 : ///
34 : /// Total by construction — no cast, so a malformed stored stamp can never
35 : /// raise; the nested `CASE` is only reached for stamps long enough to slice.
36 2488 : pub fn dt_key_sql(e: &str) -> String {
37 : // the arbiter takes the fraction only after a '.'/',' and otherwise
38 : // treats it as absent — junk between the seconds and the 'Z' is dropped
39 2488 : let frac = format!(
40 : "(CASE WHEN substr({e},20,1) IN ('.', ',') \
41 : THEN substr({e},21,length({e})-21) ELSE '' END)"
42 : );
43 : // zero-pad to six WITHOUT truncating: rpad would shorten a nanosecond
44 : // fraction the arbiter keeps in full, which turns a near-tie into a tie
45 2488 : format!(
46 : "(CASE WHEN right({e},1) = 'Z' AND length({e}) >= 20 \
47 : THEN substr({e},1,19) || '.' || {frac} || repeat('0', greatest(0, 6 - length{frac})) \
48 : ELSE {e} END) COLLATE \"C\""
49 : )
50 2488 : }
51 :
52 : /// `None` = a shape this compiler does not reproduce (unknown timerel, or
53 : /// between without an end) — the caller prunes nothing and the in-memory
54 : /// window stays the arbiter.
55 884 : pub fn compile_instance_range(
56 884 : r: &InstanceRange<'_>,
57 884 : el: &str,
58 884 : first_bind: usize,
59 884 : ) -> Option<CompiledSql> {
60 884 : let tp = format!("${first_bind}");
61 884 : let present = format!("jsonb_typeof({el} -> {tp}) = 'string'");
62 884 : let ts = dt_key_sql(&format!("({el} ->> {tp})"));
63 : // the bound is keyed too — keying one side only is what made the
64 : // pushdown stricter than the arbiter
65 1598 : let at = |n: usize| dt_key_sql(&format!("${n}::text"));
66 884 : let mut binds = vec![r.timeproperty.to_owned()];
67 884 : let sql = match r.timerel {
68 884 : "any" => present,
69 882 : "before" => {
70 52 : binds.push(antares_store::filter::canonical_datetime(r.time_at).into_owned());
71 52 : format!("{present} AND {ts} < {}", at(first_bind + 1))
72 : }
73 830 : "after" => {
74 102 : binds.push(antares_store::filter::canonical_datetime(r.time_at).into_owned());
75 102 : format!("{present} AND {ts} >= {}", at(first_bind + 1))
76 : }
77 728 : "between" => {
78 724 : let end = r.end_time_at?;
79 722 : binds.push(antares_store::filter::canonical_datetime(r.time_at).into_owned());
80 722 : binds.push(antares_store::filter::canonical_datetime(end).into_owned());
81 722 : format!(
82 : "{present} AND {ts} >= {} AND {ts} < {}",
83 722 : at(first_bind + 1),
84 722 : at(first_bind + 2)
85 : )
86 : }
87 4 : _ => return None,
88 : };
89 878 : Some(CompiledSql { sql, binds })
90 884 : }
91 :
92 : /// Widened, index-serving COLUMN bound for the 4.11 window. Returns SQL only
93 : /// — it references `$time_bind` (and `$time_bind+1` for between), the SAME
94 : /// binds the byte-exact text predicate uses, cast to timestamptz in place.
95 : ///
96 : /// A SUPERSET by construction: the parsed column and the raw stamp diverge by
97 : /// at most the two RFC 3339 offsets (±14 h each), so 48 h of slack admits
98 : /// every row the text window keeps; the extra rows it admits are dropped by
99 : /// the text predicate (or the API arbiter) right after. Purpose: the btree on
100 : /// (tenant_id, entity_id, attr_id, observed_at) can serve this range — the
101 : /// jsonb text extraction the exact predicate runs on never uses an index.
102 : /// Only timeproperties with a parsed column compile; others prune by text
103 : /// alone (`None`).
104 763 : pub fn column_range_bound(r: &InstanceRange<'_>, alias: &str, time_bind: usize) -> Option<String> {
105 : // `deleted_at` is the one nullable column of the four (0001_init.sql):
106 : // its bound must let NULL rows through to the text predicate, which
107 : // decides membership either way. `observed_at`, `created_at` and
108 : // `modified_at` are NOT NULL, so their bounds stay bare.
109 763 : let (col, nullable) = match r.timeproperty {
110 763 : "observedAt" => ("observed_at", false),
111 58 : "createdAt" => ("created_at", false),
112 6 : "modifiedAt" => ("modified_at", false),
113 6 : "deletedAt" => ("deleted_at", true),
114 2 : _ => return None,
115 : };
116 761 : let bound = match r.timerel {
117 761 : "before" => format!("{alias}.{col} < ${time_bind}::timestamptz + interval '48 hours'"),
118 710 : "after" => format!("{alias}.{col} >= ${time_bind}::timestamptz - interval '48 hours'"),
119 615 : "between" => {
120 611 : r.end_time_at?;
121 609 : format!(
122 : "{alias}.{col} >= ${time_bind}::timestamptz - interval '48 hours' \
123 : AND {alias}.{col} < ${}::timestamptz + interval '48 hours'",
124 609 : time_bind + 1
125 : )
126 : }
127 4 : _ => return None,
128 : };
129 755 : Some(if nullable {
130 4 : format!("({alias}.{col} IS NULL OR ({bound}))")
131 : } else {
132 751 : bound
133 : })
134 763 : }
135 :
136 : #[cfg(test)]
137 : mod tests {
138 : use super::*;
139 :
140 32 : fn range<'a>(rel: &'a str, at: &'a str, end: Option<&'a str>) -> InstanceRange<'a> {
141 32 : InstanceRange {
142 32 : timerel: rel,
143 32 : time_at: at,
144 32 : end_time_at: end,
145 32 : timeproperty: "observedAt",
146 32 : }
147 32 : }
148 :
149 : #[test]
150 2 : fn operators_mirror_instance_matches() {
151 : // before: strict < after: >= between: [at, end)
152 2 : let c = compile_instance_range(&range("before", "2026-01-01T00:00:00Z", None), "el", 4)
153 2 : .expect("compiles");
154 2 : assert!(c.sql.contains(" < (CASE"), "sql: {}", c.sql);
155 2 : assert!(c.sql.contains("$5::text"), "sql: {}", c.sql);
156 2 : assert_eq!(c.binds, vec!["observedAt", "2026-01-01T00:00:00Z"]);
157 :
158 2 : let c = compile_instance_range(&range("after", "t0", None), "el", 1).expect("compiles");
159 2 : assert!(c.sql.contains(" >= (CASE"), "sql: {}", c.sql);
160 2 : assert!(c.sql.contains("$2::text"), "sql: {}", c.sql);
161 :
162 2 : let c = compile_instance_range(&range("between", "t0", Some("t1")), "el", 1).expect("c");
163 2 : assert!(
164 2 : c.sql.contains(" >= (CASE") && c.sql.contains(" < (CASE"),
165 : "{}",
166 : c.sql
167 : );
168 2 : assert!(c.sql.contains("$2::text") && c.sql.contains("$3::text"));
169 2 : assert_eq!(c.binds.len(), 3);
170 2 : }
171 :
172 : #[test]
173 2 : fn byte_order_and_string_type_guard() {
174 2 : let c = compile_instance_range(&range("any", "", None), "el", 1).expect("compiles");
175 : // presence = string-typed member, exactly Value::as_str
176 2 : assert_eq!(c.sql, "jsonb_typeof(el -> $1) = 'string'");
177 : // ranged forms compare bytes, never timestamptz casts
178 2 : let c = compile_instance_range(&range("after", "t0", None), "el", 1).expect("c");
179 2 : assert!(c.sql.contains("COLLATE \"C\""), "{}", c.sql);
180 2 : assert!(!c.sql.contains("timestamptz"), "{}", c.sql);
181 2 : }
182 :
183 : /// 4.11 bounds are inclusive/exclusive on the INSTANT, and 4.6.3 spells
184 : /// one instant several ways (`…00Z`, `…00.000Z`, `…00,0Z`). The store
185 : /// prunes on this predicate, so it must key BOTH operands the way the
186 : /// arbiter's `dt_key` does — keying only the stored stamp made
187 : /// `"…00.000Z" >= "…00Z"` false in bytes and dropped an instance that
188 : /// `?timerel=after&timeAt=…00Z` must return.
189 : #[test]
190 2 : fn both_operands_are_canonically_keyed() {
191 2 : let c = compile_instance_range(&range("after", "2017-12-13T14:20:00Z", None), "el", 1)
192 2 : .expect("compiles");
193 : // one key expression per operand, and the raw jsonb text is never
194 : // compared directly against the bind
195 2 : assert_eq!(c.sql.matches("repeat('0', greatest(0, 6 -").count(), 2);
196 2 : assert!(
197 2 : !c.sql.contains("(el ->> $1) COLLATE \"C\" >= $2"),
198 : "raw byte compare survived: {}",
199 : c.sql
200 : );
201 : // the fraction separator the arbiter accepts is accepted here too
202 2 : let key = dt_key_sql("x");
203 2 : assert!(key.contains("IN ('.', ',')"), "{key}");
204 : // no cast: a malformed stored stamp must not be able to raise
205 2 : assert!(!key.contains("::timestamp"), "{key}");
206 2 : }
207 :
208 : #[test]
209 2 : fn unknown_shapes_refuse() {
210 2 : assert!(compile_instance_range(&range("since", "t0", None), "el", 1).is_none());
211 2 : assert!(compile_instance_range(&range("between", "t0", None), "el", 1).is_none());
212 2 : }
213 :
214 : /// `timeproperty` and the stamps are client strings: the first is a bind,
215 : /// the stamps are binds, and the only identifiers in the statement are the
216 : /// caller's own alias and this module's fixed column table.
217 : #[test]
218 2 : fn client_text_never_reaches_the_statement() {
219 2 : let hostile = "observedAt' OR 1=1 --";
220 2 : let r = InstanceRange {
221 2 : timerel: "between",
222 2 : time_at: "2026-01-01T00:00:00Z'; DROP TABLE attr_instances; --",
223 2 : end_time_at: Some("2026-02-01T00:00:00Z"),
224 2 : timeproperty: hostile,
225 2 : };
226 2 : let c = compile_instance_range(&r, "el", 1).expect("compiles");
227 10 : for needle in ["observedAt", "DROP", "TABLE", "--", "OR 1=1"] {
228 10 : assert!(!c.sql.contains(needle), "{needle:?} leaked: {}", c.sql);
229 : }
230 2 : assert!(c
231 2 : .sql
232 2 : .starts_with("jsonb_typeof(el -> $1) = 'string' AND (CASE"));
233 : // every identifier in the statement is this module's own, and the
234 : // only slots are $1..$3
235 6 : for n in ["$1", "$2", "$3"] {
236 6 : assert!(c.sql.contains(n), "{n} missing: {}", c.sql);
237 : }
238 2 : assert!(!c.sql.contains("$4"), "overshoot: {}", c.sql);
239 2 : assert_eq!(c.binds[0], hostile);
240 : // an unknown timeproperty has no column, so no identifier is ever
241 : // derived from client text
242 2 : assert!(column_range_bound(&r, "ai", 1).is_none());
243 2 : }
244 :
245 : #[test]
246 2 : fn column_bound_reuses_binds_and_widens_outward() {
247 : // after: lower bound moves DOWN, before: upper bound moves UP —
248 : // widening must always ADMIT more than the text window, never less
249 2 : let s = column_range_bound(&range("after", "t0", None), "ai", 5).expect("bound");
250 2 : assert_eq!(s, "ai.observed_at >= $5::timestamptz - interval '48 hours'");
251 2 : let s = column_range_bound(&range("before", "t0", None), "ai", 2).expect("bound");
252 2 : assert!(s.contains("< $2::timestamptz + interval '48 hours'"), "{s}");
253 2 : let s = column_range_bound(&range("between", "t0", Some("t1")), "ai", 2).expect("bound");
254 2 : assert!(
255 2 : s.contains(">= $2::timestamptz - interval")
256 2 : && s.contains("< $3::timestamptz + interval"),
257 : "{s}"
258 : );
259 2 : }
260 :
261 : #[test]
262 2 : fn column_bound_only_for_parsed_columns_and_known_relations() {
263 2 : let created = InstanceRange {
264 2 : timeproperty: "createdAt",
265 2 : ..range("after", "t0", None)
266 2 : };
267 2 : assert!(column_range_bound(&created, "ai", 1)
268 2 : .expect("bound")
269 2 : .contains("ai.created_at"));
270 : // deleted_at is nullable and historically unfilled — its bound must
271 : // carry the IS NULL escape so old rows reach the text predicate
272 2 : let deleted = InstanceRange {
273 2 : timeproperty: "deletedAt",
274 2 : ..range("after", "t0", None)
275 2 : };
276 2 : let s = column_range_bound(&deleted, "ai", 1).expect("bound");
277 2 : assert!(s.starts_with("(ai.deleted_at IS NULL OR ("), "{s}");
278 2 : assert!(column_range_bound(&range("any", "", None), "ai", 1).is_none());
279 2 : assert!(column_range_bound(&range("since", "t0", None), "ai", 1).is_none());
280 2 : assert!(column_range_bound(&range("between", "t0", None), "ai", 1).is_none());
281 2 : }
282 : }
|