Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! In-memory `q=` evaluation against internal expanded entities — the
3 : //! evaluator the query path and the subscription matcher share, so the two
4 : //! cannot disagree; a gateway evaluating the same `q` gets the same answer.
5 :
6 : use crate::{CmpOp, QNode, QPath, QValue};
7 : use antares_jsonld::Context;
8 : use serde_json::Value;
9 : use std::cell::Cell;
10 :
11 : /// Entity lookups one `q=` expression may buy while resolving 4.9
12 : /// linked-entity terms (`attr{…}`, EXAMPLE 13/14). The hop count is capped
13 : /// by the query language itself, but each hop fans out over every object of
14 : /// a Relationship, so the walk costs fan-out^hops store reads. Exhausting
15 : /// the budget yields no further target — the same outcome an unresolvable
16 : /// linked entity already has — instead of a store scan per candidate entity.
17 : pub const MAX_Q_LINK_LOOKUPS: usize = 512;
18 :
19 : /// Entity resolver for 4.9 linked-entity subqueries (`attr{path}`,
20 : /// EXAMPLE 13/14). Returns the expanded entity for a URI, or None when the
21 : /// entity is unknown or the evaluation context has no store access — a
22 : /// linked term then simply does not match.
23 : pub type EntityLookup<'a> = &'a dyn Fn(&str) -> Option<Value>;
24 :
25 : /// One q expression buys `MAX_Q_LINK_LOOKUPS` entity lookups for its
26 : /// 4.9 linked-entity terms, shared by every term and every recursion branch.
27 2046 : pub fn eval_q(node: &QNode, entity: &Value, ctx: &Context, lookup: EntityLookup) -> bool {
28 2046 : eval_node(node, entity, ctx, lookup, &Cell::new(MAX_Q_LINK_LOOKUPS))
29 2046 : }
30 :
31 2470 : fn eval_node(
32 2470 : node: &QNode,
33 2470 : entity: &Value,
34 2470 : ctx: &Context,
35 2470 : lookup: EntityLookup,
36 2470 : budget: &Cell<usize>,
37 2470 : ) -> bool {
38 2470 : match node {
39 140 : QNode::And(items) => items
40 140 : .iter()
41 194 : .all(|n| eval_node(n, entity, ctx, lookup, budget)),
42 134 : QNode::Or(items) => items
43 134 : .iter()
44 230 : .any(|n| eval_node(n, entity, ctx, lookup, budget)),
45 112 : QNode::Exists { path, negated } => {
46 112 : let found = !resolve_qpath(entity, path, ctx, lookup, budget).is_empty();
47 112 : found != *negated
48 : }
49 : // 4.9: "If the target element corresponds to a Relationship or
50 : // ListRelationship, the combination of such target element with any
51 : // operator different than equal or unequal shall result in not
52 : // matching."
53 2084 : QNode::Cmp { path, op, value } => {
54 : // The pattern of `~=` / `!~=` belongs to the Query Term, not to
55 : // the target: it is compiled once per term instead of once per
56 : // candidate value, and the compiled program is shared
57 : // process-wide, so re-evaluating the same term over the next
58 : // candidate entity or the next event costs no compile at all.
59 : // A pattern that does not compile has no L(R), so neither
60 : // operator matches (4.9 p.92) — that is what the `None` below
61 : // means downstream.
62 2084 : let re = match (op, value) {
63 230 : (CmpOp::Pattern | CmpOp::NotPattern, QValue::Str(s)) => {
64 230 : crate::regex::compile(s).ok()
65 : }
66 1854 : _ => None,
67 : };
68 2084 : resolve_qpath(entity, path, ctx, lookup, budget)
69 2084 : .iter()
70 2084 : .any(|(kind, v)| kind_allows(*kind, *op) && compare(v, *op, value, re.as_deref()))
71 : }
72 : }
73 2470 : }
74 :
75 : /// 4.9 expandValues: rewrite the string values of query terms whose
76 : /// top-level attribute is named in the comma-separated `expandValues` list —
77 : /// each is expanded against the @context (JSON-LD type coercion), so e.g.
78 : /// `gender==Male&expandValues=gender` compares against the Male URI
79 : /// (EXAMPLE 12).
80 524 : pub fn apply_expand_values(node: QNode, expand_values: Option<&str>, ctx: &Context) -> QNode {
81 524 : let Some(list) = expand_values else {
82 494 : return node;
83 : };
84 30 : let names: Vec<&str> = list.split(',').map(str::trim).collect();
85 28 : fn expand_val(v: QValue, ctx: &Context) -> QValue {
86 28 : match v {
87 28 : QValue::Str(s) => QValue::Str(ctx.expand_key(&s)),
88 0 : QValue::List(items) => {
89 0 : QValue::List(items.into_iter().map(|i| expand_val(i, ctx)).collect())
90 : }
91 0 : other => other,
92 : }
93 28 : }
94 30 : fn walk(node: QNode, names: &[&str], ctx: &Context) -> QNode {
95 30 : match node {
96 0 : QNode::And(items) => {
97 0 : QNode::And(items.into_iter().map(|n| walk(n, names, ctx)).collect())
98 : }
99 0 : QNode::Or(items) => QNode::Or(items.into_iter().map(|n| walk(n, names, ctx)).collect()),
100 30 : QNode::Cmp { path, op, value } if path.top().is_some_and(|t| names.contains(&t)) => {
101 28 : QNode::Cmp {
102 28 : path,
103 28 : op,
104 28 : value: expand_val(value, ctx),
105 28 : }
106 : }
107 2 : other => other,
108 : }
109 30 : }
110 30 : walk(node, &names, ctx)
111 524 : }
112 :
113 : /// 4.9 names two lists of Attributes: `expandValues`, whose values "should
114 : /// be expanded against the supplied @context using JSON-LD type coercion
115 : /// prior to executing the query", and `jsonKeys`, whose values "are to be
116 : /// considered uninterpretable as JSON-LD and should not be expanded" the
117 : /// same way. The clause states no precedence for a name in both, so the
118 : /// broker settles it: `jsonKeys` says what the value IS, `expandValues` only
119 : /// asks for a comparison, and coercing a value the client has declared
120 : /// unreadable builds a term the stored value can never carry. A name in
121 : /// both lists is therefore left out of the expansion.
122 : ///
123 : /// Returns the list [`apply_expand_values`] should read, or `None` when
124 : /// nothing is left to expand. The entity query, the temporal query and a
125 : /// Subscription's notification condition (Table 5.2.12-1) all carry the
126 : /// pair, and all three read it here.
127 584 : pub fn expansion_list(expand_values: Option<&str>, json_keys: Option<&str>) -> Option<String> {
128 584 : let names = expand_values?;
129 34 : let Some(raw) = json_keys else {
130 24 : return Some(names.to_owned());
131 : };
132 10 : let raw: Vec<&str> = raw.split(',').map(str::trim).collect();
133 10 : let kept: Vec<&str> = names
134 10 : .split(',')
135 10 : .map(str::trim)
136 10 : .filter(|n| !raw.contains(n))
137 10 : .collect();
138 10 : (!kept.is_empty()).then(|| kept.join(","))
139 584 : }
140 :
141 : /// Which value-defining member the target element carried.
142 : #[derive(Debug, Clone, Copy, PartialEq, Eq)]
143 : enum TargetKind {
144 : Value,
145 : Object,
146 : LanguageMap,
147 : Vocab,
148 : Json,
149 : ValueList,
150 : ObjectList,
151 : }
152 :
153 1064 : fn kind_allows(kind: TargetKind, op: CmpOp) -> bool {
154 1064 : match kind {
155 24 : TargetKind::Object | TargetKind::ObjectList => matches!(op, CmpOp::Eq | CmpOp::Ne),
156 1040 : _ => true,
157 : }
158 1064 : }
159 :
160 : /// Resolve a 4.9 attribute path — linked-entity hops first (EXAMPLE 13/14),
161 : /// then the dotted path with its optional trailing bracket.
162 : ///
163 : /// The hop count is capped by the query language, but each hop fans out over
164 : /// every object of the Relationship, so the walk is bounded by WORK: the
165 : /// shared `budget` counts entity lookups across every branch, and an
166 : /// exhausted budget resolves to no target — the outcome an unresolvable
167 : /// linked entity already has.
168 4260 : fn resolve_qpath(
169 4260 : entity: &Value,
170 4260 : qp: &QPath,
171 4260 : ctx: &Context,
172 4260 : lookup: EntityLookup,
173 4260 : budget: &Cell<usize>,
174 4260 : ) -> Vec<(TargetKind, Value)> {
175 4260 : let Some(link) = qp.links.first() else {
176 3678 : return resolve_targets(entity, qp, ctx);
177 : };
178 582 : let iri = ctx.expand_key(&link.attr);
179 582 : let Some(instances) = entity.get(&iri).and_then(Value::as_array) else {
180 4 : return vec![];
181 : };
182 578 : let mut uris: Vec<&str> = Vec::new();
183 2258 : for inst in instances {
184 2258 : match inst.get("object") {
185 2258 : Some(Value::String(s)) => uris.push(s),
186 0 : Some(Value::Array(a)) => uris.extend(a.iter().filter_map(Value::as_str)),
187 0 : _ => {}
188 : }
189 2258 : if let Some(Value::Array(a)) = inst.get("objectList") {
190 0 : uris.extend(a.iter().filter_map(Value::as_str));
191 2258 : }
192 : }
193 578 : let rest = QPath {
194 578 : links: qp.links[1..].to_vec(),
195 578 : path: qp.path.clone(),
196 578 : bracket: qp.bracket.clone(),
197 578 : };
198 578 : let mut out = Vec::new();
199 2088 : for uri in uris {
200 2088 : let Some(left) = budget.get().checked_sub(1) else {
201 6 : break;
202 : };
203 2082 : budget.set(left);
204 2082 : let Some(linked) = lookup(uri) else { continue };
205 : // EXAMPLE 14 type hint: only consider target entities of these types
206 2066 : if !link.types.is_empty() {
207 4 : let matched = linked["type"].as_array().is_some_and(|a| {
208 4 : a.iter()
209 4 : .filter_map(Value::as_str)
210 4 : .any(|t| link.types.iter().any(|hint| ctx.expand_key(hint) == t))
211 4 : });
212 4 : if !matched {
213 2 : continue;
214 2 : }
215 2062 : }
216 2064 : out.extend(resolve_qpath(&linked, &rest, ctx, lookup, budget));
217 : }
218 578 : out
219 4260 : }
220 :
221 : /// Resolve a dotted q path to candidate (kind, value) targets across
222 : /// instances.
223 3678 : fn resolve_targets(entity: &Value, qp: &QPath, ctx: &Context) -> Vec<(TargetKind, Value)> {
224 3678 : let Some(first) = qp.path.first() else {
225 0 : return vec![];
226 : };
227 3678 : let iri = ctx.expand_key(first);
228 3678 : let Some(instances) = entity.get(&iri).and_then(Value::as_array) else {
229 1036 : return vec![];
230 : };
231 2642 : let mut out = Vec::new();
232 2666 : for inst in instances {
233 2666 : collect(inst, &qp.path[1..], qp.bracket.as_deref(), ctx, &mut out);
234 2666 : }
235 2642 : out
236 3678 : }
237 :
238 2672 : fn collect(
239 2672 : inst: &Value,
240 2672 : rest: &[String],
241 2672 : bracket: Option<&[String]>,
242 2672 : ctx: &Context,
243 2672 : out: &mut Vec<(TargetKind, Value)>,
244 2672 : ) {
245 2672 : if rest.is_empty() {
246 2662 : terminal(inst, bracket, ctx, out);
247 2662 : return;
248 10 : }
249 10 : let seg = &rest[0];
250 : // 1. sub-attribute step (expanded key)
251 10 : let iri = ctx.expand_key(seg);
252 10 : if let Some(subs) = inst.get(&iri).and_then(Value::as_array) {
253 6 : for s in subs {
254 6 : collect(s, &rest[1..], bracket, ctx, out);
255 6 : }
256 6 : return;
257 4 : }
258 : // 2. legacy value-path step: navigate into the value object (pre-bracket
259 : // dotted access, kept as a superset of the 4.9 bracket form)
260 4 : if let Some((kind, v)) = comparable_value(inst) {
261 4 : if let Some(nested) = navigate(v, rest) {
262 2 : match bracket {
263 2 : None => push_target(kind, nested, out),
264 0 : Some(b) => {
265 0 : if let Some(deeper) = navigate(nested, b) {
266 0 : push_target(kind, deeper, out);
267 0 : }
268 : }
269 : }
270 2 : }
271 0 : }
272 2672 : }
273 :
274 : /// Terminal instance: extract the target value, applying the trailing
275 : /// bracket — a language filter on a LanguageProperty (4.9 Equal/Unequal
276 : /// languageMap semantics), a MemberExpression into a compound value
277 : /// (EXAMPLE 9/10/11) otherwise.
278 2662 : fn terminal(
279 2662 : inst: &Value,
280 2662 : bracket: Option<&[String]>,
281 2662 : ctx: &Context,
282 2662 : out: &mut Vec<(TargetKind, Value)>,
283 2662 : ) {
284 2662 : let Some((kind, v)) = comparable_value(inst) else {
285 2 : return;
286 : };
287 2660 : match (bracket, kind) {
288 28 : (None, TargetKind::Vocab) => {
289 28 : // 4.9: "If the target element is a VocabProperty, the target
290 28 : // value shall be expanded according to the @context."
291 28 : out.push((kind, expand_vocab(v, ctx)));
292 28 : }
293 2556 : (None, _) => push_target(kind, v, out),
294 54 : (Some(b), TargetKind::LanguageMap) => {
295 54 : let Some(map) = v.as_object() else { return };
296 54 : if b.len() != 1 {
297 0 : return;
298 54 : }
299 54 : if b[0] == "*" {
300 : // any language: ONE array target so that != requires no
301 : // matching value in ANY language (4.9 Unequal, color[*])
302 22 : let mut all = Vec::new();
303 50 : for val in map.values() {
304 50 : match val {
305 0 : Value::Array(a) => all.extend(a.iter().cloned()),
306 50 : other => all.push(other.clone()),
307 : }
308 : }
309 22 : out.push((kind, Value::Array(all)));
310 32 : } else if let Some(val) = map.get(&b[0]) {
311 32 : out.push((kind, val.clone()));
312 32 : }
313 : }
314 22 : (Some(b), _) => {
315 : // MemberExpression into the compound value; undefined result =
316 : // "the target element shall be considered as non-existent"
317 22 : if let Some(nested) = navigate(v, b) {
318 12 : push_target(kind, nested, out);
319 12 : }
320 : }
321 : }
322 2662 : }
323 :
324 : /// Expand a vocab value (string or array of strings) against the @context.
325 28 : fn expand_vocab(v: &Value, ctx: &Context) -> Value {
326 28 : match v {
327 28 : Value::String(s) => Value::String(ctx.expand_key(s)),
328 0 : Value::Array(a) => Value::Array(a.iter().map(|x| expand_vocab(x, ctx)).collect()),
329 0 : other => other.clone(),
330 : }
331 28 : }
332 :
333 26 : fn navigate<'a>(v: &'a Value, path: &[String]) -> Option<&'a Value> {
334 26 : let mut cur = v;
335 30 : for seg in path {
336 30 : cur = cur.get(seg)?;
337 : }
338 14 : Some(cur)
339 26 : }
340 :
341 : /// 4.9 target value: annex C.6 lets a Property value be written as a JSON-LD
342 : /// typed value, and the Value the Property carries is then the `@value`
343 : /// member rather than the object around it. Members of a list are unwrapped
344 : /// one by one, and a compound value — an object with no `@value` — is left
345 : /// whole, so a MemberExpression still navigates it.
346 2634 : fn untyped(v: &Value) -> Value {
347 2634 : match v {
348 30 : Value::Object(o) => o.get("@value").cloned().unwrap_or_else(|| v.clone()),
349 48 : Value::Array(a) => Value::Array(a.iter().map(untyped).collect()),
350 2556 : other => other.clone(),
351 : }
352 2634 : }
353 :
354 : /// One target, with the typed-value unwrap on the kinds that can carry one.
355 : /// A JsonProperty is not one of them: the core `@context` types its `json`
356 : /// member `@json`, so an `@value` inside it is data, not JSON-LD.
357 2570 : fn push_target(kind: TargetKind, v: &Value, out: &mut Vec<(TargetKind, Value)>) {
358 2570 : let v = match kind {
359 2538 : TargetKind::Value | TargetKind::ValueList => untyped(v),
360 32 : _ => v.clone(),
361 : };
362 2570 : out.push((kind, v));
363 2570 : }
364 :
365 2666 : fn comparable_value(inst: &Value) -> Option<(TargetKind, &Value)> {
366 2666 : let obj = inst.as_object()?;
367 2942 : for (k, kind) in [
368 2666 : ("value", TargetKind::Value),
369 2666 : ("object", TargetKind::Object),
370 2666 : ("languageMap", TargetKind::LanguageMap),
371 2666 : ("vocab", TargetKind::Vocab),
372 2666 : ("json", TargetKind::Json),
373 2666 : ("valueList", TargetKind::ValueList),
374 2666 : ("objectList", TargetKind::ObjectList),
375 2666 : ] {
376 2942 : if let Some(v) = obj.get(k) {
377 2664 : return Some((kind, v));
378 278 : }
379 : }
380 2 : None
381 2666 : }
382 :
383 : /// Do target and Query Term value share a datatype? 4.9 hangs two opposite
384 : /// rules on this: Equal (and the ordering operators) treat a mismatch as "not
385 : /// matching", Unequal treats it as unequal — i.e. a MATCH.
386 182 : fn same_datatype(target: &Value, want: &QValue) -> bool {
387 182 : match want {
388 60 : QValue::Num(_) => target.is_number(),
389 108 : QValue::Str(_) => target.is_string(),
390 0 : QValue::Bool(_) => target.is_boolean(),
391 : // a Range's value space is its endpoints' (the parser pins both to
392 : // one variant); Lists never reach this guard — they are unfolded
393 : // into per-element compares first, each applying its own rule
394 14 : QValue::Range(lo, _) => same_datatype(target, lo),
395 0 : QValue::List(_) => true,
396 : }
397 182 : }
398 :
399 : /// `re` is the pre-compiled pattern of the enclosing Query Term, `None`
400 : /// when the operator is not a pattern operator or the pattern is invalid.
401 1312 : fn compare(target: &Value, op: CmpOp, want: &QValue, re: Option<®ex::Regex>) -> bool {
402 : // 4.9 ValueList — Equal p.90: "identical or equivalent to ANY of the list
403 : // values"; Unequal p.91: "neither identical nor equivalent to any of the
404 : // list values" / "does not include ANY of the list values" — i.e. every
405 : // per-element != must hold. Unfold before the array unwrap so the array
406 : // rules apply per list element.
407 1312 : if let QValue::List(vals) = want {
408 60 : return match op {
409 56 : CmpOp::Eq => vals.iter().any(|v| compare(target, op, v, re)),
410 46 : CmpOp::Ne => vals.iter().all(|v| compare(target, op, v, re)),
411 0 : _ => false, // grammar-unreachable (parser rejects), stay safe
412 : };
413 1252 : }
414 1252 : if let Value::Array(items) = target {
415 : // 4.9 Unequal, p.91: "The target value does not include any of the list
416 : // values, if the target value is an array (e.g. matches
417 : // ["blue","black","green"], but not ["blue","red","green"])" — so for
418 : // `!=` EVERY element must differ. Same reading for `!~=` ("shall not
419 : // be in L(R)" — one matching element would be in it). `.any()` is
420 : // right for the rest.
421 82 : return match op {
422 64 : CmpOp::Ne | CmpOp::NotPattern => items.iter().all(|i| compare(i, op, want, re)),
423 92 : _ => items.iter().any(|i| compare(i, op, want, re)),
424 : };
425 1170 : }
426 : // 4.9 Unequal, p.92: "If the data type of the target value and the data
427 : // type of the Query Term value are different, then they shall be
428 : // considered unequal." Equal carries the mirror-image rule, so this guard
429 : // is deliberately asymmetric and must run before the casts below — which
430 : // all return false on a failed cast. (`!~=` is NOT symmetric with `!=`
431 : // here: p.92 "If the target value data type is different than String then
432 : // it shall be considered as not matching" — so no early true for it.)
433 1170 : if op == CmpOp::Ne && !same_datatype(target, want) {
434 30 : return true;
435 1140 : }
436 : // 4.9 Range — Equal p.90: "in the interval between the minimum and
437 : // maximum of the range (both included)"; Unequal p.91: "not in the
438 : // interval".
439 1140 : if let QValue::Range(lo, hi) = want {
440 60 : return match op {
441 48 : CmpOp::Eq => in_range(target, lo, hi),
442 12 : CmpOp::Ne => !in_range(target, lo, hi),
443 0 : _ => false, // grammar-unreachable (parser rejects), stay safe
444 : };
445 1080 : }
446 1080 : match want {
447 546 : QValue::Num(n) => {
448 546 : let Some(t) = target.as_f64() else {
449 46 : return false;
450 : };
451 500 : num_cmp(t, op, *n)
452 : }
453 22 : QValue::Bool(b) => match op {
454 22 : CmpOp::Eq => target.as_bool() == Some(*b),
455 0 : CmpOp::Ne => target.as_bool().is_some_and(|t| t != *b),
456 0 : _ => false,
457 : },
458 512 : QValue::Str(s) => {
459 512 : let Some(t) = target.as_str() else {
460 28 : return false;
461 : };
462 : // 4.9 p.92: "When comparing dates or times, the order relation
463 : // considered shall be a temporal one" (EXAMPLE 8 of the clause is
464 : // exactly this: `?q=temperature.observedAt>=2017-12-24T12:00:00Z`).
465 : // Equality stays a string comparison: `==` also lowers to a
466 : // jsonpath string compare in `antares_ql::sql`, and widening it
467 : // here alone would make the memory and Postgres arms disagree.
468 484 : if matches!(op, CmpOp::Gt | CmpOp::Ge | CmpOp::Lt | CmpOp::Le) {
469 70 : if let (Some(tk), Some(sk)) = (temporal_key(t), temporal_key(s)) {
470 : // equal length is equal base shape — a Time and a
471 : // DateTime are not two points on one axis
472 38 : if tk.len() == sk.len() {
473 38 : return match op {
474 22 : CmpOp::Gt => tk > sk,
475 6 : CmpOp::Ge => tk >= sk,
476 4 : CmpOp::Lt => tk < sk,
477 6 : _ => tk <= sk,
478 : };
479 0 : }
480 32 : }
481 414 : }
482 446 : match op {
483 270 : CmpOp::Eq => t == s,
484 84 : CmpOp::Ne => t != s,
485 22 : CmpOp::Gt => t > s.as_str(),
486 10 : CmpOp::Ge => t >= s.as_str(),
487 0 : CmpOp::Lt => t < s.as_str(),
488 0 : CmpOp::Le => t <= s.as_str(),
489 34 : CmpOp::Pattern => re.is_some_and(|re| re.is_match(t)),
490 : // p.92: target "shall not be in the L(R)" — an invalid regex
491 : // has no L(R), treat as not matching (same posture as ~=)
492 26 : CmpOp::NotPattern => re.is_some_and(|re| !re.is_match(t)),
493 : }
494 : }
495 : // both are answered by the unfold guards at the top of this
496 : // function; false is the same posture the arms there take
497 0 : QValue::List(_) | QValue::Range(..) => false,
498 : }
499 1312 : }
500 :
501 : /// Canonical ordering key for a 4.6.3 DateTime or Time: the trailing `Z`
502 : /// dropped and the optional seconds fraction (`.`, or the request-side `,`)
503 : /// zero-padded to six digits, so string order equals temporal order across
504 : /// spellings of one instant. Without it `.` (0x2E) sorts before `Z` (0x5A)
505 : /// and `…:00.500Z` reads as EARLIER than `…:00Z`. A Date carries no fraction
506 : /// and already orders lexicographically, so it needs no key. `None` for
507 : /// anything that is neither shape, which leaves an ordinary string ordered
508 : /// as a string. `antares_model::dt_key` is the same rule for the DateTime
509 : /// shape alone, and returns its input unchanged rather than `None`, which is
510 : /// why the two are not one function.
511 198 : fn temporal_key(s: &str) -> Option<String> {
512 198 : let body = s.strip_suffix('Z')?;
513 130 : let (base, frac) = match body.find(['.', ',']) {
514 40 : Some(i) => (&body[..i], &body[i + 1..]),
515 90 : None => (body, ""),
516 : };
517 : // hh:mm:ss (Time) or YYYY-MM-DDThh:mm:ss (DateTime). What counts as a
518 : // DateTime is 4.6.3's shape, and it is decided by the one function every
519 : // other layer asks: a length of nineteen is not a date, and treating an
520 : // arbitrary nineteen-character string ending in Z as an instant is how a
521 : // string range stops being a string range (4.9 p.92).
522 130 : let shaped = match base.len() {
523 114 : 19 => antares_jsonld::parse_datetime(s),
524 : // a Time carries no calendar part to validate, only its own shape
525 102 : 8 => base.bytes().enumerate().all(|(i, b)| {
526 102 : if i == 2 || i == 5 {
527 26 : b == b':'
528 : } else {
529 76 : b.is_ascii_digit()
530 : }
531 102 : }),
532 2 : _ => false,
533 : };
534 130 : (shaped && frac.len() <= 6 && frac.bytes().all(|c| c.is_ascii_digit()))
535 130 : .then(|| format!("{base}.{frac:0<6}"))
536 198 : }
537 :
538 : /// `t ∈ [lo, hi]`, both included (4.9 p.90). The parser guarantees both
539 : /// endpoints share one variant and are never booleans.
540 60 : fn in_range(target: &Value, lo: &QValue, hi: &QValue) -> bool {
541 60 : match (lo, hi) {
542 52 : (QValue::Num(a), QValue::Num(b)) => target.as_f64().is_some_and(|t| t >= *a && t <= *b),
543 : // 4.9 p.92 again: a Range over dates or times is an interval on the
544 : // temporal axis, so its two inclusive bounds are compared the same
545 : // way the ordering operators are. A string range stays a string
546 : // range. (A Range only reaches SQL when both endpoints are Numbers,
547 : // so this arm has no Postgres twin to diverge from.)
548 8 : (QValue::Str(a), QValue::Str(b)) => target.as_str().is_some_and(|t| {
549 8 : match (temporal_key(t), temporal_key(a), temporal_key(b)) {
550 8 : (Some(tk), Some(ak), Some(bk)) if tk.len() == ak.len() && ak.len() == bk.len() => {
551 8 : tk >= ak && tk <= bk
552 : }
553 0 : _ => t >= a.as_str() && t <= b.as_str(),
554 : }
555 8 : }),
556 0 : _ => false,
557 : }
558 60 : }
559 :
560 500 : fn num_cmp(t: f64, op: CmpOp, n: f64) -> bool {
561 500 : match op {
562 72 : CmpOp::Eq => t == n,
563 42 : CmpOp::Ne => t != n,
564 290 : CmpOp::Gt => t > n,
565 22 : CmpOp::Ge => t >= n,
566 74 : CmpOp::Lt => t < n,
567 0 : CmpOp::Le => t <= n,
568 0 : CmpOp::Pattern | CmpOp::NotPattern => false,
569 : }
570 500 : }
571 :
572 : #[cfg(test)]
573 : mod clause_4_9_extensions {
574 : use super::*;
575 : use crate::parse_q;
576 : use antares_jsonld::Context;
577 : use serde_json::json;
578 :
579 130 : fn ctx() -> std::sync::Arc<Context> {
580 130 : antares_jsonld::core_context().into()
581 130 : }
582 :
583 24 : fn expand(doc: serde_json::Value) -> Value {
584 24 : antares_jsonld::expand_entity(
585 24 : doc.as_object().expect("obj"),
586 24 : &ctx(),
587 24 : antares_jsonld::ExpandOpts::default(),
588 : )
589 24 : .expect("expand")
590 24 : }
591 :
592 88 : fn q(doc: &Value, q: &str) -> bool {
593 88 : let ast = parse_q(q).expect(q);
594 88 : eval_q(&ast, doc, &ctx(), &|_| None)
595 88 : }
596 :
597 : /// 4.9 EXAMPLE 9/10/11: trailing [path] navigates the compound value
598 : /// (MemberExpression); undefined member = target non-existent.
599 : #[test]
600 2 : fn compound_value_trailing_path() {
601 2 : let e = expand(json!({"id": "urn:x", "type": "T",
602 2 : "address": {"type": "Property",
603 2 : "value": {"city": "Berlin", "street": "Ulrich Strasse"}},
604 2 : "sensor": {"type": "Property", "value": 40,
605 2 : "rawdata": {"type": "Property",
606 2 : "value": {"airquality": {"particulate": 40, "PM20": 85}}}},
607 2 : "parkingTickets": {"type": "JsonProperty",
608 2 : "json": {"id": "85a6cc52", "value": "Overstay 60 minutes"}}}));
609 2 : assert!(q(&e, r#"address[city]=="Berlin""#), "EXAMPLE 9");
610 2 : assert!(!q(&e, r#"address[city]=="Paris""#));
611 2 : assert!(!q(&e, r#"address[postcode]=="Berlin""#), "undefined member");
612 2 : assert!(
613 2 : q(&e, "sensor.rawdata[airquality.particulate]==40"),
614 : "EXAMPLE 10"
615 : );
616 2 : assert!(!q(&e, "sensor.rawdata[airquality.missing]==40"));
617 2 : assert!(
618 2 : q(&e, r#"parkingTickets[value]=="Overstay 60 minutes""#),
619 : "EXAMPLE 11 (JsonProperty raw json navigation)"
620 : );
621 : // existence through the bracket: defined member exists, missing not
622 2 : assert!(q(&e, "address[city]"));
623 2 : assert!(!q(&e, "address[postcode]"));
624 2 : }
625 :
626 : /// 4.9 Equal/Unequal languageMap semantics: [lang] targets one language,
627 : /// [*] any; != over [*] requires NO value to match.
628 : #[test]
629 2 : fn language_property_filters() {
630 2 : let e = expand(json!({"id": "urn:x", "type": "T",
631 2 : "color": {"type": "LanguageProperty",
632 2 : "languageMap": {"fr": "rouge", "en": "red", "de": "rot"}},
633 2 : "names": {"type": "LanguageProperty",
634 2 : "languageMap": {"fr": ["chat", "rouge"], "en": ["red", "cat"]}}}));
635 2 : assert!(q(&e, r#"color[en]=="red""#));
636 2 : assert!(
637 2 : !q(&e, r#"color[en]=="rouge""#),
638 : "wrong language must not match"
639 : );
640 2 : assert!(q(&e, r#"color[*]=="rouge""#), "any-language match");
641 2 : assert!(!q(&e, r#"color[*]=="blau""#));
642 2 : assert!(
643 2 : q(&e, r#"names[en]=="cat""#),
644 : "array element in one language"
645 : );
646 : // Unequal: no matching value in ANY of the values
647 2 : assert!(q(&e, r#"color[en]!="rouge""#));
648 2 : assert!(!q(&e, r#"color[en]!="red""#));
649 2 : assert!(!q(&e, r#"color[*]!="red""#), "some language holds red");
650 2 : assert!(q(&e, r#"color[*]!="blau""#));
651 2 : }
652 :
653 : /// 4.9: "If the target element is a VocabProperty, the target value shall
654 : /// be expanded according to the @context" — the default-context expansion
655 : /// makes a URI out of the vocab term, so only URI comparisons match.
656 : #[test]
657 2 : fn vocab_property_target_expansion() {
658 2 : let e = expand(json!({"id": "urn:x", "type": "T",
659 2 : "category": {"type": "VocabProperty", "vocab": "commercial"}}));
660 2 : assert!(
661 2 : q(
662 2 : &e,
663 2 : r#"category=="https://uri.etsi.org/ngsi-ld/default-context/commercial""#
664 : ),
665 : "expanded URI equality"
666 : );
667 2 : assert!(
668 2 : !q(&e, r#"category=="somethingelse""#),
669 : "non-matching literal"
670 : );
671 2 : }
672 :
673 : /// 4.9: "If the target element corresponds to a Relationship or
674 : /// ListRelationship, the combination of such target element with any
675 : /// operator different than equal or unequal shall result in not matching."
676 : #[test]
677 2 : fn relationship_ordering_operators_never_match() {
678 2 : let e = expand(json!({"id": "urn:x", "type": "T",
679 2 : "isParked": {"type": "Relationship", "object": "urn:ngsi-ld:P:5"}}));
680 2 : assert!(q(&e, r#"isParked=="urn:ngsi-ld:P:5""#));
681 2 : assert!(
682 2 : !q(&e, r#"isParked>"urn:ngsi-ld:P:4""#),
683 : "ordering op on Relationship"
684 : );
685 2 : assert!(!q(&e, r#"isParked<"urn:ngsi-ld:P:6""#));
686 2 : assert!(!q(&e, r#"isParked~="urn.*""#), "pattern op on Relationship");
687 2 : }
688 :
689 : /// 4.9 EXAMPLE 12: expandValues coerces the query term value through the
690 : /// @context, so a VocabProperty short term matches its expanded URI.
691 : #[test]
692 2 : fn expand_values_coercion() {
693 2 : let e = expand(json!({"id": "urn:x", "type": "T",
694 2 : "category": {"type": "VocabProperty", "vocab": "commercial"}}));
695 2 : let ast = parse_q("category==commercial").expect("parse");
696 2 : assert!(
697 2 : !eval_q(&ast, &e, &ctx(), &|_| None),
698 : "without expandValues the literal does not match the expanded vocab"
699 : );
700 2 : let ast = apply_expand_values(ast, Some("category"), &ctx());
701 2 : assert!(eval_q(&ast, &e, &ctx(), &|_| None), "EXAMPLE 12");
702 : // other attributes' values stay untouched
703 2 : let ast = apply_expand_values(
704 2 : parse_q(r#"other=="commercial""#).expect("parse"),
705 2 : Some("category"),
706 2 : &ctx(),
707 : );
708 2 : match ast {
709 2 : QNode::Cmp { value, .. } => assert_eq!(value, QValue::Str("commercial".into())),
710 0 : other => panic!("unexpected {other:?}"),
711 : }
712 2 : }
713 :
714 : /// 4.9 EXAMPLE 13/14: linked entity subquery attr{[Type:]path} follows
715 : /// the Relationship object through the resolver; a missing resolver or
716 : /// non-matching type hint yields no match.
717 : #[test]
718 2 : fn linked_entity_subquery() {
719 2 : let station = expand(json!({"id": "urn:ngsi-ld:WS:123", "type": "WeatherStation",
720 2 : "sensor": {"type": "Relationship", "object": "urn:ngsi-ld:Device:345"}}));
721 2 : let device = expand(json!({"id": "urn:ngsi-ld:Device:345", "type": "Device",
722 2 : "humidity": {"type": "Property", "value": 40}}));
723 8 : let lookup = |id: &str| (id == "urn:ngsi-ld:Device:345").then(|| device.clone());
724 2 : let ast = parse_q("sensor{humidity}==40").expect("parse");
725 2 : assert!(eval_q(&ast, &station, &ctx(), &lookup), "EXAMPLE 13");
726 2 : let ast = parse_q("sensor{humidity}==50").expect("parse");
727 2 : assert!(!eval_q(&ast, &station, &ctx(), &lookup));
728 : // EXAMPLE 14: type hint — matching and non-matching
729 2 : let ast = parse_q("sensor{Device:humidity}==40").expect("parse");
730 2 : assert!(eval_q(&ast, &station, &ctx(), &lookup), "EXAMPLE 14");
731 2 : let ast = parse_q("sensor{Vehicle:humidity}==40").expect("parse");
732 2 : assert!(
733 2 : !eval_q(&ast, &station, &ctx(), &lookup),
734 : "type hint must filter"
735 : );
736 : // no resolver → no match, never an error
737 2 : let ast = parse_q("sensor{humidity}==40").expect("parse");
738 2 : assert!(!eval_q(&ast, &station, &ctx(), &|_| None));
739 2 : }
740 :
741 : /// 4.9: "If the target element is a Property, the target value is defined
742 : /// as the Value associated to such Property." Annex C.6 writes a
743 : /// DateTime-valued Property either as a string with `valueType` or as the
744 : /// JSON-LD typed value `{"@type": "DateTime", "@value": …}`; both carry
745 : /// the same Value, so both answer a Query Term the same way. The
746 : /// `!=` case is the one that goes wrong in both directions: 4.9 p.92
747 : /// makes a datatype mismatch a MATCH, so an unread wrapper turns the
748 : /// entity whose value IS the queried one into a match.
749 : #[test]
750 2 : fn a_typed_value_is_compared_by_the_value_it_carries() {
751 2 : let typed = expand(json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle",
752 2 : "testedAt": {"type": "Property",
753 2 : "value": {"@type": "DateTime", "@value": "2018-12-04T12:00:00Z"}}}));
754 2 : let bare = expand(json!({"id": "urn:ngsi-ld:Vehicle:2", "type": "Vehicle",
755 2 : "testedAt": {"type": "Property", "value": "2018-12-04T12:00:00Z"}}));
756 12 : for (term, want) in [
757 2 : ("testedAt==2018-12-04T12:00:00Z", true),
758 2 : ("testedAt!=2018-12-04T12:00:00Z", false),
759 2 : ("testedAt>=2017-12-24T12:00:00Z", true),
760 2 : ("testedAt<=2019-01-01T00:00:00Z", true),
761 2 : ("testedAt>2019-01-01T00:00:00Z", false),
762 2 : ("testedAt", true),
763 2 : ] {
764 12 : assert_eq!(q(&typed, term), want, "typed value, {term}");
765 12 : assert_eq!(q(&bare, term), want, "bare value, {term}");
766 : }
767 2 : }
768 :
769 : /// The same rule reaches a number written as a typed value, and the
770 : /// members of a ListProperty (4.5.6), whose Values are compared one by
771 : /// one — 4.9 Equal: "identical or equivalent to ANY of the list values".
772 : #[test]
773 2 : fn a_typed_number_and_a_typed_list_member_are_compared_the_same_way() {
774 2 : let e = expand(json!({"id": "urn:ngsi-ld:Vehicle:3", "type": "Vehicle",
775 2 : "speed": {"type": "Property", "value": {"@type": "Number", "@value": 60}},
776 2 : "marks": {"type": "ListProperty", "valueList": [
777 2 : {"@type": "DateTime", "@value": "2018-12-04T12:00:00Z"},
778 2 : {"@type": "DateTime", "@value": "2020-01-01T00:00:00Z"}]}}));
779 2 : assert!(q(&e, "speed==60"));
780 2 : assert!(q(&e, "speed>50"));
781 2 : assert!(!q(&e, "speed==61"));
782 2 : assert!(q(&e, "marks==2020-01-01T00:00:00Z"));
783 2 : assert!(!q(&e, "marks==2019-01-01T00:00:00Z"));
784 :
785 : // The datatype names the value space; what 4.9 compares is the Value
786 : // itself, so a number written in its lexical form stays a string and
787 : // a Number Query Term value is a different datatype (p.92).
788 2 : let lexical = expand(json!({"id": "urn:ngsi-ld:Vehicle:5", "type": "Vehicle",
789 2 : "speed": {"type": "Property",
790 2 : "value": {"@type": "http://www.w3.org/2001/XMLSchema#double",
791 2 : "@value": "3.5"}}}));
792 2 : assert!(!q(&lexical, "speed>3"));
793 2 : assert!(q(&lexical, "speed==\"3.5\""));
794 2 : }
795 :
796 : /// A JsonProperty holds arbitrary JSON — the core `@context` types it
797 : /// `@json`, so nothing inside it is interpreted as JSON-LD. An `@value`
798 : /// member there is data, and the target value stays the whole JSON
799 : /// (4.9: "the target value is defined as the JSON value").
800 : #[test]
801 2 : fn the_json_of_a_json_property_is_not_read_as_a_typed_value() {
802 2 : let e = expand(json!({"id": "urn:ngsi-ld:Vehicle:4", "type": "Vehicle",
803 2 : "ticket": {"type": "JsonProperty", "json": {"@value": 5}}}));
804 2 : assert!(!q(&e, "ticket==5"));
805 2 : assert!(q(&e, "ticket[@value]==5"));
806 2 : }
807 : }
808 :
809 : // regex compiles run for hours under Miri; the fuzz job covers them
810 : #[cfg(all(test, not(miri)))]
811 : mod bounds_and_patterns {
812 : use super::*;
813 : use crate::parse_q;
814 : use serde_json::json;
815 : use std::cell::Cell;
816 :
817 : const DC: &str = "https://uri.etsi.org/ngsi-ld/default-context/";
818 :
819 14 : fn ctx() -> std::sync::Arc<Context> {
820 14 : antares_jsonld::core_context().into()
821 14 : }
822 :
823 32 : fn with_value(attr: &str, v: Value) -> Value {
824 32 : json!({
825 32 : "id": "urn:ngsi-ld:Vehicle:9",
826 32 : "type": [format!("{DC}Vehicle")],
827 32 : format!("{DC}{attr}"): [{"type": "Property", "value": v}],
828 : })
829 32 : }
830 :
831 : /// 4.9 patternOp/notPatternOp: the pattern is compiled once per Query
832 : /// Term now, so pin the outcomes it has to keep — including the invalid
833 : /// pattern, which has no L(R) and therefore matches nothing.
834 : #[test]
835 2 : fn pattern_operators_keep_their_outcomes() {
836 2 : let ctx = ctx();
837 20 : for (q, target, want) in [
838 2 : (r#"brandName~="^Merc""#, json!("Mercedes"), true),
839 2 : (r#"brandName~="^Merc""#, json!("Volvo"), false),
840 2 : // one matching element is enough for ~=, none may match for !~=
841 2 : (r#"brandName~="^Merc""#, json!(["Volvo", "Mercedes"]), true),
842 2 : (r#"brandName!~="^Merc""#, json!(["Volvo", "Skoda"]), true),
843 2 : // non-string target: 4.9 p.92 "considered as not matching"
844 2 : (r#"brandName~="^Merc""#, json!(7), false),
845 2 : (r#"brandName!~="^Merc""#, json!(7), false),
846 2 : // an invalid pattern compiles to no language at all
847 2 : (r#"brandName~="[""#, json!("Mercedes"), false),
848 2 : (r#"brandName!~="[""#, json!("Mercedes"), false),
849 2 : // …and an empty array has no element inside that (empty) language
850 2 : (r#"brandName!~="[""#, json!([]), true),
851 2 : (r#"brandName~="[""#, json!([]), false),
852 2 : ] {
853 20 : let ast = parse_q(q).expect(q);
854 20 : assert_eq!(
855 20 : eval_q(
856 20 : &ast,
857 20 : &with_value("brandName", target.clone()),
858 20 : &ctx,
859 : &|_| None
860 : ),
861 : want,
862 : "q={q} target={target}"
863 : );
864 : }
865 2 : }
866 :
867 : /// 4.9 patternOp: the Query Term's pattern is compiled through the
868 : /// process-wide cache, so re-evaluating the same term over the next
869 : /// candidate entity (or the next event, for a subscription) reuses the
870 : /// compiled program instead of rebuilding it.
871 : #[test]
872 2 : fn pattern_term_compiles_through_the_shared_cache() {
873 2 : let _serial = crate::regex::serial_lock();
874 2 : let ctx = ctx();
875 2 : let pat = "^Merc[a-z]+-qterm$";
876 2 : assert!(
877 2 : crate::regex::cached(pat).is_none(),
878 : "the probe pattern must start uncompiled"
879 : );
880 2 : let q = format!(r#"brandName~="{pat}""#);
881 2 : let ast = parse_q(&q).expect(&q);
882 2 : let hit = with_value("brandName", json!("Mercedes-qterm"));
883 2 : assert!(eval_q(&ast, &hit, &ctx, &|_| None));
884 2 : let held = crate::regex::cached(pat).expect("the term's pattern is retained");
885 : // the next candidate reuses it: a recompile would replace the entry
886 2 : let miss = with_value("brandName", json!("Volvo"));
887 2 : assert!(!eval_q(&ast, &miss, &ctx, &|_| None));
888 2 : assert!(
889 2 : crate::regex::cached(pat).is_some_and(|now| std::sync::Arc::ptr_eq(&held, &now)),
890 : "the second candidate must not rebuild the program"
891 : );
892 : // an invalid pattern still has no L(R) (p.92) and is still not held
893 2 : let bad = parse_q(r#"brandName~="[qterm""#).expect("parses");
894 2 : assert!(!eval_q(
895 2 : &bad,
896 2 : &with_value("brandName", json!("[qterm")),
897 2 : &ctx,
898 : &|_| None
899 : ));
900 2 : assert!(
901 2 : crate::regex::cached("[qterm").is_none(),
902 : "an uncompilable pattern is never retained"
903 : );
904 2 : }
905 :
906 : /// A hostile `~=` pattern costs compile time, not match time (the regex
907 : /// engine is linear in the input). The crate's compiled-size limit turns
908 : /// an exploding pattern into a compile error, which 4.9 p.92 treats as
909 : /// not matching — so the term is rejected instead of consuming memory.
910 : #[test]
911 2 : fn hostile_pattern_cannot_blow_the_compile_budget() {
912 2 : let ctx = ctx();
913 2 : let e = with_value("brandName", json!("Mercedes"));
914 2 : let bombs = [
915 2 : "((((a{1000}){1000}){1000}){1000})".to_owned(),
916 2 : format!("(?:{})", "a{255}".repeat(64)),
917 2 : format!("{}a", "(".repeat(200)) + &")".repeat(200),
918 2 : ];
919 2 : let started = std::time::Instant::now();
920 6 : for p in bombs {
921 6 : let q = format!(r#"brandName~="{p}""#);
922 : // an unparseable q is an equally acceptable outcome — what must
923 : // not happen is an accepted term that compiles the bomb
924 6 : if let Ok(ast) = parse_q(&q) {
925 6 : assert!(!eval_q(&ast, &e, &ctx, &|_| None), "matched: {q}");
926 0 : }
927 : }
928 2 : assert!(
929 2 : started.elapsed() < std::time::Duration::from_secs(5),
930 : "pattern compilation is not bounded"
931 : );
932 2 : }
933 :
934 : /// 4.9 LinkedEntityRelation: every hop consumes one `attr{…}` level, and
935 : /// the parser caps those at 8 — so a Relationship cycle is walked a
936 : /// bounded number of times and the resolver always terminates.
937 : #[test]
938 2 : fn linked_walk_terminates_on_a_cycle_within_the_hop_cap() {
939 2 : let ctx = ctx();
940 : // urn:A points at itself twice: the walk can only end by running out
941 : // of hops, never by running out of entities
942 2 : let a = json!({
943 2 : "id": "urn:A",
944 2 : "type": [format!("{DC}Node")],
945 2 : format!("{DC}r"): [
946 2 : {"type": "Relationship", "object": "urn:A"},
947 2 : {"type": "Relationship", "object": "urn:A", "datasetId": "urn:d:2"},
948 : ],
949 2 : format!("{DC}v"): [{"type": "Property", "value": 1}],
950 : });
951 2 : let calls = Cell::new(0usize);
952 1020 : let lookup = |id: &str| {
953 1020 : calls.set(calls.get() + 1);
954 1020 : (id == "urn:A").then(|| a.clone())
955 1020 : };
956 2 : let q = format!("{}v{}==1", "r{".repeat(8), "}".repeat(8));
957 2 : let ast = parse_q(&q).expect(&q);
958 2 : assert_eq!(ast.max_link_depth(), 8);
959 2 : assert!(eval_q(&ast, &a, &ctx, &lookup), "the cycle resolves");
960 : // 2 objects per hop over 8 hops: 2 + 4 + … + 2^8 — exponential in
961 : // the fan-out, which is why the work budget, not the hop cap, is
962 : // what bounds this walk.
963 2 : assert_eq!(calls.get(), 510, "resolver lookups per query term");
964 2 : assert!(calls.get() <= MAX_Q_LINK_LOOKUPS);
965 :
966 : // one hop deeper is refused before any entity is touched
967 2 : let deep = format!("{}v{}==1", "r{".repeat(9), "}".repeat(9));
968 2 : assert!(
969 0 : matches!(
970 2 : parse_q(&deep),
971 : Err(antares_model::NgsiError::TooComplexQuery(_))
972 : ),
973 : "the 9th hop must be rejected"
974 : );
975 2 : }
976 :
977 : /// 4.9 linked-entity resolution is bounded by WORK, not only by hops:
978 : /// a wide Relationship fan-out costs F^hops entity lookups, so one q
979 : /// term may only buy `MAX_Q_LINK_LOOKUPS` of them.
980 : #[test]
981 2 : fn linked_walk_lookups_are_capped_by_the_work_budget() {
982 2 : let ctx = ctx();
983 2 : let fan: Vec<Value> = (0..40)
984 80 : .map(|i| {
985 80 : json!({"type": "Relationship", "object": "urn:A",
986 80 : "datasetId": format!("urn:ngsi-ld:Dataset:{i}")})
987 80 : })
988 2 : .collect();
989 2 : let a = json!({
990 2 : "id": "urn:A",
991 2 : "type": [format!("{DC}Node")],
992 2 : format!("{DC}r"): fan,
993 2 : format!("{DC}v"): [{"type": "Property", "value": 1}],
994 : });
995 2 : let calls = Cell::new(0usize);
996 1024 : let lookup = |id: &str| {
997 1024 : calls.set(calls.get() + 1);
998 1024 : (id == "urn:A").then(|| a.clone())
999 1024 : };
1000 2 : let ast = parse_q("r{r{r{v}}}==1").expect("q");
1001 2 : assert!(
1002 2 : eval_q(&ast, &a, &ctx, &lookup),
1003 : "a target reachable inside the budget still matches"
1004 : );
1005 2 : assert!(
1006 2 : calls.get() <= MAX_Q_LINK_LOOKUPS,
1007 : "one q term bought {} entity lookups",
1008 0 : calls.get()
1009 : );
1010 2 : }
1011 :
1012 : /// The evaluator is fed whatever the store and the notification path
1013 : /// hold: shapes that are not entities, and paths that navigate into a
1014 : /// scalar, must return "no match" rather than panic.
1015 : #[test]
1016 2 : fn non_entity_shapes_never_panic() {
1017 2 : let ctx = ctx();
1018 2 : let ast = parse_q("speed>10").expect("q");
1019 10 : for doc in [json!(null), json!(7), json!("text"), json!([]), json!({})] {
1020 10 : assert!(!eval_q(&ast, &doc, &ctx, &|_| None), "doc={doc}");
1021 : }
1022 : // a MemberExpression into a scalar value resolves to nothing
1023 2 : let e = with_value("speed", json!(80));
1024 6 : for q in ["speed[unit]==80", "speed.unit.deep==80", "speed[a.b]"] {
1025 6 : let ast = parse_q(q).expect(q);
1026 6 : assert!(!eval_q(&ast, &e, &ctx, &|_| None), "q={q}");
1027 : }
1028 : // an instance carrying no value-defining member is not a target
1029 2 : let bare = json!({
1030 2 : "id": "urn:ngsi-ld:Vehicle:9",
1031 2 : "type": [format!("{DC}Vehicle")],
1032 2 : format!("{DC}speed"): [{"type": "Property", "unitCode": "KMH"}],
1033 : });
1034 2 : assert!(!eval_q(&parse_q("speed").expect("q"), &bare, &ctx, &|_| {
1035 0 : None
1036 0 : }));
1037 2 : }
1038 :
1039 : /// 4.9 logical operators: `|` is OR, `;` is AND, `!` negates existence.
1040 : #[test]
1041 2 : fn or_and_negated_existence() {
1042 2 : let ctx = ctx();
1043 2 : let e = with_value("speed", json!(80));
1044 10 : for (q, want) in [
1045 2 : ("speed==80|speed==90", true),
1046 2 : ("speed==70|speed==90", false),
1047 2 : ("speed==80;!color", true),
1048 2 : ("speed==80;color", false),
1049 2 : ("(speed==70|speed==80);!color", true),
1050 2 : ] {
1051 10 : let ast = parse_q(q).expect(q);
1052 10 : assert_eq!(eval_q(&ast, &e, &ctx, &|_| None), want, "q={q}");
1053 : }
1054 2 : }
1055 : }
1056 :
1057 : #[cfg(test)]
1058 : mod tests {
1059 : use super::*;
1060 : use crate::parse_q;
1061 : use serde_json::json;
1062 :
1063 2 : fn entity() -> Value {
1064 2 : json!({
1065 2 : "id": "urn:ngsi-ld:Vehicle:1",
1066 2 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1067 2 : "https://uri.etsi.org/ngsi-ld/default-context/speed": [
1068 2 : {"type": "Property", "value": 85,
1069 2 : "https://uri.etsi.org/ngsi-ld/default-context/accuracy": [
1070 2 : {"type": "Property", "value": 0.9}]}
1071 : ],
1072 2 : "https://uri.etsi.org/ngsi-ld/default-context/brandName": [
1073 2 : {"type": "Property", "value": "Mercedes"}
1074 : ]
1075 : })
1076 2 : }
1077 :
1078 : #[test]
1079 2 : fn comparisons_and_paths() {
1080 2 : let ctx = antares_jsonld::core_context();
1081 2 : let e = entity();
1082 16 : for (q, want) in [
1083 2 : ("speed>80", true),
1084 2 : ("speed<80", false),
1085 2 : (r#"brandName=="Mercedes""#, true),
1086 2 : ("speed.accuracy>0.5", true),
1087 2 : ("speed>80;brandName!=\"BMW\"", true),
1088 2 : ("speed", true),
1089 2 : ("!color", true),
1090 2 : ("color", false),
1091 2 : ] {
1092 16 : let ast = parse_q(q).expect(q);
1093 16 : assert_eq!(eval_q(&ast, &e, &ctx, &|_| None), want, "q={q}");
1094 : }
1095 2 : }
1096 :
1097 : /// 4.9 p.92: "When comparing dates or times, the order relation
1098 : /// considered shall be a temporal one." 4.6.3 leaves the seconds
1099 : /// fraction optional, so one instant has several spellings — and on a
1100 : /// byte comparison `.` (0x2E) sorts before `Z` (0x5A), which puts
1101 : /// `…:00.500Z` BEFORE `…:00Z` and drops an entity the query selects.
1102 : /// EXAMPLE 8 of the clause is exactly this shape
1103 : /// (`?q=temperature.observedAt>=2017-12-24T12:00:00Z`).
1104 : #[test]
1105 2 : fn dates_and_times_order_temporally_across_fraction_spellings() {
1106 2 : let ctx = antares_jsonld::core_context();
1107 34 : let at = |v: &str| {
1108 34 : json!({
1109 34 : "id": "urn:ngsi-ld:Vehicle:3",
1110 34 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1111 34 : "https://uri.etsi.org/ngsi-ld/default-context/seen": [
1112 34 : {"type": "Property", "value": v}
1113 : ]
1114 : })
1115 34 : };
1116 34 : for (value, q, want) in [
1117 2 : // half a second after the bound: greater, however it is spelled
1118 2 : (
1119 2 : "2020-01-01T00:00:00.500Z",
1120 2 : "seen>2020-01-01T00:00:00Z",
1121 2 : true,
1122 2 : ),
1123 2 : (
1124 2 : "2020-01-01T00:00:00.500Z",
1125 2 : "seen<2020-01-01T00:00:00Z",
1126 2 : false,
1127 2 : ),
1128 2 : (
1129 2 : "2020-01-01T00:00:00Z",
1130 2 : "seen<2020-01-01T00:00:00.500Z",
1131 2 : true,
1132 2 : ),
1133 2 : (
1134 2 : "2020-01-01T00:00:00Z",
1135 2 : "seen>2020-01-01T00:00:00.500Z",
1136 2 : false,
1137 2 : ),
1138 2 : // the same instant, two spellings: neither strictly ordered,
1139 2 : // both inclusive bounds hold
1140 2 : (
1141 2 : "2020-01-01T00:00:00.000Z",
1142 2 : "seen>2020-01-01T00:00:00Z",
1143 2 : false,
1144 2 : ),
1145 2 : (
1146 2 : "2020-01-01T00:00:00.000Z",
1147 2 : "seen>=2020-01-01T00:00:00Z",
1148 2 : true,
1149 2 : ),
1150 2 : (
1151 2 : "2020-01-01T00:00:00.000Z",
1152 2 : "seen<=2020-01-01T00:00:00Z",
1153 2 : true,
1154 2 : ),
1155 2 : // 4.6.3 also admits a comma as the fraction separator
1156 2 : (
1157 2 : "2020-01-01T00:00:00,500Z",
1158 2 : "seen>2020-01-01T00:00:00Z",
1159 2 : true,
1160 2 : ),
1161 2 : // Time carries the same optional fraction
1162 2 : ("00:00:00.500Z", "seen>00:00:00Z", true),
1163 2 : ("00:00:00Z", "seen>00:00:00.500Z", false),
1164 2 : // a Date has no fraction and already orders lexicographically
1165 2 : ("2020-01-02", "seen>2020-01-01", true),
1166 2 : ("2020-01-01", "seen>2020-01-02", false),
1167 2 : // an ordinary string is still ordered as a string
1168 2 : (r#"banana"#, r#"seen>"apple""#, true),
1169 2 : (r#"apple"#, r#"seen>"banana""#, false),
1170 2 : // a Range over instants is an interval on the same axis
1171 2 : (
1172 2 : "2020-01-01T00:00:00.500Z",
1173 2 : "seen==2020-01-01T00:00:00Z..2020-01-01T00:00:01Z",
1174 2 : true,
1175 2 : ),
1176 2 : (
1177 2 : "2020-01-01T00:00:01.500Z",
1178 2 : "seen==2020-01-01T00:00:00Z..2020-01-01T00:00:01Z",
1179 2 : false,
1180 2 : ),
1181 2 : (
1182 2 : "2020-01-01T00:00:00.500Z",
1183 2 : "seen!=2020-01-01T00:00:00Z..2020-01-01T00:00:01Z",
1184 2 : false,
1185 2 : ),
1186 2 : ] {
1187 34 : let ast = parse_q(q).expect(q);
1188 34 : assert_eq!(
1189 34 : eval_q(&ast, &at(value), &ctx, &|_| None),
1190 : want,
1191 : "value {value:?} against q={q}"
1192 : );
1193 : }
1194 2 : }
1195 :
1196 : #[test]
1197 2 : fn unequal_matches_on_datatype_mismatch() {
1198 : // 4.9 Unequal, p.92: "If the data type of the target value and the data
1199 : // type of the Query Term value are different, then they shall be
1200 : // considered unequal" — so `!=` MATCHES. Equal carries the mirror rule
1201 : // ("considered as not matching"); the asymmetry is deliberate.
1202 2 : let ctx = antares_jsonld::core_context();
1203 2 : let e = json!({
1204 2 : "id": "urn:ngsi-ld:Vehicle:2",
1205 2 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1206 : // a STRING where the query asks about a number, and vice versa
1207 2 : "https://uri.etsi.org/ngsi-ld/default-context/speed": [
1208 2 : {"type": "Property", "value": "fast"}
1209 : ],
1210 2 : "https://uri.etsi.org/ngsi-ld/default-context/brandName": [
1211 2 : {"type": "Property", "value": 7}
1212 : ]
1213 : });
1214 10 : for (q, want) in [
1215 2 : ("speed!=10", true), // string vs number ⇒ unequal
1216 2 : ("speed==10", false), // …but not equal
1217 2 : (r#"brandName!="Mercedes""#, true), // number vs string ⇒ unequal
1218 2 : (r#"brandName=="Mercedes""#, false),
1219 2 : ("speed>10", false), // ordering on a mismatch does NOT match
1220 2 : ] {
1221 10 : let ast = parse_q(q).expect(q);
1222 10 : assert_eq!(eval_q(&ast, &e, &ctx, &|_| None), want, "q={q}");
1223 : }
1224 2 : }
1225 :
1226 : #[test]
1227 2 : fn unequal_over_an_array_requires_every_element_to_differ() {
1228 : // 4.9 Unequal, p.91: "The target value does not include any of the list
1229 : // values, if the target value is an array (e.g. matches
1230 : // ["blue","black","green"], but not ["blue","red","green"])."
1231 2 : let ctx = antares_jsonld::core_context();
1232 4 : let mk = |vals: Value| {
1233 4 : json!({
1234 4 : "id": "urn:ngsi-ld:Vehicle:3",
1235 4 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1236 4 : "https://uri.etsi.org/ngsi-ld/default-context/color": [
1237 4 : {"type": "Property", "value": vals}
1238 : ]
1239 : })
1240 4 : };
1241 2 : let ast = parse_q(r#"color!="red""#).expect("q");
1242 2 : assert!(
1243 2 : eval_q(&ast, &mk(json!(["blue", "black", "green"])), &ctx, &|_| {
1244 0 : None
1245 0 : }),
1246 : "no element equals red ⇒ matches"
1247 : );
1248 2 : assert!(
1249 2 : !eval_q(&ast, &mk(json!(["blue", "red", "green"])), &ctx, &|_| None),
1250 : "red is included ⇒ must NOT match (was matching on the 'blue' element)"
1251 : );
1252 2 : }
1253 :
1254 : /// 4.9 ValueList — Equal p.90 and Unequal p.91, including the spec's own
1255 : /// array examples verbatim.
1256 : #[test]
1257 2 : fn value_list_semantics() {
1258 2 : let ctx = antares_jsonld::core_context();
1259 16 : let mk = |v: Value| {
1260 16 : json!({
1261 16 : "id": "urn:ngsi-ld:Vehicle:4",
1262 16 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1263 16 : "https://uri.etsi.org/ngsi-ld/default-context/color": [
1264 16 : {"type": "Property", "value": v}
1265 : ]
1266 : })
1267 16 : };
1268 16 : for (q, target, want) in [
1269 2 : // Eq p.90: identical to ANY list value (e.g. matches "red")
1270 2 : (r#"color=="black","red""#, json!("red"), true),
1271 2 : (r#"color=="black","red""#, json!("blue"), false),
1272 2 : // Eq p.90: array includes ANY of the query values
1273 2 : (r#"color=="black","red""#, json!(["red", "blue"]), true),
1274 2 : (r#"color=="black","red""#, json!(["blue", "green"]), false),
1275 2 : // Ne p.91: identical to NO list value (e.g. matches "blue")
1276 2 : (r#"color!="black","red""#, json!("blue"), true),
1277 2 : (r#"color!="black","red""#, json!("red"), false),
1278 2 : // Ne p.91 verbatim: matches ["blue","yellow","green"],
1279 2 : // but not ["blue","red","green"]
1280 2 : (
1281 2 : r#"color!="black","red""#,
1282 2 : json!(["blue", "yellow", "green"]),
1283 2 : true,
1284 2 : ),
1285 2 : (
1286 2 : r#"color!="black","red""#,
1287 2 : json!(["blue", "red", "green"]),
1288 2 : false,
1289 2 : ),
1290 2 : ] {
1291 16 : let ast = parse_q(q).expect(q);
1292 16 : assert_eq!(
1293 16 : eval_q(&ast, &mk(target.clone()), &ctx, &|_| None),
1294 : want,
1295 : "q={q} target={target}"
1296 : );
1297 : }
1298 2 : }
1299 :
1300 : /// 4.9 Range — Equal p.90 ("both included") and Unequal p.91.
1301 : #[test]
1302 2 : fn range_semantics() {
1303 2 : let ctx = antares_jsonld::core_context();
1304 16 : let mk = |v: Value| {
1305 16 : json!({
1306 16 : "id": "urn:ngsi-ld:Vehicle:5",
1307 16 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1308 16 : "https://uri.etsi.org/ngsi-ld/default-context/temperature": [
1309 16 : {"type": "Property", "value": v}
1310 : ]
1311 : })
1312 16 : };
1313 16 : for (q, target, want) in [
1314 2 : ("temperature==10..20", json!(15), true),
1315 2 : ("temperature==10..20", json!(10), true), // min included
1316 2 : ("temperature==10..20", json!(20), true), // max included
1317 2 : ("temperature==10..20", json!(9), false),
1318 2 : ("temperature!=10..20", json!(9), true), // p.91: "matches 9"
1319 2 : ("temperature!=10..20", json!(15), false),
1320 2 : // type mismatch: p.92 "considered unequal" ⇒ != matches, == not
1321 2 : ("temperature==10..20", json!("hot"), false),
1322 2 : ("temperature!=10..20", json!("hot"), true),
1323 2 : ] {
1324 16 : let ast = parse_q(q).expect(q);
1325 16 : assert_eq!(
1326 16 : eval_q(&ast, &mk(target.clone()), &ctx, &|_| None),
1327 : want,
1328 : "q={q} target={target}"
1329 : );
1330 : }
1331 : // DateTime range endpoints (Str..Str, temporal == lexicographic in Z
1332 : // form). `eventTime` and not `observedAt`: the latter is a CORE term
1333 : // and would expand to the core IRI, not the default context.
1334 2 : let ast = parse_q("eventTime==2021-01-01T00:00:00Z..2021-06-01T00:00:00Z").expect("q");
1335 2 : let e = json!({
1336 2 : "id": "urn:ngsi-ld:Vehicle:6",
1337 2 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1338 2 : "https://uri.etsi.org/ngsi-ld/default-context/eventTime": [
1339 2 : {"type": "Property", "value": "2021-03-15T12:00:00Z"}
1340 : ]
1341 : });
1342 2 : assert!(eval_q(&ast, &e, &ctx, &|_| None));
1343 2 : }
1344 :
1345 : /// 4.9 notPatternOp p.92: NOT in L(R); non-string targets are "not
1346 : /// matching" — deliberately NOT the `!=` type-mismatch rule.
1347 : #[test]
1348 2 : fn not_pattern_semantics() {
1349 2 : let ctx = antares_jsonld::core_context();
1350 10 : let mk = |v: Value| {
1351 10 : json!({
1352 10 : "id": "urn:ngsi-ld:Vehicle:7",
1353 10 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1354 10 : "https://uri.etsi.org/ngsi-ld/default-context/brandName": [
1355 10 : {"type": "Property", "value": v}
1356 : ]
1357 : })
1358 10 : };
1359 10 : for (q, target, want) in [
1360 2 : (r#"brandName!~="^Merc""#, json!("Volvo"), true),
1361 2 : (r#"brandName!~="^Merc""#, json!("Mercedes"), false),
1362 2 : // non-string target ⇒ not matching (p.92), unlike !=
1363 2 : (r#"brandName!~="^Merc""#, json!(7), false),
1364 2 : // an array is outside L(R) only if NO element is in it
1365 2 : (r#"brandName!~="^Merc""#, json!(["Volvo", "Skoda"]), true),
1366 2 : (
1367 2 : r#"brandName!~="^Merc""#,
1368 2 : json!(["Volvo", "Mercedes"]),
1369 2 : false,
1370 2 : ),
1371 2 : ] {
1372 10 : let ast = parse_q(q).expect(q);
1373 10 : assert_eq!(
1374 10 : eval_q(&ast, &mk(target.clone()), &ctx, &|_| None),
1375 : want,
1376 : "q={q} target={target}"
1377 : );
1378 : }
1379 2 : }
1380 : }
1381 :
1382 : /// What the evaluator treats as an instant rather than as a string.
1383 : #[cfg(test)]
1384 : mod temporal_keys {
1385 : use super::*;
1386 :
1387 : /// 4.9 p.92: a Range or an ordering over dates or times is an interval
1388 : /// on the temporal axis, and everything else is a string comparison.
1389 : /// Nineteen characters and a trailing `Z` are not a DateTime.
1390 : #[test]
1391 2 : fn only_a_real_instant_gets_a_temporal_key() {
1392 10 : for s in [
1393 2 : "2020-01-01T00:00:00Z",
1394 2 : "2020-01-01T00:00:00.5Z",
1395 2 : "2020-01-01T00:00:00,5Z",
1396 2 : "12:00:00Z",
1397 2 : "12:00:00.250Z",
1398 2 : ] {
1399 10 : assert!(temporal_key(s).is_some(), "{s} is an instant");
1400 : }
1401 16 : for s in [
1402 2 : "aaaaaaaaaaaaaaaaaaaZ", // nineteen characters and a Z
1403 2 : "2020-01-01T00:00:00", // no Z
1404 2 : "2020-13-01T00:00:00Z", // no such month
1405 2 : "2020-01-01 00:00:00Z", // no T
1406 2 : "99999999Z", // eight characters, no colons
1407 2 : "2020-01-01Z", // a Date is neither
1408 2 : "2020-01-01T00:00:00.1234567Z", // fraction past six digits
1409 2 : "",
1410 2 : ] {
1411 16 : assert!(temporal_key(s).is_none(), "{s:?} is not an instant");
1412 : }
1413 : // the key is the padded form, so string order is temporal order
1414 2 : assert_eq!(
1415 2 : temporal_key("2020-01-01T00:00:00,5Z"),
1416 2 : temporal_key("2020-01-01T00:00:00.500000Z")
1417 : );
1418 2 : assert!(temporal_key("2020-01-01T00:00:00Z") < temporal_key("2020-01-01T00:00:00.5Z"));
1419 2 : }
1420 : }
|