LCOV - code coverage report
Current view: top level - antares-ql/src - eval.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.8 % 1203 1200
Test Date: 2026-11-02 13:21:27 Functions: 95.4 % 173 165

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

Generated by: LCOV version 2.0-1