LCOV - code coverage report
Current view: top level - antares-store/src - filter.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 285 285
Test Date: 2026-09-21 10:31:06 Functions: 92.0 % 50 46

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Query-filter shapes shared by every backend (the pushdown contract),
       3              : //! plus the pure pieces of the geo and temporal query shapes the filters
       4              : //! reference. Pure data — no SQL, no I/O; the memory arm consumes these
       5              : //! filters too, it just never gets a `decided` outcome.
       6              : 
       7              : use serde_json::Value;
       8              : 
       9              : pub use antares_ql::geo::{GeoSpec, Rel, LOCATION_IRI};
      10              : 
      11              : /// The 4.11 temporal window as the API already validated it.
      12              : pub struct InstanceRange<'a> {
      13              :     /// `timerel`: `before`, `after` or `between`.
      14              :     pub timerel: &'a str,
      15              :     /// `timeAt` as a DateTime string.
      16              :     pub time_at: &'a str,
      17              :     /// `endTimeAt`, present only for `between`.
      18              :     pub end_time_at: Option<&'a str>,
      19              :     /// The instance timestamp the window applies to (`observedAt`,
      20              :     /// `modifiedAt`, `createdAt` or `deletedAt`), expanded.
      21              :     pub timeproperty: &'a str,
      22              : }
      23              : 
      24              : /// Query Entities (5.7.2) predicates as the store seam receives them —
      25              : /// every member is optional and already validated by the API layer.
      26              : pub struct EntityFilter<'a> {
      27              :     /// exact entity ids (`id=` / the ids of a batch query)
      28              :     pub ids: Option<&'a [&'a str]>,
      29              :     /// A literal every `idPattern` match must carry (5.2.33): the store
      30              :     /// narrows on it, the caller's regex still decides. Absent when `id=` is
      31              :     /// given (id takes precedence over idPattern).
      32              :     pub id_literal: Option<IdLiteral<'a>>,
      33              :     /// Entity Type Selection (4.17) as OR-of-AND groups, expanded IRIs
      34              :     pub types: Option<&'a [Vec<String>]>,
      35              :     /// `attrs=`: the entity must carry at least one, expanded IRIs
      36              :     pub attrs: Option<&'a [String]>,
      37              :     /// `q=` AST; compiled when its shape is exactly reproducible, else skipped
      38              :     pub q: Option<&'a antares_ql::QNode>,
      39              :     /// `scopeQ=` verbatim (4.19); compiled over the `scopes` column
      40              :     pub scope_q: Option<&'a str>,
      41              :     /// `georel`/`geometry`/`coordinates`/`geoproperty` (4.10), compiled over
      42              :     /// the extracted `location` column
      43              :     pub geo: Option<&'a GeoSpec<'a>>,
      44              :     /// term → IRI, the request context's expander (the AST holds terms)
      45              :     pub expand: &'a (dyn Fn(&str) -> String + Sync),
      46              :     /// Pagination pushdown: applied ONLY when every present predicate
      47              :     /// compiled exactly (`decided`) — otherwise the caller's evaluator still
      48              :     /// has rows to drop and a SQL LIMIT would page over the wrong set. The
      49              :     /// caller passes it only when its own store-invisible filters (idPattern,
      50              :     /// federation, orderBy) are absent.
      51              :     pub page: Option<Page>,
      52              :     /// Projection pushdown (4.21 `pick`, top-level): keep these expanded
      53              :     /// attr IRIs + every non-attribute member. Applied only when `decided` —
      54              :     /// a projected doc can no longer answer a q= re-check.
      55              :     pub keep_attrs: Option<&'a [String]>,
      56              :     /// Projection pushdown (`omit`, top-level entries only): drop exactly
      57              :     /// these attr IRIs. Same `decided` gate.
      58              :     pub drop_attrs: Option<&'a [String]>,
      59              : }
      60              : 
      61              : impl Default for EntityFilter<'_> {
      62          784 :     fn default() -> Self {
      63              :         Self {
      64          784 :             ids: None,
      65          784 :             id_literal: None,
      66          784 :             types: None,
      67          784 :             attrs: None,
      68          784 :             q: None,
      69          784 :             scope_q: None,
      70          784 :             geo: None,
      71            2 :             expand: &|t: &str| t.to_owned(),
      72          784 :             page: None,
      73          784 :             keep_attrs: None,
      74          784 :             drop_attrs: None,
      75              :         }
      76          784 :     }
      77              : }
      78              : 
      79              : /// One page: OFFSET/LIMIT in row units, ORDER BY id (the store's stable
      80              : /// default order, same as the memory snapshot).
      81              : pub struct Page {
      82              :     /// Rows to skip.
      83              :     pub offset: i64,
      84              :     /// Maximum rows to return.
      85              :     pub limit: i64,
      86              :     /// The client asked for the match total (count=true); otherwise the
      87              :     /// store only needs to know whether a next page exists.
      88              :     pub count: bool,
      89              : }
      90              : 
      91              : /// What `query` produced. `decided` = SQL applied every present predicate
      92              : /// exactly, so re-evaluation cannot drop a row; `paged` = LIMIT/OFFSET
      93              : /// happened in SQL (implies `decided`), `total` = the pre-LIMIT match count.
      94              : pub struct QueryOutcome {
      95              :     /// Matching entity documents.
      96              :     pub rows: Vec<Value>,
      97              :     /// Every present predicate was applied exactly; no re-check needed.
      98              :     pub decided: bool,
      99              :     /// LIMIT/OFFSET already applied (implies `decided`).
     100              :     pub paged: bool,
     101              :     /// Pre-LIMIT match count, when the backend computed it.
     102              :     pub total: Option<i64>,
     103              : }
     104              : 
     105              : /// Query Temporal Evolution (5.7.4) predicates as the store seam receives
     106              : /// them — every member is optional and already validated by the API layer.
     107              : pub struct TemporalFilter<'a> {
     108              :     /// exact entity ids
     109              :     pub ids: Option<&'a [&'a str]>,
     110              :     /// flat OR list of expanded type IRIs (temporal query has no AND groups)
     111              :     pub types: Option<&'a [String]>,
     112              :     /// `attrs=`: the entity must carry at least one, expanded IRIs
     113              :     pub attrs: Option<&'a [String]>,
     114              :     /// the 4.11 window; `None` = no instance pruning
     115              :     pub range: Option<InstanceRange<'a>>,
     116              :     /// `lastN`: per-(attr, datasetId) RANK() cap — ties all kept, so the
     117              :     /// per-attr lastN the API applies afterwards always finds its instances
     118              :     pub last_n: Option<i64>,
     119              :     /// ordering key for the lastN cap (the request's timeproperty)
     120              :     pub timeproperty: &'a str,
     121              :     /// Entity-level LIMIT/OFFSET pushdown (without it a temporal query
     122              :     /// materializes the tenant's ENTIRE history). Passed only when the
     123              :     /// caller has no store-invisible entity filters (idPattern, q, geo) —
     124              :     /// when honoured, SQL also applies the caller's entity-qualification rule
     125              :     /// (≥1 instance, in-window when a range is given), so the paged set is
     126              :     /// exactly the set the evaluator would keep.
     127              :     pub page: Option<Page>,
     128              :     /// 5.7.4.4 S2 prefilter: the `q=` AST. The Pg arm compiles the leaves it
     129              :     /// can reproduce into windowed EXISTS predicates and treats everything
     130              :     /// else as TRUE — always a SUPERSET of the eval_q verdict, so the API
     131              :     /// arbiter (which always re-runs when q is present) never changes an
     132              :     /// answer, only sees fewer rows. Requires the matching `expand`.
     133              :     pub q: Option<&'a antares_ql::QNode>,
     134              :     /// term → IRI, the request context's expander (the AST holds terms).
     135              :     /// `Sync` so a filter alive across an await keeps the handler future Send.
     136              :     pub expand: &'a (dyn Fn(&str) -> String + Sync),
     137              :     /// 5.7.4.4 S3 prefilter: the geoquery plus the EXPANDED geoproperty IRI
     138              :     /// whose windowed instances the EXISTS checks (per-instance rows carry
     139              :     /// extracted geometries for EVERY geoproperty, not just `location`).
     140              :     /// Superset like `q` — `GeoQuery::matches` stays the arbiter; rows with
     141              :     /// an unextracted `geo_value` always survive.
     142              :     pub geo: Option<(&'a GeoSpec<'a>, &'a str)>,
     143              :     /// 4.5.19 aggregated representation computed by the backend: when a
     144              :     /// driver can bucket and aggregate the windowed instances itself it
     145              :     /// returns per-attribute aggregated objects instead of instance arrays
     146              :     /// and sets `TemporalOutcome::aggregated`; a driver that cannot (or a
     147              :     /// value class outside the numeric one) ignores this and the API
     148              :     /// aggregates over the reconstructed instances as before.
     149              :     pub aggregate: Option<Aggregate<'a>>,
     150              : }
     151              : 
     152              : /// 4.5.19.1: the bucket matrix a backend computes for the numeric value class.
     153              : #[derive(Clone, Copy, Debug)]
     154              : pub struct Aggregate<'a> {
     155              :     /// aggregation methods, each one of [`AGGREGATE_METHODS`]
     156              :     pub methods: &'a [String],
     157              :     /// bucket width in seconds; `None` = one bucket over the whole range (PT0S)
     158              :     pub period_secs: Option<i64>,
     159              :     /// bucket origin (the request's timeAt); `None` = each attribute's first instant
     160              :     pub anchor: Option<&'a str>,
     161              : }
     162              : 
     163              : /// Table 4.5.19.1-1: the methods every numeric Property is eligible for —
     164              : /// the set a backend may compute in place of the API.
     165              : pub const AGGREGATE_METHODS: [&str; 8] = [
     166              :     "totalCount",
     167              :     "distinctCount",
     168              :     "min",
     169              :     "max",
     170              :     "sum",
     171              :     "avg",
     172              :     "stddev",
     173              :     "sumsq",
     174              : ];
     175              : 
     176              : impl Default for TemporalFilter<'_> {
     177          452 :     fn default() -> Self {
     178              :         Self {
     179          452 :             ids: None,
     180          452 :             types: None,
     181          452 :             attrs: None,
     182          452 :             range: None,
     183          452 :             last_n: None,
     184          452 :             timeproperty: "observedAt",
     185          452 :             page: None,
     186          452 :             q: None,
     187            2 :             expand: &|t: &str| t.to_owned(),
     188          452 :             geo: None,
     189          452 :             aggregate: None,
     190              :         }
     191          452 :     }
     192              : }
     193              : 
     194              : /// 4.22 transient storage: is this doc/instance past its `expiresAt`?
     195              : /// Parses both stamps to instants (so a non-UTC-Z offset expiresAt is judged
     196              : /// correctly, matching the SQL `expires_at`/timestamptz path); byte compare is
     197              : /// only the fallback when a stamp is unparseable.
     198              : ///
     199              : /// 4.6.3 also allows a comma as the seconds-fraction separator, which RFC 3339
     200              : /// does not; a comma cannot appear anywhere else in such a stamp, so the first
     201              : /// one is rewritten to a point before parsing. Without that the comma form
     202              : /// always fell into the byte fallback, where ',' (0x2C) sorts before both '.'
     203              : /// and 'Z' and a live instance reads as expired.
     204        99988 : pub fn expired_at(v: &Value, now: &str) -> bool {
     205        99988 :     let Some(e) = v.get("expiresAt").and_then(Value::as_str) else {
     206        99408 :         return false;
     207              :     };
     208         1160 :     let instant = |s: &str| chrono::DateTime::parse_from_rfc3339(&canonical_datetime(s)).ok();
     209          580 :     match (instant(e), instant(now)) {
     210          574 :         (Some(exp), Some(n)) => exp < n,
     211            6 :         _ => e < now,
     212              :     }
     213        99988 : }
     214              : 
     215              : /// 4.6.3: "In requests, also a comma instead of a decimal point may be used
     216              : /// as separator for compatibility reasons." Nothing downstream of the request
     217              : /// boundary accepts it — not RFC 3339 parsing, not PostgreSQL's
     218              : /// `::timestamptz` cast, not byte comparison, where ',' (0x2C) sorts before
     219              : /// both '.' and 'Z'. A comma cannot appear anywhere else in a DateTime of
     220              : /// that shape, so the first one IS the fraction separator and rewriting it
     221              : /// is total: a stamp with no comma is returned untouched, and a string that
     222              : /// is not a DateTime at all is not made into one.
     223         6949 : pub fn canonical_datetime(s: &str) -> std::borrow::Cow<'_, str> {
     224         6949 :     match s.find(',') {
     225           41 :         Some(_) => std::borrow::Cow::Owned(s.replacen(',', ".", 1)),
     226         6908 :         None => std::borrow::Cow::Borrowed(s),
     227              :     }
     228         6949 : }
     229              : 
     230              : /// Apply 4.22 invalidity to a read: `true` = the ENTITY is expired (caller
     231              : /// drops it entirely); otherwise expired attribute INSTANCES are stripped in
     232              : /// place (an attribute left with zero instances disappears). The stamp marks
     233              : /// "a certain Entity, Property or Relationship", and a sub-Attribute is a
     234              : /// Property or a Relationship as well, so the pass recurses through the
     235              : /// instances it keeps. Only Attribute names are walked: 5.5.7 expansion makes
     236              : /// every one an absolute IRI, so a member without a colon is either a
     237              : /// reserved instance member or Entity metadata, and user JSON under `value`
     238              : /// or `json` — which may spell `expiresAt` itself — is never entered.
     239        89252 : pub fn strip_expired(doc: &mut Value, now: &str) -> bool {
     240        89252 :     if expired_at(doc, now) {
     241           78 :         return true;
     242        89174 :     }
     243        89174 :     if let Some(obj) = doc.as_object_mut() {
     244        89172 :         let mut empty: Vec<String> = Vec::new();
     245       300786 :         for (k, v) in obj.iter_mut() {
     246       300786 :             let Some(arr) = v.as_array_mut().filter(|_| k.contains(':')) else {
     247       254588 :                 continue;
     248              :             };
     249        46198 :             let before = arr.len();
     250        46738 :             arr.retain_mut(|inst| !strip_expired(inst, now));
     251        46198 :             if before > 0 && arr.is_empty() {
     252           36 :                 empty.push(k.clone());
     253        46162 :             }
     254              :         }
     255        89172 :         for k in empty {
     256           36 :             obj.remove(&k);
     257           36 :         }
     258            2 :     }
     259        89174 :     false
     260        89252 : }
     261              : 
     262              : /// What a temporal query produced. `paged` = LIMIT/OFFSET (and the
     263              : /// entity-qualification EXISTS) ran in SQL; `total` = pre-LIMIT match count.
     264              : pub struct TemporalOutcome {
     265              :     /// Matching temporal documents.
     266              :     pub rows: Vec<Value>,
     267              :     /// LIMIT/OFFSET already applied.
     268              :     pub paged: bool,
     269              :     /// Pre-LIMIT match count, when the backend computed it.
     270              :     pub total: Option<i64>,
     271              :     /// The rows carry the 4.5.19 aggregated attribute objects the filter's
     272              :     /// `aggregate` asked for (no instance arrays to window).
     273              :     pub aggregated: bool,
     274              : }
     275              : 
     276              : #[cfg(test)]
     277              : mod tests {
     278              :     use super::*;
     279              :     use serde_json::json;
     280              : 
     281              :     const NOW: &str = "2026-08-08T12:00:00.000Z";
     282              : 
     283              :     /// Defaults push nothing down: a filter built with `..Default::default()`
     284              :     /// must not silently add a predicate, and its expander is the identity
     285              :     /// (terms stay terms until a request context replaces it).
     286              :     #[test]
     287            2 :     fn entity_filter_default_pushes_nothing_down() {
     288            2 :         let f = EntityFilter::default();
     289            2 :         assert!(f.ids.is_none());
     290            2 :         assert!(f.types.is_none());
     291            2 :         assert!(f.attrs.is_none());
     292            2 :         assert!(f.q.is_none());
     293            2 :         assert!(f.scope_q.is_none());
     294            2 :         assert!(f.geo.is_none());
     295            2 :         assert!(f.page.is_none(), "no LIMIT until the caller proves decided");
     296            2 :         assert!(f.keep_attrs.is_none());
     297            2 :         assert!(f.drop_attrs.is_none());
     298            2 :         assert_eq!((f.expand)("Vehicle"), "Vehicle");
     299            2 :     }
     300              : 
     301              :     /// The default lastN ordering key is `observedAt` (4.11); a different
     302              :     /// default would silently rank instances by another time property.
     303              :     #[test]
     304            2 :     fn temporal_filter_default_orders_by_observed_at() {
     305            2 :         let f = TemporalFilter::default();
     306            2 :         assert_eq!(f.timeproperty, "observedAt");
     307            2 :         assert!(f.ids.is_none());
     308            2 :         assert!(f.types.is_none());
     309            2 :         assert!(f.attrs.is_none());
     310            2 :         assert!(f.range.is_none(), "no window means no instance pruning");
     311            2 :         assert!(f.last_n.is_none());
     312            2 :         assert!(f.page.is_none());
     313            2 :         assert!(f.q.is_none());
     314            2 :         assert!(f.geo.is_none());
     315            2 :         assert_eq!((f.expand)("speed"), "speed");
     316            2 :     }
     317              : 
     318              :     /// 4.22: expiry has PASSED only when the stamp lies strictly before now —
     319              :     /// the same strictness as the `expires_at < now()` reaping predicate, so a
     320              :     /// read and the sweep never disagree at the boundary instant.
     321              :     #[test]
     322            2 :     fn expiry_exactly_at_now_has_not_passed() {
     323            2 :         assert!(!expired_at(&json!({ "expiresAt": NOW }), NOW));
     324              :         // same instant, coarser spelling: still not expired
     325            2 :         assert!(!expired_at(
     326            2 :             &json!({"expiresAt": "2026-08-08T12:00:00Z"}),
     327            2 :             NOW
     328            2 :         ));
     329              :         // one millisecond earlier is
     330            2 :         assert!(expired_at(
     331            2 :             &json!({"expiresAt": "2026-08-08T11:59:59.999Z"}),
     332            2 :             NOW
     333              :         ));
     334            2 :     }
     335              : 
     336              :     /// 4.6.3 DateTime accepts a comma as the fraction separator, so a stored
     337              :     /// `expiresAt` may carry one. Judging it by bytes puts ',' (0x2C) before
     338              :     /// '.' (0x2E) and calls a still-live stamp expired — the comparison must
     339              :     /// stay on instants.
     340              :     #[test]
     341            2 :     fn comma_fraction_expiry_is_judged_by_instant() {
     342            2 :         assert!(
     343            2 :             !expired_at(&json!({"expiresAt": "2026-08-08T12:00:00,500Z"}), NOW),
     344              :             "half a second in the future is not expired"
     345              :         );
     346            2 :         assert!(expired_at(
     347            2 :             &json!({"expiresAt": "2026-08-08T11:59:59,999Z"}),
     348            2 :             NOW
     349              :         ));
     350            2 :     }
     351              : 
     352              :     /// A comma-fraction expiry still in the future must not cost the instance
     353              :     /// its place in the document.
     354              :     #[test]
     355            2 :     fn a_live_comma_fraction_instance_is_not_stripped() {
     356            2 :         let mut doc = json!({
     357            2 :             "id": "urn:x", "type": ["T"],
     358            2 :             "https://a/attr": [{"value": 1, "instanceId": "i1",
     359            2 :                                 "expiresAt": "2026-08-08T12:00:00,500Z"}]
     360              :         });
     361            2 :         assert!(!strip_expired(&mut doc, NOW));
     362            2 :         assert_eq!(doc["https://a/attr"].as_array().map(Vec::len), Some(1));
     363            2 :     }
     364              : 
     365              :     /// A non-UTC offset is judged by instant: 13:30+02:00 is 11:30Z, expired
     366              :     /// against a 12:00Z now even though its bytes sort after it.
     367              :     #[test]
     368            2 :     fn offset_expiry_is_judged_by_instant_not_bytes() {
     369            2 :         assert!(expired_at(
     370            2 :             &json!({"expiresAt": "2026-08-08T13:30:00+02:00"}),
     371            2 :             NOW
     372              :         ));
     373            2 :         assert!(!expired_at(
     374            2 :             &json!({"expiresAt": "2026-08-08T11:30:00-02:00"}),
     375            2 :             NOW
     376            2 :         ));
     377            2 :     }
     378              : 
     379              :     /// Hostile `expiresAt` shapes decide without panicking: a non-string is no
     380              :     /// expiry at all, an unparseable string falls back to the byte compare.
     381              :     #[test]
     382            2 :     fn a_hostile_expiry_never_panics() {
     383           12 :         for v in [
     384            2 :             json!({ "expiresAt": 1 }),
     385            2 :             json!({ "expiresAt": null }),
     386            2 :             json!({"expiresAt": {"@value": "2020-01-01T00:00:00Z"}}),
     387            2 :             json!({ "expiresAt": ["2020-01-01T00:00:00Z"] }),
     388            2 :             json!({ "expiresAt": true }),
     389            2 :             json!({}),
     390            2 :         ] {
     391           12 :             assert!(!expired_at(&v, NOW), "a non-string expiresAt is no expiry");
     392              :         }
     393              :         // unparseable strings stay decidable through the byte fallback
     394            2 :         assert!(expired_at(&json!({ "expiresAt": "" }), NOW));
     395            2 :         assert!(expired_at(
     396            2 :             &json!({"expiresAt": "2026-08-08T11:00:00"}),
     397            2 :             NOW
     398              :         ));
     399            2 :         assert!(!expired_at(
     400            2 :             &json!({"expiresAt": "9999-99-99T99:99:99Z"}),
     401            2 :             NOW
     402            2 :         ));
     403            2 :     }
     404              : 
     405              :     /// Multi-instance (4.5.5) attribute: the expired instance must be gone
     406              :     /// from the document entirely, not merely reordered or emptied.
     407              :     #[test]
     408            2 :     fn an_expired_instance_never_survives_a_multi_instance_attribute() {
     409            2 :         let mut doc = json!({
     410            2 :             "id": "urn:x", "type": ["T"],
     411            2 :             "https://a/attr": [
     412            2 :                 {"value": 1, "instanceId": "i1", "datasetId": "urn:d:1"},
     413            2 :                 {"value": 2, "instanceId": "i2", "datasetId": "urn:d:2",
     414            2 :                  "expiresAt": "2026-08-08T11:00:00Z"}
     415              :             ]
     416              :         });
     417            2 :         assert!(!strip_expired(&mut doc, NOW));
     418            2 :         let text = serde_json::to_string(&doc).expect("serialize");
     419            2 :         assert!(
     420            2 :             !text.contains("i2"),
     421              :             "expired instance still present: {text}"
     422              :         );
     423            2 :         assert!(!text.contains("urn:d:2"));
     424            2 :         assert!(text.contains("i1"));
     425            2 :     }
     426              : 
     427              :     /// Only attribute arrays are instance-filtered: an already-empty array is
     428              :     /// left in place (it lost nothing), and a doc that is not an object is
     429              :     /// simply not expired.
     430              :     #[test]
     431            2 :     fn strip_expired_leaves_untouched_what_it_must_not_remove() {
     432            2 :         let mut doc = json!({
     433            2 :             "id": "urn:x", "type": ["T"], "scope": ["/a"],
     434            2 :             "https://a/empty": []
     435              :         });
     436            2 :         assert!(!strip_expired(&mut doc, NOW));
     437            2 :         assert!(doc.get("https://a/empty").is_some(), "empty array kept");
     438            2 :         assert_eq!(doc["scope"], json!(["/a"]));
     439              : 
     440            2 :         let mut not_an_object = json!(["urn:x"]);
     441            2 :         assert!(!strip_expired(&mut not_an_object, NOW));
     442            2 :         assert_eq!(not_an_object, json!(["urn:x"]));
     443            2 :     }
     444              : 
     445              :     /// An entity expiry outranks the instance pass: the caller drops the whole
     446              :     /// document, so a live instance inside it must never reach a response.
     447              :     #[test]
     448            2 :     fn an_expired_entity_is_dropped_before_any_instance_survives() {
     449            2 :         let mut doc = json!({
     450            2 :             "id": "urn:x", "type": ["T"], "expiresAt": "2026-08-08T11:00:00Z",
     451            2 :             "https://a/attr": [{"value": 1, "instanceId": "i1",
     452            2 :                                 "expiresAt": "2999-01-01T00:00:00Z"}]
     453              :         });
     454            2 :         assert!(strip_expired(&mut doc, NOW));
     455            2 :     }
     456              : 
     457              :     /// 4.6.3: the comma is a request-side separator only — every consumer
     458              :     /// past that boundary wants the point form. The rewrite is first-comma
     459              :     /// only, and leaves anything that is not a DateTime alone: a `datasetId`
     460              :     /// or any other URI travels through the same code paths.
     461              :     #[test]
     462            2 :     fn canonical_datetime_rewrites_only_the_fraction_separator() {
     463            2 :         assert_eq!(
     464            2 :             canonical_datetime("2026-01-01T00:00:00,500Z"),
     465              :             "2026-01-01T00:00:00.500Z"
     466              :         );
     467            2 :         assert_eq!(
     468            2 :             canonical_datetime("2026-01-01T00:00:00.500Z"),
     469              :             "2026-01-01T00:00:00.500Z"
     470              :         );
     471            2 :         assert_eq!(
     472            2 :             canonical_datetime("2026-01-01T00:00:00Z"),
     473              :             "2026-01-01T00:00:00Z"
     474              :         );
     475            2 :         assert_eq!(canonical_datetime(""), "");
     476              :         // only the first: a second comma is not a fraction separator, so the
     477              :         // string stays something the cast will reject rather than becoming a
     478              :         // stamp that was never sent.
     479            2 :         assert_eq!(canonical_datetime("a,b,c"), "a.b,c");
     480              :         // borrowed when there is nothing to do, so the common path allocates
     481              :         // nothing
     482            2 :         assert!(matches!(
     483            2 :             canonical_datetime("2026-01-01T00:00:00Z"),
     484              :             std::borrow::Cow::Borrowed(_)
     485              :         ));
     486            2 :     }
     487              : 
     488              :     #[test]
     489            2 :     fn expired_entity_is_dropped_whole() {
     490            2 :         let mut doc = serde_json::json!({
     491            2 :             "id": "urn:x", "type": ["T"], "expiresAt": "2026-08-08T11:00:00Z",
     492            2 :             "https://a/attr": [{"value": 1, "instanceId": "i1"}]
     493              :         });
     494            2 :         assert!(strip_expired(&mut doc, NOW));
     495            2 :     }
     496              : 
     497              :     #[test]
     498            2 :     fn expired_instances_are_stripped_and_empty_attrs_disappear() {
     499            2 :         let mut doc = serde_json::json!({
     500            2 :             "id": "urn:x", "type": ["T"], "expiresAt": "2026-08-09T00:00:00Z",
     501            2 :             "https://a/keep": [
     502            2 :                 {"value": 1, "instanceId": "i1"},
     503            2 :                 {"value": 2, "instanceId": "i2", "expiresAt": "2026-08-08T11:00:00Z"}
     504              :             ],
     505            2 :             "https://a/gone": [{"value": 3, "instanceId": "i3",
     506            2 :                                 "expiresAt": "2026-08-08T00:00:00Z"}]
     507              :         });
     508            2 :         assert!(!strip_expired(&mut doc, NOW));
     509            2 :         assert_eq!(doc["https://a/keep"].as_array().map(Vec::len), Some(1));
     510            2 :         assert!(doc.get("https://a/gone").is_none(), "emptied attr removed");
     511              :         // meta arrays (type) are never instance-filtered
     512            2 :         assert_eq!(doc["type"].as_array().map(Vec::len), Some(1));
     513            2 :     }
     514              : 
     515              :     /// 4.22 draws no line at depth 1: a sub-Attribute is a Property or a
     516              :     /// Relationship, so its own `expiresAt` takes it out of the served
     517              :     /// document while its live siblings and the Attribute carrying it stay.
     518              :     /// Only expanded Attribute names are walked, so a `value` that happens to
     519              :     /// spell `expiresAt` is user JSON and survives untouched.
     520              :     #[test]
     521            2 :     fn expired_sub_attributes_leave_and_user_json_stays() {
     522            2 :         let mut doc = serde_json::json!({
     523            2 :             "id": "urn:x", "type": ["T"],
     524            2 :             "https://a/attr": [{
     525            2 :                 "value": [{"expiresAt": "2026-08-08T00:00:00Z", "keep": 1}],
     526            2 :                 "instanceId": "i1",
     527            2 :                 "https://a/gone": [{"value": 2,
     528            2 :                                     "expiresAt": "2026-08-08T11:00:00Z"}],
     529            2 :                 "https://a/live": [{"value": 3,
     530            2 :                                     "expiresAt": "2999-01-01T00:00:00Z"}]
     531              :             }]
     532              :         });
     533            2 :         assert!(!strip_expired(&mut doc, NOW));
     534            2 :         let inst = &doc["https://a/attr"][0];
     535            2 :         assert!(inst.get("https://a/gone").is_none(), "expired sub stripped");
     536            2 :         assert_eq!(inst["https://a/live"][0]["value"], 3, "live sub kept");
     537            2 :         assert_eq!(
     538            2 :             inst["value"],
     539            2 :             serde_json::json!([{"expiresAt": "2026-08-08T00:00:00Z", "keep": 1}]),
     540              :             "user JSON is not an Attribute"
     541              :         );
     542            2 :     }
     543              : 
     544              :     #[test]
     545            2 :     fn no_expiry_means_untouched() {
     546            2 :         let mut doc = serde_json::json!({
     547            2 :             "id": "urn:x", "type": ["T"],
     548            2 :             "https://a/attr": [{"value": 1, "instanceId": "i1"}]
     549              :         });
     550            2 :         let before = doc.clone();
     551            2 :         assert!(!strip_expired(&mut doc, NOW));
     552            2 :         assert_eq!(doc, before);
     553            2 :     }
     554              : }
     555              : 
     556              : /// The longest literal an `idPattern` regex forces on every match: a prefix
     557              : /// when the pattern is anchored (`^urn:x:…`), an infix otherwise. A
     558              : /// necessary condition only — the regex is still evaluated on every row.
     559              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     560              : pub struct IdLiteral<'a> {
     561              :     /// the characters every match carries
     562              :     pub text: &'a str,
     563              :     /// true = the match starts with `text`; false = it contains `text`
     564              :     pub anchored: bool,
     565              : }
     566              : 
     567              : /// Extract the literal of `pattern`, or `None` when the pattern forces no
     568              : /// literal at its start (alternation anywhere, a leading class or group,
     569              : /// an escape, an empty literal). A quantifier after the literal applies to
     570              : /// its last character, which is therefore not required.
     571           78 : pub fn id_pattern_literal(pattern: &str) -> Option<IdLiteral<'_>> {
     572           78 :     if pattern.contains('|') {
     573            4 :         return None;
     574           74 :     }
     575           74 :     let (body, anchored) = match pattern.strip_prefix('^') {
     576           30 :         Some(rest) => (rest, true),
     577           44 :         None => (pattern, false),
     578              :     };
     579           74 :     let start = pattern.len() - body.len();
     580           74 :     let mut end = start;
     581           74 :     let mut stop = None;
     582          842 :     for (i, c) in body.char_indices() {
     583          842 :         if ".^$*+?()[]{}|\\".contains(c) {
     584           50 :             stop = Some(c);
     585           50 :             break;
     586          792 :         }
     587          792 :         end = start + i + c.len_utf8();
     588              :     }
     589           74 :     if matches!(stop, Some('*' | '+' | '?' | '{')) {
     590            8 :         end -= pattern[start..end].chars().last()?.len_utf8();
     591           66 :     }
     592           74 :     (end > start).then_some(IdLiteral {
     593           74 :         text: &pattern[start..end],
     594           74 :         anchored,
     595           74 :     })
     596           78 : }
     597              : 
     598              : #[cfg(test)]
     599              : mod id_literal_tests {
     600              :     use super::*;
     601              : 
     602              :     /// 5.2.33 idPattern narrowing: the literal is a necessary condition of
     603              :     /// the regex, never a sufficient one, so it may only ever shrink the
     604              :     /// candidate set the regex then decides.
     605              :     #[test]
     606            2 :     fn literal_is_a_necessary_condition_of_the_pattern() {
     607            2 :         let lit = id_pattern_literal;
     608            8 :         let some = |text, anchored| Some(IdLiteral { text, anchored });
     609            2 :         assert_eq!(
     610            2 :             lit("^urn:ngsi-ld:Vehicle:.*"),
     611            2 :             some("urn:ngsi-ld:Vehicle:", true)
     612              :         );
     613            2 :         assert_eq!(
     614            2 :             lit("urn:ngsi-ld:Sensor:t7:7.*"),
     615            2 :             some("urn:ngsi-ld:Sensor:t7:7", false)
     616              :         );
     617            2 :         assert_eq!(lit("urn:x:abc"), some("urn:x:abc", false));
     618              :         // a quantifier binds the last character: `c` is optional in `abc*`
     619            2 :         assert_eq!(lit("^abc*"), some("ab", true));
     620            2 :         assert_eq!(lit("a?bc"), None);
     621            2 :         assert_eq!(lit("^a{2}"), None);
     622              :         // nothing is forced at the start
     623            2 :         assert_eq!(lit(".*0$"), None);
     624            2 :         assert_eq!(lit("^"), None);
     625            2 :         assert_eq!(lit(""), None);
     626            2 :         assert_eq!(lit("(?i)abc"), None);
     627            2 :         assert_eq!(lit("[ab]c"), None);
     628            2 :         assert_eq!(lit("\\d+"), None);
     629              :         // alternation anywhere: the literal would exclude the other branch
     630            2 :         assert_eq!(lit("^urn:a|urn:b"), None);
     631            2 :         assert_eq!(lit("^urn:(a|b)"), None);
     632              :         // the extracted text is a substring of every regex match
     633            6 :         for (p, sample) in [
     634            2 :             ("^urn:ngsi-ld:Vehicle:.*", "urn:ngsi-ld:Vehicle:1"),
     635            2 :             ("t7:7.*", "urn:ngsi-ld:Sensor:t7:71"),
     636            2 :             ("^abc*", "abd"),
     637            2 :         ] {
     638            6 :             let l = lit(p).expect("literal");
     639            6 :             assert!(
     640            6 :                 regex::Regex::new(p).expect("re").is_match(sample),
     641              :                 "{p} vs {sample}"
     642              :             );
     643            6 :             assert!(
     644            6 :                 if l.anchored {
     645            4 :                     sample.starts_with(l.text)
     646              :                 } else {
     647            2 :                     sample.contains(l.text)
     648              :                 },
     649              :                 "{p}: {sample}"
     650              :             );
     651              :         }
     652            2 :     }
     653              : }
        

Generated by: LCOV version 2.0-1