LCOV - code coverage report
Current view: top level - antares-api/src - temporalq.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 97.7 % 131 128
Test Date: 2026-09-21 10:31:06 Functions: 75.8 % 33 25

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! The temporal query of a request: `TemporalQ` parsed from the
       3              : //! timerel/timeAt/endTimeAt/timeproperty parameters (5.2.21, Table
       4              : //! 5.2.21-1) and the instance match it defines (4.11).
       5              : 
       6              : use antares_jsonld::parse_datetime;
       7              : use antares_model::{dt_key, NgsiError};
       8              : use serde_json::Value;
       9              : use std::collections::HashMap;
      10              : 
      11              : #[derive(Clone)]
      12              : pub struct TemporalQ {
      13              :     pub timerel: String,
      14              :     pub time_at: String,
      15              :     pub end_time_at: Option<String>,
      16              :     pub timeproperty: String,
      17              : }
      18              : 
      19              : impl TemporalQ {
      20              :     /// The 4.11 Temporal Query from its request parameters: `timerel` decides
      21              :     /// which of `timeAt`/`endTimeAt` are required, and `required` says
      22              :     /// whether the operation demands one at all (5.7.4 does, 5.7.3 does not).
      23              :     /// `GeoQuery::from_params` is the same convention for a different
      24              :     /// parameter family, not the same parser.
      25         1714 :     pub fn from_params(
      26         1714 :         params: &HashMap<String, String>,
      27         1714 :         required: bool,
      28         1714 :     ) -> Result<Option<Self>, NgsiError> {
      29         1714 :         let bad = NgsiError::BadRequestData;
      30         1714 :         let Some(timerel) = params.get("timerel") else {
      31          344 :             if required {
      32            4 :                 return Err(bad("temporal query requires timerel (5.7.4)".into()));
      33          340 :             }
      34          340 :             if params.contains_key("timeAt") || params.contains_key("endTimeAt") {
      35            4 :                 return Err(bad("timeAt given without timerel".into()));
      36          336 :             }
      37              :             // bare timeproperty: representation keyed on it; instances that
      38              :             // lack it are excluded (retrieval-by-deletedAt, 020_17/18)
      39          336 :             if let Some(tp) = params.get("timeproperty") {
      40            8 :                 if !["observedAt", "createdAt", "modifiedAt", "deletedAt"].contains(&tp.as_str()) {
      41            0 :                     return Err(bad(format!("invalid timeproperty {tp:?}")));
      42            8 :                 }
      43            8 :                 return Ok(Some(Self {
      44            8 :                     timerel: "any".into(),
      45            8 :                     time_at: String::new(),
      46            8 :                     end_time_at: None,
      47            8 :                     timeproperty: tp.clone(),
      48            8 :                 }));
      49          328 :             }
      50          328 :             return Ok(None);
      51              :         };
      52         1370 :         if !["before", "after", "between"].contains(&timerel.as_str()) {
      53           16 :             return Err(bad(format!("invalid timerel {timerel:?}")));
      54         1354 :         }
      55         1354 :         let time_at = params
      56         1354 :             .get("timeAt")
      57         1354 :             .filter(|s| parse_datetime(s))
      58         1354 :             .ok_or_else(|| bad("timeAt must be a valid ISO 8601 DateTime (4.11)".into()))?
      59         1334 :             .clone();
      60         1334 :         let end_time_at = match params.get("endTimeAt") {
      61          592 :             Some(s) if parse_datetime(s) => Some(s.clone()),
      62            0 :             Some(_) => return Err(bad("endTimeAt must be a valid ISO 8601 DateTime".into())),
      63          742 :             None => None,
      64              :         };
      65         1334 :         if timerel == "between" && end_time_at.is_none() {
      66           12 :             return Err(bad("timerel=between requires endTimeAt (4.11)".into()));
      67         1322 :         }
      68         1322 :         let timeproperty = params
      69         1322 :             .get("timeproperty")
      70         1322 :             .cloned()
      71         1322 :             .unwrap_or_else(|| "observedAt".into());
      72         1322 :         if !["observedAt", "createdAt", "modifiedAt", "deletedAt"].contains(&timeproperty.as_str())
      73              :         {
      74            4 :             return Err(bad(format!("invalid timeproperty {timeproperty:?}")));
      75         1318 :         }
      76         1318 :         Ok(Some(Self {
      77         1318 :             timerel: timerel.clone(),
      78         1318 :             time_at,
      79         1318 :             end_time_at,
      80         1318 :             timeproperty,
      81         1318 :         }))
      82         1714 :     }
      83              : 
      84         3188 :     pub(crate) fn instance_matches(&self, inst: &Value) -> bool {
      85         3188 :         let Some(t) = inst.get(&self.timeproperty).and_then(Value::as_str) else {
      86           30 :             return false;
      87              :         };
      88              :         // 4.11: before = exclusive bound, after = inclusive bound, between =
      89              :         // inclusive lower / exclusive upper. Compared on the canonical key so
      90              :         // equal instants written with different 4.6.3 fraction forms
      91              :         // ("…00Z" / "…00.000Z" / "…00,5Z") hit the bounds exactly.
      92         3158 :         let t = dt_key(t);
      93         3158 :         match self.timerel.as_str() {
      94         3158 :             "any" => true, // bare timeproperty: presence is the filter
      95         3154 :             "before" => t < dt_key(&self.time_at),
      96         3096 :             "after" => t >= dt_key(&self.time_at),
      97         2476 :             "between" => {
      98         2476 :                 t >= dt_key(&self.time_at)
      99         2234 :                     && self.end_time_at.as_deref().is_some_and(|e| t < dt_key(e))
     100              :             }
     101            0 :             _ => false,
     102              :         }
     103         3188 :     }
     104              : }
     105              : 
     106              : #[cfg(test)]
     107              : mod tests {
     108              :     use super::*;
     109              :     use serde_json::json;
     110              : 
     111           20 :     fn tq(timerel: &str, time_at: &str, end: Option<&str>) -> TemporalQ {
     112           20 :         let mut p = HashMap::new();
     113           20 :         p.insert("timerel".to_owned(), timerel.to_owned());
     114           20 :         p.insert("timeAt".to_owned(), time_at.to_owned());
     115           20 :         if let Some(e) = end {
     116            4 :             p.insert("endTimeAt".to_owned(), e.to_owned());
     117           16 :         }
     118           20 :         TemporalQ::from_params(&p, true).unwrap().unwrap()
     119           20 :     }
     120              : 
     121           48 :     fn inst(observed_at: &str) -> Value {
     122           48 :         json!({"observedAt": observed_at, "value": 1})
     123           48 :     }
     124              : 
     125              :     /// 4.11 after: "The specified value is used as an INCLUSIVE bound" — an
     126              :     /// instance at exactly timeAt matches, regardless of the equal instant
     127              :     /// being written with or without a seconds fraction (4.6.3 allows both).
     128              :     #[test]
     129            4 :     fn after_is_inclusive_across_fraction_forms() {
     130            4 :         let q = tq("after", "2017-12-13T14:20:00Z", None);
     131            4 :         assert!(q.instance_matches(&inst("2017-12-13T14:20:00Z")));
     132            4 :         assert!(
     133            4 :             q.instance_matches(&inst("2017-12-13T14:20:00.000Z")),
     134              :             "same instant with a fraction must be included"
     135              :         );
     136            4 :         assert!(!q.instance_matches(&inst("2017-12-13T14:19:59.999999Z")));
     137            4 :     }
     138              : 
     139              :     /// 4.11 before: "The specified value is used as an EXCLUSIVE bound" — an
     140              :     /// instance at exactly timeAt does not match, in any equal spelling.
     141              :     #[test]
     142            4 :     fn before_is_exclusive_across_fraction_forms() {
     143            4 :         let q = tq("before", "2017-12-13T14:20:00Z", None);
     144            4 :         assert!(!q.instance_matches(&inst("2017-12-13T14:20:00Z")));
     145            4 :         assert!(
     146            4 :             !q.instance_matches(&inst("2017-12-13T14:20:00.000Z")),
     147              :             "same instant with a fraction must stay excluded"
     148              :         );
     149            4 :         assert!(q.instance_matches(&inst("2017-12-13T14:19:59.999999Z")));
     150            4 :     }
     151              : 
     152              :     /// 4.11 between: "the lower bound of the range is inclusive and ... the
     153              :     /// upper bound of the range is exclusive."
     154              :     #[test]
     155            4 :     fn between_bounds_inclusive_lower_exclusive_upper() {
     156            4 :         let q = tq(
     157            4 :             "between",
     158            4 :             "2017-12-13T14:20:00Z",
     159            4 :             Some("2017-12-13T14:40:00Z"),
     160              :         );
     161            4 :         assert!(
     162            4 :             q.instance_matches(&inst("2017-12-13T14:20:00.000Z")),
     163              :             "lower incl"
     164              :         );
     165            4 :         assert!(q.instance_matches(&inst("2017-12-13T14:30:00Z")));
     166            4 :         assert!(
     167            4 :             !q.instance_matches(&inst("2017-12-13T14:40:00.000Z")),
     168              :             "upper excl in any spelling"
     169              :         );
     170            4 :         assert!(!q.instance_matches(&inst("2017-12-13T14:19:59Z")));
     171            4 :     }
     172              : 
     173              :     /// 4.6.3: "a comma instead of a decimal point may be used" in requests —
     174              :     /// the comma form must compare as the same instant.
     175              :     #[test]
     176            4 :     fn comma_fraction_compares_as_the_same_instant() {
     177            4 :         let q = tq("after", "2017-12-13T14:20:00,500000Z", None);
     178            4 :         assert!(q.instance_matches(&inst("2017-12-13T14:20:00.5Z")));
     179            4 :         assert!(!q.instance_matches(&inst("2017-12-13T14:20:00.499999Z")));
     180            4 :     }
     181              : 
     182              :     /// 4.11: "Entities which do not convey the target Temporal Property of
     183              :     /// the query shall be considered as non-matching" + timeproperty
     184              :     /// defaults to observedAt.
     185              :     #[test]
     186            4 :     fn missing_timeproperty_is_a_nonmatch_and_default_is_observed_at() {
     187            4 :         let q = tq("after", "1970-01-01T00:00:00Z", None);
     188            4 :         assert_eq!(q.timeproperty, "observedAt");
     189            4 :         assert!(!q.instance_matches(&json!({"modifiedAt": "2020-01-01T00:00:00Z"})));
     190            4 :     }
     191              : 
     192              :     /// 4.11 grammar: only before/after/between; timeAt mandatory and a
     193              :     /// DateTime; between requires endTimeAt.
     194              :     #[test]
     195            4 :     fn grammar_rejections() {
     196           20 :         let mk = |pairs: &[(&str, &str)]| {
     197           20 :             let mut p = HashMap::new();
     198           32 :             for (k, v) in pairs {
     199           32 :                 p.insert((*k).to_owned(), (*v).to_owned());
     200           32 :             }
     201           20 :             TemporalQ::from_params(&p, false)
     202           20 :         };
     203            4 :         assert!(mk(&[("timerel", "during"), ("timeAt", "2020-01-01T00:00:00Z")]).is_err());
     204            4 :         assert!(mk(&[("timerel", "before")]).is_err(), "timeAt mandatory");
     205            4 :         assert!(
     206            4 :             mk(&[("timerel", "before"), ("timeAt", "2020-01-01")]).is_err(),
     207              :             "Date is not a DateTime"
     208              :         );
     209            4 :         assert!(
     210            4 :             mk(&[("timerel", "between"), ("timeAt", "2020-01-01T00:00:00Z")]).is_err(),
     211              :             "between requires endTimeAt"
     212              :         );
     213            4 :         assert!(
     214            4 :             mk(&[("timeAt", "2020-01-01T00:00:00Z")]).is_err(),
     215              :             "timeAt without timerel"
     216              :         );
     217            4 :     }
     218              : }
        

Generated by: LCOV version 2.0-1