LCOV - code coverage report
Current view: top level - antares-store/src - filter.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 97.9 % 516 505
Test Date: 2026-11-02 13:21:27 Functions: 77.3 % 110 85

            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              :     /// The id starts with one of these, a necessary condition of
      34              :     /// the caller's id selection, which still decides. With it a store that
      35              :     /// takes it walks the match set and `after_id` in byte order, the order
      36              :     /// of the C-collated id index that answers the prefixes.
      37              :     pub id_prefixes: Option<&'a [String]>,
      38              :     /// The id matches one of these `idPattern`s, each read whole as a policy
      39              :     /// rule reads it (`^(?:pattern)$`): an exact condition. A store
      40              :     /// that cannot apply one exactly ([`id_pattern_sql`]) leaves it to the
      41              :     /// caller and reports the outcome undecided.
      42              :     pub id_patterns: Option<&'a [String]>,
      43              :     /// Entity Type Selection (4.17) as OR-of-AND groups, expanded IRIs
      44              :     pub types: Option<&'a [Vec<String>]>,
      45              :     /// `attrs=`: the entity must carry at least one, expanded IRIs
      46              :     pub attrs: Option<&'a [String]>,
      47              :     /// `q=` AST; compiled when its shape is exactly reproducible, else skipped
      48              :     pub q: Option<&'a antares_ql::QNode>,
      49              :     /// `scopeQ=` verbatim (4.19); compiled over the `scopes` column
      50              :     pub scope_q: Option<&'a str>,
      51              :     /// `georel`/`geometry`/`coordinates`/`geoproperty` (4.10), compiled over
      52              :     /// the extracted `location` column. Every one must hold: the request's
      53              :     /// own and a policy narrowing's. Empty = no geoquery.
      54              :     pub geo: &'a [GeoSpec<'a>],
      55              :     /// term → IRI, the request context's expander (the AST holds terms)
      56              :     pub expand: &'a (dyn Fn(&str) -> String + Sync),
      57              :     /// The request @context, for a store that evaluates `q` on documents
      58              :     /// with the API's own evaluator (`antares_ql::eval::eval_q`). None = such
      59              :     /// a store leaves `q` to the caller.
      60              :     pub ctx: Option<&'a antares_jsonld::Context>,
      61              :     /// Pagination pushdown: applied ONLY when every present predicate
      62              :     /// compiled exactly (`decided`) — otherwise the caller's evaluator still
      63              :     /// has rows to drop and a SQL LIMIT would page over the wrong set. The
      64              :     /// caller passes it only when its own store-invisible filters (idPattern,
      65              :     /// federation, orderBy) are absent.
      66              :     pub page: Option<Page>,
      67              :     /// Projection pushdown (4.21 `pick`, top-level): keep these expanded
      68              :     /// attr IRIs + every non-attribute member. Applied only when `decided` —
      69              :     /// a projected doc can no longer answer a q= re-check.
      70              :     pub keep_attrs: Option<&'a [String]>,
      71              :     /// Projection pushdown (`omit`, top-level entries only): drop exactly
      72              :     /// these attr IRIs. Same `decided` gate.
      73              :     pub drop_attrs: Option<&'a [String]>,
      74              :     /// Keyset: only the Entities whose id sorts after this one, in
      75              :     /// the store's id order. A walk over a whole set passes the last id it
      76              :     /// read instead of a growing `offset`, so every page is an index seek and
      77              :     /// each row is read once: an OFFSET walk re-reads every row it skips, and
      78              :     /// `/types?details=true` over a 25 000-Entity tenant took 57 s. Exact, so
      79              :     /// `decided` is untouched.
      80              :     pub after_id: Option<&'a str>,
      81              : }
      82              : 
      83              : impl Default for EntityFilter<'_> {
      84         4802 :     fn default() -> Self {
      85              :         Self {
      86         4802 :             ids: None,
      87         4802 :             id_literal: None,
      88         4802 :             id_prefixes: None,
      89         4802 :             id_patterns: None,
      90         4802 :             types: None,
      91         4802 :             attrs: None,
      92         4802 :             q: None,
      93         4802 :             scope_q: None,
      94         4802 :             geo: &[],
      95            2 :             expand: &|t: &str| t.to_owned(),
      96         4802 :             ctx: None,
      97         4802 :             page: None,
      98         4802 :             keep_attrs: None,
      99         4802 :             drop_attrs: None,
     100         4802 :             after_id: None,
     101              :         }
     102         4802 :     }
     103              : }
     104              : 
     105              : /// One page: OFFSET/LIMIT in row units, ORDER BY id (the store's stable
     106              : /// default order, same as the memory snapshot).
     107              : pub struct Page {
     108              :     /// Rows to skip.
     109              :     pub offset: i64,
     110              :     /// Maximum rows to return.
     111              :     pub limit: i64,
     112              :     /// The client asked for the match total (count=true); otherwise the
     113              :     /// store only needs to know whether a next page exists.
     114              :     pub count: bool,
     115              : }
     116              : 
     117              : /// What `query` produced. `decided` = SQL applied every present predicate
     118              : /// exactly, so re-evaluation cannot drop a row; `paged` = LIMIT/OFFSET
     119              : /// happened in SQL (implies `decided`, except from `query_candidates`),
     120              : /// `total` = the pre-LIMIT match count.
     121              : pub struct QueryOutcome {
     122              :     /// Matching entity documents.
     123              :     pub rows: Vec<Value>,
     124              :     /// Every present predicate was applied exactly; no re-check needed.
     125              :     pub decided: bool,
     126              :     /// LIMIT/OFFSET already applied (implies `decided`, except from
     127              :     /// `Store::query_candidates`).
     128              :     pub paged: bool,
     129              :     /// Pre-LIMIT match count, when the backend computed it.
     130              :     pub total: Option<i64>,
     131              : }
     132              : 
     133              : /// Query Temporal Evolution (5.7.4) predicates as the store seam receives
     134              : /// them — every member is optional and already validated by the API layer.
     135              : pub struct TemporalFilter<'a> {
     136              :     /// exact entity ids
     137              :     pub ids: Option<&'a [&'a str]>,
     138              :     /// flat OR list of expanded type IRIs (temporal query has no AND groups)
     139              :     pub types: Option<&'a [String]>,
     140              :     /// `attrs=`: the entity must carry at least one, expanded IRIs
     141              :     pub attrs: Option<&'a [String]>,
     142              :     /// the 4.11 window; `None` = no instance pruning
     143              :     pub range: Option<InstanceRange<'a>>,
     144              :     /// `lastN`: per-(attr, datasetId) RANK() cap — ties all kept, so the
     145              :     /// per-attr lastN the API applies afterwards always finds its instances
     146              :     pub last_n: Option<i64>,
     147              :     /// the cap keeps the OLDEST instances instead (firstN, TS 104 176
     148              :     /// 6.4.7.3); same RANK() semantics, ascending
     149              :     pub oldest_first: bool,
     150              :     /// ordering key for the lastN cap (the request's timeproperty)
     151              :     pub timeproperty: &'a str,
     152              :     /// Entity-level LIMIT/OFFSET pushdown (without it a temporal query
     153              :     /// materializes the tenant's ENTIRE history). Passed only when the
     154              :     /// caller has no store-invisible entity filters (idPattern, q, geo) —
     155              :     /// when honoured, SQL also applies the caller's entity-qualification rule
     156              :     /// (≥1 instance, in-window when a range is given), so the paged set is
     157              :     /// exactly the set the evaluator would keep.
     158              :     pub page: Option<Page>,
     159              :     /// With `page`: an Entity qualifies for the page only through an
     160              :     /// instance of one of these Attributes (expanded IRIs), the ones a
     161              :     /// policy's rules show; `None` qualifies through any.
     162              :     pub page_attrs: Option<&'a [String]>,
     163              :     /// 5.7.4.4 S2 prefilter: the `q=` AST. The Pg arm compiles the leaves it
     164              :     /// can reproduce into windowed EXISTS predicates and treats everything
     165              :     /// else as TRUE — always a SUPERSET of the eval_q verdict, so the API
     166              :     /// arbiter (which always re-runs when q is present) never changes an
     167              :     /// answer, only sees fewer rows. Requires the matching `expand`.
     168              :     pub q: Option<&'a antares_ql::QNode>,
     169              :     /// term → IRI, the request context's expander (the AST holds terms).
     170              :     /// `Sync` so a filter alive across an await keeps the handler future Send.
     171              :     pub expand: &'a (dyn Fn(&str) -> String + Sync),
     172              :     /// 5.7.4.4 S3 prefilter: the geoquery plus the EXPANDED geoproperty IRI
     173              :     /// whose windowed instances the EXISTS checks (per-instance rows carry
     174              :     /// extracted geometries for EVERY geoproperty, not just `location`).
     175              :     /// Superset like `q` — `GeoQuery::matches` stays the arbiter; rows with
     176              :     /// an unextracted `geo_value` always survive. Every one must hold, like
     177              :     /// [`EntityFilter::geo`]; empty = no geoquery.
     178              :     pub geo: &'a [(GeoSpec<'a>, String)],
     179              :     /// 4.5.19 aggregated representation computed by the backend: when a
     180              :     /// driver can bucket and aggregate the windowed instances itself it
     181              :     /// returns per-attribute aggregated objects instead of instance arrays
     182              :     /// and sets `TemporalOutcome::aggregated`; a driver that cannot (or a
     183              :     /// value class outside the numeric one) ignores this and the API
     184              :     /// aggregates over the reconstructed instances as before.
     185              :     pub aggregate: Option<Aggregate<'a>>,
     186              : }
     187              : 
     188              : /// 4.5.19.1: the bucket matrix a backend computes for the numeric value class.
     189              : #[derive(Clone, Copy, Debug)]
     190              : pub struct Aggregate<'a> {
     191              :     /// aggregation methods, each one of [`AGGREGATE_METHODS`]
     192              :     pub methods: &'a [String],
     193              :     /// bucket width in seconds; `None` = one bucket over the whole range (PT0S)
     194              :     pub period_secs: Option<i64>,
     195              :     /// bucket origin (the request's timeAt); `None` = each attribute's first instant
     196              :     pub anchor: Option<&'a str>,
     197              : }
     198              : 
     199              : /// Table 4.5.19.1-1: the methods every numeric Property is eligible for —
     200              : /// the set a backend may compute in place of the API.
     201              : pub const AGGREGATE_METHODS: [&str; 8] = [
     202              :     "totalCount",
     203              :     "distinctCount",
     204              :     "min",
     205              :     "max",
     206              :     "sum",
     207              :     "avg",
     208              :     "stddev",
     209              :     "sumsq",
     210              : ];
     211              : 
     212              : impl Default for TemporalFilter<'_> {
     213         1796 :     fn default() -> Self {
     214              :         Self {
     215         1796 :             ids: None,
     216         1796 :             types: None,
     217         1796 :             attrs: None,
     218         1796 :             range: None,
     219         1796 :             last_n: None,
     220              :             oldest_first: false,
     221         1796 :             timeproperty: "observedAt",
     222         1796 :             page: None,
     223         1796 :             page_attrs: None,
     224         1796 :             q: None,
     225            2 :             expand: &|t: &str| t.to_owned(),
     226         1796 :             geo: &[],
     227         1796 :             aggregate: None,
     228              :         }
     229         1796 :     }
     230              : }
     231              : 
     232              : /// 4.22 transient storage: is this doc/instance past its `expiresAt`?
     233              : /// Parses both stamps to instants (so a non-UTC-Z offset expiresAt is judged
     234              : /// correctly, matching the SQL `expires_at`/timestamptz path); byte compare is
     235              : /// only the fallback when a stamp is unparseable.
     236              : ///
     237              : /// 4.6.3 also allows a comma as the seconds-fraction separator, which RFC 3339
     238              : /// does not; a comma cannot appear anywhere else in such a stamp, so the first
     239              : /// one is rewritten to a point before parsing. Without that the comma form
     240              : /// always fell into the byte fallback, where ',' (0x2C) sorts before both '.'
     241              : /// and 'Z' and a live instance reads as expired.
     242      1200444 : pub fn expired_at(v: &Value, now: &str) -> bool {
     243      1200444 :     let Some(e) = v.get("expiresAt").and_then(Value::as_str) else {
     244      1198727 :         return false;
     245              :     };
     246         3434 :     let instant = |s: &str| chrono::DateTime::parse_from_rfc3339(&canonical_datetime(s)).ok();
     247         1717 :     match (instant(e), instant(now)) {
     248         1711 :         (Some(exp), Some(n)) => exp < n,
     249            6 :         _ => e < now,
     250              :     }
     251      1200444 : }
     252              : 
     253              : /// 4.6.3: "In requests, also a comma instead of a decimal point may be used
     254              : /// as separator for compatibility reasons." Nothing downstream of the request
     255              : /// boundary accepts it — not RFC 3339 parsing, not PostgreSQL's
     256              : /// `::timestamptz` cast, not byte comparison, where ',' (0x2C) sorts before
     257              : /// both '.' and 'Z'. A comma cannot appear anywhere else in a DateTime of
     258              : /// that shape, so the first one IS the fraction separator and rewriting it
     259              : /// is total: a stamp with no comma is returned untouched, and a string that
     260              : /// is not a DateTime at all is not made into one.
     261       200096 : pub fn canonical_datetime(s: &str) -> std::borrow::Cow<'_, str> {
     262       200096 :     match s.find(',') {
     263           51 :         Some(_) => std::borrow::Cow::Owned(s.replacen(',', ".", 1)),
     264       200045 :         None => std::borrow::Cow::Borrowed(s),
     265              :     }
     266       200096 : }
     267              : 
     268              : /// Apply 4.22 invalidity to a read: `true` = the ENTITY is expired (caller
     269              : /// drops it entirely); otherwise expired attribute INSTANCES are stripped in
     270              : /// place (an attribute left with zero instances disappears). The stamp marks
     271              : /// "a certain Entity, Property or Relationship", and a sub-Attribute is a
     272              : /// Property or a Relationship as well, so the pass recurses through the
     273              : /// instances it keeps. Only Attribute names are walked: 5.5.7 expansion makes
     274              : /// every one an absolute IRI, so a member without a colon is either a
     275              : /// reserved instance member or Entity metadata, and user JSON under `value`
     276              : /// or `json` — which may spell `expiresAt` itself — is never entered.
     277       257808 : pub fn strip_expired(doc: &mut Value, now: &str) -> bool {
     278       257808 :     if expired_at(doc, now) {
     279           83 :         return true;
     280       257725 :     }
     281       257725 :     if let Some(obj) = doc.as_object_mut() {
     282       257723 :         let mut empty: Vec<String> = Vec::new();
     283      1108813 :         for (k, v) in obj.iter_mut() {
     284      1108813 :             let Some(arr) = v.as_array_mut().filter(|_| k.contains(':')) else {
     285      1012315 :                 continue;
     286              :             };
     287        96498 :             let before = arr.len();
     288       184165 :             arr.retain_mut(|inst| !strip_expired(inst, now));
     289        96498 :             if before > 0 && arr.is_empty() {
     290           42 :                 empty.push(k.clone());
     291        96456 :             }
     292              :         }
     293       257723 :         for k in empty {
     294           42 :             obj.remove(&k);
     295           42 :         }
     296            2 :     }
     297       257725 :     false
     298       257808 : }
     299              : 
     300              : /// What a temporal query produced. `paged` = LIMIT/OFFSET (and the
     301              : /// entity-qualification EXISTS) ran in SQL; `total` = pre-LIMIT match count.
     302              : pub struct TemporalOutcome {
     303              :     /// Matching temporal documents.
     304              :     pub rows: Vec<Value>,
     305              :     /// LIMIT/OFFSET already applied.
     306              :     pub paged: bool,
     307              :     /// Pre-LIMIT match count, when the backend computed it.
     308              :     pub total: Option<i64>,
     309              :     /// The rows carry the 4.5.19 aggregated attribute objects the filter's
     310              :     /// `aggregate` asked for (no instance arrays to window).
     311              :     pub aggregated: bool,
     312              : }
     313              : 
     314              : #[cfg(test)]
     315              : mod tests {
     316              :     use super::*;
     317              :     use serde_json::json;
     318              : 
     319              :     const NOW: &str = "2026-08-08T12:00:00.000Z";
     320              : 
     321              :     /// Defaults push nothing down: a filter built with `..Default::default()`
     322              :     /// must not silently add a predicate, and its expander is the identity
     323              :     /// (terms stay terms until a request context replaces it).
     324              :     #[test]
     325            2 :     fn entity_filter_default_pushes_nothing_down() {
     326            2 :         let f = EntityFilter::default();
     327            2 :         assert!(f.ids.is_none());
     328            2 :         assert!(f.types.is_none());
     329            2 :         assert!(f.attrs.is_none());
     330            2 :         assert!(f.q.is_none());
     331            2 :         assert!(f.scope_q.is_none());
     332            2 :         assert!(f.geo.is_empty());
     333            2 :         assert!(f.page.is_none(), "no LIMIT until the caller proves decided");
     334            2 :         assert!(f.keep_attrs.is_none());
     335            2 :         assert!(f.drop_attrs.is_none());
     336            2 :         assert_eq!((f.expand)("Vehicle"), "Vehicle");
     337            2 :     }
     338              : 
     339              :     /// The default lastN ordering key is `observedAt` (4.11); a different
     340              :     /// default would silently rank instances by another time property.
     341              :     #[test]
     342            2 :     fn temporal_filter_default_orders_by_observed_at() {
     343            2 :         let f = TemporalFilter::default();
     344            2 :         assert_eq!(f.timeproperty, "observedAt");
     345            2 :         assert!(f.ids.is_none());
     346            2 :         assert!(f.types.is_none());
     347            2 :         assert!(f.attrs.is_none());
     348            2 :         assert!(f.range.is_none(), "no window means no instance pruning");
     349            2 :         assert!(f.last_n.is_none());
     350            2 :         assert!(f.page.is_none());
     351            2 :         assert!(f.q.is_none());
     352            2 :         assert!(f.geo.is_empty());
     353            2 :         assert_eq!((f.expand)("speed"), "speed");
     354            2 :     }
     355              : 
     356              :     /// 4.22: expiry has PASSED only when the stamp lies strictly before now —
     357              :     /// the same strictness as the `expires_at < now()` reaping predicate, so a
     358              :     /// read and the sweep never disagree at the boundary instant.
     359              :     #[test]
     360            2 :     fn expiry_exactly_at_now_has_not_passed() {
     361            2 :         assert!(!expired_at(&json!({ "expiresAt": NOW }), NOW));
     362              :         // same instant, coarser spelling: still not expired
     363            2 :         assert!(!expired_at(
     364            2 :             &json!({"expiresAt": "2026-08-08T12:00:00Z"}),
     365            2 :             NOW
     366            2 :         ));
     367              :         // one millisecond earlier is
     368            2 :         assert!(expired_at(
     369            2 :             &json!({"expiresAt": "2026-08-08T11:59:59.999Z"}),
     370            2 :             NOW
     371              :         ));
     372            2 :     }
     373              : 
     374              :     /// 4.6.3 DateTime accepts a comma as the fraction separator, so a stored
     375              :     /// `expiresAt` may carry one. Judging it by bytes puts ',' (0x2C) before
     376              :     /// '.' (0x2E) and calls a still-live stamp expired — the comparison must
     377              :     /// stay on instants.
     378              :     #[test]
     379            2 :     fn comma_fraction_expiry_is_judged_by_instant() {
     380            2 :         assert!(
     381            2 :             !expired_at(&json!({"expiresAt": "2026-08-08T12:00:00,500Z"}), NOW),
     382              :             "half a second in the future is not expired"
     383              :         );
     384            2 :         assert!(expired_at(
     385            2 :             &json!({"expiresAt": "2026-08-08T11:59:59,999Z"}),
     386            2 :             NOW
     387              :         ));
     388            2 :     }
     389              : 
     390              :     /// A comma-fraction expiry still in the future must not cost the instance
     391              :     /// its place in the document.
     392              :     #[test]
     393            2 :     fn a_live_comma_fraction_instance_is_not_stripped() {
     394            2 :         let mut doc = json!({
     395            2 :             "id": "urn:x", "type": ["T"],
     396            2 :             "https://a/attr": [{"value": 1, "instanceId": "i1",
     397            2 :                                 "expiresAt": "2026-08-08T12:00:00,500Z"}]
     398              :         });
     399            2 :         assert!(!strip_expired(&mut doc, NOW));
     400            2 :         assert_eq!(doc["https://a/attr"].as_array().map(Vec::len), Some(1));
     401            2 :     }
     402              : 
     403              :     /// A non-UTC offset is judged by instant: 13:30+02:00 is 11:30Z, expired
     404              :     /// against a 12:00Z now even though its bytes sort after it.
     405              :     #[test]
     406            2 :     fn offset_expiry_is_judged_by_instant_not_bytes() {
     407            2 :         assert!(expired_at(
     408            2 :             &json!({"expiresAt": "2026-08-08T13:30:00+02:00"}),
     409            2 :             NOW
     410              :         ));
     411            2 :         assert!(!expired_at(
     412            2 :             &json!({"expiresAt": "2026-08-08T11:30:00-02:00"}),
     413            2 :             NOW
     414            2 :         ));
     415            2 :     }
     416              : 
     417              :     /// Hostile `expiresAt` shapes decide without panicking: a non-string is no
     418              :     /// expiry at all, an unparseable string falls back to the byte compare.
     419              :     #[test]
     420            2 :     fn a_hostile_expiry_never_panics() {
     421           12 :         for v in [
     422            2 :             json!({ "expiresAt": 1 }),
     423            2 :             json!({ "expiresAt": null }),
     424            2 :             json!({"expiresAt": {"@value": "2020-01-01T00:00:00Z"}}),
     425            2 :             json!({ "expiresAt": ["2020-01-01T00:00:00Z"] }),
     426            2 :             json!({ "expiresAt": true }),
     427            2 :             json!({}),
     428            2 :         ] {
     429           12 :             assert!(!expired_at(&v, NOW), "a non-string expiresAt is no expiry");
     430              :         }
     431              :         // unparseable strings stay decidable through the byte fallback
     432            2 :         assert!(expired_at(&json!({ "expiresAt": "" }), NOW));
     433            2 :         assert!(expired_at(
     434            2 :             &json!({"expiresAt": "2026-08-08T11:00:00"}),
     435            2 :             NOW
     436              :         ));
     437            2 :         assert!(!expired_at(
     438            2 :             &json!({"expiresAt": "9999-99-99T99:99:99Z"}),
     439            2 :             NOW
     440            2 :         ));
     441            2 :     }
     442              : 
     443              :     /// Multi-instance (4.5.5) attribute: the expired instance must be gone
     444              :     /// from the document entirely, not merely reordered or emptied.
     445              :     #[test]
     446            2 :     fn an_expired_instance_never_survives_a_multi_instance_attribute() {
     447            2 :         let mut doc = json!({
     448            2 :             "id": "urn:x", "type": ["T"],
     449            2 :             "https://a/attr": [
     450            2 :                 {"value": 1, "instanceId": "i1", "datasetId": "urn:d:1"},
     451            2 :                 {"value": 2, "instanceId": "i2", "datasetId": "urn:d:2",
     452            2 :                  "expiresAt": "2026-08-08T11:00:00Z"}
     453              :             ]
     454              :         });
     455            2 :         assert!(!strip_expired(&mut doc, NOW));
     456            2 :         let text = serde_json::to_string(&doc).expect("serialize");
     457            2 :         assert!(
     458            2 :             !text.contains("i2"),
     459              :             "expired instance still present: {text}"
     460              :         );
     461            2 :         assert!(!text.contains("urn:d:2"));
     462            2 :         assert!(text.contains("i1"));
     463            2 :     }
     464              : 
     465              :     /// Only attribute arrays are instance-filtered: an already-empty array is
     466              :     /// left in place (it lost nothing), and a doc that is not an object is
     467              :     /// simply not expired.
     468              :     #[test]
     469            2 :     fn strip_expired_leaves_untouched_what_it_must_not_remove() {
     470            2 :         let mut doc = json!({
     471            2 :             "id": "urn:x", "type": ["T"], "scope": ["/a"],
     472            2 :             "https://a/empty": []
     473              :         });
     474            2 :         assert!(!strip_expired(&mut doc, NOW));
     475            2 :         assert!(doc.get("https://a/empty").is_some(), "empty array kept");
     476            2 :         assert_eq!(doc["scope"], json!(["/a"]));
     477              : 
     478            2 :         let mut not_an_object = json!(["urn:x"]);
     479            2 :         assert!(!strip_expired(&mut not_an_object, NOW));
     480            2 :         assert_eq!(not_an_object, json!(["urn:x"]));
     481            2 :     }
     482              : 
     483              :     /// An entity expiry outranks the instance pass: the caller drops the whole
     484              :     /// document, so a live instance inside it must never reach a response.
     485              :     #[test]
     486            2 :     fn an_expired_entity_is_dropped_before_any_instance_survives() {
     487            2 :         let mut doc = json!({
     488            2 :             "id": "urn:x", "type": ["T"], "expiresAt": "2026-08-08T11:00:00Z",
     489            2 :             "https://a/attr": [{"value": 1, "instanceId": "i1",
     490            2 :                                 "expiresAt": "2999-01-01T00:00:00Z"}]
     491              :         });
     492            2 :         assert!(strip_expired(&mut doc, NOW));
     493            2 :     }
     494              : 
     495              :     /// 4.6.3: the comma is a request-side separator only — every consumer
     496              :     /// past that boundary wants the point form. The rewrite is first-comma
     497              :     /// only, and leaves anything that is not a DateTime alone: a `datasetId`
     498              :     /// or any other URI travels through the same code paths.
     499              :     #[test]
     500            2 :     fn canonical_datetime_rewrites_only_the_fraction_separator() {
     501            2 :         assert_eq!(
     502            2 :             canonical_datetime("2026-01-01T00:00:00,500Z"),
     503              :             "2026-01-01T00:00:00.500Z"
     504              :         );
     505            2 :         assert_eq!(
     506            2 :             canonical_datetime("2026-01-01T00:00:00.500Z"),
     507              :             "2026-01-01T00:00:00.500Z"
     508              :         );
     509            2 :         assert_eq!(
     510            2 :             canonical_datetime("2026-01-01T00:00:00Z"),
     511              :             "2026-01-01T00:00:00Z"
     512              :         );
     513            2 :         assert_eq!(canonical_datetime(""), "");
     514              :         // only the first: a second comma is not a fraction separator, so the
     515              :         // string stays something the cast will reject rather than becoming a
     516              :         // stamp that was never sent.
     517            2 :         assert_eq!(canonical_datetime("a,b,c"), "a.b,c");
     518              :         // borrowed when there is nothing to do, so the common path allocates
     519              :         // nothing
     520            2 :         assert!(matches!(
     521            2 :             canonical_datetime("2026-01-01T00:00:00Z"),
     522              :             std::borrow::Cow::Borrowed(_)
     523              :         ));
     524            2 :     }
     525              : 
     526              :     #[test]
     527            2 :     fn expired_entity_is_dropped_whole() {
     528            2 :         let mut doc = serde_json::json!({
     529            2 :             "id": "urn:x", "type": ["T"], "expiresAt": "2026-08-08T11:00:00Z",
     530            2 :             "https://a/attr": [{"value": 1, "instanceId": "i1"}]
     531              :         });
     532            2 :         assert!(strip_expired(&mut doc, NOW));
     533            2 :     }
     534              : 
     535              :     #[test]
     536            2 :     fn expired_instances_are_stripped_and_empty_attrs_disappear() {
     537            2 :         let mut doc = serde_json::json!({
     538            2 :             "id": "urn:x", "type": ["T"], "expiresAt": "2026-08-09T00:00:00Z",
     539            2 :             "https://a/keep": [
     540            2 :                 {"value": 1, "instanceId": "i1"},
     541            2 :                 {"value": 2, "instanceId": "i2", "expiresAt": "2026-08-08T11:00:00Z"}
     542              :             ],
     543            2 :             "https://a/gone": [{"value": 3, "instanceId": "i3",
     544            2 :                                 "expiresAt": "2026-08-08T00:00:00Z"}]
     545              :         });
     546            2 :         assert!(!strip_expired(&mut doc, NOW));
     547            2 :         assert_eq!(doc["https://a/keep"].as_array().map(Vec::len), Some(1));
     548            2 :         assert!(doc.get("https://a/gone").is_none(), "emptied attr removed");
     549              :         // meta arrays (type) are never instance-filtered
     550            2 :         assert_eq!(doc["type"].as_array().map(Vec::len), Some(1));
     551            2 :     }
     552              : 
     553              :     /// 4.22 draws no line at depth 1: a sub-Attribute is a Property or a
     554              :     /// Relationship, so its own `expiresAt` takes it out of the served
     555              :     /// document while its live siblings and the Attribute carrying it stay.
     556              :     /// Only expanded Attribute names are walked, so a `value` that happens to
     557              :     /// spell `expiresAt` is user JSON and survives untouched.
     558              :     #[test]
     559            2 :     fn expired_sub_attributes_leave_and_user_json_stays() {
     560            2 :         let mut doc = serde_json::json!({
     561            2 :             "id": "urn:x", "type": ["T"],
     562            2 :             "https://a/attr": [{
     563            2 :                 "value": [{"expiresAt": "2026-08-08T00:00:00Z", "keep": 1}],
     564            2 :                 "instanceId": "i1",
     565            2 :                 "https://a/gone": [{"value": 2,
     566            2 :                                     "expiresAt": "2026-08-08T11:00:00Z"}],
     567            2 :                 "https://a/live": [{"value": 3,
     568            2 :                                     "expiresAt": "2999-01-01T00:00:00Z"}]
     569              :             }]
     570              :         });
     571            2 :         assert!(!strip_expired(&mut doc, NOW));
     572            2 :         let inst = &doc["https://a/attr"][0];
     573            2 :         assert!(inst.get("https://a/gone").is_none(), "expired sub stripped");
     574            2 :         assert_eq!(inst["https://a/live"][0]["value"], 3, "live sub kept");
     575            2 :         assert_eq!(
     576            2 :             inst["value"],
     577            2 :             serde_json::json!([{"expiresAt": "2026-08-08T00:00:00Z", "keep": 1}]),
     578              :             "user JSON is not an Attribute"
     579              :         );
     580            2 :     }
     581              : 
     582              :     #[test]
     583            2 :     fn no_expiry_means_untouched() {
     584            2 :         let mut doc = serde_json::json!({
     585            2 :             "id": "urn:x", "type": ["T"],
     586            2 :             "https://a/attr": [{"value": 1, "instanceId": "i1"}]
     587              :         });
     588            2 :         let before = doc.clone();
     589            2 :         assert!(!strip_expired(&mut doc, NOW));
     590            2 :         assert_eq!(doc, before);
     591            2 :     }
     592              : }
     593              : 
     594              : /// The longest literal an `idPattern` regex forces on every match: a prefix
     595              : /// when the pattern is anchored (`^urn:x:…`), an infix otherwise. A
     596              : /// necessary condition only — the regex is still evaluated on every row.
     597              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     598              : pub struct IdLiteral<'a> {
     599              :     /// the characters every match carries
     600              :     pub text: &'a str,
     601              :     /// true = the match starts with `text`; false = it contains `text`
     602              :     pub anchored: bool,
     603              : }
     604              : 
     605              : /// Extract the literal of `pattern`, or `None` when the pattern forces no
     606              : /// literal at its start (alternation anywhere, a leading class or group,
     607              : /// an escape, an empty literal). A quantifier after the literal applies to
     608              : /// its last character, which is therefore not required.
     609          124 : pub fn id_pattern_literal(pattern: &str) -> Option<IdLiteral<'_>> {
     610          124 :     if pattern.contains('|') {
     611            4 :         return None;
     612          120 :     }
     613          120 :     let (body, anchored) = match pattern.strip_prefix('^') {
     614           28 :         Some(rest) => (rest, true),
     615           92 :         None => (pattern, false),
     616              :     };
     617          120 :     let start = pattern.len() - body.len();
     618          120 :     let mut end = start;
     619          120 :     let mut stop = None;
     620         2058 :     for (i, c) in body.char_indices() {
     621         2058 :         if ".^$*+?()[]{}|\\".contains(c) {
     622           98 :             stop = Some(c);
     623           98 :             break;
     624         1960 :         }
     625         1960 :         end = start + i + c.len_utf8();
     626              :     }
     627          120 :     if matches!(stop, Some('*' | '+' | '?' | '{')) {
     628            8 :         end -= pattern[start..end].chars().last()?.len_utf8();
     629          112 :     }
     630          120 :     (end > start).then_some(IdLiteral {
     631          120 :         text: &pattern[start..end],
     632          120 :         anchored,
     633          120 :     })
     634          124 : }
     635              : 
     636              : /// A literal every id a policy rule's `pattern` selects carries, the
     637              : /// longest one after the start, else the prefix,
     638              : /// read as the rule reads it, anchored at both ends (`^(?:pattern)$`); the
     639              : /// text and whether ids start with it. Unlike [`id_pattern_literal`] it
     640              : /// takes a literal from anywhere in the top-level sequence, so
     641              : /// `^urn:x:KPI:[a-z]+:metro:stat-[a-z]+$` narrows on `:metro:stat-`
     642              : /// and not on the prefix every KPI shares. `None` for a pattern
     643              : /// that does not parse or forces no literal (alternation, a case-insensitive
     644              : /// group, only classes). A necessary condition only: the regex still decides.
     645           34 : pub fn id_pattern_required(pattern: &str) -> Option<(String, bool)> {
     646              :     use regex_syntax::hir::{HirKind, Look};
     647           34 :     let hir = regex_syntax::Parser::new()
     648           34 :         .parse(&format!("^(?:{pattern})$"))
     649           34 :         .ok()?;
     650           32 :     let HirKind::Concat(parts) = hir.kind() else {
     651            0 :         return None;
     652              :     };
     653           32 :     let body = parts
     654           32 :         .iter()
     655           80 :         .skip_while(|h| matches!(h.kind(), HirKind::Look(Look::Start)));
     656           32 :     let mut best: Option<(String, bool)> = None;
     657          134 :     for (i, h) in body.enumerate() {
     658          134 :         let HirKind::Literal(l) = h.kind() else {
     659           96 :             continue;
     660              :         };
     661           38 :         let Ok(text) = std::str::from_utf8(&l.0) else {
     662            0 :             continue;
     663              :         };
     664              :         // an inner literal beats the prefix, which in `urn:ngsi-ld:{Type}:`
     665              :         // ids repeats the type the read already narrows on
     666           38 :         let rank = |len: usize, anchored: bool| (!anchored, len);
     667           38 :         if best
     668           38 :             .as_ref()
     669           38 :             .is_none_or(|(b, a)| rank(b.len(), *a) < rank(text.len(), i == 0))
     670           38 :         {
     671           38 :             best = Some((text.to_owned(), i == 0));
     672           38 :         }
     673              :     }
     674           32 :     best
     675           34 : }
     676              : 
     677              : /// An anchored `idPattern` read as `lead`, then a run of characters none of
     678              : /// which is `sep`, then `tail` (which starts with `sep`), as in
     679              : /// `^urn:ngsi-ld:KPI:[a-z0-9.-]+:metro:stat-…`. Every id it selects
     680              : /// starts with `lead + s + tail`, `s` being the text between `lead` and the
     681              : /// first `sep` after it, so the few values of `s` a Tenant's ids show turn
     682              : /// the pattern into exact id prefixes.
     683              : #[derive(Clone, Debug, PartialEq, Eq)]
     684              : pub struct IdSegments {
     685              :     /// the literal every match starts with
     686              :     pub lead: String,
     687              :     /// the first character of `tail`, which the run never holds
     688              :     pub sep: char,
     689              :     /// the literal right after the run
     690              :     pub tail: String,
     691              : }
     692              : 
     693              : /// [`IdSegments`] of `pattern` read as a policy rule reads it
     694              : /// (`^(?:pattern)$`); `None` unless its top-level sequence starts with a
     695              : /// literal, a class or a repetition of one that excludes an ASCII `sep`,
     696              : /// and a literal starting with `sep`.
     697           46 : pub fn id_pattern_segments(pattern: &str) -> Option<IdSegments> {
     698              :     use regex_syntax::hir::{Class, HirKind, Look};
     699           46 :     let hir = regex_syntax::Parser::new()
     700           46 :         .parse(&format!("^(?:{pattern})$"))
     701           46 :         .ok()?;
     702           42 :     let HirKind::Concat(parts) = hir.kind() else {
     703            0 :         return None;
     704              :     };
     705           42 :     let mut body = parts
     706           42 :         .iter()
     707          106 :         .skip_while(|h| matches!(h.kind(), HirKind::Look(Look::Start)));
     708           78 :     let text = |h: Option<&regex_syntax::hir::Hir>| match h.map(|h| h.kind()) {
     709           62 :         Some(HirKind::Literal(l)) => std::str::from_utf8(&l.0).ok().map(str::to_owned),
     710           16 :         _ => None,
     711           78 :     };
     712           42 :     let lead = text(body.next())?;
     713           36 :     let run = body.next()?;
     714           36 :     let tail = text(body.next())?;
     715           26 :     let sep = tail.chars().next().filter(char::is_ascii)?;
     716           26 :     let class = match run.kind() {
     717           22 :         HirKind::Repetition(r) => r.sub.kind(),
     718            4 :         k => k,
     719              :     };
     720           24 :     let holds_sep = match class {
     721           24 :         HirKind::Class(Class::Unicode(c)) => c
     722           24 :             .ranges()
     723           24 :             .iter()
     724           62 :             .any(|r| (r.start()..=r.end()).contains(&sep)),
     725            0 :         HirKind::Class(Class::Bytes(c)) => {
     726            0 :             let b = u8::try_from(sep).ok()?;
     727            0 :             c.ranges()
     728            0 :                 .iter()
     729            0 :                 .any(|r| (r.start()..=r.end()).contains(&b))
     730              :         }
     731            2 :         _ => return None,
     732              :     };
     733           24 :     (!holds_sep).then_some(IdSegments { lead, sep, tail })
     734           46 : }
     735              : 
     736              : /// `pattern` read as a policy rule reads it (`^(?:pattern)$`), written as a
     737              : /// PostgreSQL advanced regular expression with the same matches, so a store
     738              : /// counts a dataset view's Entities in one statement where the API judged
     739              : /// 164 000 of them per row and the gateway cut the read at 10 s.
     740              : /// `None` outside a subset both engines read alike: ASCII literals, classes
     741              : /// of printable ASCII, groups, alternation, greedy repetition up to 255
     742              : /// (PostgreSQL's `RE_DUP_MAX`), `^` and `$`. Membership of a regular language
     743              : /// does not depend on the engine's match preference, so within it the two
     744              : /// select the same ids.
     745              : /// No `\d`, `.` or non-ASCII folding (Unicode classes differ); add one
     746              : /// here with a parity case when a seeded pattern needs it.
     747           72 : pub fn id_pattern_sql(pattern: &str) -> Option<String> {
     748           72 :     let hir = regex_syntax::Parser::new()
     749           72 :         .parse(&format!("^(?:{pattern})$"))
     750           72 :         .ok()?;
     751           70 :     let mut out = String::new();
     752           70 :     are(&hir, &mut out)?;
     753              :     // a long pattern may pass `regex`'s bound and still be too complex for
     754              :     // PostgreSQL, which would fail the statement instead of the pushdown
     755           46 :     (out.len() <= 1024).then_some(out)
     756           72 : }
     757              : 
     758              : /// One printable ASCII character as an ARE atom: `\` keeps punctuation
     759              : /// literal, inside a bracket expression too.
     760         2040 : fn are_char(c: u8, out: &mut String) -> Option<()> {
     761         2040 :     if !c.is_ascii_graphic() {
     762           14 :         return None;
     763         2026 :     }
     764         2026 :     if !c.is_ascii_alphanumeric() {
     765          554 :         out.push('\\');
     766         1472 :     }
     767         2026 :     out.push(char::from(c));
     768         2026 :     Some(())
     769         2040 : }
     770              : 
     771          684 : fn are(h: &regex_syntax::hir::Hir, out: &mut String) -> Option<()> {
     772              :     use regex_syntax::hir::{Class, HirKind, Look};
     773          684 :     match h.kind() {
     774         1866 :         HirKind::Literal(l) => l.0.iter().try_for_each(|&b| are_char(b, out))?,
     775          130 :         HirKind::Class(Class::Unicode(c)) => {
     776          130 :             out.push('[');
     777          304 :             for r in c.ranges() {
     778          304 :                 let (s, e) = (u8::try_from(r.start()).ok()?, u8::try_from(r.end()).ok()?);
     779          300 :                 let kin = |a: u8, b: u8| {
     780          232 :                     (a.is_ascii_digit() && b.is_ascii_digit())
     781          152 :                         || (a.is_ascii_lowercase() && b.is_ascii_lowercase())
     782           58 :                         || (a.is_ascii_uppercase() && b.is_ascii_uppercase())
     783          232 :                 };
     784          300 :                 if s < e && kin(s, e) {
     785          174 :                     out.push(char::from(s));
     786          174 :                     out.push('-');
     787          174 :                     out.push(char::from(e));
     788          174 :                 } else {
     789          174 :                     (s..=e).try_for_each(|b| are_char(b, out))?;
     790              :                 }
     791              :             }
     792          116 :             out.push(']');
     793              :         }
     794          110 :         HirKind::Look(Look::Start) => out.push('^'),
     795           86 :         HirKind::Look(Look::End) => out.push('$'),
     796          118 :         HirKind::Repetition(r) if r.greedy && !matches!(r.sub.kind(), HirKind::Look(_)) => {
     797          138 :             let n = |v: u32| (v <= 255).then_some(v);
     798          116 :             out.push_str("(?:");
     799          116 :             are(&r.sub, out)?;
     800          108 :             out.push(')');
     801          108 :             let q = match (n(r.min)?, r.max.map(n)) {
     802            0 :                 (0, None) => "*".to_owned(),
     803           76 :                 (1, None) => "+".to_owned(),
     804            4 :                 (0, Some(Some(1))) => "?".to_owned(),
     805            0 :                 (m, None) => format!("{{{m},}}"),
     806           26 :                 (m, Some(Some(x))) if m == x => format!("{{{m}}}"),
     807            2 :                 (m, Some(Some(x))) => format!("{{{m},{x}}}"),
     808            0 :                 (_, Some(None)) => return None,
     809              :             };
     810          106 :             out.push_str(&q);
     811              :         }
     812            8 :         HirKind::Capture(c) => {
     813            8 :             out.push_str("(?:");
     814            8 :             are(&c.sub, out)?;
     815            8 :             out.push(')');
     816              :         }
     817          476 :         HirKind::Concat(hs) => hs.iter().try_for_each(|h| are(h, out))?,
     818            6 :         HirKind::Alternation(hs) => {
     819            6 :             out.push_str("(?:");
     820           14 :             for (i, h) in hs.iter().enumerate() {
     821           14 :                 if i > 0 {
     822            8 :                     out.push('|');
     823            8 :                 }
     824           14 :                 are(h, out)?;
     825              :             }
     826            4 :             out.push(')');
     827              :         }
     828            4 :         _ => return None,
     829              :     }
     830          626 :     Some(())
     831          684 : }
     832              : 
     833              : /// The least string that sorts, byte by byte, after every string starting
     834              : /// with `prefix`; `None` when nothing does (an empty prefix, or one of
     835              : /// `char::MAX` only). With it `[prefix, end)` is exactly the strings that
     836              : /// start with `prefix` under `COLLATE "C"`.
     837          138 : pub fn prefix_end(prefix: &str) -> Option<String> {
     838          138 :     let mut s = prefix.to_owned();
     839          142 :     while let Some(c) = s.pop() {
     840          138 :         let next = (u32::from(c).saturating_add(1)..=u32::from(char::MAX)).find_map(char::from_u32);
     841          138 :         if let Some(n) = next {
     842          134 :             s.push(n);
     843          134 :             return Some(s);
     844            4 :         }
     845              :     }
     846            4 :     None
     847          138 : }
     848              : 
     849              : #[cfg(test)]
     850              : mod id_literal_tests {
     851              :     use super::*;
     852              : 
     853              :     /// the seeded dataset patterns translate, each to an ARE that
     854              :     /// keeps punctuation literal; what both engines may read apart does not.
     855              :     #[test]
     856            2 :     fn id_pattern_sql_writes_the_seeded_patterns_and_refuses_the_rest() {
     857            2 :         let sql = id_pattern_sql;
     858            2 :         assert_eq!(
     859            2 :             sql("^urn:ngsi-ld:Tree:[a-z0-9.-]+:town-centre:green-[a-z-]+-[0-9a-f-]{36}$")
     860            2 :                 .as_deref(),
     861              :             Some(
     862              :                 "^^urn\\:ngsi\\-ld\\:Tree\\:(?:[\\-\\.0-9a-z])+\\:town\\-centre\\:green\\-\
     863              :                  (?:[\\-a-z])+\\-(?:[\\-0-9a-f]){36}$$"
     864              :             )
     865              :         );
     866            2 :         assert_eq!(
     867            2 :             sql("^urn:x:(1|2|both)-[0-9]{1,4}(-[a-z]+)?$").as_deref(),
     868              :             Some("^^urn\\:x\\:(?:(?:1|2|both))\\-(?:[0-9]){1,4}(?:(?:\\-(?:[a-z])+))?$$")
     869              :         );
     870           20 :         for refused in [
     871            2 :             "urn:x:.*",
     872            2 :             "urn:x:\\d+",
     873            2 :             "(?i)urn:k",
     874            2 :             "urn:x:[^a]",
     875            2 :             "urn:x:a+?",
     876            2 :             "urn:x:a{256}",
     877            2 :             "urn:x:\\bz",
     878            2 :             "urn:é",
     879            2 :             "urn:x |",
     880            2 :             "(unclosed",
     881            2 :         ] {
     882           20 :             assert_eq!(sql(refused), None, "{refused}");
     883              :         }
     884            2 :     }
     885              : 
     886              :     /// a rule's pattern narrows on its most selective literal; each
     887              :     /// result is a substring (a prefix when anchored) of every id it selects.
     888              :     #[test]
     889            2 :     fn required_literal_is_the_longest_the_rule_pattern_forces() {
     890            2 :         let req = id_pattern_required;
     891            2 :         let kpi = "^urn:ngsi-ld:KeyPerformanceIndicator:[a-z0-9.-]+:metro:stat-environment-[A-Za-z0-9._-]+$";
     892            2 :         assert_eq!(
     893            2 :             req(kpi),
     894            2 :             Some((":metro:stat-environment-".to_owned(), false))
     895              :         );
     896            2 :         assert_eq!(req("urn:x:abc.*"), Some(("urn:x:abc".to_owned(), true)));
     897              :         // a quantifier binds the last character
     898            2 :         assert_eq!(req("^abc*$"), Some(("ab".to_owned(), true)));
     899            2 :         assert_eq!(req("a|b"), None);
     900            2 :         assert_eq!(req("urn:(a|b)"), Some(("urn:".to_owned(), true)));
     901              :         // case-insensitive letters are classes; only the `:` is forced
     902            2 :         assert_eq!(req("(?i)urn:a"), Some((":".to_owned(), false)));
     903            2 :         assert_eq!(req("[ab]+"), None);
     904            2 :         assert_eq!(req("(unclosed"), None);
     905            2 :         assert_eq!(req(""), None);
     906            8 :         for (p, id) in [
     907            2 :             (
     908            2 :                 kpi,
     909            2 :                 "urn:ngsi-ld:KeyPerformanceIndicator:x.y:metro:stat-environment-a1",
     910            2 :             ),
     911            2 :             ("urn:x:abc.*", "urn:x:abc9"),
     912            2 :             ("^abc*$", "ab"),
     913            2 :             ("urn:(a|b)", "urn:b"),
     914            2 :         ] {
     915            8 :             let re = regex::Regex::new(&format!("^(?:{p})$")).expect("re");
     916            8 :             assert!(re.is_match(id), "{p} vs {id}");
     917            8 :             let (text, anchored) = req(p).expect("literal");
     918            8 :             assert!(
     919            8 :                 if anchored {
     920            6 :                     id.starts_with(&text)
     921              :                 } else {
     922            2 :                     id.contains(&text)
     923              :                 },
     924              :                 "{p}: {id}"
     925              :             );
     926              :         }
     927            2 :     }
     928              : 
     929              :     /// 5.2.33 idPattern narrowing: the literal is a necessary condition of
     930              :     /// the regex, never a sufficient one, so it may only ever shrink the
     931              :     /// candidate set the regex then decides.
     932              :     #[test]
     933            2 :     fn literal_is_a_necessary_condition_of_the_pattern() {
     934            2 :         let lit = id_pattern_literal;
     935            8 :         let some = |text, anchored| Some(IdLiteral { text, anchored });
     936            2 :         assert_eq!(
     937            2 :             lit("^urn:ngsi-ld:Vehicle:.*"),
     938            2 :             some("urn:ngsi-ld:Vehicle:", true)
     939              :         );
     940            2 :         assert_eq!(
     941            2 :             lit("urn:ngsi-ld:Sensor:t7:7.*"),
     942            2 :             some("urn:ngsi-ld:Sensor:t7:7", false)
     943              :         );
     944            2 :         assert_eq!(lit("urn:x:abc"), some("urn:x:abc", false));
     945              :         // a quantifier binds the last character: `c` is optional in `abc*`
     946            2 :         assert_eq!(lit("^abc*"), some("ab", true));
     947            2 :         assert_eq!(lit("a?bc"), None);
     948            2 :         assert_eq!(lit("^a{2}"), None);
     949              :         // nothing is forced at the start
     950            2 :         assert_eq!(lit(".*0$"), None);
     951            2 :         assert_eq!(lit("^"), None);
     952            2 :         assert_eq!(lit(""), None);
     953            2 :         assert_eq!(lit("(?i)abc"), None);
     954            2 :         assert_eq!(lit("[ab]c"), None);
     955            2 :         assert_eq!(lit("\\d+"), None);
     956              :         // alternation anywhere: the literal would exclude the other branch
     957            2 :         assert_eq!(lit("^urn:a|urn:b"), None);
     958            2 :         assert_eq!(lit("^urn:(a|b)"), None);
     959              :         // the extracted text is a substring of every regex match
     960            6 :         for (p, sample) in [
     961            2 :             ("^urn:ngsi-ld:Vehicle:.*", "urn:ngsi-ld:Vehicle:1"),
     962            2 :             ("t7:7.*", "urn:ngsi-ld:Sensor:t7:71"),
     963            2 :             ("^abc*", "abd"),
     964            2 :         ] {
     965            6 :             let l = lit(p).expect("literal");
     966            6 :             assert!(
     967            6 :                 regex::Regex::new(p).expect("re").is_match(sample),
     968              :                 "{p} vs {sample}"
     969              :             );
     970            6 :             assert!(
     971            6 :                 if l.anchored {
     972            4 :                     sample.starts_with(l.text)
     973              :                 } else {
     974            2 :                     sample.contains(l.text)
     975              :                 },
     976              :                 "{p}: {sample}"
     977              :             );
     978              :         }
     979            2 :     }
     980              : 
     981              :     /// a pattern's segments are exact prefixes of every id it
     982              :     /// selects, and `prefix_end` bounds exactly the strings with a prefix.
     983              :     #[test]
     984            2 :     fn segments_split_a_pattern_at_its_first_run() {
     985            2 :         let kpi = "^urn:ngsi-ld:KeyPerformanceIndicator:[a-z0-9.-]+:metro:stat-environment-[A-Za-z0-9._-]+$";
     986            2 :         let s = id_pattern_segments(kpi).expect("segments");
     987            2 :         assert_eq!(s.lead, "urn:ngsi-ld:KeyPerformanceIndicator:");
     988            2 :         assert_eq!(s.sep, ':');
     989            2 :         assert_eq!(s.tail, ":metro:stat-environment-");
     990            2 :         let id = "urn:ngsi-ld:KeyPerformanceIndicator:city.example:metro:stat-environment-a1";
     991            2 :         let re = regex::Regex::new(&format!("^(?:{kpi})$")).expect("re");
     992            2 :         assert!(re.is_match(id));
     993            2 :         let run = &id[s.lead.len()..];
     994            2 :         let seg = &run[..run.find(s.sep).expect("sep")];
     995            2 :         assert!(id.starts_with(&format!("{}{seg}{}", s.lead, s.tail)));
     996            2 :         assert_eq!(
     997            2 :             id_pattern_segments("urn:x:[a-z]:t").map(|s| s.tail),
     998            2 :             Some(":t".to_owned())
     999              :         );
    1000              :         // the run may hold the separator: no split
    1001            2 :         assert_eq!(id_pattern_segments("urn:x:.+:t"), None);
    1002            2 :         assert_eq!(id_pattern_segments("urn:x:[a-z:]+:t"), None);
    1003              :         // no literal after the run, a run first, alternation, case folding
    1004            2 :         assert_eq!(id_pattern_segments("urn:x:[a-z]+"), None);
    1005            2 :         assert_eq!(id_pattern_segments("[a-z]+:t"), None);
    1006            2 :         assert_eq!(id_pattern_segments("urn:(a|b):t"), None);
    1007            2 :         assert_eq!(id_pattern_segments("(?i)urn:[a-z]+:t"), None);
    1008            2 :         assert_eq!(id_pattern_segments("(unclosed"), None);
    1009              : 
    1010            2 :         assert_eq!(prefix_end("ab:").as_deref(), Some("ab;"));
    1011            2 :         assert_eq!(prefix_end("a\u{10FFFF}").as_deref(), Some("b"));
    1012            2 :         assert_eq!(prefix_end("a\u{D7FF}").as_deref(), Some("a\u{E000}"));
    1013            2 :         assert_eq!(prefix_end(""), None);
    1014            2 :         assert_eq!(prefix_end("\u{10FFFF}"), None);
    1015            2 :         let end = prefix_end("ab:").expect("end");
    1016           10 :         for (s, inside) in [
    1017            2 :             ("ab:", true),
    1018            2 :             ("ab:\u{10FFFF}z", true),
    1019            2 :             ("ab;", false),
    1020            2 :             ("ab", false),
    1021            2 :             ("ab9", false),
    1022            2 :         ] {
    1023           10 :             assert_eq!(
    1024           10 :                 s.as_bytes() >= b"ab:".as_slice() && s.as_bytes() < end.as_bytes(),
    1025              :                 inside,
    1026              :                 "{s}"
    1027              :             );
    1028              :         }
    1029            2 :     }
    1030              : }
        

Generated by: LCOV version 2.0-1