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