LCOV - code coverage report
Current view: top level - antares-matcher/src - lib.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 319 319
Test Date: 2026-11-02 13:21:27 Functions: 98.2 % 55 54

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Subscription matching (CIM 009 5.8.6) against one in-memory entity: the
       3              : //! predicates the broker's notification path applies, usable without a
       4              : //! broker — a gateway or an edge component can answer "would this change
       5              : //! notify subscription X" with the broker's own semantics.
       6              : //!
       7              : //! Inputs are the stored (internal, expanded) forms: the subscription
       8              : //! document as created (5.2.12, selector entity types expanded) and the
       9              : //! entity document as the broker stores it (expanded attribute IRIs,
      10              : //! `type` as an array). Matching is
      11              : //! index-shaped in the broker (candidate lookup by (tenant, type) /
      12              : //! (tenant, watched attribute)); every predicate here evaluates one
      13              : //! candidate self-contained.
      14              : //!
      15              : #![doc = include_str!("../README.md")]
      16              : // every surface calls into this crate on a request path, where a
      17              : // panic is a remote DoS. Outside tests it may not unwrap, expect, panic,
      18              : // index out of bounds or overflow.
      19              : #![cfg_attr(
      20              :     not(test),
      21              :     deny(
      22              :         clippy::unwrap_used,
      23              :         clippy::expect_used,
      24              :         clippy::panic,
      25              :         clippy::indexing_slicing,
      26              :         clippy::arithmetic_side_effects
      27              :     )
      28              : )]
      29              : #![deny(missing_docs)]
      30              : 
      31              : use antares_jsonld::Context;
      32              : use antares_model::dt_key;
      33              : use antares_ql::eval::EntityLookup;
      34              : use antares_ql::geo::GeoQuery;
      35              : use antares_ql::type_selection_matches;
      36              : use serde_json::{Map, Value};
      37              : 
      38        18866 : fn sub_str<'a>(sub: &'a Value, key: &str) -> Option<&'a str> {
      39        18866 :     sub.get(key).and_then(Value::as_str)
      40        18866 : }
      41              : 
      42           44 : fn now_iso() -> String {
      43           44 :     chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
      44           44 : }
      45              : 
      46              : /// Every predicate at once: active, entities selector (5.2.33), `q` /
      47              : /// `scopeQ` / `geoQ` conditions, throttling window. `lookup` resolves 4.9
      48              : /// linked-entity terms (`attr{…}`); `&|_| None` when no store is at hand.
      49          100 : pub fn would_notify(sub: &Value, doc: &Value, ctx: &Context, lookup: EntityLookup) -> bool {
      50          100 :     is_active(sub)
      51           96 :         && selector_match(sub, doc, ctx)
      52           50 :         && conditions_match(sub, doc, ctx, lookup)
      53           46 :         && !throttled(sub)
      54          100 : }
      55              : 
      56              : /// 5.8.1.4 / 5.2.12: `isActive` false or an `expiresAt` in the past means
      57              : /// the subscription notifies nothing.
      58        15970 : pub fn is_active(sub: &Value) -> bool {
      59        15970 :     if sub.get("isActive") == Some(&Value::Bool(false)) {
      60           22 :         return false;
      61        15948 :     }
      62              :     // 5.8.1.4 auto-expiry; dt_key so fraction spellings cannot misorder
      63              :     // around the boundary second (4.11)
      64        15948 :     !sub.get("expiresAt")
      65        15948 :         .and_then(Value::as_str)
      66        15948 :         .is_some_and(|e| dt_key(e) < dt_key(&now_iso()))
      67        15970 : }
      68              : 
      69              : /// entities selector (5.2.33) against an internal entity doc.
      70         9592 : pub fn selector_match(sub: &Value, doc: &Value, ctx: &Context) -> bool {
      71         9592 :     let Some(sel) = sub.get("entities").and_then(Value::as_array) else {
      72           18 :         return true; // watchedAttributes-only subscription
      73              :     };
      74         9574 :     let types: Vec<&str> = doc
      75         9574 :         .get("type")
      76         9574 :         .and_then(Value::as_array)
      77         9574 :         .map(|a| a.iter().filter_map(Value::as_str).collect())
      78         9574 :         .unwrap_or_default();
      79         9574 :     let id = doc.get("id").and_then(Value::as_str).unwrap_or("");
      80         9582 :     sel.iter().any(|e| {
      81         9582 :         let t_ok = e.get("type").and_then(Value::as_str).is_none_or(|t| {
      82              :             // Table 5.2.33-1: "*" indicates "a request for all Entities" —
      83              :             // neither a term nor a 4.17 expression, but the absence of a type
      84              :             // predicate, exactly as `type=*` is on the query side.
      85         9522 :             if t == "*" {
      86           18 :                 true
      87         9504 :             } else if t.contains(['|', ',', ';', '(']) {
      88           28 :                 type_selection_matches(t, &types, ctx)
      89              :             } else {
      90         9476 :                 types.contains(&t)
      91              :             }
      92         9522 :         });
      93              :         // Table 5.2.33-1: id is String or String[]; "id takes precedence
      94              :         // over idPattern" — a selector carrying id ignores its idPattern.
      95         9582 :         let id_ok = match e.get("id") {
      96         9424 :             None => true,
      97          134 :             Some(Value::String(i)) => i == id,
      98           30 :             Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).any(|i| i == id),
      99            2 :             Some(_) => false,
     100              :         };
     101         9582 :         let pat_ok = e.get("id").is_some()
     102         9424 :             || e.get("idPattern").and_then(Value::as_str).is_none_or(|p| {
     103           48 :                 antares_ql::regex::compile(p).is_ok_and(|re| re.find(id).is_some())
     104           48 :             });
     105         9582 :         t_ok && id_ok && pat_ok
     106         9582 :     })
     107         9592 : }
     108              : 
     109              : /// A subscription's `geoQ` (Table 5.2.13-1) in the parameter shape the 4.10
     110              : /// GeoQuery parser takes. The one reading of that table: every validator,
     111              : /// matcher and forwarder in the broker turns a `geoQ` object into query
     112              : /// parameters here, so `coordinates` is spelled the same way on all of them.
     113          118 : pub fn geo_params(g: &Map<String, Value>) -> std::collections::HashMap<String, String> {
     114          118 :     let mut params: std::collections::HashMap<String, String> = Default::default();
     115          354 :     for k in ["georel", "geometry", "geoproperty"] {
     116          354 :         if let Some(s) = g.get(k).and_then(Value::as_str) {
     117          288 :             params.insert(k.into(), s.to_owned());
     118          288 :         }
     119              :     }
     120          118 :     if let Some(c) = g.get("coordinates") {
     121          116 :         params.insert(
     122          116 :             "coordinates".into(),
     123          116 :             match c {
     124            8 :                 Value::String(s) => s.clone(),
     125          108 :                 other => other.to_string(),
     126              :             },
     127              :         );
     128            2 :     }
     129          118 :     params
     130          118 : }
     131              : 
     132              : /// 5.8.6 notification matching: the subscription's q (4.9), scopeQ (4.19)
     133              : /// and geoQ (4.10) conditions against an internal entity doc; all present
     134              : /// conditions must hold.
     135         9366 : pub fn conditions_match(sub: &Value, doc: &Value, ctx: &Context, lookup: EntityLookup) -> bool {
     136         9366 :     if let Some(q) = sub_str(sub, "q") {
     137              :         // a JSON string, read as written: no percent-decoding (5.2.12, 046_05)
     138              :         // parsed once per distinct q text, not once per event per candidate
     139           94 :         match antares_ql::regex::q_node(q) {
     140           92 :             Some(node) => {
     141              :                 // Table 5.2.12-1 gives the Subscription the `expandValues`
     142              :                 // and `jsonKeys` pair 4.9 gives the query, and this
     143              :                 // condition IS that query: a term the first list names is
     144              :                 // compared after JSON-LD type coercion against the @context
     145              :                 // the Subscription was supplied with, less the names the
     146              :                 // second declares uninterpretable.
     147              :                 // ponytail: the coercion rebuilds the tree, so it costs one
     148              :                 // clone per event for a Subscription that names the list and
     149              :                 // nothing at all for one that does not; cache the coerced
     150              :                 // tree beside the parsed one if a deployment measures it.
     151              :                 let coerced;
     152           92 :                 let node: &antares_ql::QNode = match antares_ql::eval::expansion_list(
     153           92 :                     sub_str(sub, "expandValues"),
     154           92 :                     sub_str(sub, "jsonKeys"),
     155           92 :                 ) {
     156           86 :                     None => &node,
     157            6 :                     Some(list) => {
     158            6 :                         coerced = antares_ql::eval::apply_expand_values(
     159            6 :                             (*node).clone(),
     160            6 :                             Some(&list),
     161            6 :                             ctx,
     162              :                         );
     163            6 :                         &coerced
     164              :                     }
     165              :                 };
     166           92 :                 if !antares_ql::eval::eval_q(node, doc, ctx, lookup) {
     167           48 :                     return false;
     168           44 :                 }
     169              :             }
     170            2 :             None => return false,
     171              :         }
     172         9272 :     }
     173         9316 :     if let Some(sq) = sub_str(sub, "scopeQ") {
     174            4 :         if !antares_ql::scope::scope_matches(sq, doc) {
     175            2 :             return false;
     176            2 :         }
     177         9312 :     }
     178         9314 :     if let Some(g) = sub.get("geoQ").and_then(Value::as_object) {
     179              :         // the geometry parse is shared per distinct geoQ member; the
     180              :         // serialization of the stored member is the key
     181           12 :         let key = serde_json::to_string(g).unwrap_or_default();
     182           12 :         let gq = antares_ql::regex::geo_query(&key, || {
     183            4 :             GeoQuery::from_params(&geo_params(g)).ok().flatten()
     184            4 :         });
     185           12 :         match gq {
     186           10 :             Some(gq) => {
     187           10 :                 if !gq.matches(doc, ctx) {
     188            2 :                     return false;
     189            8 :                 }
     190              :             }
     191            2 :             None => return false,
     192              :         }
     193         9302 :     }
     194         9310 :     true
     195         9366 : }
     196              : 
     197              : /// 5.2.12 `throttling`: true while the last notification is younger than the
     198              : /// throttling window, so no further notification is due yet.
     199         9098 : pub fn throttled(sub: &Value) -> bool {
     200         9098 :     let Some(secs) = sub.get("throttling").and_then(Value::as_f64) else {
     201         9073 :         return false;
     202              :     };
     203           25 :     let Some(last) = sub
     204           25 :         .get("notification")
     205           25 :         .and_then(|n| n.get("lastNotification"))
     206           25 :         .and_then(Value::as_str)
     207              :     else {
     208            9 :         return false;
     209              :     };
     210           16 :     chrono::DateTime::parse_from_rfc3339(last).is_ok_and(|t| {
     211           16 :         chrono::Utc::now()
     212           16 :             .signed_duration_since(t.with_timezone(&chrono::Utc))
     213           16 :             .num_milliseconds()
     214           16 :             < (secs * 1000.0) as i64
     215           16 :     })
     216         9098 : }
     217              : 
     218              : #[cfg(test)]
     219              : mod tests {
     220              :     use super::*;
     221              :     use serde_json::json;
     222              :     use std::sync::Arc;
     223              : 
     224              :     const DC: &str = "https://uri.etsi.org/ngsi-ld/default-context";
     225              : 
     226              :     /// The core context once per process, with no loader behind it: the
     227              :     /// interpreter-run of these tests (Miri) must not pay for a cache and an
     228              :     /// HTTP client it never uses.
     229           58 :     fn ctx() -> Arc<Context> {
     230              :         static CORE: std::sync::OnceLock<Arc<Context>> = std::sync::OnceLock::new();
     231           58 :         CORE.get_or_init(|| Arc::new(antares_jsonld::core_context()))
     232           58 :             .clone()
     233           58 :     }
     234              : 
     235              :     /// A stored entity in its internal form: the broker's own expansion of
     236              :     /// the API payload (expanded IRIs, `type` as an array, instance arrays).
     237           32 :     fn expand(doc: Value) -> Value {
     238           32 :         antares_jsonld::expand_entity(
     239           32 :             doc.as_object().expect("object"),
     240           32 :             &ctx(),
     241           32 :             antares_jsonld::ExpandOpts::default(),
     242              :         )
     243           32 :         .expect("a valid entity")
     244           32 :     }
     245              : 
     246           24 :     fn vehicle(speed: f64, lon: f64) -> Value {
     247           24 :         expand(json!({
     248           24 :             "id": "urn:ngsi-ld:Vehicle:A1",
     249           24 :             "type": "Vehicle",
     250           24 :             "speed": {"type": "Property", "value": speed},
     251           24 :             "driver": {"type": "Relationship", "object": "urn:ngsi-ld:Person:P1"},
     252           24 :             "location": {"type": "GeoProperty",
     253           24 :                 "value": {"type": "Point", "coordinates": [lon, 40.4]}}
     254              :         }))
     255           24 :     }
     256              : 
     257              :     /// A subscription as the broker stores it: entity types in the selector
     258              :     /// are already expanded (5.2.33), `q`/`geoQ` as sent.
     259           28 :     fn sub() -> Value {
     260           28 :         json!({
     261           28 :             "id": "urn:ngsi-ld:Subscription:1",
     262           28 :             "type": "Subscription",
     263           28 :             "entities": [{"type": format!("{DC}/Vehicle"), "idPattern": "^urn:ngsi-ld:Vehicle:.*"}],
     264           28 :             "q": "speed>25",
     265           28 :             "geoQ": {"georel": "near;maxDistance==2000", "geometry": "Point",
     266           28 :                      "coordinates": "[-3.7,40.4]"},
     267           28 :             "notification": {"endpoint": {"uri": "http://x/n"}}
     268              :         })
     269           28 :     }
     270              : 
     271              :     /// Table 5.2.12-1 gives a Subscription the same `expandValues` and
     272              :     /// `jsonKeys` pair 4.9 gives a query, and the notification condition IS
     273              :     /// that query: a term whose Attribute `expandValues` names is compared
     274              :     /// after JSON-LD type coercion, and a name `jsonKeys` carries as well is
     275              :     /// left uninterpreted (6.4.3.2, the broker's precedence).
     276              :     #[test]
     277            2 :     fn clause_5_2_12_expand_values_coerces_the_condition_value() {
     278            2 :         let c = ctx();
     279            2 :         let none = |_: &str| None;
     280            2 :         let doc = expand(json!({
     281            2 :             "id": "urn:ngsi-ld:Vehicle:A1",
     282            2 :             "type": "Vehicle",
     283            2 :             "category": {"type": "Property", "value": format!("{DC}/Camping")}
     284              :         }));
     285            2 :         let mut s = sub();
     286            2 :         s.as_object_mut().expect("object").remove("geoQ");
     287            2 :         s["q"] = json!("category==\"Camping\"");
     288            2 :         assert!(
     289            2 :             !conditions_match(&s, &doc, &c, &none),
     290              :             "the literal cannot match the expanded value on its own"
     291              :         );
     292            2 :         s["expandValues"] = json!("category");
     293            2 :         assert!(
     294            2 :             conditions_match(&s, &doc, &c, &none),
     295              :             "expandValues coerces the term value"
     296              :         );
     297            2 :         s["jsonKeys"] = json!("category");
     298            2 :         assert!(
     299            2 :             !conditions_match(&s, &doc, &c, &none),
     300              :             "a name in both lists is left uninterpreted"
     301              :         );
     302            2 :     }
     303              : 
     304              :     /// The lookup of a test with no store: every linked Entity is absent.
     305            2 :     fn no_store(_: &str) -> Option<Value> {
     306            2 :         None
     307            2 :     }
     308              : 
     309              :     /// 5.8.6: every predicate holds → the change would notify.
     310              :     #[test]
     311            2 :     fn matching_change_notifies() {
     312            2 :         assert!(would_notify(
     313            2 :             &sub(),
     314            2 :             &vehicle(30.0, -3.7),
     315            2 :             &ctx(),
     316            2 :             &no_store
     317              :         ));
     318            2 :     }
     319              : 
     320              :     /// Each predicate alone refuses: q (4.9), geoQ (4.10), selector type,
     321              :     /// idPattern (5.2.33), `isActive`, `expiresAt` (5.8.1.4), throttling.
     322              :     #[test]
     323            2 :     fn each_failing_predicate_refuses() {
     324            2 :         let c = ctx();
     325            2 :         let none = |_: &str| None;
     326            2 :         assert!(!would_notify(&sub(), &vehicle(20.0, -3.7), &c, &none), "q");
     327            2 :         assert!(
     328            2 :             !would_notify(&sub(), &vehicle(30.0, -4.7), &c, &none),
     329              :             "geoQ"
     330              :         );
     331            2 :         let mut s = sub();
     332            2 :         s["entities"][0]["type"] = json!(format!("{DC}/Bicycle"));
     333            2 :         assert!(!would_notify(&s, &vehicle(30.0, -3.7), &c, &none), "type");
     334            2 :         let mut s = sub();
     335            2 :         s["entities"][0]["idPattern"] = json!("^urn:ngsi-ld:Bicycle:.*");
     336            2 :         assert!(
     337            2 :             !would_notify(&s, &vehicle(30.0, -3.7), &c, &none),
     338              :             "idPattern"
     339              :         );
     340            2 :         let mut s = sub();
     341            2 :         s["isActive"] = json!(false);
     342            2 :         assert!(
     343            2 :             !would_notify(&s, &vehicle(30.0, -3.7), &c, &none),
     344              :             "isActive"
     345              :         );
     346            2 :         let mut s = sub();
     347            2 :         s["expiresAt"] = json!("2000-01-01T00:00:00Z");
     348            2 :         assert!(
     349            2 :             !would_notify(&s, &vehicle(30.0, -3.7), &c, &none),
     350              :             "expiresAt"
     351              :         );
     352            2 :         let mut s = sub();
     353            2 :         s["throttling"] = json!(3600);
     354            2 :         s["notification"]["lastNotification"] = json!(now_iso());
     355            2 :         assert!(
     356            2 :             !would_notify(&s, &vehicle(30.0, -3.7), &c, &none),
     357              :             "throttling"
     358              :         );
     359            2 :         assert!(throttled(&s));
     360            2 :         s["notification"]["lastNotification"] = json!("2000-01-01T00:00:00Z");
     361            2 :         assert!(
     362            2 :             !throttled(&s),
     363              :             "an old lastNotification is outside the window"
     364              :         );
     365              :         // the window is in seconds: 10 s ago is inside 60 s and outside 5 s
     366            2 :         let ago = (chrono::Utc::now() - chrono::Duration::seconds(10))
     367            2 :             .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
     368            2 :         s["notification"]["lastNotification"] = json!(ago);
     369            2 :         s["throttling"] = json!(60);
     370            2 :         assert!(throttled(&s), "10 s ago, 60 s window");
     371            2 :         s["throttling"] = json!(5);
     372            2 :         assert!(!throttled(&s), "10 s ago, 5 s window");
     373            2 :     }
     374              : 
     375              :     /// 5.2.33 Table 5.2.33-1: `id` takes precedence over `idPattern`, and a
     376              :     /// selector without `type` matches every type.
     377              :     #[test]
     378            2 :     fn selector_id_precedence_and_typeless_selector() {
     379            2 :         let c = ctx();
     380            2 :         let doc = vehicle(30.0, -3.7);
     381            2 :         let s = json!({"entities": [{"id": "urn:ngsi-ld:Vehicle:A1", "idPattern": "^nothing$"}]});
     382            2 :         assert!(selector_match(&s, &doc, &c));
     383            2 :         let s = json!({"entities": [{"id": ["urn:ngsi-ld:Vehicle:B2"], "type": format!("{DC}/Vehicle")}]});
     384            2 :         assert!(!selector_match(&s, &doc, &c));
     385            2 :         let s = json!({"watchedAttributes": ["speed"]});
     386            2 :         assert!(
     387            2 :             selector_match(&s, &doc, &c),
     388              :             "watchedAttributes-only subscription"
     389              :         );
     390            2 :     }
     391              : 
     392              :     /// The edges of the selector and the conditions (the `ngsild` coverage gate): a type
     393              :     /// expression of 4.17, an `id` that is neither a String nor a String[] (Table 5.2.33-1), a
     394              :     /// `q` or a `geoQ` the broker cannot read (it notifies nothing rather than everything), and
     395              :     /// `coordinates` written as a JSON array instead of the string Table 5.2.13-1 shows.
     396              :     #[test]
     397            2 :     fn selector_and_condition_edges() {
     398            2 :         let c = ctx();
     399            2 :         let none = |_: &str| None;
     400            2 :         let doc = vehicle(30.0, -3.7);
     401            4 :         let typed = |t: String| json!({"entities": [{"type": t}]});
     402            2 :         assert!(selector_match(
     403            2 :             &typed(format!("{DC}/Bicycle|{DC}/Vehicle")),
     404            2 :             &doc,
     405            2 :             &c
     406              :         ));
     407            2 :         assert!(!selector_match(
     408            2 :             &typed(format!("{DC}/Bicycle|{DC}/Car")),
     409            2 :             &doc,
     410            2 :             &c
     411            2 :         ));
     412            2 :         assert!(
     413            2 :             !selector_match(&json!({"entities": [{"id": 7}]}), &doc, &c),
     414              :             "id of no listed shape"
     415              :         );
     416              : 
     417            2 :         let mut s = sub();
     418            2 :         s["q"] = json!("(speed>25");
     419            2 :         assert!(antares_ql::parse_q("(speed>25").is_err());
     420            2 :         assert!(!conditions_match(&s, &doc, &c, &none), "an unreadable q");
     421            2 :         let mut s = sub();
     422            2 :         s["geoQ"]["geometry"] = json!("Nothing");
     423            2 :         assert!(!conditions_match(&s, &doc, &c, &none), "an unreadable geoQ");
     424              : 
     425            2 :         let g = json!({"georel": "near;maxDistance==2000", "geometry": "Point", "coordinates": [-3.7, 40.4]});
     426            2 :         assert_eq!(
     427            2 :             geo_params(g.as_object().expect("object"))["coordinates"],
     428              :             "[-3.7,40.4]"
     429              :         );
     430            2 :         let g = json!({"georel": "near;maxDistance==2000", "geometry": "Point"});
     431            2 :         assert!(!geo_params(g.as_object().expect("object")).contains_key("coordinates"));
     432            2 :     }
     433              : 
     434              :     /// 4.9 EXAMPLE 13/14: a linked-entity term (`driver{name}`) resolves the
     435              :     /// Relationship object through `lookup`; without a store it cannot match.
     436              :     #[test]
     437            2 :     fn linked_entity_term_uses_the_lookup() {
     438            2 :         let c = ctx();
     439            2 :         let mut s = sub();
     440            2 :         s["q"] = json!("driver{name}==\"Ann\"");
     441            2 :         let doc = vehicle(30.0, -3.7);
     442            2 :         let store = |uri: &str| {
     443            2 :             (uri == "urn:ngsi-ld:Person:P1").then(|| {
     444            2 :                 expand(json!({"id": uri, "type": "Person",
     445            2 :                        "name": {"type": "Property", "value": "Ann"}}))
     446            2 :             })
     447            2 :         };
     448            2 :         assert!(conditions_match(&s, &doc, &c, &store));
     449            2 :         assert!(
     450            2 :             !conditions_match(&s, &doc, &c, &no_store),
     451              :             "no store, no match"
     452              :         );
     453            2 :     }
     454              : 
     455              :     /// 4.19: `scopeQ` is a condition like `q`.
     456              :     #[test]
     457            2 :     fn scope_q_is_a_condition() {
     458            2 :         let c = ctx();
     459            2 :         let mut doc = vehicle(30.0, -3.7);
     460            2 :         doc["scope"] = json!(["/Madrid/Centre"]);
     461            2 :         let mut s = sub();
     462            2 :         s["scopeQ"] = json!("/Madrid/#");
     463            2 :         assert!(conditions_match(&s, &doc, &c, &no_store));
     464            2 :         s["scopeQ"] = json!("/Paris/#");
     465            2 :         assert!(!conditions_match(&s, &doc, &c, &no_store));
     466            2 :     }
     467              : 
     468              :     /// Table 5.2.12-1: a Subscription's `q` is a JSON string, not part of a
     469              :     /// URI, so it is read as written: `%20` is three characters (forge
     470              :     /// cd2ef2ba, 046_05).
     471              :     #[test]
     472            2 :     fn a_subscription_q_is_not_percent_decoded() {
     473            2 :         let c = ctx();
     474            4 :         let named = |name: &str| {
     475            4 :             expand(json!({
     476            4 :                 "id": "urn:ngsi-ld:Vehicle:A1",
     477            4 :                 "type": "Vehicle",
     478            4 :                 "name": {"type": "Property", "value": name}
     479              :             }))
     480            4 :         };
     481            2 :         let mut s = sub();
     482            2 :         s.as_object_mut().expect("object").remove("geoQ");
     483            2 :         s["q"] = json!("name==\"Eiffel%20Tower\"");
     484            2 :         assert!(conditions_match(
     485            2 :             &s,
     486            2 :             &named("Eiffel%20Tower"),
     487            2 :             &c,
     488            2 :             &no_store
     489              :         ));
     490            2 :         assert!(!conditions_match(&s, &named("Eiffel Tower"), &c, &no_store));
     491            2 :     }
     492              : 
     493              :     /// Table 5.2.33-1 `type`: "A valid type selection string as per clause
     494              :     /// 4.17. To indicate a request for all Entities (with implied local
     495              :     /// scope), \"*\" is also allowed as a value." 5.2.33 scopes EntitySelector
     496              :     /// to what is "queried or subscribed to", so a Subscription carrying it
     497              :     /// selects every Entity Type — it is not a 4.17 expression and not a term
     498              :     /// to expand, but the absence of a type predicate.
     499              :     #[test]
     500            2 :     fn a_star_selector_type_selects_every_entity_type() {
     501            2 :         let sub = json!({"entities": [{"type": "*"}]});
     502            6 :         for doc in [
     503            2 :             json!({"id": "urn:ngsi-ld:Vehicle:1", "type": [format!("{DC}/Vehicle")]}),
     504            2 :             json!({"id": "urn:ngsi-ld:Parking:1", "type": [format!("{DC}/Parking")]}),
     505            2 :             json!({"id": "urn:ngsi-ld:Odd:1", "type": ["urn:example:Odd"]}),
     506            2 :         ] {
     507            6 :             assert!(
     508            6 :                 selector_match(&sub, &doc, &ctx()),
     509              :                 "a \"*\" selector must match every type: {doc}"
     510              :             );
     511              :         }
     512              :         // "*" is the type predicate only — id and idPattern still narrow.
     513            2 :         let pinned = json!({"entities": [{"type": "*", "id": "urn:ngsi-ld:Vehicle:1"}]});
     514            2 :         assert!(selector_match(
     515            2 :             &pinned,
     516            2 :             &json!({"id": "urn:ngsi-ld:Vehicle:1", "type": [format!("{DC}/Vehicle")]}),
     517            2 :             &ctx()
     518              :         ));
     519            2 :         assert!(
     520            2 :             !selector_match(
     521            2 :                 &pinned,
     522            2 :                 &json!({"id": "urn:ngsi-ld:Vehicle:2", "type": [format!("{DC}/Vehicle")]}),
     523            2 :                 &ctx()
     524            2 :             ),
     525              :             "a \"*\" type does not widen an id that was given"
     526              :         );
     527            2 :     }
     528              : }
        

Generated by: LCOV version 2.0-1