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 32246 : pub fn eval_q(node: &QNode, entity: &Value, ctx: &Context, lookup: EntityLookup) -> bool {
28 32246 : eval_node(node, entity, ctx, lookup, &Cell::new(MAX_Q_LINK_LOOKUPS))
29 32246 : }
30 :
31 62770 : fn eval_node(
32 62770 : node: &QNode,
33 62770 : entity: &Value,
34 62770 : ctx: &Context,
35 62770 : lookup: EntityLookup,
36 62770 : budget: &Cell<usize>,
37 62770 : ) -> bool {
38 62770 : match node {
39 7588 : QNode::And(items) => items
40 7588 : .iter()
41 11724 : .all(|n| eval_node(n, entity, ctx, lookup, budget)),
42 1612 : QNode::Or(items) => items
43 1612 : .iter()
44 18800 : .any(|n| eval_node(n, entity, ctx, lookup, budget)),
45 11020 : QNode::Exists { path, negated } => {
46 11020 : let found = !resolve_qpath(entity, path, ctx, lookup, budget).is_empty();
47 11020 : 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 42550 : 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 42550 : let re = match (op, value) {
63 1044 : (CmpOp::Pattern | CmpOp::NotPattern, QValue::Str(s)) => {
64 1044 : crate::regex::compile(s).ok()
65 : }
66 41506 : _ => None,
67 : };
68 42550 : resolve_qpath(entity, path, ctx, lookup, budget)
69 42550 : .iter()
70 42550 : .any(|(kind, v)| kind_allows(*kind, *op) && compare(v, *op, value, re.as_deref()))
71 : }
72 : }
73 62770 : }
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 1240 : pub fn apply_expand_values(node: QNode, expand_values: Option<&str>, ctx: &Context) -> QNode {
81 1240 : let Some(list) = expand_values else {
82 1204 : return node;
83 : };
84 36 : let names: Vec<&str> = list.split(',').map(str::trim).collect();
85 42 : fn expand_val(v: QValue, ctx: &Context) -> QValue {
86 42 : match v {
87 38 : QValue::Str(s) => QValue::Str(ctx.expand_key(&s)),
88 2 : QValue::List(items) => {
89 4 : QValue::List(items.into_iter().map(|i| expand_val(i, ctx)).collect())
90 : }
91 2 : other => other,
92 : }
93 42 : }
94 46 : fn walk(node: QNode, names: &[&str], ctx: &Context) -> QNode {
95 42 : match node {
96 2 : QNode::And(items) => {
97 6 : QNode::And(items.into_iter().map(|n| walk(n, names, ctx)).collect())
98 : }
99 4 : QNode::Or(items) => QNode::Or(items.into_iter().map(|n| walk(n, names, ctx)).collect()),
100 42 : QNode::Cmp { path, op, value } if path.top().is_some_and(|t| names.contains(&t)) => {
101 38 : QNode::Cmp {
102 38 : path,
103 38 : op,
104 38 : value: expand_val(value, ctx),
105 38 : }
106 : }
107 4 : other => other,
108 : }
109 46 : }
110 36 : walk(node, &names, ctx)
111 1240 : }
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 1326 : pub fn expansion_list(expand_values: Option<&str>, json_keys: Option<&str>) -> Option<String> {
128 1326 : let names = expand_values?;
129 44 : let Some(raw) = json_keys else {
130 30 : return Some(names.to_owned());
131 : };
132 14 : let raw: Vec<&str> = raw.split(',').map(str::trim).collect();
133 14 : let kept: Vec<&str> = names
134 14 : .split(',')
135 14 : .map(str::trim)
136 16 : .filter(|n| !raw.contains(n))
137 14 : .collect();
138 14 : (!kept.is_empty()).then(|| kept.join(","))
139 1326 : }
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 28629 : fn kind_allows(kind: TargetKind, op: CmpOp) -> bool {
154 28629 : match kind {
155 102 : TargetKind::Object | TargetKind::ObjectList => matches!(op, CmpOp::Eq | CmpOp::Ne),
156 28527 : _ => true,
157 : }
158 28629 : }
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 55662 : fn resolve_qpath(
169 55662 : entity: &Value,
170 55662 : qp: &QPath,
171 55662 : ctx: &Context,
172 55662 : lookup: EntityLookup,
173 55662 : budget: &Cell<usize>,
174 55662 : ) -> Vec<(TargetKind, Value)> {
175 55662 : let Some(link) = qp.links.first() else {
176 54980 : return resolve_targets(entity, qp, ctx);
177 : };
178 682 : let iri = ctx.expand_key(&link.attr);
179 682 : let Some(instances) = entity.get(&iri).and_then(Value::as_array) else {
180 18 : return vec![];
181 : };
182 664 : let mut uris: Vec<&str> = Vec::new();
183 2344 : for inst in instances {
184 2344 : match inst.get("object") {
185 2338 : Some(Value::String(s)) => uris.push(s),
186 2 : Some(Value::Array(a)) => uris.extend(a.iter().filter_map(Value::as_str)),
187 4 : _ => {}
188 : }
189 2344 : if let Some(Value::Array(a)) = inst.get("objectList") {
190 2 : uris.extend(a.iter().filter_map(Value::as_str));
191 2342 : }
192 : }
193 664 : let rest = QPath {
194 664 : links: qp.links.get(1..).unwrap_or_default().to_vec(),
195 664 : path: qp.path.clone(),
196 664 : bracket: qp.bracket.clone(),
197 664 : };
198 664 : let mut out = Vec::new();
199 2174 : for uri in uris {
200 2174 : let Some(left) = budget.get().checked_sub(1) else {
201 6 : break;
202 : };
203 2168 : budget.set(left);
204 2168 : let Some(linked) = lookup(uri) else { continue };
205 : // EXAMPLE 14 type hint: only consider target entities of these types
206 2098 : if !link.types.is_empty() {
207 12 : let matched = linked
208 12 : .get("type")
209 12 : .and_then(Value::as_array)
210 12 : .is_some_and(|a| {
211 12 : a.iter()
212 12 : .filter_map(Value::as_str)
213 12 : .any(|t| link.types.iter().any(|hint| ctx.expand_key(hint) == t))
214 12 : });
215 12 : if !matched {
216 6 : continue;
217 6 : }
218 2086 : }
219 2092 : out.extend(resolve_qpath(&linked, &rest, ctx, lookup, budget));
220 : }
221 664 : out
222 55662 : }
223 :
224 : /// Resolve a dotted q path to candidate (kind, value) targets across
225 : /// instances.
226 54982 : fn resolve_targets(entity: &Value, qp: &QPath, ctx: &Context) -> Vec<(TargetKind, Value)> {
227 54982 : let Some(first) = qp.path.first() else {
228 2 : return vec![];
229 : };
230 54980 : let iri = ctx.expand_key(first);
231 54980 : let Some(instances) = entity.get(&iri).and_then(Value::as_array) else {
232 14913 : return vec![];
233 : };
234 40067 : let mut out = Vec::new();
235 40075 : for inst in instances {
236 38692 : collect(
237 38692 : inst,
238 38692 : qp.path.get(1..).unwrap_or_default(),
239 38692 : qp.bracket.as_deref(),
240 38692 : ctx,
241 38692 : &mut out,
242 38692 : );
243 38692 : }
244 40067 : out
245 54982 : }
246 :
247 38703 : fn collect(
248 38703 : inst: &Value,
249 38703 : rest: &[String],
250 38703 : bracket: Option<&[String]>,
251 38703 : ctx: &Context,
252 38703 : out: &mut Vec<(TargetKind, Value)>,
253 38703 : ) {
254 38703 : let Some((seg, tail)) = rest.split_first() else {
255 38643 : terminal(inst, bracket, ctx, out);
256 38643 : return;
257 : };
258 : // 1. sub-attribute step (expanded key)
259 60 : let iri = ctx.expand_key(seg);
260 : // 0. a core member of the instance, kept under its own name by
261 : // expansion: `temperature.observedAt>=...` (4.9 EXAMPLE 8) and
262 : // `unitCode`, each a plain value and the last segment
263 60 : if tail.is_empty() && bracket.is_none() {
264 50 : let core = iri
265 50 : .strip_prefix("https://uri.etsi.org/ngsi-ld/")
266 50 : .filter(|name| matches!(*name, "observedAt" | "unitCode"));
267 50 : if let Some(v) = core
268 50 : .and_then(|name| inst.get(name))
269 50 : .filter(|v| v.is_string())
270 : {
271 8 : push_target(TargetKind::Value, v, out);
272 8 : return;
273 42 : }
274 10 : }
275 52 : if let Some(subs) = inst.get(&iri).and_then(Value::as_array) {
276 7 : for s in subs {
277 7 : collect(s, tail, bracket, ctx, out);
278 7 : }
279 7 : return;
280 45 : }
281 : // 2. legacy value-path step: navigate into the value object (pre-bracket
282 : // dotted access, kept as a superset of the 4.9 bracket form)
283 45 : if let Some((kind, v)) = comparable_value(inst) {
284 43 : if let Some(nested) = navigate(v, rest) {
285 10 : match bracket {
286 6 : None => push_target(kind, nested, out),
287 4 : Some(b) => {
288 4 : if let Some(deeper) = navigate(nested, b) {
289 2 : push_target(kind, deeper, out);
290 2 : }
291 : }
292 : }
293 33 : }
294 2 : }
295 38703 : }
296 :
297 : /// Terminal instance: extract the target value, applying the trailing
298 : /// bracket — a language filter on a LanguageProperty (4.9 Equal/Unequal
299 : /// languageMap semantics), a MemberExpression into a compound value
300 : /// (EXAMPLE 9/10/11) otherwise.
301 38751 : fn terminal(
302 38751 : inst: &Value,
303 38751 : bracket: Option<&[String]>,
304 38751 : ctx: &Context,
305 38751 : out: &mut Vec<(TargetKind, Value)>,
306 38751 : ) {
307 38751 : let Some((kind, v)) = comparable_value(inst) else {
308 2 : return;
309 : };
310 38749 : match (bracket, kind) {
311 44 : (None, TargetKind::Vocab) => {
312 44 : // 4.9: "If the target element is a VocabProperty, the target
313 44 : // value shall be expanded according to the @context."
314 44 : out.push((kind, expand_vocab(v, ctx)));
315 44 : }
316 : // 4.9: with no target language "the target value is defined as a
317 : // value from any of the key-value pairs held within the languageMap"
318 : // — the bare name reads as `[*]`, never as the map object
319 104 : (None, TargetKind::LanguageMap) => terminal(inst, Some(&["*".to_owned()]), ctx, out),
320 38269 : (None, _) => push_target(kind, v, out),
321 210 : (Some(b), TargetKind::LanguageMap) => {
322 210 : let Some(map) = v.as_object() else { return };
323 208 : let [lang] = b else { return };
324 206 : if lang == "*" {
325 : // any language: ONE array target so that != requires no
326 : // matching value in ANY language (4.9 Unequal, color[*])
327 148 : let mut all = Vec::new();
328 308 : for val in map.values() {
329 308 : match val {
330 10 : Value::Array(a) => all.extend(a.iter().cloned()),
331 298 : other => all.push(other.clone()),
332 : }
333 : }
334 148 : out.push((kind, Value::Array(all)));
335 58 : } else if let Some(val) = map.get(lang) {
336 51 : out.push((kind, val.clone()));
337 51 : }
338 : }
339 122 : (Some(b), _) => {
340 : // MemberExpression into the compound value; undefined result =
341 : // "the target element shall be considered as non-existent".
342 : // Several reached (through an array of JSON objects) are ONE
343 : // array target, so == holds when one equals and != when none
344 : // does, as over [*] above (TS 104 175 7.2.3.4 conditions 8, 9)
345 122 : match reach(v, b).as_slice() {
346 122 : [] => {}
347 28 : [one] => push_target(kind, one, out),
348 18 : several => {
349 18 : let mut all = Vec::new();
350 36 : for val in several {
351 36 : match val {
352 2 : Value::Array(a) => all.extend(a.iter().cloned()),
353 34 : other => all.push((*other).clone()),
354 : }
355 : }
356 18 : push_target(kind, &Value::Array(all), out);
357 : }
358 : }
359 : }
360 : }
361 38751 : }
362 :
363 : /// Expand a vocab value (string or array of strings) against the @context.
364 54 : fn expand_vocab(v: &Value, ctx: &Context) -> Value {
365 54 : match v {
366 48 : Value::String(s) => Value::String(ctx.expand_key(s)),
367 8 : Value::Array(a) => Value::Array(a.iter().map(|x| expand_vocab(x, ctx)).collect()),
368 2 : other => other.clone(),
369 : }
370 54 : }
371 :
372 47 : fn navigate<'a>(v: &'a Value, path: &[String]) -> Option<&'a Value> {
373 47 : let mut cur = v;
374 47 : for seg in path {
375 47 : cur = cur.get(seg)?;
376 : }
377 12 : Some(cur)
378 47 : }
379 :
380 : /// The values a trailing `[path]` reaches inside `v`. Through an array it
381 : /// reaches into each JSON object of it (TS 104 175 7.2.3.4 conditions 8 and
382 : /// 9), so one path may reach several values; none reached is a non-existent
383 : /// target.
384 122 : fn reach<'a>(v: &'a Value, path: &[String]) -> Vec<&'a Value> {
385 122 : let mut cur = vec![v];
386 132 : for seg in path {
387 132 : cur = cur
388 132 : .into_iter()
389 134 : .flat_map(|c| match c {
390 434 : Value::Array(items) => items.iter().filter_map(|i| i.get(seg)).collect(),
391 87 : other => other.get(seg).into_iter().collect::<Vec<_>>(),
392 134 : })
393 132 : .collect();
394 : }
395 122 : cur
396 122 : }
397 :
398 : /// 4.9 target value: annex C.6 lets a Property value be written as a JSON-LD
399 : /// typed value, and the Value the Property carries is then the `@value`
400 : /// member rather than the object around it. Members of a list are unwrapped
401 : /// one by one, and a compound value — an object with no `@value` — is left
402 : /// whole, so a MemberExpression still navigates it.
403 41488 : fn untyped(v: &Value) -> Value {
404 41488 : match v {
405 1097 : Value::Object(o) => o.get("@value").cloned().unwrap_or_else(|| v.clone()),
406 487 : Value::Array(a) => Value::Array(a.iter().map(untyped).collect()),
407 39904 : other => other.clone(),
408 : }
409 41488 : }
410 :
411 : /// One target, with the typed-value unwrap on the kinds that can carry one.
412 : /// A JsonProperty is not one of them: the core `@context` types its `json`
413 : /// member `@json`, so an `@value` inside it is data, not JSON-LD.
414 38331 : fn push_target(kind: TargetKind, v: &Value, out: &mut Vec<(TargetKind, Value)>) {
415 38331 : let v = match kind {
416 38202 : TargetKind::Value | TargetKind::ValueList => untyped(v),
417 129 : _ => v.clone(),
418 : };
419 38331 : out.push((kind, v));
420 38331 : }
421 :
422 38796 : fn comparable_value(inst: &Value) -> Option<(TargetKind, &Value)> {
423 38796 : let obj = inst.as_object()?;
424 39778 : for (k, kind) in [
425 38796 : ("value", TargetKind::Value),
426 38796 : ("object", TargetKind::Object),
427 38796 : ("languageMap", TargetKind::LanguageMap),
428 38796 : ("vocab", TargetKind::Vocab),
429 38796 : ("json", TargetKind::Json),
430 38796 : ("valueList", TargetKind::ValueList),
431 38796 : ("objectList", TargetKind::ObjectList),
432 38796 : ] {
433 39778 : if let Some(v) = obj.get(k) {
434 38792 : return Some((kind, v));
435 986 : }
436 : }
437 4 : None
438 38796 : }
439 :
440 : /// Do target and Query Term value share a datatype? 4.9 hangs two opposite
441 : /// rules on this: Equal (and the ordering operators) treat a mismatch as "not
442 : /// matching", Unequal treats it as unequal — i.e. a MATCH.
443 481 : fn same_datatype(target: &Value, want: &QValue) -> bool {
444 481 : match want {
445 142 : QValue::Num(_) => target.is_number(),
446 266 : QValue::Str(_) => target.is_string(),
447 45 : QValue::Bool(_) => target.is_boolean(),
448 : // a Range's value space is its endpoints' (the parser pins both to
449 : // one variant); Lists never reach this guard — they are unfolded
450 : // into per-element compares first, each applying its own rule
451 26 : QValue::Range(lo, _) => same_datatype(target, lo),
452 2 : QValue::List(_) => true,
453 : }
454 481 : }
455 :
456 : /// `re` is the pre-compiled pattern of the enclosing Query Term, `None`
457 : /// when the operator is not a pattern operator or the pattern is invalid.
458 31510 : fn compare(target: &Value, op: CmpOp, want: &QValue, re: Option<®ex::Regex>) -> bool {
459 : // 4.9 ValueList — Equal p.90: "identical or equivalent to ANY of the list
460 : // values"; Unequal p.91: "neither identical nor equivalent to any of the
461 : // list values" / "does not include ANY of the list values" — i.e. every
462 : // per-element != must hold. Unfold before the array unwrap so the array
463 : // rules apply per list element.
464 31510 : if let QValue::List(vals) = want {
465 161 : return match op {
466 184 : CmpOp::Eq => vals.iter().any(|v| compare(target, op, v, re)),
467 92 : CmpOp::Ne => vals.iter().all(|v| compare(target, op, v, re)),
468 2 : _ => false, // grammar-unreachable (parser rejects), stay safe
469 : };
470 31349 : }
471 31349 : if let Value::Array(items) = target {
472 : // 4.9 Unequal, p.91: "The target value does not include any of the list
473 : // values, if the target value is an array (e.g. matches
474 : // ["blue","black","green"], but not ["blue","red","green"])" — so for
475 : // `!=` EVERY element must differ. Same reading for `!~=` ("shall not
476 : // be in L(R)" — one matching element would be in it). `.any()` is
477 : // right for the rest.
478 581 : return match op {
479 256 : CmpOp::Ne | CmpOp::NotPattern => items.iter().all(|i| compare(i, op, want, re)),
480 2382 : _ => items.iter().any(|i| compare(i, op, want, re)),
481 : };
482 30768 : }
483 : // 4.9 Unequal, p.92: "If the data type of the target value and the data
484 : // type of the Query Term value are different, then they shall be
485 : // considered unequal." Equal carries the mirror-image rule, so this guard
486 : // is deliberately asymmetric and must run before the casts below — which
487 : // all return false on a failed cast. (`!~=` is NOT symmetric with `!=`
488 : // here: p.92 "If the target value data type is different than String then
489 : // it shall be considered as not matching" — so no early true for it.)
490 30768 : if op == CmpOp::Ne && !same_datatype(target, want) {
491 161 : return true;
492 30607 : }
493 : // 4.9 Range — Equal p.90: "in the interval between the minimum and
494 : // maximum of the range (both included)"; Unequal p.91: "not in the
495 : // interval".
496 30607 : if let QValue::Range(lo, hi) = want {
497 137 : return match op {
498 111 : CmpOp::Eq => in_range(target, lo, hi),
499 24 : CmpOp::Ne => !in_range(target, lo, hi),
500 2 : _ => false, // grammar-unreachable (parser rejects), stay safe
501 : };
502 30470 : }
503 30470 : match want {
504 6378 : QValue::Num(n) => {
505 6378 : let Some(t) = target.as_f64() else {
506 241 : return false;
507 : };
508 6137 : num_cmp(t, op, *n)
509 : }
510 529 : QValue::Bool(b) => match op {
511 520 : CmpOp::Eq => target.as_bool() == Some(*b),
512 7 : CmpOp::Ne => target.as_bool().is_some_and(|t| t != *b),
513 2 : _ => false,
514 : },
515 23563 : QValue::Str(s) => {
516 23563 : let Some(t) = target.as_str() else {
517 1330 : return false;
518 : };
519 : // 4.9 p.92: "When comparing dates or times, the order relation
520 : // considered shall be a temporal one" (EXAMPLE 8 of the clause is
521 : // exactly this: `?q=temperature.observedAt>=2017-12-24T12:00:00Z`).
522 : // Equality stays a string comparison: `==` also lowers to a
523 : // jsonpath string compare in `ngsild_ql::sql`, and widening it
524 : // here alone would make the memory and Postgres arms disagree.
525 22233 : if matches!(op, CmpOp::Gt | CmpOp::Ge | CmpOp::Lt | CmpOp::Le) {
526 144 : if let (Some(tk), Some(sk)) = (temporal_key(t), temporal_key(s)) {
527 : // equal length is equal base shape — a Time and a
528 : // DateTime are not two points on one axis
529 62 : if tk.len() == sk.len() {
530 60 : return match op {
531 34 : CmpOp::Gt => tk > sk,
532 8 : CmpOp::Ge => tk >= sk,
533 10 : CmpOp::Lt => tk < sk,
534 8 : _ => tk <= sk,
535 : };
536 2 : }
537 82 : }
538 22089 : }
539 22173 : match op {
540 21743 : CmpOp::Eq => t == s,
541 155 : CmpOp::Ne => t != s,
542 40 : CmpOp::Gt => t > s.as_str(),
543 31 : CmpOp::Ge => t >= s.as_str(),
544 7 : CmpOp::Lt => t < s.as_str(),
545 6 : CmpOp::Le => t <= s.as_str(),
546 146 : CmpOp::Pattern => re.is_some_and(|re| re.is_match(t)),
547 : // p.92: target "shall not be in the L(R)" — an invalid regex
548 : // has no L(R), treat as not matching (same posture as ~=)
549 45 : CmpOp::NotPattern => re.is_some_and(|re| !re.is_match(t)),
550 : }
551 : }
552 : // both are answered by the unfold guards at the top of this
553 : // function; false is the same posture the arms there take
554 0 : QValue::List(_) | QValue::Range(..) => false,
555 : }
556 31510 : }
557 :
558 : /// Canonical ordering key for a 4.6.3 DateTime or Time: the trailing `Z`
559 : /// dropped and the optional seconds fraction (`.`, or the request-side `,`)
560 : /// zero-padded to six digits, so string order equals temporal order across
561 : /// spellings of one instant. Without it `.` (0x2E) sorts before `Z` (0x5A)
562 : /// and `…:00.500Z` reads as EARLIER than `…:00Z`. A Date carries no fraction
563 : /// and already orders lexicographically, so it needs no key. `None` for
564 : /// anything that is neither shape, which leaves an ordinary string ordered
565 : /// as a string. `antares_model::dt_key` is the same rule for the DateTime
566 : /// shape alone, and returns its input unchanged rather than `None`, which is
567 : /// why the two are not one function.
568 410 : fn temporal_key(s: &str) -> Option<String> {
569 410 : let body = s.strip_suffix('Z')?;
570 218 : let (base, frac) = body.split_once(['.', ',']).unwrap_or((body, ""));
571 : // hh:mm:ss (Time) or YYYY-MM-DDThh:mm:ss (DateTime). What counts as a
572 : // DateTime is 4.6.3's shape, and it is decided by the one function every
573 : // other layer asks: a length of nineteen is not a date, and treating an
574 : // arbitrary nineteen-character string ending in Z as an instant is how a
575 : // string range stops being a string range (4.9 p.92).
576 218 : let shaped = match base.len() {
577 186 : 19 => antares_jsonld::parse_datetime(s),
578 : // a Time carries no calendar part to validate, only its own shape
579 230 : 8 => base.bytes().enumerate().all(|(i, b)| {
580 230 : if i == 2 || i == 5 {
581 58 : b == b':'
582 : } else {
583 172 : b.is_ascii_digit()
584 : }
585 230 : }),
586 2 : _ => false,
587 : };
588 218 : (shaped && frac.len() <= 6 && frac.bytes().all(|c| c.is_ascii_digit()))
589 218 : .then(|| format!("{base}.{frac:0<6}"))
590 410 : }
591 :
592 : /// `t ∈ [lo, hi]`, both included (4.9 p.90). The parser guarantees both
593 : /// endpoints share one variant and are never booleans.
594 145 : fn in_range(target: &Value, lo: &QValue, hi: &QValue) -> bool {
595 145 : match (lo, hi) {
596 115 : (QValue::Num(a), QValue::Num(b)) => target.as_f64().is_some_and(|t| t >= *a && t <= *b),
597 : // 4.9 p.92 again: a Range over dates or times is an interval on the
598 : // temporal axis, so its two inclusive bounds are compared the same
599 : // way the ordering operators are. A string range stays a string
600 : // range. (A Range only reaches SQL when both endpoints are Numbers,
601 : // so this arm has no Postgres twin to diverge from.)
602 28 : (QValue::Str(a), QValue::Str(b)) => target.as_str().is_some_and(|t| {
603 28 : match (temporal_key(t), temporal_key(a), temporal_key(b)) {
604 20 : (Some(tk), Some(ak), Some(bk)) if tk.len() == ak.len() && ak.len() == bk.len() => {
605 12 : tk >= ak && tk <= bk
606 : }
607 16 : _ => t >= a.as_str() && t <= b.as_str(),
608 : }
609 28 : }),
610 2 : _ => false,
611 : }
612 145 : }
613 :
614 6139 : fn num_cmp(t: f64, op: CmpOp, n: f64) -> bool {
615 6139 : match op {
616 1294 : CmpOp::Eq => t == n,
617 106 : CmpOp::Ne => t != n,
618 1704 : CmpOp::Gt => t > n,
619 2729 : CmpOp::Ge => t >= n,
620 256 : CmpOp::Lt => t < n,
621 48 : CmpOp::Le => t <= n,
622 2 : CmpOp::Pattern | CmpOp::NotPattern => false,
623 : }
624 6139 : }
625 :
626 : #[cfg(test)]
627 : mod clause_4_9_extensions {
628 : use super::*;
629 : use crate::parse_q;
630 : use antares_jsonld::Context;
631 : use serde_json::json;
632 :
633 276 : fn ctx() -> std::sync::Arc<Context> {
634 276 : crate::test_core()
635 276 : }
636 :
637 46 : fn expand(doc: serde_json::Value) -> Value {
638 46 : antares_jsonld::expand_entity(
639 46 : doc.as_object().expect("obj"),
640 46 : &ctx(),
641 46 : antares_jsonld::ExpandOpts::default(),
642 : )
643 46 : .expect("expand")
644 46 : }
645 :
646 210 : fn q(doc: &Value, q: &str) -> bool {
647 210 : let ast = parse_q(q).expect(q);
648 210 : eval_q(&ast, doc, &ctx(), &|_| None)
649 210 : }
650 :
651 : /// 4.9 EXAMPLE 8 (and 7.2.3.2 EXAMPLE 9 of the API):
652 : /// `temperature.observedAt>=...` compares the Property's `observedAt`.
653 : #[test]
654 2 : fn observed_at_is_a_sub_attribute_path() {
655 2 : let e = expand(json!({"id": "urn:x", "type": "T",
656 2 : "temperature": {"type": "Property", "value": 20,
657 2 : "observedAt": "2017-12-24T13:00:00Z"}}));
658 2 : assert!(q(&e, "temperature.observedAt>=2017-12-24T12:00:00Z"));
659 2 : assert!(!q(&e, "temperature.observedAt<2017-12-24T12:00:00Z"));
660 2 : assert!(q(&e, "temperature.observedAt==2017-12-24T13:00:00Z"));
661 2 : assert!(!q(&e, "humidity.observedAt>=2017-12-24T12:00:00Z"));
662 2 : let unit = expand(json!({"id": "urn:x", "type": "T",
663 2 : "temperature": {"type": "Property", "value": 20, "unitCode": "CEL"}}));
664 2 : assert!(q(&unit, r#"temperature.unitCode=="CEL""#));
665 2 : assert!(
666 2 : !q(&unit, "temperature.observedAt>=2017-12-24T12:00:00Z"),
667 : "absent"
668 : );
669 2 : }
670 :
671 : /// 4.9 EXAMPLE 9/10/11: trailing [path] navigates the compound value
672 : /// (MemberExpression); undefined member = target non-existent.
673 : #[test]
674 2 : fn compound_value_trailing_path() {
675 2 : let e = expand(json!({"id": "urn:x", "type": "T",
676 2 : "address": {"type": "Property",
677 2 : "value": {"city": "Berlin", "street": "Ulrich Strasse"}},
678 2 : "sensor": {"type": "Property", "value": 40,
679 2 : "rawdata": {"type": "Property",
680 2 : "value": {"airquality": {"particulate": 40, "PM20": 85}}}},
681 2 : "parkingTickets": {"type": "JsonProperty",
682 2 : "json": {"id": "85a6cc52", "value": "Overstay 60 minutes"}}}));
683 2 : assert!(q(&e, r#"address[city]=="Berlin""#), "EXAMPLE 9");
684 2 : assert!(!q(&e, r#"address[city]=="Paris""#));
685 2 : assert!(!q(&e, r#"address[postcode]=="Berlin""#), "undefined member");
686 2 : assert!(
687 2 : q(&e, "sensor.rawdata[airquality.particulate]==40"),
688 : "EXAMPLE 10"
689 : );
690 2 : assert!(!q(&e, "sensor.rawdata[airquality.missing]==40"));
691 2 : assert!(
692 2 : q(&e, r#"parkingTickets[value]=="Overstay 60 minutes""#),
693 : "EXAMPLE 11 (JsonProperty raw json navigation)"
694 : );
695 : // existence through the bracket: defined member exists, missing not
696 2 : assert!(q(&e, "address[city]"));
697 2 : assert!(!q(&e, "address[postcode]"));
698 2 : }
699 :
700 : /// TS 104 175 7.2.3.4 conditions 8 and 9: a trailing path into a value
701 : /// that is an array of JSON objects matches if one of the objects holds
702 : /// it (its example: `address[city]==Berlin` over London and Berlin), and
703 : /// `!=` then needs none of them to equal; the same over a ListProperty
704 : /// and a JsonProperty.
705 : #[test]
706 2 : fn trailing_path_matches_inside_an_array_of_json_objects() {
707 2 : let e = expand(json!({"id": "urn:x", "type": "T",
708 2 : "address": {"type": "Property",
709 2 : "value": [{"city": "London"}, {"city": "Berlin", "zip": "10115"}]},
710 2 : "stops": {"type": "ListProperty",
711 2 : "valueList": [{"city": "Rome"}, {"city": "Oslo"}]},
712 2 : "raw": {"type": "JsonProperty",
713 2 : "json": [{"to": {"city": "Kyiv"}}, {"to": {"city": "Lima"}}]}}));
714 2 : assert!(q(&e, r#"address[city]=="Berlin""#));
715 2 : assert!(q(&e, r#"address[city]=="Berlin","Tokyo""#));
716 2 : assert!(!q(&e, r#"address[city]=="Rome""#));
717 2 : assert!(!q(&e, r#"address[city]!="Berlin""#), "one element equals");
718 2 : assert!(q(&e, r#"address[city]!="Rome""#));
719 2 : assert!(q(&e, "address[zip]"), "held by one object");
720 2 : assert!(!q(&e, "address[street]"));
721 2 : assert!(q(&e, r#"stops[city]=="Oslo""#));
722 2 : assert!(q(&e, r#"raw[to.city]=="Lima""#));
723 2 : assert!(!q(&e, r#"raw[to.city]=="Rome""#));
724 2 : }
725 :
726 : /// 4.9 Equal/Unequal languageMap semantics: [lang] targets one language,
727 : /// [*] any; != over [*] requires NO value to match.
728 : #[test]
729 2 : fn language_property_filters() {
730 2 : let e = expand(json!({"id": "urn:x", "type": "T",
731 2 : "color": {"type": "LanguageProperty",
732 2 : "languageMap": {"fr": "rouge", "en": "red", "de": "rot"}},
733 2 : "names": {"type": "LanguageProperty",
734 2 : "languageMap": {"fr": ["chat", "rouge"], "en": ["red", "cat"]}}}));
735 2 : assert!(q(&e, r#"color[en]=="red""#));
736 2 : assert!(
737 2 : !q(&e, r#"color[en]=="rouge""#),
738 : "wrong language must not match"
739 : );
740 2 : assert!(q(&e, r#"color[*]=="rouge""#), "any-language match");
741 2 : assert!(!q(&e, r#"color[*]=="blau""#));
742 2 : assert!(
743 2 : q(&e, r#"names[en]=="cat""#),
744 : "array element in one language"
745 : );
746 : // Unequal: no matching value in ANY of the values
747 2 : assert!(q(&e, r#"color[en]!="rouge""#));
748 2 : assert!(!q(&e, r#"color[en]!="red""#));
749 2 : assert!(!q(&e, r#"color[*]!="red""#), "some language holds red");
750 2 : assert!(q(&e, r#"color[*]!="blau""#));
751 2 : }
752 :
753 : /// 4.9: "If the target element is a LanguageProperty, and no target
754 : /// language is specified, the target value is defined as a value from
755 : /// any of the key-value pairs held within the languageMap" — a bare
756 : /// name reads like `[*]`, not like the map object.
757 : #[test]
758 2 : fn language_property_without_a_filter_compares_any_language_value() {
759 2 : let e = expand(json!({"id": "urn:x", "type": "T",
760 2 : "color": {"type": "LanguageProperty",
761 2 : "languageMap": {"fr": "rouge", "en": "red"}},
762 2 : "names": {"type": "LanguageProperty",
763 2 : "languageMap": {"fr": ["chat", "rouge"], "en": ["red", "cat"]}}}));
764 2 : assert!(q(&e, r#"color=="red""#), "a value of any key-value pair");
765 2 : assert!(q(&e, r#"color=="blau","rouge""#));
766 2 : assert!(!q(&e, r#"color=="blau""#));
767 2 : assert!(!q(&e, r#"color!="red""#), "some language holds red");
768 2 : assert!(q(&e, r#"color!="blau""#));
769 2 : assert!(q(&e, r#"names=="cat""#), "an element of an array value");
770 2 : assert!(!q(&e, r#"names!="chat""#));
771 2 : assert!(q(&e, "color~=ou"));
772 2 : assert!(q(&e, "color"), "existence still holds");
773 2 : }
774 :
775 : /// 4.9 `patternOp RegExp`: an unquoted digit pattern is a regex over the
776 : /// String target, not a Number compared to it.
777 : #[test]
778 2 : fn an_unquoted_digit_pattern_matches_strings() {
779 2 : let e = expand(json!({"id": "urn:x", "type": "T",
780 2 : "code": {"type": "Property", "value": "A123"}}));
781 2 : assert!(q(&e, "code~=123"));
782 2 : assert!(q(&e, "code~=A..3"));
783 2 : assert!(!q(&e, "code!~=123"));
784 2 : assert!(!q(&e, "code~=124"));
785 2 : }
786 :
787 : /// 4.9: "If the target element is a VocabProperty, the target value shall
788 : /// be expanded according to the @context" — the default-context expansion
789 : /// makes a URI out of the vocab term, so only URI comparisons match.
790 : #[test]
791 2 : fn vocab_property_target_expansion() {
792 2 : let e = expand(json!({"id": "urn:x", "type": "T",
793 2 : "category": {"type": "VocabProperty", "vocab": "commercial"}}));
794 2 : assert!(
795 2 : q(
796 2 : &e,
797 2 : r#"category=="https://uri.etsi.org/ngsi-ld/default-context/commercial""#
798 : ),
799 : "expanded URI equality"
800 : );
801 2 : assert!(
802 2 : !q(&e, r#"category=="somethingelse""#),
803 : "non-matching literal"
804 : );
805 2 : }
806 :
807 : /// A vocab list expands term by term; any member equal to the URI matches.
808 : #[test]
809 2 : fn vocab_list_expands_every_term() {
810 2 : let e = expand(json!({"id": "urn:x", "type": "T",
811 2 : "category": {"type": "VocabProperty", "vocab": ["commercial", "public"]}}));
812 2 : assert!(q(
813 2 : &e,
814 2 : r#"category=="https://uri.etsi.org/ngsi-ld/default-context/public""#
815 : ));
816 2 : assert!(!q(&e, r#"category=="public""#));
817 2 : }
818 :
819 : /// Every 4.9 operator on a number, a boolean and a string: booleans
820 : /// answer only `==`/`!=`, a pattern never matches a number.
821 : #[test]
822 2 : fn every_operator_on_numbers_booleans_and_strings() {
823 2 : let e = expand(json!({"id": "urn:x", "type": "T",
824 2 : "n": {"type": "Property", "value": 5},
825 2 : "b": {"type": "Property", "value": true},
826 2 : "s": {"type": "Property", "value": "m"}}));
827 24 : for (query, want) in [
828 2 : ("n!=4", true),
829 2 : ("n!=5", false),
830 2 : ("n>=5", true),
831 2 : ("n<=4", false),
832 2 : ("n<6", true),
833 2 : (r#"n~="5""#, false),
834 2 : ("b==true", true),
835 2 : ("b!=false", true),
836 2 : ("b!=true", false),
837 2 : (r#"s>="m""#, true),
838 2 : (r#"s<"n""#, true),
839 2 : (r#"s<="l""#, false),
840 2 : ] {
841 24 : assert_eq!(q(&e, query), want, "{query}");
842 : }
843 2 : }
844 :
845 : /// every ordering operator at its own boundary, on
846 : /// numbers, plain strings and DateTimes — equal is outside `<`/`>` and
847 : /// inside `<=`/`>=`.
848 : #[test]
849 2 : fn ordering_operators_at_the_boundary() {
850 2 : let e = expand(json!({"id": "urn:x", "type": "T",
851 2 : "n": {"type": "Property", "value": 5},
852 2 : "s": {"type": "Property", "value": "m"},
853 2 : "t": {"type": "Property", "value": "2020-01-01T00:00:00Z"}}));
854 20 : for (query, want) in [
855 2 : ("n>5", false),
856 2 : ("n<5", false),
857 2 : ("n>4", true),
858 2 : ("n<=5", true),
859 2 : (r#"s>"m""#, false),
860 2 : (r#"s<"m""#, false),
861 2 : ("t<2020-01-01T00:00:00Z", false),
862 2 : ("t<2020-01-01T00:00:00.5Z", true),
863 2 : ("t>2020-01-01T00:00:00Z", false),
864 2 : ("t<=2020-01-01T00:00:00Z", true),
865 2 : ] {
866 20 : assert_eq!(q(&e, query), want, "{query}");
867 : }
868 2 : }
869 :
870 : /// 4.9 p.90: a Range includes both ends. A string range orders as
871 : /// strings; a temporal one only when all three are one shape, so a
872 : /// mixed range falls back to string order (`.5Z` sorts before `Z`).
873 : #[test]
874 2 : fn ranges_include_both_ends_and_mix_shapes_as_strings() {
875 2 : let e = expand(json!({"id": "urn:x", "type": "T",
876 2 : "s": {"type": "Property", "value": "m"},
877 2 : "t": {"type": "Property", "value": "2020-01-01T00:00:00.5Z"}}));
878 12 : for (query, want) in [
879 2 : (r#"s=="a".."z""#, true),
880 2 : (r#"s=="n".."z""#, false),
881 2 : (r#"s=="a".."l""#, false),
882 2 : (r#"s=="m".."m""#, true),
883 2 : ("t==2020-01-01T00:00:00Z..2020-01-02T00:00:00Z", true),
884 2 : ("t==2020-01-01T00:00:00Z..23:59:59Z", false),
885 2 : ] {
886 12 : assert_eq!(q(&e, query), want, "{query}");
887 : }
888 2 : }
889 :
890 : /// The legacy dotted step into a Property's object value, and a
891 : /// Relationship whose `object` is an array of URIs (each one followed).
892 : #[test]
893 2 : fn value_paths_and_object_arrays() {
894 2 : let e = expand(json!({"id": "urn:x", "type": "T",
895 2 : "address": {"type": "Property", "value": {"city": "Berlin"}}}));
896 2 : assert!(q(&e, r#"address.city=="Berlin""#));
897 2 : assert!(!q(&e, r#"address.city=="Rome""#));
898 4 : let d = |t: &str| format!("https://uri.etsi.org/ngsi-ld/default-context/{t}");
899 2 : let station = json!({"id": "urn:ngsi-ld:WS:1", "type": [d("WeatherStation")],
900 2 : d("sensor"): [{"type": "Relationship",
901 2 : "object": ["urn:ngsi-ld:Device:0", "urn:ngsi-ld:Device:345"]}]});
902 2 : let device = expand(json!({"id": "urn:ngsi-ld:Device:345", "type": "Device",
903 2 : "humidity": {"type": "Property", "value": 40}}));
904 4 : let lookup = |id: &str| (id == "urn:ngsi-ld:Device:345").then(|| device.clone());
905 2 : let ast = parse_q("sensor{humidity}==40").expect("parse");
906 2 : assert!(eval_q(&ast, &station, &ctx(), &lookup));
907 2 : }
908 :
909 : /// 4.9: "If the target element corresponds to a Relationship or
910 : /// ListRelationship, the combination of such target element with any
911 : /// operator different than equal or unequal shall result in not matching."
912 : #[test]
913 2 : fn relationship_ordering_operators_never_match() {
914 2 : let e = expand(json!({"id": "urn:x", "type": "T",
915 2 : "isParked": {"type": "Relationship", "object": "urn:ngsi-ld:P:5"}}));
916 2 : assert!(q(&e, r#"isParked=="urn:ngsi-ld:P:5""#));
917 2 : assert!(
918 2 : !q(&e, r#"isParked>"urn:ngsi-ld:P:4""#),
919 : "ordering op on Relationship"
920 : );
921 2 : assert!(!q(&e, r#"isParked<"urn:ngsi-ld:P:6""#));
922 2 : assert!(!q(&e, r#"isParked~="urn.*""#), "pattern op on Relationship");
923 2 : }
924 :
925 : /// 4.9 EXAMPLE 12: expandValues coerces the query term value through the
926 : /// @context, so a VocabProperty short term matches its expanded URI.
927 : #[test]
928 2 : fn expand_values_coercion() {
929 2 : let e = expand(json!({"id": "urn:x", "type": "T",
930 2 : "category": {"type": "VocabProperty", "vocab": "commercial"}}));
931 2 : let ast = parse_q("category==commercial").expect("parse");
932 2 : assert!(
933 2 : !eval_q(&ast, &e, &ctx(), &|_| None),
934 : "without expandValues the literal does not match the expanded vocab"
935 : );
936 2 : let ast = apply_expand_values(ast, Some("category"), &ctx());
937 2 : assert!(eval_q(&ast, &e, &ctx(), &|_| None), "EXAMPLE 12");
938 : // other attributes' values stay untouched
939 2 : let ast = apply_expand_values(
940 2 : parse_q(r#"other=="commercial""#).expect("parse"),
941 2 : Some("category"),
942 2 : &ctx(),
943 : );
944 2 : assert!(
945 2 : matches!(&ast, QNode::Cmp { value: QValue::Str(s), .. } if s == "commercial"),
946 : "{ast:?}"
947 : );
948 2 : }
949 :
950 : /// 4.9 EXAMPLE 13/14: linked entity subquery attr{[Type:]path} follows
951 : /// the Relationship object through the resolver; a missing resolver or
952 : /// non-matching type hint yields no match.
953 : #[test]
954 2 : fn linked_entity_subquery() {
955 2 : let station = expand(json!({"id": "urn:ngsi-ld:WS:123", "type": "WeatherStation",
956 2 : "sensor": {"type": "Relationship", "object": "urn:ngsi-ld:Device:345"}}));
957 2 : let device = expand(json!({"id": "urn:ngsi-ld:Device:345", "type": "Device",
958 2 : "humidity": {"type": "Property", "value": 40}}));
959 8 : let lookup = |id: &str| (id == "urn:ngsi-ld:Device:345").then(|| device.clone());
960 2 : let ast = parse_q("sensor{humidity}==40").expect("parse");
961 2 : assert!(eval_q(&ast, &station, &ctx(), &lookup), "EXAMPLE 13");
962 2 : let ast = parse_q("sensor{humidity}==50").expect("parse");
963 2 : assert!(!eval_q(&ast, &station, &ctx(), &lookup));
964 : // EXAMPLE 14: type hint — matching and non-matching
965 2 : let ast = parse_q("sensor{Device:humidity}==40").expect("parse");
966 2 : assert!(eval_q(&ast, &station, &ctx(), &lookup), "EXAMPLE 14");
967 2 : let ast = parse_q("sensor{Vehicle:humidity}==40").expect("parse");
968 2 : assert!(
969 2 : !eval_q(&ast, &station, &ctx(), &lookup),
970 : "type hint must filter"
971 : );
972 : // no resolver → no match, never an error
973 2 : let ast = parse_q("sensor{humidity}==40").expect("parse");
974 2 : assert!(!eval_q(&ast, &station, &ctx(), &|_| None));
975 2 : }
976 :
977 : /// 4.9: "If the target element is a Property, the target value is defined
978 : /// as the Value associated to such Property." Annex C.6 writes a
979 : /// DateTime-valued Property either as a string with `valueType` or as the
980 : /// JSON-LD typed value `{"@type": "DateTime", "@value": …}`; both carry
981 : /// the same Value, so both answer a Query Term the same way. The
982 : /// `!=` case is the one that goes wrong in both directions: 4.9 p.92
983 : /// makes a datatype mismatch a MATCH, so an unread wrapper turns the
984 : /// entity whose value IS the queried one into a match.
985 : #[test]
986 2 : fn a_typed_value_is_compared_by_the_value_it_carries() {
987 2 : let typed = expand(json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle",
988 2 : "testedAt": {"type": "Property",
989 2 : "value": {"@type": "DateTime", "@value": "2018-12-04T12:00:00Z"}}}));
990 2 : let bare = expand(json!({"id": "urn:ngsi-ld:Vehicle:2", "type": "Vehicle",
991 2 : "testedAt": {"type": "Property", "value": "2018-12-04T12:00:00Z"}}));
992 12 : for (term, want) in [
993 2 : ("testedAt==2018-12-04T12:00:00Z", true),
994 2 : ("testedAt!=2018-12-04T12:00:00Z", false),
995 2 : ("testedAt>=2017-12-24T12:00:00Z", true),
996 2 : ("testedAt<=2019-01-01T00:00:00Z", true),
997 2 : ("testedAt>2019-01-01T00:00:00Z", false),
998 2 : ("testedAt", true),
999 2 : ] {
1000 12 : assert_eq!(q(&typed, term), want, "typed value, {term}");
1001 12 : assert_eq!(q(&bare, term), want, "bare value, {term}");
1002 : }
1003 2 : }
1004 :
1005 : /// The same rule reaches a number written as a typed value, and the
1006 : /// members of a ListProperty (4.5.6), whose Values are compared one by
1007 : /// one — 4.9 Equal: "identical or equivalent to ANY of the list values".
1008 : #[test]
1009 2 : fn a_typed_number_and_a_typed_list_member_are_compared_the_same_way() {
1010 2 : let e = expand(json!({"id": "urn:ngsi-ld:Vehicle:3", "type": "Vehicle",
1011 2 : "speed": {"type": "Property", "value": {"@type": "Number", "@value": 60}},
1012 2 : "marks": {"type": "ListProperty", "valueList": [
1013 2 : {"@type": "DateTime", "@value": "2018-12-04T12:00:00Z"},
1014 2 : {"@type": "DateTime", "@value": "2020-01-01T00:00:00Z"}]}}));
1015 2 : assert!(q(&e, "speed==60"));
1016 2 : assert!(q(&e, "speed>50"));
1017 2 : assert!(!q(&e, "speed==61"));
1018 2 : assert!(q(&e, "marks==2020-01-01T00:00:00Z"));
1019 2 : assert!(!q(&e, "marks==2019-01-01T00:00:00Z"));
1020 :
1021 : // The datatype names the value space; what 4.9 compares is the Value
1022 : // itself, so a number written in its lexical form stays a string and
1023 : // a Number Query Term value is a different datatype (p.92).
1024 2 : let lexical = expand(json!({"id": "urn:ngsi-ld:Vehicle:5", "type": "Vehicle",
1025 2 : "speed": {"type": "Property",
1026 2 : "value": {"@type": "http://www.w3.org/2001/XMLSchema#double",
1027 2 : "@value": "3.5"}}}));
1028 2 : assert!(!q(&lexical, "speed>3"));
1029 2 : assert!(q(&lexical, "speed==\"3.5\""));
1030 2 : }
1031 :
1032 : /// A JsonProperty holds arbitrary JSON — the core `@context` types it
1033 : /// `@json`, so nothing inside it is interpreted as JSON-LD. An `@value`
1034 : /// member there is data, and the target value stays the whole JSON
1035 : /// (4.9: "the target value is defined as the JSON value").
1036 : #[test]
1037 2 : fn the_json_of_a_json_property_is_not_read_as_a_typed_value() {
1038 2 : let e = expand(json!({"id": "urn:ngsi-ld:Vehicle:4", "type": "Vehicle",
1039 2 : "ticket": {"type": "JsonProperty", "json": {"@value": 5}}}));
1040 2 : assert!(!q(&e, "ticket==5"));
1041 2 : assert!(q(&e, "ticket[@value]==5"));
1042 2 : }
1043 : }
1044 :
1045 : // regex compiles run for hours under Miri; the fuzz job covers them
1046 : #[cfg(all(test, not(miri)))]
1047 : mod bounds_and_patterns {
1048 : use super::*;
1049 : use crate::parse_q;
1050 : use serde_json::json;
1051 : use std::cell::Cell;
1052 :
1053 : const DC: &str = "https://uri.etsi.org/ngsi-ld/default-context/";
1054 :
1055 14 : fn ctx() -> std::sync::Arc<Context> {
1056 14 : crate::test_core()
1057 14 : }
1058 :
1059 32 : fn with_value(attr: &str, v: Value) -> Value {
1060 32 : json!({
1061 32 : "id": "urn:ngsi-ld:Vehicle:9",
1062 32 : "type": [format!("{DC}Vehicle")],
1063 32 : format!("{DC}{attr}"): [{"type": "Property", "value": v}],
1064 : })
1065 32 : }
1066 :
1067 : /// 4.9 patternOp/notPatternOp: the pattern is compiled once per Query
1068 : /// Term now, so pin the outcomes it has to keep — including the invalid
1069 : /// pattern, which has no L(R) and therefore matches nothing.
1070 : #[test]
1071 2 : fn pattern_operators_keep_their_outcomes() {
1072 2 : let ctx = ctx();
1073 20 : for (q, target, want) in [
1074 2 : (r#"brandName~="^Merc""#, json!("Mercedes"), true),
1075 2 : (r#"brandName~="^Merc""#, json!("Volvo"), false),
1076 2 : // one matching element is enough for ~=, none may match for !~=
1077 2 : (r#"brandName~="^Merc""#, json!(["Volvo", "Mercedes"]), true),
1078 2 : (r#"brandName!~="^Merc""#, json!(["Volvo", "Skoda"]), true),
1079 2 : // non-string target: 4.9 p.92 "considered as not matching"
1080 2 : (r#"brandName~="^Merc""#, json!(7), false),
1081 2 : (r#"brandName!~="^Merc""#, json!(7), false),
1082 2 : // an invalid pattern compiles to no language at all
1083 2 : (r#"brandName~="[""#, json!("Mercedes"), false),
1084 2 : (r#"brandName!~="[""#, json!("Mercedes"), false),
1085 2 : // …and an empty array has no element inside that (empty) language
1086 2 : (r#"brandName!~="[""#, json!([]), true),
1087 2 : (r#"brandName~="[""#, json!([]), false),
1088 2 : ] {
1089 20 : let ast = parse_q(q).expect(q);
1090 20 : assert_eq!(
1091 20 : eval_q(
1092 20 : &ast,
1093 20 : &with_value("brandName", target.clone()),
1094 20 : &ctx,
1095 : &|_| None
1096 : ),
1097 : want,
1098 : "q={q} target={target}"
1099 : );
1100 : }
1101 2 : }
1102 :
1103 : /// 4.9 patternOp: the Query Term's pattern is compiled through the
1104 : /// process-wide cache, so re-evaluating the same term over the next
1105 : /// candidate entity (or the next event, for a subscription) reuses the
1106 : /// compiled program instead of rebuilding it.
1107 : #[test]
1108 2 : fn pattern_term_compiles_through_the_shared_cache() {
1109 2 : let _serial = crate::regex::serial_lock();
1110 2 : let ctx = ctx();
1111 2 : let pat = "^Merc[a-z]+-qterm$";
1112 2 : assert!(
1113 2 : crate::regex::cached(pat).is_none(),
1114 : "the probe pattern must start uncompiled"
1115 : );
1116 2 : let q = format!(r#"brandName~="{pat}""#);
1117 2 : let ast = parse_q(&q).expect(&q);
1118 2 : let hit = with_value("brandName", json!("Mercedes-qterm"));
1119 2 : assert!(eval_q(&ast, &hit, &ctx, &|_| None));
1120 2 : let held = crate::regex::cached(pat).expect("the term's pattern is retained");
1121 : // the next candidate reuses it: a recompile would replace the entry
1122 2 : let miss = with_value("brandName", json!("Volvo"));
1123 2 : assert!(!eval_q(&ast, &miss, &ctx, &|_| None));
1124 2 : assert!(
1125 2 : crate::regex::cached(pat).is_some_and(|now| std::sync::Arc::ptr_eq(&held, &now)),
1126 : "the second candidate must not rebuild the program"
1127 : );
1128 : // an invalid pattern still has no L(R) (p.92) and is still not held
1129 2 : let bad = parse_q(r#"brandName~="[qterm""#).expect("parses");
1130 2 : assert!(!eval_q(
1131 2 : &bad,
1132 2 : &with_value("brandName", json!("[qterm")),
1133 2 : &ctx,
1134 : &|_| None
1135 : ));
1136 2 : assert!(
1137 2 : crate::regex::cached("[qterm").is_none(),
1138 : "an uncompilable pattern is never retained"
1139 : );
1140 2 : }
1141 :
1142 : /// A hostile `~=` pattern costs compile time, not match time (the regex
1143 : /// engine is linear in the input). The crate's compiled-size limit turns
1144 : /// an exploding pattern into a compile error, which 4.9 p.92 treats as
1145 : /// not matching — so the term is rejected instead of consuming memory.
1146 : #[test]
1147 2 : fn hostile_pattern_cannot_blow_the_compile_budget() {
1148 2 : let ctx = ctx();
1149 2 : let e = with_value("brandName", json!("Mercedes"));
1150 2 : let bombs = [
1151 2 : "((((a{1000}){1000}){1000}){1000})".to_owned(),
1152 2 : format!("(?:{})", "a{255}".repeat(64)),
1153 2 : format!("{}a", "(".repeat(200)) + &")".repeat(200),
1154 2 : ];
1155 2 : let started = std::time::Instant::now();
1156 6 : for p in bombs {
1157 6 : let q = format!(r#"brandName~="{p}""#);
1158 : // an unparseable q is an equally acceptable outcome — what must
1159 : // not happen is an accepted term that compiles the bomb
1160 6 : let none = |_: &str| None::<Value>;
1161 6 : assert!(
1162 6 : !parse_q(&q).is_ok_and(|ast| eval_q(&ast, &e, &ctx, &none)),
1163 : "matched: {q}"
1164 : );
1165 : }
1166 2 : assert!(
1167 2 : started.elapsed() < std::time::Duration::from_secs(5),
1168 : "pattern compilation is not bounded"
1169 : );
1170 2 : }
1171 :
1172 : /// 4.9 LinkedEntityRelation: every hop consumes one `attr{…}` level, and
1173 : /// the parser caps those at 8 — so a Relationship cycle is walked a
1174 : /// bounded number of times and the resolver always terminates.
1175 : #[test]
1176 2 : fn linked_walk_terminates_on_a_cycle_within_the_hop_cap() {
1177 2 : let ctx = ctx();
1178 : // urn:A points at itself twice: the walk can only end by running out
1179 : // of hops, never by running out of entities
1180 2 : let a = json!({
1181 2 : "id": "urn:A",
1182 2 : "type": [format!("{DC}Node")],
1183 2 : format!("{DC}r"): [
1184 2 : {"type": "Relationship", "object": "urn:A"},
1185 2 : {"type": "Relationship", "object": "urn:A", "datasetId": "urn:d:2"},
1186 : ],
1187 2 : format!("{DC}v"): [{"type": "Property", "value": 1}],
1188 : });
1189 2 : let calls = Cell::new(0usize);
1190 1020 : let lookup = |id: &str| {
1191 1020 : calls.set(calls.get() + 1);
1192 1020 : (id == "urn:A").then(|| a.clone())
1193 1020 : };
1194 2 : let q = format!("{}v{}==1", "r{".repeat(8), "}".repeat(8));
1195 2 : let ast = parse_q(&q).expect(&q);
1196 2 : assert_eq!(ast.max_link_depth(), 8);
1197 2 : assert!(eval_q(&ast, &a, &ctx, &lookup), "the cycle resolves");
1198 : // 2 objects per hop over 8 hops: 2 + 4 + … + 2^8 — exponential in
1199 : // the fan-out, which is why the work budget, not the hop cap, is
1200 : // what bounds this walk.
1201 2 : assert_eq!(calls.get(), 510, "resolver lookups per query term");
1202 2 : assert!(calls.get() <= MAX_Q_LINK_LOOKUPS);
1203 :
1204 : // one hop deeper is refused before any entity is touched
1205 2 : let deep = format!("{}v{}==1", "r{".repeat(9), "}".repeat(9));
1206 2 : assert!(
1207 0 : matches!(
1208 2 : parse_q(&deep),
1209 : Err(antares_model::NgsiError::TooComplexQuery(_))
1210 : ),
1211 : "the 9th hop must be rejected"
1212 : );
1213 2 : }
1214 :
1215 : /// 4.9 linked-entity resolution is bounded by WORK, not only by hops:
1216 : /// a wide Relationship fan-out costs F^hops entity lookups, so one q
1217 : /// term may only buy `MAX_Q_LINK_LOOKUPS` of them.
1218 : #[test]
1219 2 : fn linked_walk_lookups_are_capped_by_the_work_budget() {
1220 2 : let ctx = ctx();
1221 2 : let fan: Vec<Value> = (0..40)
1222 80 : .map(|i| {
1223 80 : json!({"type": "Relationship", "object": "urn:A",
1224 80 : "datasetId": format!("urn:ngsi-ld:Dataset:{i}")})
1225 80 : })
1226 2 : .collect();
1227 2 : let a = json!({
1228 2 : "id": "urn:A",
1229 2 : "type": [format!("{DC}Node")],
1230 2 : format!("{DC}r"): fan,
1231 2 : format!("{DC}v"): [{"type": "Property", "value": 1}],
1232 : });
1233 2 : let calls = Cell::new(0usize);
1234 1024 : let lookup = |id: &str| {
1235 1024 : calls.set(calls.get() + 1);
1236 1024 : (id == "urn:A").then(|| a.clone())
1237 1024 : };
1238 2 : let ast = parse_q("r{r{r{v}}}==1").expect("q");
1239 2 : assert!(
1240 2 : eval_q(&ast, &a, &ctx, &lookup),
1241 : "a target reachable inside the budget still matches"
1242 : );
1243 2 : assert!(
1244 2 : calls.get() <= MAX_Q_LINK_LOOKUPS,
1245 : "one q term bought {} entity lookups",
1246 0 : calls.get()
1247 : );
1248 2 : }
1249 :
1250 : /// The evaluator is fed whatever the store and the notification path
1251 : /// hold: shapes that are not entities, and paths that navigate into a
1252 : /// scalar, must return "no match" rather than panic.
1253 : #[test]
1254 2 : fn non_entity_shapes_never_panic() {
1255 2 : let ctx = ctx();
1256 2 : let ast = parse_q("speed>10").expect("q");
1257 10 : for doc in [json!(null), json!(7), json!("text"), json!([]), json!({})] {
1258 10 : assert!(!eval_q(&ast, &doc, &ctx, &|_| None), "doc={doc}");
1259 : }
1260 : // a MemberExpression into a scalar value resolves to nothing
1261 2 : let e = with_value("speed", json!(80));
1262 6 : for q in ["speed[unit]==80", "speed.unit.deep==80", "speed[a.b]"] {
1263 6 : let ast = parse_q(q).expect(q);
1264 6 : assert!(!eval_q(&ast, &e, &ctx, &|_| None), "q={q}");
1265 : }
1266 : // an instance carrying no value-defining member is not a target
1267 2 : let bare = json!({
1268 2 : "id": "urn:ngsi-ld:Vehicle:9",
1269 2 : "type": [format!("{DC}Vehicle")],
1270 2 : format!("{DC}speed"): [{"type": "Property", "unitCode": "KMH"}],
1271 : });
1272 2 : let none = |_: &str| None::<Value>;
1273 2 : assert!(!eval_q(&parse_q("speed").expect("q"), &bare, &ctx, &none));
1274 2 : }
1275 :
1276 : /// 4.9 logical operators: `|` is OR, `;` is AND, `!` negates existence.
1277 : #[test]
1278 2 : fn or_and_negated_existence() {
1279 2 : let ctx = ctx();
1280 2 : let e = with_value("speed", json!(80));
1281 10 : for (q, want) in [
1282 2 : ("speed==80|speed==90", true),
1283 2 : ("speed==70|speed==90", false),
1284 2 : ("speed==80;!color", true),
1285 2 : ("speed==80;color", false),
1286 2 : ("(speed==70|speed==80);!color", true),
1287 2 : ] {
1288 10 : let ast = parse_q(q).expect(q);
1289 10 : assert_eq!(eval_q(&ast, &e, &ctx, &|_| None), want, "q={q}");
1290 : }
1291 2 : }
1292 : }
1293 :
1294 : #[cfg(test)]
1295 : mod tests {
1296 : use super::*;
1297 : use crate::parse_q;
1298 : use serde_json::json;
1299 :
1300 2 : fn entity() -> Value {
1301 2 : json!({
1302 2 : "id": "urn:ngsi-ld:Vehicle:1",
1303 2 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1304 2 : "https://uri.etsi.org/ngsi-ld/default-context/speed": [
1305 2 : {"type": "Property", "value": 85,
1306 2 : "https://uri.etsi.org/ngsi-ld/default-context/accuracy": [
1307 2 : {"type": "Property", "value": 0.9}]}
1308 : ],
1309 2 : "https://uri.etsi.org/ngsi-ld/default-context/brandName": [
1310 2 : {"type": "Property", "value": "Mercedes"}
1311 : ]
1312 : })
1313 2 : }
1314 :
1315 : #[test]
1316 2 : fn comparisons_and_paths() {
1317 2 : let ctx = crate::test_core();
1318 2 : let e = entity();
1319 16 : for (q, want) in [
1320 2 : ("speed>80", true),
1321 2 : ("speed<80", false),
1322 2 : (r#"brandName=="Mercedes""#, true),
1323 2 : ("speed.accuracy>0.5", true),
1324 2 : ("speed>80;brandName!=\"BMW\"", true),
1325 2 : ("speed", true),
1326 2 : ("!color", true),
1327 2 : ("color", false),
1328 2 : ] {
1329 16 : let ast = parse_q(q).expect(q);
1330 16 : assert_eq!(eval_q(&ast, &e, &ctx, &|_| None), want, "q={q}");
1331 : }
1332 2 : }
1333 :
1334 : /// 4.9 p.92: "When comparing dates or times, the order relation
1335 : /// considered shall be a temporal one." 4.6.3 leaves the seconds
1336 : /// fraction optional, so one instant has several spellings — and on a
1337 : /// byte comparison `.` (0x2E) sorts before `Z` (0x5A), which puts
1338 : /// `…:00.500Z` BEFORE `…:00Z` and drops an entity the query selects.
1339 : /// EXAMPLE 8 of the clause is exactly this shape
1340 : /// (`?q=temperature.observedAt>=2017-12-24T12:00:00Z`).
1341 : #[test]
1342 2 : fn dates_and_times_order_temporally_across_fraction_spellings() {
1343 2 : let ctx = crate::test_core();
1344 34 : let at = |v: &str| {
1345 34 : json!({
1346 34 : "id": "urn:ngsi-ld:Vehicle:3",
1347 34 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1348 34 : "https://uri.etsi.org/ngsi-ld/default-context/seen": [
1349 34 : {"type": "Property", "value": v}
1350 : ]
1351 : })
1352 34 : };
1353 34 : for (value, q, want) in [
1354 2 : // half a second after the bound: greater, however it is spelled
1355 2 : (
1356 2 : "2020-01-01T00:00:00.500Z",
1357 2 : "seen>2020-01-01T00:00:00Z",
1358 2 : true,
1359 2 : ),
1360 2 : (
1361 2 : "2020-01-01T00:00:00.500Z",
1362 2 : "seen<2020-01-01T00:00:00Z",
1363 2 : false,
1364 2 : ),
1365 2 : (
1366 2 : "2020-01-01T00:00:00Z",
1367 2 : "seen<2020-01-01T00:00:00.500Z",
1368 2 : true,
1369 2 : ),
1370 2 : (
1371 2 : "2020-01-01T00:00:00Z",
1372 2 : "seen>2020-01-01T00:00:00.500Z",
1373 2 : false,
1374 2 : ),
1375 2 : // the same instant, two spellings: neither strictly ordered,
1376 2 : // both inclusive bounds hold
1377 2 : (
1378 2 : "2020-01-01T00:00:00.000Z",
1379 2 : "seen>2020-01-01T00:00:00Z",
1380 2 : false,
1381 2 : ),
1382 2 : (
1383 2 : "2020-01-01T00:00:00.000Z",
1384 2 : "seen>=2020-01-01T00:00:00Z",
1385 2 : true,
1386 2 : ),
1387 2 : (
1388 2 : "2020-01-01T00:00:00.000Z",
1389 2 : "seen<=2020-01-01T00:00:00Z",
1390 2 : true,
1391 2 : ),
1392 2 : // 4.6.3 also admits a comma as the fraction separator
1393 2 : (
1394 2 : "2020-01-01T00:00:00,500Z",
1395 2 : "seen>2020-01-01T00:00:00Z",
1396 2 : true,
1397 2 : ),
1398 2 : // Time carries the same optional fraction
1399 2 : ("00:00:00.500Z", "seen>00:00:00Z", true),
1400 2 : ("00:00:00Z", "seen>00:00:00.500Z", false),
1401 2 : // a Date has no fraction and already orders lexicographically
1402 2 : ("2020-01-02", "seen>2020-01-01", true),
1403 2 : ("2020-01-01", "seen>2020-01-02", false),
1404 2 : // an ordinary string is still ordered as a string
1405 2 : (r#"banana"#, r#"seen>"apple""#, true),
1406 2 : (r#"apple"#, r#"seen>"banana""#, false),
1407 2 : // a Range over instants is an interval on the same axis
1408 2 : (
1409 2 : "2020-01-01T00:00:00.500Z",
1410 2 : "seen==2020-01-01T00:00:00Z..2020-01-01T00:00:01Z",
1411 2 : true,
1412 2 : ),
1413 2 : (
1414 2 : "2020-01-01T00:00:01.500Z",
1415 2 : "seen==2020-01-01T00:00:00Z..2020-01-01T00:00:01Z",
1416 2 : false,
1417 2 : ),
1418 2 : (
1419 2 : "2020-01-01T00:00:00.500Z",
1420 2 : "seen!=2020-01-01T00:00:00Z..2020-01-01T00:00:01Z",
1421 2 : false,
1422 2 : ),
1423 2 : ] {
1424 34 : let ast = parse_q(q).expect(q);
1425 34 : assert_eq!(
1426 34 : eval_q(&ast, &at(value), &ctx, &|_| None),
1427 : want,
1428 : "value {value:?} against q={q}"
1429 : );
1430 : }
1431 2 : }
1432 :
1433 : #[test]
1434 2 : fn unequal_matches_on_datatype_mismatch() {
1435 : // 4.9 Unequal, p.92: "If the data type of the target value and the data
1436 : // type of the Query Term value are different, then they shall be
1437 : // considered unequal" — so `!=` MATCHES. Equal carries the mirror rule
1438 : // ("considered as not matching"); the asymmetry is deliberate.
1439 2 : let ctx = crate::test_core();
1440 2 : let e = json!({
1441 2 : "id": "urn:ngsi-ld:Vehicle:2",
1442 2 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1443 : // a STRING where the query asks about a number, and vice versa
1444 2 : "https://uri.etsi.org/ngsi-ld/default-context/speed": [
1445 2 : {"type": "Property", "value": "fast"}
1446 : ],
1447 2 : "https://uri.etsi.org/ngsi-ld/default-context/brandName": [
1448 2 : {"type": "Property", "value": 7}
1449 : ]
1450 : });
1451 10 : for (q, want) in [
1452 2 : ("speed!=10", true), // string vs number ⇒ unequal
1453 2 : ("speed==10", false), // …but not equal
1454 2 : (r#"brandName!="Mercedes""#, true), // number vs string ⇒ unequal
1455 2 : (r#"brandName=="Mercedes""#, false),
1456 2 : ("speed>10", false), // ordering on a mismatch does NOT match
1457 2 : ] {
1458 10 : let ast = parse_q(q).expect(q);
1459 10 : assert_eq!(eval_q(&ast, &e, &ctx, &|_| None), want, "q={q}");
1460 : }
1461 2 : }
1462 :
1463 : #[test]
1464 2 : fn unequal_over_an_array_requires_every_element_to_differ() {
1465 : // 4.9 Unequal, p.91: "The target value does not include any of the list
1466 : // values, if the target value is an array (e.g. matches
1467 : // ["blue","black","green"], but not ["blue","red","green"])."
1468 2 : let ctx = crate::test_core();
1469 4 : let mk = |vals: Value| {
1470 4 : json!({
1471 4 : "id": "urn:ngsi-ld:Vehicle:3",
1472 4 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1473 4 : "https://uri.etsi.org/ngsi-ld/default-context/color": [
1474 4 : {"type": "Property", "value": vals}
1475 : ]
1476 : })
1477 4 : };
1478 2 : let none = |_: &str| None::<Value>;
1479 2 : let ast = parse_q(r#"color!="red""#).expect("q");
1480 2 : assert!(
1481 2 : eval_q(&ast, &mk(json!(["blue", "black", "green"])), &ctx, &none),
1482 : "no element equals red ⇒ matches"
1483 : );
1484 2 : assert!(
1485 2 : !eval_q(&ast, &mk(json!(["blue", "red", "green"])), &ctx, &|_| None),
1486 : "red is included ⇒ must NOT match (was matching on the 'blue' element)"
1487 : );
1488 2 : }
1489 :
1490 : /// 4.9 ValueList — Equal p.90 and Unequal p.91, including the spec's own
1491 : /// array examples verbatim.
1492 : #[test]
1493 2 : fn value_list_semantics() {
1494 2 : let ctx = crate::test_core();
1495 16 : let mk = |v: Value| {
1496 16 : json!({
1497 16 : "id": "urn:ngsi-ld:Vehicle:4",
1498 16 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1499 16 : "https://uri.etsi.org/ngsi-ld/default-context/color": [
1500 16 : {"type": "Property", "value": v}
1501 : ]
1502 : })
1503 16 : };
1504 16 : for (q, target, want) in [
1505 2 : // Eq p.90: identical to ANY list value (e.g. matches "red")
1506 2 : (r#"color=="black","red""#, json!("red"), true),
1507 2 : (r#"color=="black","red""#, json!("blue"), false),
1508 2 : // Eq p.90: array includes ANY of the query values
1509 2 : (r#"color=="black","red""#, json!(["red", "blue"]), true),
1510 2 : (r#"color=="black","red""#, json!(["blue", "green"]), false),
1511 2 : // Ne p.91: identical to NO list value (e.g. matches "blue")
1512 2 : (r#"color!="black","red""#, json!("blue"), true),
1513 2 : (r#"color!="black","red""#, json!("red"), false),
1514 2 : // Ne p.91 verbatim: matches ["blue","yellow","green"],
1515 2 : // but not ["blue","red","green"]
1516 2 : (
1517 2 : r#"color!="black","red""#,
1518 2 : json!(["blue", "yellow", "green"]),
1519 2 : true,
1520 2 : ),
1521 2 : (
1522 2 : r#"color!="black","red""#,
1523 2 : json!(["blue", "red", "green"]),
1524 2 : false,
1525 2 : ),
1526 2 : ] {
1527 16 : let ast = parse_q(q).expect(q);
1528 16 : assert_eq!(
1529 16 : eval_q(&ast, &mk(target.clone()), &ctx, &|_| None),
1530 : want,
1531 : "q={q} target={target}"
1532 : );
1533 : }
1534 2 : }
1535 :
1536 : /// 4.9 Range — Equal p.90 ("both included") and Unequal p.91.
1537 : #[test]
1538 2 : fn range_semantics() {
1539 2 : let ctx = crate::test_core();
1540 16 : let mk = |v: Value| {
1541 16 : json!({
1542 16 : "id": "urn:ngsi-ld:Vehicle:5",
1543 16 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1544 16 : "https://uri.etsi.org/ngsi-ld/default-context/temperature": [
1545 16 : {"type": "Property", "value": v}
1546 : ]
1547 : })
1548 16 : };
1549 16 : for (q, target, want) in [
1550 2 : ("temperature==10..20", json!(15), true),
1551 2 : ("temperature==10..20", json!(10), true), // min included
1552 2 : ("temperature==10..20", json!(20), true), // max included
1553 2 : ("temperature==10..20", json!(9), false),
1554 2 : ("temperature!=10..20", json!(9), true), // p.91: "matches 9"
1555 2 : ("temperature!=10..20", json!(15), false),
1556 2 : // type mismatch: p.92 "considered unequal" ⇒ != matches, == not
1557 2 : ("temperature==10..20", json!("hot"), false),
1558 2 : ("temperature!=10..20", json!("hot"), true),
1559 2 : ] {
1560 16 : let ast = parse_q(q).expect(q);
1561 16 : assert_eq!(
1562 16 : eval_q(&ast, &mk(target.clone()), &ctx, &|_| None),
1563 : want,
1564 : "q={q} target={target}"
1565 : );
1566 : }
1567 : // DateTime range endpoints (Str..Str, temporal == lexicographic in Z
1568 : // form). `eventTime` and not `observedAt`: the latter is a CORE term
1569 : // and would expand to the core IRI, not the default context.
1570 2 : let ast = parse_q("eventTime==2021-01-01T00:00:00Z..2021-06-01T00:00:00Z").expect("q");
1571 2 : let e = json!({
1572 2 : "id": "urn:ngsi-ld:Vehicle:6",
1573 2 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1574 2 : "https://uri.etsi.org/ngsi-ld/default-context/eventTime": [
1575 2 : {"type": "Property", "value": "2021-03-15T12:00:00Z"}
1576 : ]
1577 : });
1578 2 : assert!(eval_q(&ast, &e, &ctx, &|_| None));
1579 2 : }
1580 :
1581 : /// 4.9 notPatternOp p.92: NOT in L(R); non-string targets are "not
1582 : /// matching" — deliberately NOT the `!=` type-mismatch rule.
1583 : #[test]
1584 2 : fn not_pattern_semantics() {
1585 2 : let ctx = crate::test_core();
1586 10 : let mk = |v: Value| {
1587 10 : json!({
1588 10 : "id": "urn:ngsi-ld:Vehicle:7",
1589 10 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
1590 10 : "https://uri.etsi.org/ngsi-ld/default-context/brandName": [
1591 10 : {"type": "Property", "value": v}
1592 : ]
1593 : })
1594 10 : };
1595 10 : for (q, target, want) in [
1596 2 : (r#"brandName!~="^Merc""#, json!("Volvo"), true),
1597 2 : (r#"brandName!~="^Merc""#, json!("Mercedes"), false),
1598 2 : // non-string target ⇒ not matching (p.92), unlike !=
1599 2 : (r#"brandName!~="^Merc""#, json!(7), false),
1600 2 : // an array is outside L(R) only if NO element is in it
1601 2 : (r#"brandName!~="^Merc""#, json!(["Volvo", "Skoda"]), true),
1602 2 : (
1603 2 : r#"brandName!~="^Merc""#,
1604 2 : json!(["Volvo", "Mercedes"]),
1605 2 : false,
1606 2 : ),
1607 2 : ] {
1608 10 : let ast = parse_q(q).expect(q);
1609 10 : assert_eq!(
1610 10 : eval_q(&ast, &mk(target.clone()), &ctx, &|_| None),
1611 : want,
1612 : "q={q} target={target}"
1613 : );
1614 : }
1615 2 : }
1616 : }
1617 :
1618 : /// What the evaluator treats as an instant rather than as a string.
1619 : #[cfg(test)]
1620 : mod temporal_keys {
1621 : use super::*;
1622 :
1623 : /// 4.9 p.92: a Range or an ordering over dates or times is an interval
1624 : /// on the temporal axis, and everything else is a string comparison.
1625 : /// Nineteen characters and a trailing `Z` are not a DateTime.
1626 : #[test]
1627 2 : fn only_a_real_instant_gets_a_temporal_key() {
1628 10 : for s in [
1629 2 : "2020-01-01T00:00:00Z",
1630 2 : "2020-01-01T00:00:00.5Z",
1631 2 : "2020-01-01T00:00:00,5Z",
1632 2 : "12:00:00Z",
1633 2 : "12:00:00.250Z",
1634 2 : ] {
1635 10 : assert!(temporal_key(s).is_some(), "{s} is an instant");
1636 : }
1637 16 : for s in [
1638 2 : "aaaaaaaaaaaaaaaaaaaZ", // nineteen characters and a Z
1639 2 : "2020-01-01T00:00:00", // no Z
1640 2 : "2020-13-01T00:00:00Z", // no such month
1641 2 : "2020-01-01 00:00:00Z", // no T
1642 2 : "99999999Z", // eight characters, no colons
1643 2 : "2020-01-01Z", // a Date is neither
1644 2 : "2020-01-01T00:00:00.1234567Z", // fraction past six digits
1645 2 : "",
1646 2 : ] {
1647 16 : assert!(temporal_key(s).is_none(), "{s:?} is not an instant");
1648 : }
1649 : // the key is the padded form, so string order is temporal order
1650 2 : assert_eq!(
1651 2 : temporal_key("2020-01-01T00:00:00,5Z"),
1652 2 : temporal_key("2020-01-01T00:00:00.500000Z")
1653 : );
1654 2 : assert!(temporal_key("2020-01-01T00:00:00Z") < temporal_key("2020-01-01T00:00:00.5Z"));
1655 2 : }
1656 :
1657 : /// 4.9 expandValues: only the named top-level attributes' values are
1658 : /// expanded, inside And/Or and lists; others and non-strings stay.
1659 : #[test]
1660 2 : fn expand_values_rewrites_only_the_named_attributes() {
1661 2 : let ctx = crate::test_core();
1662 2 : let male = "https://uri.etsi.org/ngsi-ld/default-context/Male";
1663 2 : let q = crate::parse_q(
1664 2 : "gender==\"Male\";(kind==\"Male\"|gender==\"Male\",\"Female\");gender==1",
1665 : )
1666 2 : .expect("parse");
1667 2 : assert_eq!(apply_expand_values(q.clone(), None, &ctx), q);
1668 2 : let want = crate::parse_q(&format!(
1669 2 : "gender==\"{male}\";(kind==\"Male\"|gender==\"{male}\",\"{female}\");gender==1",
1670 2 : female = male.replace("Male", "Female")
1671 2 : ))
1672 2 : .expect("parse");
1673 2 : assert_eq!(apply_expand_values(q, Some("gender, other"), &ctx), want);
1674 2 : }
1675 :
1676 : /// 4.9: a name in jsonKeys is never expanded; nothing left is None.
1677 : #[test]
1678 2 : fn expansion_list_drops_json_keys() {
1679 2 : assert_eq!(expansion_list(None, Some("a")), None);
1680 2 : assert_eq!(expansion_list(Some("a,b"), None).as_deref(), Some("a,b"));
1681 2 : assert_eq!(
1682 2 : expansion_list(Some("a, b"), Some("b")).as_deref(),
1683 : Some("a")
1684 : );
1685 2 : assert_eq!(expansion_list(Some("a"), Some(" a ")), None);
1686 2 : }
1687 : }
1688 :
1689 : /// The edges no clause example walks (the `ngsild` coverage gate): a linked-entity term
1690 : /// whose Relationship is missing or carries an `objectList`, a legacy dotted path that reaches
1691 : /// nothing or a bracket under it, a LanguageProperty asked in a language it lacks or holding
1692 : /// arrays, and the operator arms the parser never builds, which answer false.
1693 : #[cfg(test)]
1694 : mod edges {
1695 : use super::*;
1696 : use crate::parse_q;
1697 : use serde_json::json;
1698 :
1699 8 : fn expand(doc: Value) -> Value {
1700 8 : antares_jsonld::expand_entity(
1701 8 : doc.as_object().expect("obj"),
1702 8 : &crate::test_core(),
1703 8 : antares_jsonld::ExpandOpts::default(),
1704 : )
1705 8 : .expect("expand")
1706 8 : }
1707 :
1708 16 : fn q(doc: &Value, text: &str, lookup: EntityLookup) -> bool {
1709 16 : eval_q(
1710 16 : &parse_q(text).expect(text),
1711 16 : doc,
1712 16 : &crate::test_core(),
1713 16 : lookup,
1714 : )
1715 16 : }
1716 :
1717 : #[test]
1718 2 : fn linked_terms_read_an_object_list_and_skip_what_is_not_a_link() {
1719 2 : let device = expand(json!({"id": "urn:ngsi-ld:Device:1", "type": "Device",
1720 2 : "humidity": {"type": "Property", "value": 40}}));
1721 2 : let lookup = |id: &str| (id == "urn:ngsi-ld:Device:1").then(|| device.clone());
1722 2 : let listed = expand(json!({"id": "urn:ngsi-ld:WS:1", "type": "WeatherStation",
1723 2 : "sensor": {"type": "ListRelationship", "objectList": [{"object": "urn:ngsi-ld:Device:1"}]}}));
1724 2 : assert!(q(&listed, "sensor{humidity}==40", &lookup), "objectList");
1725 2 : let mut odd = listed.clone();
1726 2 : let sensor = odd
1727 2 : .as_object_mut()
1728 2 : .expect("obj")
1729 2 : .values_mut()
1730 2 : .find(|v| v.is_array())
1731 2 : .expect("sensor");
1732 2 : sensor[0] = json!({"object": 7});
1733 2 : assert!(
1734 2 : !q(&odd, "sensor{humidity}==40", &lookup),
1735 : "an object that is no URI"
1736 : );
1737 2 : let none = expand(json!({"id": "urn:ngsi-ld:WS:2", "type": "WeatherStation"}));
1738 2 : assert!(
1739 2 : !q(&none, "sensor{humidity}==40", &lookup),
1740 : "no Relationship at all"
1741 : );
1742 2 : }
1743 :
1744 : #[test]
1745 2 : fn dotted_paths_language_maps_and_compound_arrays() {
1746 2 : let doc = expand(json!({"id": "urn:ngsi-ld:B:1", "type": "Building",
1747 2 : "address": {"type": "Property", "value": {"street": {"name": "Main"}}},
1748 2 : "name": {"type": "LanguageProperty", "languageMap": {"en": ["Hall", "Room"], "fi": "Sali"}},
1749 2 : "rooms": {"type": "Property", "value": [{"n": [1, 2]}, {"n": 3}]}}));
1750 2 : let none = |_: &str| None;
1751 2 : assert!(q(&doc, "address.street[name]==\"Main\"", &none));
1752 2 : assert!(!q(&doc, "address.nothing==\"Main\"", &none));
1753 2 : assert!(q(&doc, "name[*]==\"Room\"", &none));
1754 2 : assert!(
1755 2 : !q(&doc, "name[sv]==\"Sali\"", &none),
1756 : "a language the map lacks"
1757 : );
1758 2 : assert!(q(&doc, "rooms[n]==2", &none));
1759 2 : }
1760 :
1761 : #[test]
1762 2 : fn shapes_no_entity_expansion_produces_reach_nothing() {
1763 2 : let ctx = crate::test_core();
1764 2 : let mut out = Vec::new();
1765 2 : let en = ["en".to_owned()];
1766 2 : terminal(
1767 2 : &json!({"languageMap": "not a map"}),
1768 2 : Some(&en),
1769 2 : &ctx,
1770 2 : &mut out,
1771 : );
1772 2 : terminal(
1773 2 : &json!({"languageMap": {"en": "a"}}),
1774 2 : Some(&["en".to_owned(), "x".to_owned()]),
1775 2 : &ctx,
1776 2 : &mut out,
1777 : );
1778 2 : collect(
1779 2 : &json!({"neither": 1}),
1780 2 : &["street".to_owned()],
1781 2 : None,
1782 2 : &ctx,
1783 2 : &mut out,
1784 : );
1785 2 : collect(
1786 2 : &json!({"value": {"street": 1}}),
1787 2 : &["street".to_owned()],
1788 2 : Some(&en),
1789 2 : &ctx,
1790 2 : &mut out,
1791 : );
1792 2 : assert!(out.is_empty(), "{out:?}");
1793 2 : let bare = QPath {
1794 2 : links: vec![],
1795 2 : path: vec![],
1796 2 : bracket: None,
1797 2 : };
1798 2 : assert!(resolve_targets(&json!({}), &bare, &ctx).is_empty());
1799 2 : assert_eq!(
1800 2 : temporal_key("12:00:00.1234567Z"),
1801 : None,
1802 : "seven fraction digits"
1803 : );
1804 2 : assert_eq!(
1805 2 : temporal_key("12:00:00.12a4Z"),
1806 : None,
1807 : "a fraction of no digits"
1808 : );
1809 : // bounds of two shapes are no interval on the temporal axis: string order decides
1810 2 : let (dt, time) = (
1811 2 : QValue::Str("2018-12-04T12:00:00Z".into()),
1812 2 : QValue::Str("12:00:00Z".into()),
1813 2 : );
1814 2 : assert!(!in_range(&json!("2018-12-05T00:00:00Z"), &dt, &time));
1815 2 : assert!(
1816 2 : !in_range(&json!("11:00:00Z"), &time, &dt),
1817 : "\"11:…\" sorts before \"12:…\""
1818 : );
1819 2 : let later = QValue::Str("2019-01-01T00:00:00Z".into());
1820 2 : assert!(
1821 2 : !in_range(&json!("11:00:00Z"), &dt, &later),
1822 : "a Time against two DateTimes"
1823 : );
1824 2 : assert!(
1825 2 : !in_range(&json!("2017-01-01T00:00:00Z"), &dt, &later),
1826 : "before the lower bound"
1827 : );
1828 2 : }
1829 :
1830 : #[test]
1831 2 : fn the_arms_the_grammar_never_reaches_answer_false() {
1832 2 : let list = QValue::List(vec![QValue::Num(1.0)]);
1833 2 : let range = QValue::Range(Box::new(QValue::Num(0.0)), Box::new(QValue::Num(2.0)));
1834 2 : assert!(!compare(&json!(1), CmpOp::Gt, &list, None));
1835 2 : assert!(!compare(&json!(1), CmpOp::Gt, &range, None));
1836 2 : assert!(!compare(&json!(true), CmpOp::Gt, &QValue::Bool(true), None));
1837 2 : assert!(same_datatype(&json!(1), &list));
1838 2 : assert!(!in_range(
1839 2 : &json!(true),
1840 2 : &QValue::Bool(false),
1841 2 : &QValue::Bool(true)
1842 2 : ));
1843 2 : assert!(!num_cmp(1.0, CmpOp::Pattern, 1.0));
1844 2 : assert!(expand_vocab(&json!(5), &crate::test_core()) == json!(5));
1845 : // a Time and a DateTime are not two points on one axis: ordered as strings
1846 2 : assert!(compare(
1847 2 : &json!("12:00:00Z"),
1848 2 : CmpOp::Lt,
1849 2 : &QValue::Str("2018-12-04T12:00:00Z".into()),
1850 2 : None
1851 : ));
1852 2 : }
1853 : }
|