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