LCOV - code coverage report
Current view: top level - antares-sql/src/compile - temporal.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 143 143
Test Date: 2026-09-21 10:31:06 Functions: 100.0 % 20 20

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

Generated by: LCOV version 2.0-1