LCOV - code coverage report
Current view: top level - antares-ql/src - eval.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 95.4 % 899 858
Test Date: 2026-09-21 10:31:06 Functions: 83.5 % 139 116

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

Generated by: LCOV version 2.0-1