LCOV - code coverage report
Current view: top level - antares-api/src - repr.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 97.2 % 1692 1644
Test Date: 2026-11-02 13:21:27 Functions: 58.4 % 647 378

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Representation transforms (6.3.7, 4.5.4, concise, sysAttrs, attrs
       3              : //! projection, lang filter) — applied on the INTERNAL expanded form before
       4              : //! compaction.
       5              : 
       6              : use crate::state::AppState;
       7              : use antares_jsonld::{compact_entity, compact_entity_shallow, Context};
       8              : use antares_model::NgsiError;
       9              : use antares_model::{is_meta, TenantId};
      10              : use antares_store::Kind;
      11              : use serde_json::{Map, Value};
      12              : use std::collections::HashMap;
      13              : 
      14              : #[derive(Debug, Default, Clone)]
      15              : pub struct Repr {
      16              :     pub sys_attrs: bool,
      17              :     pub key_values: bool,
      18              :     pub concise: bool,
      19              :     /// expanded attribute IRIs to project (attrs=): entity meta stays.
      20              :     pub attrs: Option<Vec<String>>,
      21              :     /// pick= (4.21): STRICT projection — core members (id/type/scope/…) only
      22              :     /// survive when explicitly picked. Nodes may carry nested selections for
      23              :     /// linked entities.
      24              :     pub pick: Option<Vec<ProjNode>>,
      25              :     /// omit= (4.21): nodes WITHOUT children omit their head; nodes WITH
      26              :     /// children only constrain the linked entity below that head.
      27              :     pub omit: Option<Vec<ProjNode>>,
      28              :     /// The policy seam's own projection (ADR-0020), kept apart from the
      29              :     /// request's. A 4.21 projection belongs to ONE level — its nested
      30              :     /// selections describe the linked Entity below a head, so a bare `omit`
      31              :     /// name leaves that member alone on a joined document — while a
      32              :     /// narrowing is about what may be seen at all, and a member the subject
      33              :     /// may not see is no more visible one Relationship away. These travel
      34              :     /// down the join walk unchanged (`joined_repr`); `pick` and `omit` do
      35              :     /// not.
      36              :     pub policy_pick: Option<Vec<ProjNode>>,
      37              :     pub policy_omit: Option<Vec<ProjNode>>,
      38              :     /// The narrowing's row predicate, carried down the join walk like the
      39              :     /// policy projection: a Linked Entity is read the way the Linking Entity
      40              :     /// was (5.7.1.4), so one outside the narrowing is not embedded.
      41              :     pub policy_reach: Option<std::sync::Arc<PolicyReach>>,
      42              :     /// The narrowing's rules, names expanded
      43              :     /// ([`type_rules`]). Each document is judged by its own rules, so this
      44              :     /// too travels down the join walk unchanged.
      45              :     pub policy_types: Option<std::sync::Arc<crate::policy::Filter>>,
      46              :     /// The client's own `q`, apart from the engine's ([`note_reads`]): under
      47              :     /// rules it is evaluated on each Entity as it is shown, so an Attribute
      48              :     /// a matched rule does not show is absent to it (the narrowing rules,
      49              :     /// 2 per Entity). `None` unless `policy_types` is set.
      50              :     pub policy_client_q: Option<antares_ql::QNode>,
      51              :     pub lang: Option<String>,
      52              :     /// datasetId= instance filter; entry "@none" selects default instances.
      53              :     pub dataset_id: Option<Vec<String>>,
      54              : }
      55              : 
      56         4722 : pub fn parse_repr(params: &HashMap<String, String>, ctx: &Context) -> Result<Repr, NgsiError> {
      57         4722 :     let mut r = Repr::default();
      58         4722 :     let mut format: Option<String> = None;
      59         4722 :     if let Some(opts) = params.get("options") {
      60          300 :         for o in opts.split(',') {
      61          300 :             match o.trim() {
      62          300 :                 "sysAttrs" => r.sys_attrs = true,
      63          148 :                 "keyValues" | "simplified" => {
      64          104 :                     format.get_or_insert("simplified".into());
      65          104 :                 }
      66           44 :                 "concise" => {
      67            4 :                     format.get_or_insert("concise".into());
      68            4 :                 }
      69           40 :                 "normalized" => {
      70           12 :                     format.get_or_insert("normalized".into());
      71           12 :                 }
      72              :                 _ => {
      73           28 :                     return Err(NgsiError::InvalidRequest(format!(
      74           28 :                         "unsupported options value {o:?}"
      75           28 :                     )))
      76              :                 }
      77              :             };
      78              :         }
      79         4434 :     }
      80              :     // format wins over options on conflict (6.3.7)
      81         4694 :     if let Some(f) = params.get("format") {
      82           56 :         match f.as_str() {
      83           56 :             "normalized" | "concise" | "simplified" | "keyValues" => {
      84           44 :                 format = Some(if f == "keyValues" {
      85           28 :                     "simplified".into()
      86              :                 } else {
      87           16 :                     f.clone()
      88              :                 })
      89              :             }
      90              :             _ => {
      91           12 :                 return Err(NgsiError::InvalidRequest(format!(
      92           12 :                     "unsupported format value {f:?}"
      93           12 :                 )))
      94              :             }
      95              :         }
      96         4638 :     }
      97         4682 :     match format.as_deref() {
      98          144 :         Some("simplified") => r.key_values = true,
      99           16 :         Some("concise") => r.concise = true,
     100         4554 :         _ => {}
     101              :     }
     102         4682 :     check_projection_exclusive(params)?;
     103         4642 :     if let Some(a) = params.get("attrs") {
     104          118 :         let mut list = Vec::new();
     105          138 :         for t in a.split(',') {
     106          138 :             let t = t.trim();
     107          138 :             if t.is_empty() || ENTITY_META.contains(&t) {
     108           28 :                 return Err(NgsiError::BadRequestData(format!(
     109           28 :                     "invalid attribute name {t:?} in attrs"
     110           28 :                 )));
     111          110 :             }
     112          110 :             list.push(ctx.expand_key(t));
     113              :         }
     114           90 :         r.attrs = Some(list);
     115         4524 :     }
     116         4614 :     if let Some(pck) = params.get("pick") {
     117          202 :         r.pick = Some(parse_projection(pck, ctx)?);
     118         4412 :     }
     119         4554 :     if let Some(o) = params.get("omit") {
     120          108 :         r.omit = Some(parse_projection(o, ctx)?);
     121         4446 :     }
     122         4546 :     r.lang = params.get("lang").cloned();
     123         4546 :     if let Some(list) = params.get("datasetId") {
     124           44 :         let mut ids = Vec::new();
     125           58 :         for d in list.split(',').map(str::trim) {
     126              :             // Table 6.4.3.2-1: "Shall be valid URIs, "@none" for ... the default"
     127           58 :             if d != "@none" && antares_model::EntityId::new(d).is_err() {
     128           12 :                 return Err(NgsiError::BadRequestData(format!(
     129           12 :                     "datasetId {d:?} is not a URI"
     130           12 :                 )));
     131           46 :             }
     132           46 :             ids.push(d.to_owned());
     133              :         }
     134           32 :         r.dataset_id = Some(ids);
     135         4502 :     }
     136         4534 :     Ok(r)
     137         4722 : }
     138              : 
     139              : /// 4.5.5.1: the datasetId "is of datatype URI, or equal to the JSON-LD
     140              : /// keyword `@none`", and "if no datasetId is provided, or `"datasetId":
     141              : /// "@none"` is supplied, it is considered as the default Attribute
     142              : /// instance". A default instance carries no datasetId of its own, so the
     143              : /// write paths that select ONE instance by the `?datasetId=` parameter
     144              : /// (5.6.5.4, 5.6.13.4) match `@none` against its absence. Instance members
     145              : /// reach the same rule one layer earlier, in 5.5.7 expansion.
     146          368 : pub fn target_dataset_id(params: &HashMap<String, String>) -> Option<&str> {
     147          368 :     params
     148          368 :         .get("datasetId")
     149          368 :         .map(String::as_str)
     150          368 :         .filter(|d| *d != "@none")
     151          368 : }
     152              : 
     153              : /// Narrow a representation by a policy decision (ADR-0020).
     154              : ///
     155              : /// `omit` is appended to whatever the request asked to omit: removing more
     156              : /// members can only remove more. `pick` intersects with the request's,
     157              : /// because a pick that added a name would serve a member the request did
     158              : /// not ask for and the engine's narrowing would have widened the answer.
     159              : ///
     160              : /// The Entity frame survives a policy pick. 6.5.3.1 makes `pick` reduce an
     161              : /// Entity "down to only contain the listed Entity members", and a client
     162              : /// that wants `id` back names it; but the engine is restricting what may be
     163              : /// seen rather than choosing a representation, and 5.2.4 makes `id` and
     164              : /// `type` the Entity — a document without them is not one.
     165         2044 : pub fn narrow_projection(
     166         2044 :     pick: &mut Option<Vec<ProjNode>>,
     167         2044 :     omit: &mut Option<Vec<ProjNode>>,
     168         2044 :     f: &crate::policy::Filter,
     169         2044 :     ctx: &Context,
     170         2044 : ) -> Result<(), NgsiError> {
     171         2044 :     if !f.omit.is_empty() {
     172           14 :         let mut nodes = policy_nodes(&f.omit);
     173           14 :         match omit {
     174            0 :             Some(own) => own.append(&mut nodes),
     175           14 :             None => *omit = Some(nodes),
     176              :         }
     177         2030 :     }
     178         2044 :     if !f.pick.is_empty() {
     179            0 :         let allowed = policy_nodes(&f.pick);
     180            0 :         let mut kept = match pick.take() {
     181            0 :             None => allowed,
     182            0 :             Some(own) => own
     183            0 :                 .into_iter()
     184            0 :                 .filter(|n| allowed.iter().any(|a| a.iri == n.iri))
     185            0 :                 .collect(),
     186              :         };
     187            0 :         for frame in parse_projection("id,type", ctx)? {
     188            0 :             if !kept.iter().any(|n| n.raw == frame.raw) {
     189            0 :                 kept.push(frame);
     190            0 :             }
     191              :         }
     192            0 :         *pick = Some(kept);
     193         2044 :     }
     194         2044 :     Ok(())
     195         2044 : }
     196              : 
     197              : /// The same narrowing on a current-state [`Repr`], which is the one a join
     198              : /// walk descends. It goes into the policy fields rather than into the
     199              : /// request's own projection, so every document the answer carries is
     200              : /// narrowed and not only the one at the top: `?join=flat` returns Entities
     201              : /// reached over a Relationship, and a member the subject may not see is not
     202              : /// less hidden for being one hop away.
     203              : ///
     204              : /// The Entity frame survives a policy pick — 5.2.4 makes `id` and `type`
     205              : /// the Entity, and a document without them is not one — which is why the
     206              : /// policy pick is applied to Attributes and never to the core members.
     207         4538 : pub fn narrow_repr(r: &mut Repr, f: &std::sync::Arc<crate::policy::Filter>) {
     208              :     // A view or an engine answers every request with the same narrowing,
     209              :     // and the gate hands it out as one shared `Arc`: its reach is built
     210              :     // once and handed out again while that `Arc` comes back. The memo
     211              :     // holds the `Arc` itself, so its address cannot be reused by another
     212              :     // Filter while it is kept, and `ptr_eq` never answers for rules other
     213              :     // than its own (O16).
     214              :     type Reached = (
     215              :         std::sync::Arc<crate::policy::Filter>,
     216              :         Option<std::sync::Arc<PolicyReach>>,
     217              :         Option<std::sync::Arc<crate::policy::Filter>>,
     218              :     );
     219              :     thread_local! {
     220              :         static LAST: std::cell::RefCell<Option<Reached>> = const { std::cell::RefCell::new(None) };
     221              :     }
     222         4538 :     let hit = LAST.with_borrow(|last| {
     223         4538 :         last.as_ref()
     224         4538 :             .filter(|l| std::sync::Arc::ptr_eq(&l.0, f))
     225         4538 :             .map(|l| (l.1.clone(), l.2.clone()))
     226         4538 :     });
     227         4538 :     let (reach, rules) = hit.unwrap_or_else(|| {
     228         1403 :         let built = reach_of(f);
     229         1403 :         LAST.set(Some((f.clone(), built.0.clone(), built.1.clone())));
     230         1403 :         built
     231         1403 :     });
     232         4538 :     fill(r, f, reach, rules);
     233         4538 : }
     234              : 
     235              : /// The reach of `f` and its expanded rules, built afresh.
     236         2883 : fn reach_of(
     237         2883 :     f: &crate::policy::Filter,
     238         2883 : ) -> (
     239         2883 :     Option<std::sync::Arc<PolicyReach>>,
     240         2883 :     Option<std::sync::Arc<crate::policy::Filter>>,
     241         2883 : ) {
     242         2883 :     let reach = crate::policy::Condition::of(f);
     243         2883 :     let rules = reach.as_ref().and_then(|r| r.rules.clone());
     244         2883 :     let reach = reach.map(std::sync::Arc::new);
     245         2883 :     (reach, rules)
     246         2883 : }
     247              : 
     248         6018 : fn fill(
     249         6018 :     r: &mut Repr,
     250         6018 :     f: &crate::policy::Filter,
     251         6018 :     reach: Option<std::sync::Arc<PolicyReach>>,
     252         6018 :     rules: Option<std::sync::Arc<crate::policy::Filter>>,
     253         6018 : ) {
     254         6018 :     if reach.is_some() {
     255          632 :         r.policy_reach = reach;
     256         5386 :     }
     257         6018 :     if !f.omit.is_empty() {
     258           86 :         r.policy_omit = Some(policy_nodes(&f.omit));
     259         5932 :     }
     260         6018 :     if !f.pick.is_empty() {
     261            8 :         r.policy_pick = Some(policy_nodes(&f.pick));
     262         6010 :     }
     263         6018 :     r.policy_types = rules;
     264         6018 : }
     265              : 
     266              : /// The narrowing's rules with every type and Attribute name expanded
     267              : /// against [`POLICY_CONTEXT`], the form an internal document's keys and
     268              : /// `type` values are in; `None` when the narrowing has none. Conditions
     269              : /// stay as written: [`shown`] reads them through the same context.
     270        27511 : pub(crate) fn type_rules(
     271        27511 :     f: &crate::policy::Filter,
     272        27511 : ) -> Option<std::sync::Arc<crate::policy::Filter>> {
     273        27511 :     if !f.has_rules() {
     274        26923 :         return None;
     275          588 :     }
     276              :     // The expansion is a function of the rules alone, and a view or an
     277              :     // engine answers every request with the same ones: the last expansion
     278              :     // is kept per thread and handed out again while its rules come back.
     279              :     // It is keyed by the whole input, so it never answers for
     280              :     // rules other than its own (O16).
     281              :     thread_local! {
     282              :         static LAST: std::cell::RefCell<Option<(crate::policy::Filter, std::sync::Arc<crate::policy::Filter>)>> =
     283              :             const { std::cell::RefCell::new(None) };
     284              :     }
     285          588 :     let hit = LAST.with_borrow(|last| {
     286          588 :         last.as_ref()
     287          588 :             .filter(|l| l.0.rules == f.rules && l.0.omit_by_type == f.omit_by_type)
     288          588 :             .map(|l| l.1.clone())
     289          588 :     });
     290          588 :     if hit.is_some() {
     291          268 :         return hit;
     292          320 :     }
     293         1318 :     let expand = |names: &[String]| -> Vec<String> {
     294         1388 :         names.iter().map(|n| POLICY_CONTEXT.expand_key(n)).collect()
     295         1318 :     };
     296          320 :     let expanded = std::sync::Arc::new(crate::policy::Filter {
     297          320 :         rules: f
     298          320 :             .rules
     299          320 :             .iter()
     300          320 :             .map(|r| crate::policy::Rule {
     301          656 :                 types: expand(&r.types),
     302          656 :                 attributes: expand(&r.attributes),
     303          656 :                 q: r.q.clone(),
     304          656 :                 scope_q: r.scope_q.clone(),
     305          656 :                 geo_q: r.geo_q.clone(),
     306          656 :                 geo_also: r.geo_also.clone(),
     307          656 :                 ids: r.ids.clone(),
     308          656 :                 id_patterns: r.id_patterns.clone(),
     309          656 :                 every_attribute: r.every_attribute,
     310          656 :                 temporal: r.temporal.clone(),
     311          656 :             })
     312          320 :             .collect(),
     313          320 :         omit_by_type: f
     314          320 :             .omit_by_type
     315          320 :             .iter()
     316          320 :             .map(|(t, attrs)| (POLICY_CONTEXT.expand_key(t), expand(attrs)))
     317          320 :             .collect(),
     318          320 :         ..crate::policy::Filter::default()
     319              :     });
     320          320 :     let key = crate::policy::Filter {
     321          320 :         rules: f.rules.clone(),
     322          320 :         omit_by_type: f.omit_by_type.clone(),
     323          320 :         ..crate::policy::Filter::default()
     324          320 :     };
     325          320 :     LAST.set(Some((key, expanded.clone())));
     326          320 :     Some(expanded)
     327        27511 : }
     328              : 
     329              : /// What the expanded `rules` let the INTERNAL document `doc` show: every
     330              : /// member without rules, `None` when no rule holds for it.
     331        28900 : pub(crate) fn shown<'a>(
     332        28900 :     rules: Option<&'a crate::policy::Filter>,
     333        28900 :     doc: &Value,
     334        28900 : ) -> Option<crate::policy::Shown<'a>> {
     335        28900 :     match rules {
     336        23268 :         None => Some(crate::policy::Shown::default()),
     337         5632 :         Some(f) => f.shown(doc, &POLICY_CONTEXT),
     338              :     }
     339        28900 : }
     340              : 
     341              : /// The client's own `q` as the query path reads it (4.9 `expandValues`
     342              : /// applied), from the request as sent.
     343          364 : pub(crate) fn client_q(
     344          364 :     params: &HashMap<String, String>,
     345          364 :     ctx: &Context,
     346          364 : ) -> Result<Option<antares_ql::QNode>, NgsiError> {
     347          364 :     params
     348          364 :         .get("q")
     349          364 :         .map(|q| {
     350           64 :             Ok(antares_ql::eval::apply_expand_values(
     351           64 :                 antares_ql::parse_q(q)?,
     352           64 :                 antares_ql::eval::expansion_list(
     353           64 :                     params.get("expandValues").map(String::as_str),
     354           64 :                     params.get("jsonKeys").map(String::as_str),
     355           64 :                 )
     356           64 :                 .as_deref(),
     357           64 :                 ctx,
     358              :             ))
     359           64 :         })
     360          364 :         .transpose()
     361          364 : }
     362              : 
     363              : /// [`narrowing`] holding the client's own `q` as well, for a caller that
     364              : /// runs a query under the narrowing ([`note_reads`]).
     365          450 : pub(crate) fn narrowing_reading(
     366          450 :     f: &crate::policy::Filter,
     367          450 :     client_params: &HashMap<String, String>,
     368          450 :     ctx: &Context,
     369          450 : ) -> Result<Repr, NgsiError> {
     370          450 :     let mut r = narrowing(f);
     371          450 :     note_reads(&mut r, client_params, ctx)?;
     372          450 :     Ok(r)
     373          450 : }
     374              : 
     375              : /// Record the client's own `q` on a narrowed representation, before the
     376              : /// engine's is conjoined into the parameters; nothing to do without rules.
     377         2532 : pub(crate) fn note_reads(
     378         2532 :     r: &mut Repr,
     379         2532 :     client_params: &HashMap<String, String>,
     380         2532 :     ctx: &Context,
     381         2532 : ) -> Result<(), NgsiError> {
     382         2532 :     if r.policy_types.is_some() {
     383          310 :         r.policy_client_q = client_q(client_params, ctx)?;
     384         2222 :     }
     385         2532 :     Ok(())
     386         2532 : }
     387              : 
     388              : /// Order `docs` by what each one shows, so `orderBy` on an Attribute the
     389              : /// narrowing hides orders as if it were absent
     390              : /// and never by the hidden value. Without a projecting narrowing, `order`
     391              : /// runs on `docs` as they are.
     392          308 : pub(crate) fn order_as_shown<E>(
     393          308 :     docs: &mut Vec<Value>,
     394          308 :     narrow: Option<&Repr>,
     395          308 :     order: impl FnOnce(&mut Vec<Value>) -> Result<(), E>,
     396          308 : ) -> Result<(), E> {
     397          308 :     let Some(r) = narrow
     398          308 :         .filter(|r| r.policy_pick.is_some() || r.policy_omit.is_some() || r.policy_types.is_some())
     399              :     else {
     400          304 :         return order(docs);
     401              :     };
     402              :     // the position travels under a key no expanded IRI or core member can
     403              :     // take, and comes back off the ordered views
     404              :     const AT: &str = " position";
     405            4 :     let mut views: Vec<Value> = docs
     406            4 :         .iter()
     407            4 :         .enumerate()
     408           12 :         .map(|(i, d)| {
     409           12 :             let mut v = policy_view(d, r);
     410           12 :             if let Some(o) = v.as_object_mut() {
     411           12 :                 o.insert(AT.to_owned(), Value::from(i));
     412           12 :             }
     413           12 :             v
     414           12 :         })
     415            4 :         .collect();
     416            4 :     order(&mut views)?;
     417            4 :     let mut slots: Vec<Option<Value>> = std::mem::take(docs).into_iter().map(Some).collect();
     418            4 :     *docs = views
     419            4 :         .iter()
     420           12 :         .filter_map(|v| v.get(AT).and_then(Value::as_u64))
     421           12 :         .filter_map(|i| slots.get_mut(usize::try_from(i).ok()?)?.take())
     422            4 :         .collect();
     423            4 :     Ok(())
     424          308 : }
     425              : 
     426              : /// The rows a narrowing admits (ADR-0020 `Filter::q`, `scope_q` and its
     427              : /// areas).
     428              : #[derive(Debug, Clone)]
     429              : pub struct PolicyReach {
     430              :     pub q: Option<antares_ql::QNode>,
     431              :     pub scope_q: Option<String>,
     432              :     /// Every area the narrowing holds; each must hold.
     433              :     pub areas: Vec<antares_ql::geo::GeoQuery>,
     434              :     /// The narrowing's expanded rules: an Entity none of them holds for is
     435              :     /// not admitted.
     436              :     pub rules: Option<std::sync::Arc<crate::policy::Filter>>,
     437              : }
     438              : 
     439              : /// Whether the narrowing admits the INTERNAL document `doc`; no narrowing
     440              : /// admits everything. The one check the retrieve answer and every joined
     441              : /// Entity go through, so a join cannot reach a row the retrieve hides.
     442        14402 : pub(crate) async fn policy_admits(
     443        14402 :     st: &AppState,
     444        14402 :     tenant: &TenantId,
     445        14402 :     ctx: &Context,
     446        14402 :     reach: Option<&PolicyReach>,
     447        14402 :     doc: &Value,
     448        14402 : ) -> bool {
     449        14402 :     let Some(reach) = reach else {
     450        11626 :         return true;
     451              :     };
     452         2776 :     if shown(reach.rules.as_deref(), doc).is_none() {
     453          860 :         return false;
     454         1916 :     }
     455         1916 :     if let Some(scope) = &reach.scope_q {
     456           48 :         if !crate::scope_matches(scope, doc) {
     457           24 :             return false;
     458           24 :         }
     459         1868 :     }
     460         1892 :     if !reach.areas.iter().all(|geo| geo.matches(doc, ctx)) {
     461            2 :         return false;
     462         1890 :     }
     463         1890 :     match &reach.q {
     464          110 :         Some(ast) => {
     465          110 :             crate::notify::linked_eval(st, tenant, None, |l| {
     466          110 :                 antares_ql::eval::eval_q(ast, doc, ctx, l)
     467          110 :             })
     468          110 :             .await
     469              :         }
     470         1780 :         None => true,
     471              :     }
     472        14402 : }
     473              : 
     474              : /// The INTERNAL document `doc` as the narrowing lets it be read: the
     475              : /// Entity frame and the Attributes the policy projection keeps.
     476         1968 : pub(crate) fn policy_view(doc: &Value, r: &Repr) -> Value {
     477         1968 :     if r.policy_pick.is_none() && r.policy_omit.is_none() && r.policy_types.is_none() {
     478           26 :         return doc.clone();
     479         1942 :     }
     480         1942 :     let shown = shown(r.policy_types.as_deref(), doc).unwrap_or_else(crate::policy::Shown::nothing);
     481         1942 :     match doc.as_object() {
     482         1942 :         Some(obj) => Value::Object(
     483         1942 :             obj.iter()
     484         7712 :                 .filter(|(k, _)| {
     485         7712 :                     is_meta(k)
     486         3352 :                         || (policy_projected(r.policy_pick.as_deref(), r.policy_omit.as_deref(), k)
     487         3328 :                             && shown.allows(k))
     488         7712 :                 })
     489         6498 :                 .map(|(k, v)| (k.clone(), v.clone()))
     490         1942 :                 .collect(),
     491              :         ),
     492            0 :         None => doc.clone(),
     493              :     }
     494         1968 : }
     495              : 
     496              : /// The narrowing half of a representation alone, for a caller that holds
     497              : /// the Filter but builds no representation of its own.
     498         1480 : pub fn narrowing(f: &crate::policy::Filter) -> Repr {
     499         1480 :     let mut r = Repr::default();
     500         1480 :     let (reach, rules) = reach_of(f);
     501         1480 :     fill(&mut r, f, reach, rules);
     502         1480 :     r
     503         1480 : }
     504              : 
     505              : /// The same `pick`/`omit` names in the form an already-compacted document
     506              : /// carries them. The query path folds a policy projection into the
     507              : /// request's own representation, where the names are IRIs; a notification
     508              : /// is compacted by `notify::build_data` long before the seam sees it, so
     509              : /// there the names have to travel the other way — parsed against the same
     510              : /// `@context` the document was compacted with, then compacted back. An
     511              : /// engine writes one rule set either way, and a rule written as an IRI
     512              : /// (which is what ADR-0020 asks of an engine) removes the member it names
     513              : /// rather than silently matching nothing.
     514           14 : pub fn compacted_filter(f: &crate::policy::Filter, ctx: &Context) -> crate::policy::Filter {
     515           28 :     let names = |raw: &[String]| -> Vec<String> {
     516           28 :         policy_nodes(raw)
     517           28 :             .iter()
     518           28 :             .map(|n| ctx.compact_iri(&n.iri))
     519           28 :             .collect()
     520           28 :     };
     521           14 :     let by_type = |m: &std::collections::BTreeMap<String, Vec<String>>| {
     522           14 :         m.iter()
     523           14 :             .map(|(t, attrs)| (ctx.compact_iri(&POLICY_CONTEXT.expand_key(t)), names(attrs)))
     524           14 :             .collect()
     525           14 :     };
     526              :     // the rules' conditions read the stored Entity, not this compacted
     527              :     // one: the notification path evaluates them itself (`notify::shown_now`)
     528           14 :     crate::policy::Filter {
     529           14 :         pick: names(&f.pick),
     530           14 :         omit: names(&f.omit),
     531           14 :         rules: Vec::new(),
     532           14 :         omit_by_type: by_type(&f.omit_by_type),
     533           14 :         ..f.clone()
     534           14 :     }
     535           14 : }
     536              : 
     537              : /// The `@context` a policy name is read in. Deliberately NOT the request's:
     538              : /// [`antares_jsonld::Context::expand_key`] consults the term map before it
     539              : /// decides a name is already an IRI, so a caller that binds the term a rule
     540              : /// names — or binds the rule's own IRI as a term, which its own inline
     541              : /// `@context` is enough to do — would move the rule off its target and walk
     542              : /// out of the narrowing. A deployment's rule means the same thing whatever
     543              : /// the caller sends. ADR-0020 asks an engine to write its rules as IRIs and
     544              : /// those pass through unchanged; a short name is read here the way a name a
     545              : /// request does not define is read anywhere else.
     546              : /// [`POLICY_CONTEXT`], for the rule conditions read outside this module.
     547         8556 : pub(crate) fn policy_context() -> &'static Context {
     548         8556 :     &POLICY_CONTEXT
     549         8556 : }
     550              : 
     551              : static POLICY_CONTEXT: std::sync::LazyLock<Context> =
     552              :     std::sync::LazyLock::new(antares_jsonld::core_context);
     553              : 
     554              : /// A policy's `pick`/`omit` names as projection nodes, expanded against
     555              : /// [`POLICY_CONTEXT`].
     556              : ///
     557              : /// Deliberately NOT the 4.21 parser: 6.5.3.1 lets those members name `"id"`,
     558              : /// `"type"`, `"scope"` or one projected Attribute, and ADR-0020 asks an
     559              : /// engine to write its rules against IRIs — while 4.21 reads a dot as the
     560              : /// sub-attribute path separator, so `https://uri.etsi.org/…/colour` through
     561              : /// that grammar becomes the member `https://uri` and removes nothing. An
     562              : /// engine names one member; it is expanded, and both forms of the name are
     563              : /// kept so the projection matches a document whichever form its keys are in.
     564          136 : fn policy_nodes(names: &[String]) -> Vec<ProjNode> {
     565          136 :     names
     566          136 :         .iter()
     567          136 :         .map(|n| ProjNode {
     568          146 :             raw: n.clone(),
     569          146 :             iri: POLICY_CONTEXT.expand_key(n),
     570          146 :             children: None,
     571          146 :         })
     572          136 :         .collect()
     573          136 : }
     574              : 
     575              : /// Maximum `{…}` selection depth of a projection tree — the number of
     576              : /// Linked Entity hops it implies (5.7.1.4: must not exceed joinLevel).
     577          582 : pub fn proj_depth(nodes: &[ProjNode]) -> usize {
     578          582 :     nodes
     579          582 :         .iter()
     580          866 :         .map(|n| match &n.children {
     581          208 :             Some(c) => proj_depth(c).saturating_add(1),
     582          658 :             None => 0,
     583          866 :         })
     584          582 :         .max()
     585          582 :         .unwrap_or(0)
     586          582 : }
     587              : 
     588              : /// The entity-level members of 4.5.1: everything an Entity carries that is
     589              : /// NOT an Attribute. Every layer that has to tell the two apart reads this
     590              : /// one list — `attrs`/`pick`/`omit` validation and projection here, the
     591              : /// notification diff and tombstone in `notify`, the 4.3.6.8 amendment in
     592              : /// `conformance`, the registration-scope narrowing in `federation`. A layer
     593              : /// with its own copy is a layer that will disagree with the others about
     594              : /// what an attribute is.
     595              : pub(crate) const ENTITY_META: &[&str] = antares_model::ENTITY_META_KEYS;
     596              : 
     597              : /// One node of a 4.21 attribute-projection expression; `children` carries a
     598              : /// nested `{…}` selection (applied to linked entities on join).
     599              : #[derive(Debug, Clone)]
     600              : pub struct ProjNode {
     601              :     pub raw: String,
     602              :     pub iri: String,
     603              :     pub children: Option<Vec<ProjNode>>,
     604              : }
     605              : 
     606              : /// Parse + validate a pick=/omit= value (4.21) into a projection tree.
     607          784 : pub(crate) fn parse_projection(s: &str, ctx: &Context) -> Result<Vec<ProjNode>, NgsiError> {
     608          784 :     let bad = || NgsiError::BadRequestData(format!("invalid attribute projection {s:?} (4.21)"));
     609              :     // Each `{…}` level is one Linked Entity hop (5.7.1.4), so a selection
     610              :     // deeper than the joinLevel ceiling can never be satisfied. Bounding it
     611              :     // here, before the recursive descent below, is what keeps the recursion
     612              :     // finite: pick=/omit= reach this parser as plain STRINGS — from the URI,
     613              :     // or from inside a query body, where the JSON nesting wall never sees
     614              :     // their braces.
     615          784 :     if s.is_empty()
     616          776 :         || s.matches('{').count() != s.matches('}').count()
     617          736 :         || crate::bounds::json_depth(s.as_bytes()) > crate::bounds::MAX_JOIN_LEVEL
     618          720 :         || !s
     619          720 :             .chars()
     620         7712 :             .all(|c| c.is_ascii_alphanumeric() || "_,.:{}#/%-+@|".contains(c))
     621              :     {
     622          104 :         return Err(bad());
     623          680 :     }
     624         1004 :     fn split_top(s: &str) -> Option<Vec<&str>> {
     625         1004 :         let mut out = Vec::new();
     626         1004 :         let mut depth = 0usize;
     627         1004 :         let mut start = 0usize;
     628        10724 :         for (i, c) in s.char_indices() {
     629          466 :             match c {
     630          560 :                 '{' => depth = depth.saturating_add(1),
     631          560 :                 '}' => depth = depth.checked_sub(1)?,
     632              :                 // 4.21 orOp = | / , — both split at the same depth
     633          466 :                 ',' | '|' if depth == 0 => {
     634          428 :                     out.push(&s[start..i]);
     635          428 :                     // the separator is one byte
     636          428 :                     start = i.saturating_add(1);
     637          428 :                 }
     638         9176 :                 _ => {}
     639              :             }
     640              :         }
     641         1004 :         out.push(&s[start..]);
     642         1004 :         Some(out)
     643         1004 :     }
     644         1004 :     fn parse_nodes(s: &str, ctx: &Context) -> Result<Vec<ProjNode>, NgsiError> {
     645         1004 :         let bad = |m: &str| NgsiError::BadRequestData(format!("invalid attribute projection: {m}"));
     646         1004 :         let parts = split_top(s).ok_or_else(|| bad("unbalanced braces"))?;
     647         1004 :         let mut out: Vec<ProjNode> = Vec::new();
     648         1384 :         for t in parts {
     649         1384 :             let t = t.trim();
     650         1384 :             if t.is_empty() {
     651           68 :                 return Err(bad("empty projection member"));
     652         1316 :             }
     653         1316 :             let (head_part, children) = match t.split_once('{') {
     654          324 :                 Some((head, rest)) => {
     655          324 :                     let inner = rest
     656          324 :                         .strip_suffix('}')
     657          324 :                         .ok_or_else(|| bad("unclosed brace"))?;
     658          324 :                     (head, Some(parse_nodes(inner, ctx)?))
     659              :                 }
     660          992 :                 None => (t, None),
     661              :             };
     662              :             // An IRI (a `:` before any `.`) is one Attribute name (5.5.7):
     663              :             // the dotted path is a short name's syntax only.
     664         1292 :             let iri = match (head_part.find(':'), head_part.find('.')) {
     665            8 :                 (Some(colon), Some(dot)) => colon < dot,
     666         1284 :                 (colon, _) => colon.is_some(),
     667              :             };
     668         1292 :             let head = if iri {
     669            8 :                 head_part
     670              :             } else {
     671         1284 :                 head_part.split('.').next().unwrap_or(head_part)
     672              :             };
     673         1292 :             if head.is_empty() || (!iri && head_part.split('.').any(str::is_empty)) {
     674            8 :                 return Err(bad("empty path segment"));
     675         1284 :             }
     676         1284 :             if !head
     677         1284 :                 .chars()
     678         1284 :                 .next()
     679         1284 :                 .is_some_and(|c| c.is_ascii_alphanumeric() || "_:@#".contains(c))
     680              :             {
     681            0 :                 return Err(bad("projection member starts with a special character"));
     682         1284 :             }
     683         1284 :             if out.iter().any(|n| n.raw == head) {
     684            0 :                 return Err(bad("duplicate projection member"));
     685         1284 :             }
     686         1284 :             out.push(ProjNode {
     687         1284 :                 raw: head.to_owned(),
     688         1284 :                 iri: ctx.expand_key(head),
     689         1284 :                 children,
     690         1284 :             });
     691              :         }
     692          904 :         Ok(out)
     693         1004 :     }
     694          680 :     parse_nodes(s, ctx)
     695          784 : }
     696              : 
     697              : /// Apply the representation to an internal doc, producing a new internal doc
     698              : /// 4.21 Projections. CIM 009 V1.9.1 4.21 and TS 104 175 7.3.2 state no rule
     699              : /// for combining them; the ETSI TPs 018_18, 020_22 and 028_08 refuse pick
     700              : /// with attrs, omit with attrs and one member in both pick and omit. Antares
     701              : /// refuses every pair (a stricter reading, kept), the one
     702              : /// reading, so an operation cannot accept a combination another rejects.
     703         6850 : pub fn check_projection_exclusive(params: &HashMap<String, String>) -> Result<(), NgsiError> {
     704         6850 :     let excl = ["pick", "omit", "attrs"]
     705         6850 :         .iter()
     706        20550 :         .filter(|k| params.contains_key(**k))
     707         6850 :         .count();
     708         6850 :     if excl > 1 {
     709           80 :         return Err(NgsiError::BadRequestData(
     710           80 :             "pick, omit and attrs cannot be combined in one request".into(),
     711           80 :         ));
     712         6770 :     }
     713         6770 :     Ok(())
     714         6850 : }
     715              : 
     716              : /// ADR-0020: does an Attribute survive the policy's own projection? Both
     717              : /// forms of the name are tried, because a document reaches this function
     718              : /// expanded on the query path and compacted on the notification path, and a
     719              : /// rule names one Attribute either way.
     720        36664 : pub fn policy_projected(pick: Option<&[ProjNode]>, omit: Option<&[ProjNode]>, k: &str) -> bool {
     721        36664 :     let names = |n: &ProjNode| n.iri == *k || n.raw == *k;
     722        36664 :     if let Some(pick) = pick {
     723           12 :         if !pick.iter().any(names) {
     724            6 :             return false;
     725            6 :         }
     726        36652 :     }
     727        36658 :     if let Some(omit) = omit {
     728          174 :         if omit.iter().any(names) {
     729           66 :             return false;
     730          108 :         }
     731        36484 :     }
     732        36592 :     true
     733        36664 : }
     734              : 
     735              : /// 4.21: does a core Entity member (id, type, scope, the system temporal
     736              : /// Properties) survive the projection? `pick` constrains core members
     737              : /// strictly — only what is named survives; `omit` drops a named member only
     738              : /// when the node carries no children, because a node with children
     739              : /// constrains the linked Entity below the head, not the head itself. The
     740              : /// current-state and temporal representations project core members by the
     741              : /// same rule and differ only below it.
     742       110392 : pub fn meta_projected(pick: Option<&[ProjNode]>, omit: Option<&[ProjNode]>, k: &str) -> bool {
     743       110392 :     if let Some(pick) = pick {
     744         1044 :         if !pick.iter().any(|n| n.raw == *k) {
     745          440 :             return false;
     746          300 :         }
     747       109652 :     }
     748       109952 :     if let Some(omit) = omit {
     749          918 :         if omit.iter().any(|n| n.raw == *k && n.children.is_none()) {
     750          140 :             return false;
     751          604 :         }
     752       109208 :     }
     753       109812 :     true
     754       110392 : }
     755              : 
     756              : /// ready for compaction.
     757        23494 : pub fn apply(doc: &Value, r: &Repr) -> Value {
     758        23494 :     let Some(obj) = doc.as_object() else {
     759            0 :         return doc.clone();
     760              :     };
     761              :     // an Entity no rule holds for was left out before it got here; one that
     762              :     // arrives anyway shows its frame only
     763        23494 :     let shown = shown(r.policy_types.as_deref(), doc).unwrap_or_else(crate::policy::Shown::nothing);
     764        23494 :     let mut out = Map::new();
     765       115560 :     for (k, v) in obj {
     766       115560 :         let is_meta = ENTITY_META.contains(&k.as_str());
     767       115560 :         if is_meta {
     768        84184 :             match k.as_str() {
     769              :                 // 6.3.11 Table 6.3.11-1: expiresAt is a system temporal
     770              :                 // attribute — included only when options=sysAttrs.
     771        84184 :                 "createdAt" | "modifiedAt" | "expiresAt" if !r.sys_attrs => continue,
     772        53240 :                 _ => {}
     773              :             }
     774              :             // the policy pick never reaches a core member: 5.2.4 makes id
     775              :             // and type the Entity, so a narrowing that removed them would
     776              :             // answer something that is not one
     777        53240 :             if !meta_projected(r.pick.as_deref(), r.omit.as_deref(), k)
     778        52796 :                 || !meta_projected(None, r.policy_omit.as_deref(), k)
     779              :             {
     780          444 :                 continue;
     781        52796 :             }
     782        52796 :             out.insert(k.clone(), v.clone());
     783        52796 :             continue;
     784        31376 :         }
     785        31376 :         if let Some(keep) = &r.attrs {
     786          140 :             if !keep.contains(k) {
     787           64 :                 continue;
     788           76 :             }
     789        31236 :         }
     790        31312 :         if let Some(pick) = &r.pick {
     791         1368 :             if !pick.iter().any(|n| n.iri == *k) {
     792          376 :                 continue;
     793          320 :             }
     794        30616 :         }
     795        30936 :         if let Some(drop) = &r.omit {
     796          944 :             if drop.iter().any(|n| n.iri == *k && n.children.is_none()) {
     797          170 :                 continue;
     798          506 :             }
     799        30260 :         }
     800        30766 :         if !policy_projected(r.policy_pick.as_deref(), r.policy_omit.as_deref(), k)
     801        30726 :             || !shown.allows(k)
     802              :         {
     803         1376 :             continue;
     804        29390 :         }
     805        29390 :         let raw: Vec<Value> = v.as_array().cloned().unwrap_or_else(|| vec![v.clone()]);
     806        29390 :         let kept: Vec<&Value> = raw
     807        29390 :             .iter()
     808        30070 :             .filter(|inst| match (&r.dataset_id, inst.get("datasetId")) {
     809        29914 :                 (None, _) => true,
     810          108 :                 (Some(want), Some(Value::String(have))) => want.iter().any(|w| w == have),
     811           64 :                 (Some(want), None) => want.iter().any(|w| w == "@none"),
     812            0 :                 _ => false,
     813        30070 :             })
     814        29390 :             .collect();
     815        29390 :         let mut instances: Vec<Value> = kept
     816        29390 :             .iter()
     817        29966 :             .map(|inst| transform_instance(inst, r))
     818        29390 :             .collect();
     819        29390 :         if instances.is_empty() {
     820            8 :             continue;
     821        29382 :         }
     822        29382 :         if r.key_values {
     823          512 :             if instances.len() == 1 {
     824              :                 // 4.5.4: a lone instance simplifies to its bare value
     825          484 :                 out.extend(instances.pop().map(|one| (k.clone(), one)));
     826              :             } else {
     827              :                 // 4.5.4 multi-attribute case: a "dataset" map holding one
     828              :                 // key-value pair for each datasetId, "@none" for the default
     829              :                 // instance. The pairing below is positional, so `kept` and
     830              :                 // `instances` must both still be whole here.
     831           28 :                 let mut ds = Map::new();
     832           68 :                 for (orig, simple) in kept.iter().zip(instances.iter()) {
     833           68 :                     let key = orig
     834           68 :                         .get("datasetId")
     835           68 :                         .and_then(Value::as_str)
     836           68 :                         .unwrap_or("@none");
     837           68 :                     ds.insert(key.to_owned(), simple.clone());
     838           68 :                 }
     839           28 :                 out.insert(
     840           28 :                     k.clone(),
     841           28 :                     serde_json::json!({ "dataset": Value::Object(ds) }),
     842              :                 );
     843              :             }
     844        28870 :         } else {
     845        28870 :             out.insert(k.clone(), Value::Array(instances));
     846        28870 :         }
     847              :     }
     848        23494 :     Value::Object(out)
     849        23494 : }
     850              : 
     851        31076 : fn transform_instance(inst: &Value, r: &Repr) -> Value {
     852        31076 :     let Some(obj) = inst.as_object() else {
     853            0 :         return inst.clone();
     854              :     };
     855              :     // lang filter first: LanguageProperty → Property under a selected language
     856        31076 :     let mut obj = obj.clone();
     857        31076 :     if let Some(lang) = &r.lang {
     858           52 :         apply_lang(&mut obj, lang);
     859        31024 :     }
     860              : 
     861        31076 :     if r.key_values {
     862          552 :         return simplified_value(&obj);
     863        30524 :     }
     864              : 
     865        30524 :     let mut out = Map::new();
     866       121134 :     for (k, v) in &obj {
     867       121134 :         match k.as_str() {
     868              :             // 6.3.11: expiresAt shares the sysAttrs gate on attribute
     869              :             // instances (current-state and temporal alike).
     870       121134 :             "createdAt" | "modifiedAt" | "expiresAt" if !r.sys_attrs => continue,
     871        65320 :             "type" if r.concise => continue,
     872        78962 :             _ => {}
     873              :         }
     874              :         // sub-attributes recurse
     875        78962 :         if let Some(arr) = v.as_array().filter(|_| !is_reserved_member(k)) {
     876         1106 :             let subs: Vec<Value> = arr.iter().map(|i| transform_instance(i, r)).collect();
     877         1106 :             out.insert(k.clone(), Value::Array(subs));
     878        77856 :         } else {
     879        77856 :             out.insert(k.clone(), v.clone());
     880        77856 :         }
     881              :     }
     882        30524 :     if r.concise {
     883              :         // bare-value collapse: a Property with only `value` collapses
     884           48 :         if out.len() == 1 {
     885           12 :             if let Some(v) = out.get("value") {
     886            8 :                 return v.clone();
     887            4 :             }
     888           36 :         }
     889        30476 :     }
     890        30516 :     Value::Object(out)
     891        31076 : }
     892              : 
     893              : /// 4.15 Language Filter on one attribute instance (5.7.2.5, and the `lang`
     894              : /// rows of Tables 6.18.3.2-1 / 6.19.3.1 for the temporal forms): a
     895              : /// LanguageProperty "shall be converted into a Property" holding the chosen
     896              : /// languageMap entry, with the non-reified `lang` member naming it.
     897          124 : pub(crate) fn apply_lang(obj: &mut Map<String, Value>, lang: &str) {
     898          124 :     if obj.get("type").and_then(Value::as_str) != Some("LanguageProperty") {
     899           24 :         return;
     900          100 :     }
     901          100 :     let Some(lm) = obj.get("languageMap").and_then(Value::as_object) else {
     902            0 :         return;
     903              :     };
     904          100 :     if let Some((chosen_lang, value)) = select_lang(lm, lang) {
     905          100 :         obj.remove("languageMap");
     906          100 :         obj.insert("type".into(), Value::String("Property".into()));
     907          100 :         obj.insert("value".into(), value);
     908          100 :         obj.insert("lang".into(), Value::String(chosen_lang));
     909          100 :     }
     910          124 : }
     911              : 
     912              : /// 4.15 Language Filter: pick one languageMap entry for a lang priority
     913              : /// list. Ranges are ordered by their q weights (RFC 3282, default 1, list
     914              : /// position breaking ties); tags compare case-insensitively (RFC 5646); a
     915              : /// range matches an exact tag, then a longer tag by prefix (fr → fr-CH),
     916              : /// then a shorter tag by truncation (fr-CH → fr). "*" — or no match at
     917              : /// all — "shall default to any supported language" (@none preferred).
     918          136 : fn select_lang(lm: &Map<String, Value>, lang: &str) -> Option<(String, Value)> {
     919          136 :     let mut ranges: Vec<(f64, usize, &str)> = lang
     920          136 :         .split(',')
     921          136 :         .enumerate()
     922          168 :         .filter_map(|(i, part)| {
     923          168 :             let mut it = part.trim().split(';');
     924          168 :             let tag = it.next()?.trim();
     925          168 :             if tag.is_empty() {
     926            0 :                 return None;
     927          168 :             }
     928          168 :             let q = it
     929          168 :                 .find_map(|p| {
     930           40 :                     p.trim()
     931           40 :                         .strip_prefix("q=")
     932           40 :                         .and_then(|v| v.parse::<f64>().ok())
     933           40 :                 })
     934          168 :                 .unwrap_or(1.0);
     935          168 :             Some((q, i, tag))
     936          168 :         })
     937          136 :         .collect();
     938          136 :     ranges.sort_by(|a, b| {
     939           32 :         b.0.partial_cmp(&a.0)
     940           32 :             .unwrap_or(std::cmp::Ordering::Equal)
     941           32 :             .then(a.1.cmp(&b.1))
     942           32 :     });
     943          248 :     let ci = |k: &str, want: &str| k.eq_ignore_ascii_case(want);
     944          140 :     for (q, _, want) in &ranges {
     945              :         // q=0 = "not acceptable" (RFC 3282); "*" = any → the fallback below
     946          140 :         if *q <= 0.0 || *want == "*" {
     947           12 :             continue;
     948          128 :         }
     949          216 :         if let Some((k, v)) = lm.iter().find(|(k, _)| ci(k, want)) {
     950           80 :             return Some((k.clone(), v.clone()));
     951           48 :         }
     952           88 :         if let Some((k, v)) = lm.iter().find(|(k, _)| {
     953           88 :             k.len() > want.len()
     954            8 :                 && k.as_bytes().get(want.len()) == Some(&b'-')
     955            8 :                 && ci(&k[..want.len()], want)
     956           88 :         }) {
     957            8 :             return Some((k.clone(), v.clone()));
     958           40 :         }
     959           40 :         let mut w = *want;
     960           40 :         while let Some(cut) = w.rfind('-') {
     961           16 :             w = &w[..cut];
     962           24 :             if let Some((k, v)) = lm.iter().find(|(k, _)| ci(k, w)) {
     963           16 :                 return Some((k.clone(), v.clone()));
     964            0 :             }
     965              :         }
     966              :     }
     967              :     // any: prefer @none, then first
     968           32 :     if let Some(v) = lm.get("@none") {
     969            0 :         return Some(("@none".to_owned(), v.clone()));
     970           32 :     }
     971           32 :     lm.iter().next().map(|(k, v)| (k.clone(), v.clone()))
     972          136 : }
     973              : 
     974         1150 : fn is_reserved_member(k: &str) -> bool {
     975           44 :     matches!(
     976         1150 :         k,
     977         1150 :         "type"
     978         1150 :             | "value"
     979         1144 :             | "object"
     980         1144 :             | "objectType"
     981         1144 :             | "datasetId"
     982         1144 :             | "observedAt"
     983         1144 :             | "unitCode"
     984         1144 :             | "lang"
     985         1144 :             | "languageMap"
     986         1144 :             | "vocab"
     987         1140 :             | "json"
     988         1132 :             | "valueList"
     989         1122 :             | "objectList"
     990         1110 :             | "createdAt"
     991         1110 :             | "modifiedAt"
     992         1110 :             | "deletedAt"
     993         1110 :             | "instanceId"
     994         1110 :             | "previousValue"
     995         1106 :             | "previousObject"
     996         1106 :             | "previousLanguageMap"
     997         1106 :             | "previousJson"
     998         1106 :             | "previousVocab"
     999              :     )
    1000         1150 : }
    1001              : 
    1002              : /// 4.5.4: the simplified (keyValues) value of one instance — bare value for
    1003              : /// Property/GeoProperty, bare URI(s) for a Relationship, bare ordered arrays
    1004              : /// for ListProperty/ListRelationship, but the single-key wrapper objects
    1005              : /// {"languageMap": …} / {"json": …} / {"vocab": …} for the Language, Json
    1006              : /// and Vocab subtypes (Examples 4–6).
    1007          580 : fn simplified_value(obj: &Map<String, Value>) -> Value {
    1008          892 :     for k in ["value", "object", "valueList", "objectList"] {
    1009          892 :         if let Some(v) = obj.get(k) {
    1010          540 :             return v.clone();
    1011          352 :         }
    1012              :     }
    1013           76 :     for k in ["languageMap", "json", "vocab"] {
    1014           76 :         if let Some(v) = obj.get(k) {
    1015           40 :             return serde_json::json!({ k: v.clone() });
    1016           36 :         }
    1017              :     }
    1018            0 :     Value::Object(obj.clone())
    1019          580 : }
    1020              : 
    1021              : /// Compaction for a shaped doc under a representation: keyValues docs get
    1022              : /// shallow key renaming only (values are already plain JSON).
    1023        23412 : pub fn compact_for(
    1024        23412 :     repr: &crate::repr::Repr,
    1025        23412 :     shaped: &Value,
    1026        23412 :     ctx: &antares_jsonld::Context,
    1027        23412 : ) -> Value {
    1028        23412 :     if repr.key_values {
    1029          316 :         compact_entity_shallow(shaped, ctx)
    1030              :     } else {
    1031        23096 :         compact_entity(shaped, ctx)
    1032              :     }
    1033        23412 : }
    1034              : 
    1035              : /// The child representation for a linked entity under `key` (4.21 nested
    1036              : /// projections apply to the joined entity, not the relationship itself).
    1037         6608 : fn joined_repr(parent: &crate::repr::Repr, key_compact: &str, key_iri: &str) -> crate::repr::Repr {
    1038         6608 :     let mut r = crate::repr::Repr {
    1039         6608 :         sys_attrs: parent.sys_attrs,
    1040         6608 :         key_values: parent.key_values,
    1041         6608 :         concise: parent.concise,
    1042         6608 :         lang: parent.lang.clone(),
    1043         6608 :         // ADR-0020: the narrowing is not a per-level projection — it
    1044         6608 :         // travels to every document the answer carries
    1045         6608 :         policy_pick: parent.policy_pick.clone(),
    1046         6608 :         policy_omit: parent.policy_omit.clone(),
    1047         6608 :         policy_reach: parent.policy_reach.clone(),
    1048         6608 :         policy_types: parent.policy_types.clone(),
    1049         6608 :         ..Default::default()
    1050         6608 :     };
    1051         6608 :     if let Some(pick) = &parent.pick {
    1052          176 :         if let Some(n) = pick
    1053          176 :             .iter()
    1054          402 :             .find(|n| n.raw == key_compact || n.iri == key_iri)
    1055          176 :         {
    1056          176 :             r.pick = n.children.clone();
    1057          176 :         }
    1058         6432 :     }
    1059         6608 :     if let Some(omit) = &parent.omit {
    1060          248 :         if let Some(n) = omit
    1061          248 :             .iter()
    1062          392 :             .find(|n| (n.raw == key_compact || n.iri == key_iri) && n.children.is_some())
    1063           90 :         {
    1064           90 :             r.omit = n.children.clone();
    1065          158 :         }
    1066         6360 :     }
    1067         6608 :     r
    1068         6608 : }
    1069              : 
    1070              : /// 4.5.23.1: "When retrieving Linked Entities, it is necessary to limit
    1071              : /// retrieval to avoid cascades of an excessive length, duplicates or loops."
    1072              : /// joinLevel bounds the DEPTH of the walk; this bounds its WIDTH — the total
    1073              : /// number of Linked Entity reads a single request may buy, so that a densely
    1074              : /// linked graph cannot turn one retrieval into an unbounded store scan.
    1075              : pub(crate) const MAX_JOIN_LOOKUPS: usize = 1_000;
    1076              : 
    1077              : /// State of one Linked Entity Retrieval walk (4.5.23.1): the entity ids
    1078              : /// already resolved — a loop or a duplicate is never walked a second time —
    1079              : /// and the remaining lookup budget. `complete` goes false as soon as the walk
    1080              : /// left something out, which the caller reports as an NGSILD-Warning.
    1081              : struct JoinWalk {
    1082              :     seen: std::collections::BTreeSet<String>,
    1083              :     budget: usize,
    1084              :     complete: bool,
    1085              :     /// The request's headers when distributed operations are on: a target
    1086              :     /// held by a registered source is then read through its registration.
    1087              :     remote: Option<axum::http::HeaderMap>,
    1088              :     /// simplified (keyValues): the Linking Entity's Relationships, read off
    1089              :     /// the Entity it was rendered from; taken by the walk's first level.
    1090              :     root_links: Option<std::collections::BTreeSet<String>>,
    1091              : }
    1092              : 
    1093              : impl JoinWalk {
    1094              :     /// The Linking Entity is already part of the response, so it counts as
    1095              :     /// resolved before the walk starts — and so does every id the client
    1096              :     /// passed in `containedBy`. `budget` is what is LEFT of the request's
    1097              :     /// allowance: a page walks one entity at a time and each walk hands the
    1098              :     /// remainder to the next, so the ceiling bounds the request rather than
    1099              :     /// each of its entities.
    1100          486 :     fn rooted(root: Option<&str>, contained_by: &[String], budget: usize) -> Self {
    1101          486 :         let mut seen: std::collections::BTreeSet<String> = contained_by.iter().cloned().collect();
    1102          486 :         if let Some(id) = root {
    1103          486 :             seen.insert(id.to_owned());
    1104          486 :         }
    1105          486 :         JoinWalk {
    1106          486 :             seen,
    1107          486 :             budget,
    1108          486 :             complete: true,
    1109          486 :             remote: None,
    1110          486 :             root_links: None,
    1111          486 :         }
    1112          486 :     }
    1113              : }
    1114              : 
    1115              : /// Linked Entity Retrieval, inline form (4.5.23.2): embed each relationship
    1116              : /// target under an "entity" member (normalized) or replace the object URI by
    1117              : /// the linked entity representation (simplified). Operates on COMPACTED docs;
    1118              : /// `stored` is the Entity `compacted` was rendered from, in its internal
    1119              : /// form, which still tells a Relationship from a Property holding a URI.
    1120              : /// Returns false when 4.5.23.1 truncated the walk (loop, duplicate, budget).
    1121           36 : pub async fn inline_join(
    1122           36 :     st: &AppState,
    1123           36 :     tenant: &TenantId,
    1124           36 :     ctx: &antares_jsonld::Context,
    1125           36 :     repr: &crate::repr::Repr,
    1126           36 :     compacted: &mut Value,
    1127           36 :     stored: &Value,
    1128           36 :     level: usize,
    1129           36 : ) -> bool {
    1130           36 :     inline_join_beyond(
    1131           36 :         st,
    1132           36 :         tenant,
    1133           36 :         ctx,
    1134           36 :         repr,
    1135           36 :         compacted,
    1136           36 :         stored,
    1137           36 :         level,
    1138           36 :         &[],
    1139           36 :         &mut { MAX_JOIN_LOOKUPS },
    1140           36 :         None,
    1141           36 :     )
    1142           36 :     .await
    1143           36 : }
    1144              : 
    1145              : /// Same, continuing an Entity Graph the client is already holding: the
    1146              : /// `containedBy` ids count as encountered (Table 6.4.3.2-1). `remote` (the
    1147              : /// request's headers, `None` with distributed operations off) lets a target
    1148              : /// held by a registered source join too.
    1149              : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
    1150          264 : pub async fn inline_join_beyond(
    1151          264 :     st: &AppState,
    1152          264 :     tenant: &TenantId,
    1153          264 :     ctx: &antares_jsonld::Context,
    1154          264 :     repr: &crate::repr::Repr,
    1155          264 :     compacted: &mut Value,
    1156          264 :     stored: &Value,
    1157          264 :     level: usize,
    1158          264 :     contained_by: &[String],
    1159          264 :     budget: &mut usize,
    1160          264 :     remote: Option<&axum::http::HeaderMap>,
    1161          264 : ) -> bool {
    1162          264 :     let mut walk = JoinWalk::rooted(
    1163          264 :         stored
    1164          264 :             .get("id")
    1165          264 :             .or_else(|| compacted.get("id"))
    1166          264 :             .and_then(Value::as_str),
    1167          264 :         contained_by,
    1168          264 :         *budget,
    1169              :     );
    1170          264 :     walk.remote = remote.cloned();
    1171              :     // simplified, the Linking Entity's Relationships are read off the form
    1172              :     // it was rendered from: a re-read by the rendered `id` found nothing
    1173              :     // when a projection had taken the `id` out (every URI-valued Property
    1174              :     // was then joined) or when the Entity came from a Context Source
    1175          264 :     walk.root_links = repr.key_values.then(|| linking_attrs(stored));
    1176          264 :     inline_join_walk(st, tenant, ctx, repr, compacted, level, &mut walk).await;
    1177          264 :     *budget = walk.budget;
    1178          264 :     walk.complete
    1179          264 : }
    1180              : 
    1181              : /// One place in a compacted document where Linked Entities join: the JSON
    1182              : /// pointer of a Relationship instance (or, simplified, of its URI string),
    1183              : /// the representation its targets take, how they are written, and their ids.
    1184              : struct Slot {
    1185              :     at: String,
    1186              :     child: crate::repr::Repr,
    1187              :     kind: SlotKind,
    1188              :     targets: Vec<String>,
    1189              :     /// The Relationship's objectType (4.5.23.1), as written.
    1190              :     object_type: Option<String>,
    1191              : }
    1192              : 
    1193              : enum SlotKind {
    1194              :     /// 4.5.23.2 normalized: the targets go under "entity".
    1195              :     Entity,
    1196              :     /// 4.5.22.2: a ListRelationship's targets go under "entityList".
    1197              :     EntityList,
    1198              :     /// simplified: the URI string itself is replaced by the target.
    1199              :     Replace,
    1200              : }
    1201              : 
    1202              : /// A key as one JSON pointer segment (RFC 6901: `~` and `/` escaped).
    1203         4168 : fn segment(key: &str) -> String {
    1204         4168 :     key.replace('~', "~0").replace('/', "~1")
    1205         4168 : }
    1206              : 
    1207              : /// The join slots of one compacted document, in document order. `links`
    1208              : /// = the expanded Attributes of the Entity that are Relationships, which a
    1209              : /// simplified document can no longer tell from a Property holding a URI
    1210              : /// string ([`linking_attrs`]); `None` for a normalized one, whose
    1211              : /// instances say it themselves.
    1212          366 : fn slots_of(
    1213          366 :     ctx: &antares_jsonld::Context,
    1214          366 :     repr: &crate::repr::Repr,
    1215          366 :     doc: &Value,
    1216          366 :     at: &str,
    1217          366 :     links: Option<&std::collections::BTreeSet<String>>,
    1218          366 : ) -> Vec<Slot> {
    1219          366 :     let mut out = Vec::new();
    1220          366 :     let Some(obj) = doc.as_object() else {
    1221            0 :         return out;
    1222              :     };
    1223          366 :     let metas = ["id", "type", "scope", "createdAt", "modifiedAt", "@context"];
    1224         4864 :     for (k, v) in obj {
    1225         4864 :         if metas.contains(&k.as_str()) {
    1226          684 :             continue;
    1227         4180 :         }
    1228         4180 :         let iri = ctx.expand_key(k);
    1229         4180 :         if links.is_some_and(|l| !l.contains(&iri)) {
    1230           20 :             continue;
    1231         4160 :         }
    1232         4160 :         let child = joined_repr(repr, k, &iri);
    1233         4160 :         slots_in(repr, &child, v, format!("{at}/{}", segment(k)), &mut out);
    1234              :     }
    1235          366 :     out
    1236          366 : }
    1237              : 
    1238         4168 : fn slots_in(
    1239         4168 :     repr: &crate::repr::Repr,
    1240         4168 :     child: &crate::repr::Repr,
    1241         4168 :     v: &Value,
    1242         4168 :     at: String,
    1243         4168 :     out: &mut Vec<Slot>,
    1244         4168 : ) {
    1245         4168 :     let slot = |kind, targets, object_type| Slot {
    1246         3994 :         at: at.clone(),
    1247         3994 :         child: child.clone(),
    1248         3994 :         kind,
    1249         3994 :         targets,
    1250         3994 :         object_type,
    1251         3994 :     };
    1252           68 :     match v {
    1253            0 :         Value::Array(items) => {
    1254            0 :             for (i, item) in items.iter().enumerate() {
    1255            0 :                 slots_in(repr, child, item, format!("{at}/{i}"), out);
    1256            0 :             }
    1257              :         }
    1258              :         // 4.5.4 simplified multi-attribute: one value per datasetId
    1259         4100 :         Value::Object(o) if repr.key_values => {
    1260            4 :             if let Some(Value::Object(ds)) = o.get("dataset") {
    1261            8 :                 for (id, one) in ds {
    1262            8 :                     slots_in(
    1263            8 :                         repr,
    1264            8 :                         child,
    1265            8 :                         one,
    1266            8 :                         format!("{at}/dataset/{}", segment(id)),
    1267            8 :                         out,
    1268            8 :                     );
    1269            8 :                 }
    1270            0 :             }
    1271              :         }
    1272         4096 :         Value::Object(inst) => {
    1273              :             // 4.5.22.2: the compacted objectList carries {"object": URI}
    1274              :             // entries (or bare URIs).
    1275         4096 :             if let Some(Value::Array(ol)) = inst.get("objectList") {
    1276            2 :                 let targets = ol
    1277            2 :                     .iter()
    1278            4 :                     .filter_map(|e| match e {
    1279            0 :                         Value::String(id) => Some(id.clone()),
    1280            4 :                         Value::Object(o) => {
    1281            4 :                             o.get("object").and_then(Value::as_str).map(str::to_owned)
    1282              :                         }
    1283            0 :                         _ => None,
    1284            4 :                     })
    1285            2 :                     .collect();
    1286            2 :                 out.push(slot(SlotKind::EntityList, targets, None));
    1287            2 :                 return;
    1288         4094 :             }
    1289         4094 :             let targets = match inst.get("object") {
    1290         3920 :                 Some(Value::String(id)) => vec![id.clone()],
    1291            4 :                 Some(Value::Array(a)) => a
    1292            4 :                     .iter()
    1293            4 :                     .filter_map(Value::as_str)
    1294            4 :                     .map(str::to_owned)
    1295            4 :                     .collect(),
    1296          170 :                 _ => return,
    1297              :             };
    1298         3924 :             let object_type = match inst.get("objectType") {
    1299            8 :                 Some(Value::String(t)) => Some(t.clone()),
    1300         3916 :                 _ => None,
    1301              :             };
    1302         3924 :             out.push(slot(SlotKind::Entity, targets, object_type));
    1303              :         }
    1304           68 :         Value::String(id) if repr.key_values => {
    1305           68 :             out.push(slot(SlotKind::Replace, vec![id.clone()], None));
    1306           68 :         }
    1307            0 :         _ => {}
    1308              :     }
    1309         4168 : }
    1310              : 
    1311              : /// TS 104 175 7.7.4 Depth of retrieval: the walk goes breadth-first, one
    1312              : /// depth at a time, so the first time an Entity is met is at its shallowest
    1313              : /// depth, and that is where its own Linked Entities expand; every later
    1314              : /// encounter embeds it without walking it again (4.5.23.1). Depth-first, an
    1315              : /// Entity met first deep spent its one expansion there and stayed bare at
    1316              : /// depth 1. The lookup budget and the `seen` set are the same ones.
    1317          264 : async fn inline_join_walk(
    1318          264 :     st: &AppState,
    1319          264 :     tenant: &TenantId,
    1320          264 :     ctx: &antares_jsonld::Context,
    1321          264 :     repr: &crate::repr::Repr,
    1322          264 :     compacted: &mut Value,
    1323          264 :     level: usize,
    1324          264 :     walk: &mut JoinWalk,
    1325          264 : ) {
    1326          264 :     let links = walk.root_links.take();
    1327          264 :     let mut frontier = vec![(String::new(), repr.clone(), level, links)];
    1328          620 :     while !frontier.is_empty() {
    1329          356 :         let mut next = Vec::new();
    1330          366 :         for (at, repr, level, links) in frontier {
    1331          366 :             let slots = match compacted.pointer(&at) {
    1332          366 :                 Some(doc) => slots_of(ctx, &repr, doc, &at, links.as_ref()),
    1333            0 :                 None => continue,
    1334              :             };
    1335         3994 :             for slot in slots {
    1336         3994 :                 let mut joined = Vec::new();
    1337         8392 :                 for id in &slot.targets {
    1338         7548 :                     if let Some(found) =
    1339         8392 :                         lookup_joined(st, tenant, ctx, &slot, id, level, walk).await
    1340         7548 :                     {
    1341         7548 :                         joined.push(found);
    1342         7548 :                     }
    1343              :                 }
    1344         3994 :                 if joined.is_empty() {
    1345          444 :                     continue;
    1346         3550 :                 }
    1347         3550 :                 let Some(place) = compacted.pointer_mut(&slot.at) else {
    1348            0 :                     continue;
    1349              :                 };
    1350         3550 :                 let single = joined.len() == 1;
    1351         3550 :                 let (docs, expand): (Vec<Value>, Vec<(bool, Option<_>)>) =
    1352         7548 :                     joined.into_iter().map(|(d, e, l)| (d, (e, l))).unzip();
    1353         3550 :                 let at = match slot.kind {
    1354              :                     SlotKind::Replace => {
    1355           68 :                         *place = docs.into_iter().next().unwrap_or(Value::Null);
    1356           68 :                         vec![slot.at.clone()]
    1357              :                     }
    1358              :                     SlotKind::EntityList => {
    1359            2 :                         let count = docs.len();
    1360            2 :                         if let Some(inst) = place.as_object_mut() {
    1361            2 :                             inst.insert("entityList".into(), Value::Array(docs));
    1362            2 :                         }
    1363            2 :                         (0..count)
    1364            4 :                             .map(|i| format!("{}/entityList/{i}", slot.at))
    1365            2 :                             .collect()
    1366              :                     }
    1367         3476 :                     SlotKind::Entity if single => {
    1368         3476 :                         if let Some(inst) = place.as_object_mut() {
    1369         3476 :                             inst.insert(
    1370         3476 :                                 "entity".into(),
    1371         3476 :                                 docs.into_iter().next().unwrap_or(Value::Null),
    1372         3476 :                             );
    1373         3476 :                         }
    1374         3476 :                         vec![format!("{}/entity", slot.at)]
    1375              :                     }
    1376              :                     SlotKind::Entity => {
    1377            4 :                         let count = docs.len();
    1378            4 :                         if let Some(inst) = place.as_object_mut() {
    1379            4 :                             inst.insert("entity".into(), Value::Array(docs));
    1380            4 :                         }
    1381            4 :                         (0..count)
    1382         4000 :                             .map(|i| format!("{}/entity/{i}", slot.at))
    1383            4 :                             .collect()
    1384              :                     }
    1385              :                 };
    1386         7548 :                 for (child_at, (expand, links)) in at.into_iter().zip(expand) {
    1387         7548 :                     if expand {
    1388          102 :                         next.push((child_at, slot.child.clone(), level.saturating_sub(1), links));
    1389         7446 :                     }
    1390              :                 }
    1391              :             }
    1392              :         }
    1393          356 :         frontier = next;
    1394              :     }
    1395          264 : }
    1396              : 
    1397              : /// The expanded Attributes of an Entity (as stored) that are Relationships
    1398              : /// or ListRelationships: an instance with an `object` or an `objectList`.
    1399          112 : fn linking_attrs(doc: &Value) -> std::collections::BTreeSet<String> {
    1400          112 :     doc.as_object()
    1401          112 :         .into_iter()
    1402          112 :         .flatten()
    1403          544 :         .filter(|(k, v)| {
    1404          544 :             !is_meta(k)
    1405           96 :                 && v.as_array().is_some_and(|insts| {
    1406           96 :                     insts
    1407           96 :                         .iter()
    1408           96 :                         .any(|i| i.get("object").is_some() || i.get("objectList").is_some())
    1409           96 :                 })
    1410          544 :         })
    1411          112 :         .map(|(k, _)| k.clone())
    1412          112 :         .collect()
    1413          112 : }
    1414              : 
    1415              : /// One Linked Entity read under the walk's budget and the narrowing: the
    1416              : /// compacted target, whether its own links are expanded (the first time
    1417              : /// it is met, while depth remains) and, simplified, its Relationships
    1418              : /// ([`slots_of`]). A target outside the narrowing reads as absent, like a
    1419              : /// dangling link.
    1420         8392 : async fn lookup_joined(
    1421         8392 :     st: &AppState,
    1422         8392 :     tenant: &TenantId,
    1423         8392 :     ctx: &antares_jsonld::Context,
    1424         8392 :     slot: &Slot,
    1425         8392 :     id: &str,
    1426         8392 :     level: usize,
    1427         8392 :     walk: &mut JoinWalk,
    1428         8392 : ) -> Option<(Value, bool, Option<std::collections::BTreeSet<String>>)> {
    1429         8392 :     if walk.budget == 0 {
    1430          800 :         walk.complete = false;
    1431          800 :         return None;
    1432         7592 :     }
    1433         7592 :     walk.budget = walk.budget.saturating_sub(1);
    1434         7592 :     let child = &slot.child;
    1435         7592 :     let local = st.store.get(tenant, Kind::Entity, id).await.ok().flatten();
    1436         7592 :     let target = match local {
    1437         7574 :         Some(t) => t,
    1438           18 :         None => remote_target(st, tenant, ctx, slot, id, walk).await?,
    1439              :     };
    1440         7576 :     if !policy_admits(st, tenant, ctx, child.policy_reach.as_deref(), &target).await {
    1441           28 :         return None;
    1442         7548 :     }
    1443         7548 :     let shaped = apply(&target, child);
    1444         7548 :     let c = compact_for(child, &shaped, ctx);
    1445         7548 :     let links = child.key_values.then(|| linking_attrs(&target));
    1446         7548 :     let mut expand = false;
    1447         7548 :     if level > 1 {
    1448          136 :         if walk.seen.insert(id.to_owned()) {
    1449          102 :             expand = true;
    1450          102 :         } else {
    1451           34 :             // 4.5.23.1: an already-resolved target is a loop or a duplicate —
    1452           34 :             // it is still embedded, but its own links are not walked again.
    1453           34 :             walk.complete = false;
    1454           34 :         }
    1455         7412 :     }
    1456         7548 :     Some((c, expand, links))
    1457         8392 : }
    1458              : 
    1459              : /// 4.5.23.1 (TS 104 175 7.7.1): "Only Relationships targeting a locally
    1460              : /// stored Entity or Relationships annotated with an objectType whose object
    1461              : /// is an Internal Linked Entity are considered to be retrievable in this
    1462              : /// manner." A target not stored here joins only when the Relationship names
    1463              : /// its objectType and a registration holds that id under that type; it is
    1464              : /// then read as a distributed Retrieve Entity.
    1465           18 : async fn remote_target(
    1466           18 :     st: &AppState,
    1467           18 :     tenant: &TenantId,
    1468           18 :     ctx: &antares_jsonld::Context,
    1469           18 :     slot: &Slot,
    1470           18 :     id: &str,
    1471           18 :     walk: &JoinWalk,
    1472           18 : ) -> Option<Value> {
    1473           18 :     let (headers, object_type) = (walk.remote.as_ref()?, slot.object_type.as_ref()?);
    1474            4 :     let spec = crate::registry::CsrSpec {
    1475            4 :         ids: Some(vec![id.to_owned()]),
    1476            4 :         types: Some(vec![ctx.expand_key(object_type)]),
    1477            4 :         ..Default::default()
    1478            4 :     };
    1479            4 :     let regs = crate::federation::matching_regs(st, tenant, &spec, ctx, headers)
    1480            4 :         .await
    1481            4 :         .ok()?;
    1482            4 :     if regs.is_empty() {
    1483            2 :         return None;
    1484            2 :     }
    1485            2 :     let mut warnings = Vec::new();
    1486            2 :     let fed = crate::federation::fed_retrieve(
    1487            2 :         st,
    1488            2 :         tenant,
    1489            2 :         headers,
    1490            2 :         ctx,
    1491            2 :         id,
    1492            2 :         None,
    1493            2 :         None,
    1494            2 :         &mut warnings,
    1495            2 :         &mut None,
    1496            2 :     )
    1497            2 :     .await
    1498            2 :     .ok()?;
    1499            2 :     let mut base = fed
    1500            2 :         .iter()
    1501            2 :         .find(|(aux, _)| !aux)
    1502            2 :         .or_else(|| fed.first())
    1503            2 :         .map(|(_, d)| d.clone())?;
    1504            4 :     for aux_pass in [false, true] {
    1505            4 :         for (aux, d) in &fed {
    1506            4 :             if *aux == aux_pass {
    1507            2 :                 crate::federation::merge_docs(&mut base, d, *aux);
    1508            2 :             }
    1509              :         }
    1510              :     }
    1511            2 :     Some(base)
    1512           18 : }
    1513              : 
    1514              : /// Linked Entity Retrieval, flattened form (4.5.23.3): collect targets with
    1515              : /// the child representation that applies to each. The Linking Entity is
    1516              : /// already in the flattened array, so 4.5.23.1 ("avoid ... duplicates or
    1517              : /// loops") keeps it out of `out` even when a Relationship points back at it.
    1518              : /// Returns false when the walk was truncated by the lookup budget.
    1519           24 : pub async fn collect_flat(
    1520           24 :     st: &AppState,
    1521           24 :     tenant: &TenantId,
    1522           24 :     ctx: &Context,
    1523           24 :     repr: &crate::repr::Repr,
    1524           24 :     internal_doc: &Value,
    1525           24 :     level: usize,
    1526           24 :     out: &mut std::collections::BTreeMap<String, (Value, crate::repr::Repr)>,
    1527           24 : ) -> bool {
    1528           24 :     collect_flat_beyond(st, tenant, ctx, repr, internal_doc, level, out, &[], &mut {
    1529           24 :         MAX_JOIN_LOOKUPS
    1530           24 :     })
    1531           24 :     .await
    1532           24 : }
    1533              : 
    1534              : /// Same, continuing an Entity Graph the client is already holding: the
    1535              : /// `containedBy` ids count as encountered (Table 6.4.3.2-1).
    1536              : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
    1537          222 : pub async fn collect_flat_beyond(
    1538          222 :     st: &AppState,
    1539          222 :     tenant: &TenantId,
    1540          222 :     ctx: &Context,
    1541          222 :     repr: &crate::repr::Repr,
    1542          222 :     internal_doc: &Value,
    1543          222 :     level: usize,
    1544          222 :     out: &mut std::collections::BTreeMap<String, (Value, crate::repr::Repr)>,
    1545          222 :     contained_by: &[String],
    1546          222 :     budget: &mut usize,
    1547          222 : ) -> bool {
    1548          222 :     let mut walk = JoinWalk::rooted(
    1549          222 :         internal_doc.get("id").and_then(Value::as_str),
    1550          222 :         contained_by,
    1551          222 :         *budget,
    1552              :     );
    1553          222 :     walk.seen.extend(out.keys().cloned());
    1554          222 :     collect_flat_walk(st, tenant, ctx, repr, internal_doc, level, out, &mut walk).await;
    1555          222 :     *budget = walk.budget;
    1556          222 :     walk.complete
    1557          222 : }
    1558              : 
    1559              : /// Breadth-first like [`inline_join_walk`] (TS 104 175 7.7.4): every Entity
    1560              : /// of one depth is collected before the next depth is read, so an Entity is
    1561              : /// walked from its shallowest occurrence and its Linked Entities get the
    1562              : /// depth that occurrence leaves.
    1563              : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
    1564          222 : async fn collect_flat_walk(
    1565          222 :     st: &AppState,
    1566          222 :     tenant: &TenantId,
    1567          222 :     ctx: &Context,
    1568          222 :     repr: &crate::repr::Repr,
    1569          222 :     internal_doc: &Value,
    1570          222 :     level: usize,
    1571          222 :     out: &mut std::collections::BTreeMap<String, (Value, crate::repr::Repr)>,
    1572          222 :     walk: &mut JoinWalk,
    1573          222 : ) {
    1574          222 :     let mut frontier = vec![(internal_doc.clone(), repr.clone(), level)];
    1575          534 :     while !frontier.is_empty() {
    1576          314 :         let mut next = Vec::new();
    1577          316 :         for (doc, repr, level) in &frontier {
    1578          316 :             let Some(obj) = doc.as_object() else {
    1579            0 :                 continue;
    1580              :             };
    1581          316 :             let shown = shown(repr.policy_types.as_deref(), doc)
    1582          316 :                 .unwrap_or_else(crate::policy::Shown::nothing);
    1583         3846 :             for (k, v) in obj {
    1584         3846 :                 if is_meta(k) {
    1585         1270 :                     continue;
    1586         2576 :                 }
    1587              :                 // only traverse relationships that survive THIS doc's projection
    1588         2576 :                 if let Some(pick) = &repr.pick {
    1589          364 :                     if !pick.iter().any(|n| n.iri == *k || n.raw == *k) {
    1590           84 :                         continue;
    1591           84 :                     }
    1592         2408 :                 }
    1593         2492 :                 if let Some(omit) = &repr.omit {
    1594          160 :                     if omit
    1595          160 :                         .iter()
    1596          232 :                         .any(|n| (n.iri == *k || n.raw == *k) && n.children.is_none())
    1597              :                     {
    1598           40 :                         continue;
    1599          120 :                     }
    1600         2332 :                 }
    1601              :                 // a Relationship the subject may not see is not a road either
    1602         2452 :                 if !policy_projected(repr.policy_pick.as_deref(), repr.policy_omit.as_deref(), k)
    1603         2450 :                     || !shown.allows(k)
    1604              :                 {
    1605            4 :                     continue;
    1606         2448 :                 }
    1607         2448 :                 let Some(instances) = v.as_array() else {
    1608            0 :                     continue;
    1609              :                 };
    1610         2448 :                 let child = joined_repr(repr, k, k);
    1611         2448 :                 for inst in instances {
    1612              :                     // Relationship objects plus ListRelationship objectList targets
    1613              :                     // (internal form stores bare URIs) — 4.5.23.3 appends both kinds
    1614              :                     // of Linked Entities to the flattened array.
    1615         2448 :                     let targets: Vec<&str> = match (inst.get("object"), inst.get("objectList")) {
    1616         2312 :                         (Some(Value::String(id)), _) => vec![id.as_str()],
    1617            0 :                         (Some(Value::Array(a)), _) => a.iter().filter_map(Value::as_str).collect(),
    1618            2 :                         (None, Some(Value::Array(a))) => {
    1619            2 :                             a.iter().filter_map(Value::as_str).collect()
    1620              :                         }
    1621          134 :                         _ => continue,
    1622              :                     };
    1623         2316 :                     for id in targets {
    1624         2316 :                         if walk.seen.contains(id) {
    1625           12 :                             continue;
    1626         2304 :                         }
    1627         2304 :                         if walk.budget == 0 {
    1628            2 :                             walk.complete = false;
    1629            2 :                             return;
    1630         2302 :                         }
    1631         2302 :                         walk.budget = walk.budget.saturating_sub(1);
    1632         2298 :                         let Some(target) =
    1633         2302 :                             st.store.get(tenant, Kind::Entity, id).await.ok().flatten()
    1634              :                         else {
    1635            4 :                             continue;
    1636              :                         };
    1637         2298 :                         if !policy_admits(st, tenant, ctx, child.policy_reach.as_deref(), &target)
    1638         2298 :                             .await
    1639              :                         {
    1640           26 :                             continue;
    1641         2272 :                         }
    1642         2272 :                         walk.seen.insert(id.to_owned());
    1643         2272 :                         out.insert(id.to_owned(), (target.clone(), child.clone()));
    1644         2272 :                         if *level > 1 {
    1645           94 :                             next.push((target, child.clone(), level.saturating_sub(1)));
    1646         2178 :                         }
    1647              :                     }
    1648              :                 }
    1649              :             }
    1650              :         }
    1651          312 :         frontier = next;
    1652              :     }
    1653          222 : }
    1654              : 
    1655              : /// 4.5.16.2 GeoJSON Feature, members per Table 5.2.29-1 (5.2.29 Feature):
    1656              : /// id = entity id (URI), fixed type "Feature", geometry = the selected
    1657              : /// GeoProperty's value or null (4.5.16.1: geometryProperty parameter,
    1658              : /// default "location"), properties = the 5.2.31 FeatureProperties (entity
    1659              : /// type + attributes). The @context member is added by respond() (6.3.6).
    1660           98 : pub fn to_geojson_feature(entity: Value, geometry_property: Option<&String>) -> Value {
    1661           98 :     let geometry = geometry_of(&entity, geometry_property.map(String::as_str));
    1662           98 :     let id = entity.get("id").cloned().unwrap_or(Value::Null);
    1663           98 :     let mut props = entity.as_object().cloned().unwrap_or_default();
    1664           98 :     props.remove("id");
    1665           98 :     let mut feature = Map::new();
    1666           98 :     feature.insert("id".into(), id);
    1667           98 :     feature.insert("type".into(), Value::String("Feature".into()));
    1668           98 :     feature.insert("geometry".into(), geometry);
    1669           98 :     feature.insert("properties".into(), Value::Object(props));
    1670           98 :     Value::Object(feature)
    1671           98 : }
    1672              : 
    1673              : /// 4.5.16.3 GeoJSON FeatureCollection, members per Table 5.2.30-1 (5.2.30
    1674              : /// FeatureCollection): fixed type "FeatureCollection" + features array of
    1675              : /// 4.5.16.2 Feature objects — empty array when no matches, no per-Feature
    1676              : /// @context; the top-level @context is added by respond() (6.3.6).
    1677           40 : pub fn to_geojson_collection(entities: Vec<Value>, geometry_property: Option<&String>) -> Value {
    1678           40 :     let features: Vec<Value> = entities
    1679           40 :         .into_iter()
    1680           40 :         .map(|e| to_geojson_feature(e, geometry_property))
    1681           40 :         .collect();
    1682           40 :     serde_json::json!({"type": "FeatureCollection", "features": features})
    1683           40 : }
    1684              : 
    1685              : /// The one geometry rule of every GeoJSON this broker writes (NGSI-LD geo+json, and
    1686              : /// the `geojson` surface): the GeoProperty `property` (default `location`), its default
    1687              : /// instance, `null` unless it holds a valid geometry, rings wound as RFC 7946 3.1.6 asks. A
    1688              : /// geometry across the antimeridian is served as stored, not cut (3.1.9 is a SHOULD).
    1689              : #[must_use]
    1690          106 : pub fn geometry_of(entity: &Value, property: Option<&str>) -> Value {
    1691          106 :     entity
    1692          106 :         .get(property.unwrap_or("location"))
    1693          106 :         .map(geo_value_of)
    1694          106 :         .map_or(Value::Null, rewound)
    1695          106 : }
    1696              : 
    1697              : /// RFC 7946 3.1.6: a Polygon's exterior ring counterclockwise, its holes clockwise, in every
    1698              : /// Polygon of a MultiPolygon and a GeometryCollection; other geometries, and a ring across the
    1699              : /// antimeridian, as they are.
    1700              : #[must_use]
    1701           90 : pub fn rewound(mut geometry: Value) -> Value {
    1702              :     /// Twice the signed planar area, `None` for a ring with an edge across the antimeridian,
    1703              :     /// whose plane winding is not its winding on the globe.
    1704           20 :     fn area(ring: &[Value]) -> Option<f64> {
    1705          136 :         let point = |p: &Value| {
    1706          136 :             (
    1707          136 :                 p.get(0).and_then(Value::as_f64),
    1708          136 :                 p.get(1).and_then(Value::as_f64),
    1709          136 :             )
    1710          136 :         };
    1711           20 :         let mut sum = 0.0;
    1712           68 :         for w in ring.windows(2) {
    1713           68 :             if let (Some((Some(x0), Some(y0))), Some((Some(x1), Some(y1)))) =
    1714           68 :                 (w.first().map(point), w.get(1).map(point))
    1715              :             {
    1716           68 :                 if (x1 - x0).abs() > 180.0 {
    1717            4 :                     return None;
    1718           64 :                 }
    1719           64 :                 sum += x0 * y1 - x1 * y0;
    1720            0 :             }
    1721              :         }
    1722           16 :         Some(sum)
    1723           20 :     }
    1724           16 :     fn polygon(rings: &mut Value) {
    1725           20 :         for (i, ring) in rings.as_array_mut().into_iter().flatten().enumerate() {
    1726           20 :             if let Some(points) = ring.as_array_mut() {
    1727           20 :                 if area(points).is_some_and(|a| (a > 0.0) == (i > 0)) {
    1728           12 :                     points.reverse();
    1729           12 :                 }
    1730            0 :             }
    1731              :         }
    1732           16 :     }
    1733           90 :     match geometry.get("type").and_then(Value::as_str) {
    1734           82 :         Some("Polygon") => {
    1735           12 :             if let Some(rings) = geometry.get_mut("coordinates") {
    1736           12 :                 polygon(rings);
    1737           12 :             }
    1738              :         }
    1739           70 :         Some("MultiPolygon") => {
    1740            4 :             let all = geometry
    1741            4 :                 .get_mut("coordinates")
    1742            4 :                 .and_then(Value::as_array_mut);
    1743            4 :             all.into_iter().flatten().for_each(polygon);
    1744            4 :         }
    1745           66 :         Some("GeometryCollection") => {
    1746            0 :             if let Some(Value::Array(parts)) = geometry.get_mut("geometries") {
    1747            0 :                 for part in parts.iter_mut() {
    1748            0 :                     *part = rewound(part.take());
    1749            0 :                 }
    1750            0 :             }
    1751              :         }
    1752           74 :         _ => {}
    1753              :     }
    1754           90 :     geometry
    1755           90 : }
    1756              : 
    1757              : /// 4.5.16.1: with multiple instances the default one (no datasetId) is
    1758              : /// selected unless a datasetId filter already narrowed the set to one; a
    1759              : /// missing GeoProperty or a value that "does not hold a valid GeoJSON
    1760              : /// geometry object" yields null — "which is syntactically valid GeoJSON".
    1761           78 : fn geo_value_of(attr: &Value) -> Value {
    1762           78 :     let inst = match attr {
    1763           32 :         Value::Array(a) => match a.iter().find(|i| i.get("datasetId").is_none()) {
    1764           16 :             Some(default) => default.clone(),
    1765            0 :             None => match a.as_slice() {
    1766            0 :                 [only] => only.clone(),
    1767            0 :                 _ => return Value::Null,
    1768              :             },
    1769              :         },
    1770           62 :         other => other.clone(),
    1771              :     };
    1772           78 :     let v = inst.get("value").cloned().unwrap_or(inst);
    1773              :     // 4.5.17.1: in the simplified representation a multi-instance GeoProperty
    1774              :     // is the {"dataset": {…}} map — the default ("@none") instance is the
    1775              :     // 4.5.16.1 selection.
    1776           78 :     let v = match v.as_object() {
    1777           70 :         Some(o) if o.len() == 1 && o.contains_key("dataset") => {
    1778            8 :             o["dataset"].get("@none").cloned().unwrap_or(Value::Null)
    1779              :         }
    1780           70 :         _ => v,
    1781              :     };
    1782           78 :     match antares_jsonld::expand::validate_geojson("geometry", &v) {
    1783           70 :         Ok(()) => v,
    1784            8 :         Err(_) => Value::Null,
    1785              :     }
    1786           78 : }
    1787              : 
    1788              : #[cfg(test)]
    1789              : mod tests {
    1790              :     use super::*;
    1791              :     use serde_json::json;
    1792              : 
    1793              :     /// 4.5.4 Examples 1–16: the simplified value of one instance per
    1794              :     /// attribute type — bare for Property/GeoProperty/Relationship/List*,
    1795              :     /// wrapped single-key objects for Language/Json/Vocab subtypes.
    1796              :     #[test]
    1797            4 :     fn simplified_values_per_attribute_type() {
    1798           28 :         let v = |j: Value| simplified_value(j.as_object().unwrap());
    1799            4 :         assert_eq!(v(json!({"type": "Property", "value": 5})), json!(5));
    1800            4 :         assert_eq!(
    1801            4 :             v(json!({"type": "Relationship", "object": "urn:a"})),
    1802            4 :             json!("urn:a")
    1803              :         );
    1804            4 :         assert_eq!(
    1805            4 :             v(json!({"type": "ListProperty", "valueList": [1, 2]})),
    1806            4 :             json!([1, 2])
    1807              :         );
    1808            4 :         assert_eq!(
    1809            4 :             v(json!({"type": "ListRelationship", "objectList": ["urn:a"]})),
    1810            4 :             json!(["urn:a"])
    1811              :         );
    1812            4 :         assert_eq!(
    1813            4 :             v(json!({"type": "LanguageProperty", "languageMap": {"en": "hi"}})),
    1814            4 :             json!({"languageMap": {"en": "hi"}})
    1815              :         );
    1816            4 :         assert_eq!(
    1817            4 :             v(json!({"type": "JsonProperty", "json": {"k": 1}})),
    1818            4 :             json!({"json": {"k": 1}})
    1819              :         );
    1820            4 :         assert_eq!(
    1821            4 :             v(json!({"type": "VocabProperty", "vocab": "V"})),
    1822            4 :             json!({"vocab": "V"})
    1823              :         );
    1824            4 :     }
    1825              : }
    1826              : 
    1827              : #[cfg(test)]
    1828              : mod clause_4_15 {
    1829              :     use super::*;
    1830              :     use serde_json::json;
    1831              : 
    1832           36 :     fn lm(pairs: &[(&str, &str)]) -> Map<String, Value> {
    1833           36 :         pairs
    1834           36 :             .iter()
    1835           60 :             .map(|(k, v)| ((*k).to_owned(), json!(*v)))
    1836           36 :             .collect()
    1837           36 :     }
    1838              : 
    1839              :     /// 4.15 EXAMPLE 4: quality value ranking — entries are ordered by their
    1840              :     /// q weight (default 1), not by list position.
    1841              :     #[test]
    1842            4 :     fn q_values_rank_the_priority_list() {
    1843            4 :         let m = lm(&[("en", "red"), ("fr", "rouge")]);
    1844            4 :         let (l, v) = select_lang(&m, "en;q=0.2,fr;q=0.9").expect("pick");
    1845            4 :         assert_eq!((l.as_str(), &v), ("fr", &json!("rouge")), "fr outranks en");
    1846              :         // default q=1: plain fr-CH beats fr;q=0.9
    1847            4 :         let m = lm(&[("fr-CH", "rouge suisse"), ("fr", "rouge")]);
    1848            4 :         let (l, _) = select_lang(&m, "fr-CH,fr;q=0.9").expect("pick");
    1849            4 :         assert_eq!(l, "fr-CH");
    1850              :         // wildcard with low q still yields a fallback when nothing else fits
    1851            4 :         let m = lm(&[("de", "rot")]);
    1852            4 :         let (l, _) = select_lang(&m, "fr;q=0.9,*;q=0.5").expect("pick");
    1853            4 :         assert_eq!(l, "de");
    1854            4 :     }
    1855              : 
    1856              :     /// RFC 5646 (via 4.15): language tags compare case-insensitively.
    1857              :     #[test]
    1858            4 :     fn langtags_compare_case_insensitively() {
    1859            4 :         let m = lm(&[("en-US", "color")]);
    1860            4 :         let (l, v) = select_lang(&m, "en-us").expect("pick");
    1861            4 :         assert_eq!((l.as_str(), &v), ("en-US", &json!("color")));
    1862            4 :         let m = lm(&[("fr", "rouge"), ("de", "rot")]);
    1863            4 :         let (l, _) = select_lang(&m, "FR").expect("pick");
    1864            4 :         assert_eq!(l, "fr");
    1865            4 :     }
    1866              : 
    1867              :     /// RFC 5646 lookup (via 4.15): a shorter range matches a longer tag
    1868              :     /// (lang=fr picks fr-CH) and a longer range truncates onto a shorter tag
    1869              :     /// (lang=fr-CH picks fr) — the `lang` subproperty reports the ACTUAL tag.
    1870              :     #[test]
    1871            4 :     fn prefix_and_truncation_fallbacks() {
    1872              :         // decoy `de` sorts first — a naive any-fallback would pick it
    1873            4 :         let m = lm(&[("de", "rot"), ("fr-CH", "rouge suisse")]);
    1874            4 :         let (l, _) = select_lang(&m, "fr").expect("pick");
    1875            4 :         assert_eq!(l, "fr-CH", "range fr matches tag fr-CH, not the decoy");
    1876            4 :         let m = lm(&[("de", "rot"), ("fr", "rouge")]);
    1877            4 :         let (l, _) = select_lang(&m, "fr-CH").expect("pick");
    1878            4 :         assert_eq!(l, "fr", "range fr-CH truncates onto tag fr, not the decoy");
    1879              :         // an exact match still beats a prefix match at the same rank
    1880            4 :         let m = lm(&[("fr-CH", "suisse"), ("fr", "rouge")]);
    1881            4 :         let (l, _) = select_lang(&m, "fr").expect("pick");
    1882            4 :         assert_eq!(l, "fr");
    1883            4 :     }
    1884              : 
    1885              :     /// 4.15: "If the Context Broker cannot serve any matching language, it
    1886              :     /// shall default to any supported language" — and the augmented `lang`
    1887              :     /// subproperty carries the actually returned one.
    1888              :     #[test]
    1889            4 :     fn no_match_falls_back_to_any_supported_language() {
    1890            4 :         let m = lm(&[("de", "rot")]);
    1891            4 :         let (l, v) = select_lang(&m, "pt").expect("fallback");
    1892            4 :         assert_eq!((l.as_str(), &v), ("de", &json!("rot")));
    1893              :         // the transform augments with the actual language and converts the
    1894              :         // LanguageProperty to a Property — languageMap must NOT survive
    1895            4 :         let inst = json!({"type": "LanguageProperty",
    1896            4 :             "languageMap": {"en": "red", "fr": "rouge"}});
    1897            4 :         let r = Repr {
    1898            4 :             lang: Some("fr".into()),
    1899            4 :             ..Repr::default()
    1900            4 :         };
    1901            4 :         let out = transform_instance(&inst, &r);
    1902            4 :         assert_eq!(out["type"], "Property");
    1903            4 :         assert_eq!(out["value"], "rouge");
    1904            4 :         assert_eq!(out["lang"], "fr");
    1905            4 :         assert!(
    1906            4 :             out.get("languageMap").is_none(),
    1907              :             "languageMap must not remain after conversion"
    1908              :         );
    1909            4 :     }
    1910              : }
    1911              : 
    1912              : #[cfg(test)]
    1913              : mod clause_4_21 {
    1914              :     use super::*;
    1915              :     use antares_jsonld::Loader;
    1916              : 
    1917              :     /// 4.21: "either a comma or a pipe character can be used as alternative
    1918              :     /// representations of the or operator" — including inside a nested
    1919              :     /// LinkedEntityTerm (EXAMPLE 3).
    1920              :     #[test]
    1921            4 :     fn pipe_and_comma_are_both_or_operators() {
    1922            4 :         let ctx = Loader::new().core();
    1923            4 :         let comma = parse_projection("temperature,humidity", &ctx).expect("comma");
    1924            4 :         let pipe = parse_projection("temperature|humidity", &ctx).expect("pipe");
    1925            4 :         assert_eq!(comma.len(), 2);
    1926            4 :         assert_eq!(pipe.len(), 2);
    1927            4 :         assert_eq!(comma[0].raw, pipe[0].raw);
    1928            4 :         assert_eq!(comma[1].raw, pipe[1].raw);
    1929            4 :         let nested = parse_projection("observation{temperature|humidity}", &ctx).expect("nested");
    1930            4 :         assert_eq!(nested.len(), 1);
    1931            4 :         let kids = nested[0].children.as_ref().expect("children");
    1932            4 :         assert_eq!(kids.len(), 2, "pipe splits inside the braces too");
    1933            4 :     }
    1934              : 
    1935              :     /// 4.21 grammar: an empty member or unbalanced braces are violations.
    1936              :     #[test]
    1937            4 :     fn grammar_rejections_hold_for_both_spellings() {
    1938            4 :         let ctx = Loader::new().core();
    1939           20 :         for bad in ["a||b", "a|,b", "|a", "a|", "a{b|}"] {
    1940           20 :             assert!(
    1941           20 :                 parse_projection(bad, &ctx).is_err(),
    1942              :                 "{bad:?} must be rejected"
    1943              :             );
    1944              :         }
    1945            4 :     }
    1946              : 
    1947              :     /// Each `{…}` level of a projection is one Linked Entity hop (5.7.1.4),
    1948              :     /// so a selection deeper than the joinLevel ceiling can never be
    1949              :     /// satisfied. The depth is bounded BEFORE the recursive descent parses
    1950              :     /// it: pick= arrives as a plain string inside a query body, where the
    1951              :     /// JSON nesting wall never sees its braces.
    1952              :     #[test]
    1953            4 :     fn projection_nesting_is_bounded_before_the_parser_recurses() {
    1954            4 :         let ctx = Loader::new().core();
    1955            4 :         let cap = crate::bounds::MAX_JOIN_LEVEL;
    1956           12 :         let nested = |n: usize| "a{".repeat(n) + "b" + &"}".repeat(n);
    1957              :         // the body path can carry a projection far past any stack budget
    1958            4 :         assert!(
    1959            4 :             parse_projection(&nested(200_000), &ctx).is_err(),
    1960              :             "a body-sized projection must be rejected, not recursed into"
    1961              :         );
    1962            4 :         assert!(parse_projection(&nested(cap), &ctx).is_ok(), "at the cap");
    1963            4 :         assert!(
    1964            4 :             parse_projection(&nested(cap + 1), &ctx).is_err(),
    1965              :             "one level over the cap must be rejected"
    1966              :         );
    1967            4 :     }
    1968              : 
    1969              :     /// 5.7.1.4: proj_depth reports the hops a projection implies, so the
    1970              :     /// joinLevel comparison upstream is made against the deepest branch.
    1971              :     #[test]
    1972            4 :     fn projection_depth_counts_the_deepest_branch() {
    1973            4 :         let ctx = Loader::new().core();
    1974           12 :         let d = |s: &str| proj_depth(&parse_projection(s, &ctx).expect("parse"));
    1975            4 :         assert_eq!(d("a,b"), 0, "a flat selection implies no hop");
    1976            4 :         assert_eq!(d("a{b}"), 1);
    1977            4 :         assert_eq!(d("a,b{c{d}}"), 2, "the deepest branch wins");
    1978            4 :     }
    1979              : }
    1980              : 
    1981              : #[cfg(test)]
    1982              : mod clause_6_3_7 {
    1983              :     use super::*;
    1984              :     use antares_jsonld::Loader;
    1985              :     use serde_json::json;
    1986              : 
    1987          104 :     fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
    1988          104 :         pairs
    1989          104 :             .iter()
    1990          132 :             .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
    1991          104 :             .collect()
    1992          104 :     }
    1993              : 
    1994              :     /// 6.3.7: an unknown options or format value is not silently ignored.
    1995              :     #[test]
    1996            4 :     fn unknown_options_and_format_values_are_rejected() {
    1997            4 :         let ctx = Loader::new().core();
    1998           20 :         for p in [
    1999            4 :             params(&[("options", "sysattrs")]),
    2000            4 :             params(&[("options", "keyValues,bogus")]),
    2001            4 :             params(&[("options", "")]),
    2002            4 :             params(&[("format", "verbose")]),
    2003            4 :             params(&[("format", "KeyValues")]),
    2004            4 :         ] {
    2005           20 :             let e = parse_repr(&p, &ctx).expect_err("must be rejected");
    2006           20 :             assert!(
    2007           20 :                 matches!(e, NgsiError::InvalidRequest(_)),
    2008              :                 "unsupported representation value is InvalidRequest, got {e:?}"
    2009              :             );
    2010              :         }
    2011            4 :     }
    2012              : 
    2013              :     /// 6.3.7: format wins over options when the two disagree, and keyValues
    2014              :     /// is the older spelling of simplified.
    2015              :     #[test]
    2016            4 :     fn format_wins_over_options_on_conflict() {
    2017            4 :         let ctx = Loader::new().core();
    2018            4 :         let r = parse_repr(
    2019            4 :             &params(&[("options", "concise"), ("format", "simplified")]),
    2020            4 :             &ctx,
    2021              :         )
    2022            4 :         .expect("parse");
    2023            4 :         assert!(r.key_values, "format=simplified wins");
    2024            4 :         assert!(!r.concise, "options=concise must NOT survive the conflict");
    2025            4 :         let r = parse_repr(
    2026            4 :             &params(&[("options", "keyValues"), ("format", "normalized")]),
    2027            4 :             &ctx,
    2028              :         )
    2029            4 :         .expect("parse");
    2030            4 :         assert!(!r.key_values && !r.concise, "normalized is neither");
    2031            4 :         let r = parse_repr(&params(&[("options", "sysAttrs,keyValues")]), &ctx).expect("parse");
    2032            4 :         assert!(r.sys_attrs && r.key_values);
    2033            4 :     }
    2034              : 
    2035              :     /// Table 6.4.3.2-1 datasetId: "Shall be valid URIs, "@none" for including
    2036              :     /// the default Attribute instances" (Coraine url_param_datasetid_validation).
    2037              :     #[test]
    2038            4 :     fn a_dataset_id_parameter_is_a_uri_or_none() {
    2039            4 :         let ctx = Loader::new().core();
    2040           12 :         for bad in ["14", "urn:ds:1,14", ""] {
    2041           12 :             let e = parse_repr(&params(&[("datasetId", bad)]), &ctx).expect_err(bad);
    2042           12 :             assert!(matches!(e, NgsiError::BadRequestData(_)), "{bad}: {e:?}");
    2043              :         }
    2044            4 :         assert!(parse_repr(&params(&[("datasetId", "urn:ds:1,@none")]), &ctx).is_ok());
    2045            4 :     }
    2046              : 
    2047              :     /// pick, omit and attrs: any pair is a 400 (see check_projection_exclusive),
    2048              :     /// each one alone is fine.
    2049              :     #[test]
    2050            4 :     fn pick_omit_and_attrs_cannot_be_combined() {
    2051            4 :         let ctx = Loader::new().core();
    2052           16 :         for p in [
    2053            4 :             params(&[("pick", "a"), ("omit", "b")]),
    2054            4 :             params(&[("pick", "a"), ("attrs", "b")]),
    2055            4 :             params(&[("omit", "a"), ("attrs", "b")]),
    2056            4 :             params(&[("pick", "a"), ("omit", "b"), ("attrs", "c")]),
    2057            4 :         ] {
    2058           16 :             let e = parse_repr(&p, &ctx).expect_err("must be rejected");
    2059           16 :             assert!(matches!(e, NgsiError::BadRequestData(_)), "got {e:?}");
    2060              :         }
    2061            4 :         assert!(parse_repr(&params(&[("pick", "a")]), &ctx).is_ok());
    2062            4 :         assert!(parse_repr(&params(&[("attrs", "a")]), &ctx).is_ok());
    2063            4 :     }
    2064              : 
    2065              :     /// attrs= selects ATTRIBUTES: an entity meta member or @context is not an
    2066              :     /// attribute name, and an empty member is a grammar violation.
    2067              :     #[test]
    2068            4 :     fn attrs_rejects_entity_members_and_empty_names() {
    2069            4 :         let ctx = Loader::new().core();
    2070           28 :         for bad in ["id", "type", "scope", "createdAt", "@context", "a,,b", ""] {
    2071           28 :             assert!(
    2072           28 :                 parse_repr(&params(&[("attrs", bad)]), &ctx).is_err(),
    2073              :                 "attrs={bad:?} must be rejected"
    2074              :             );
    2075              :         }
    2076            4 :         let r = parse_repr(&params(&[("attrs", "temperature, humidity")]), &ctx).expect("parse");
    2077            4 :         let list = r.attrs.expect("attrs");
    2078            4 :         assert_eq!(list.len(), 2);
    2079            8 :         assert!(list.iter().all(|a| a.contains("://")), "names are expanded");
    2080            4 :     }
    2081              : 
    2082           36 :     fn entity() -> Value {
    2083           36 :         json!({
    2084           36 :             "id": "urn:ngsi-ld:E:1",
    2085           36 :             "type": "T",
    2086           36 :             "createdAt": "2026-01-01T00:00:00Z",
    2087           36 :             "modifiedAt": "2026-01-02T00:00:00Z",
    2088           36 :             "https://example.org/temperature": [
    2089           36 :                 {"type": "Property", "value": 21,
    2090           36 :                  "createdAt": "2026-01-01T00:00:00Z",
    2091           36 :                  "https://example.org/accuracy": [{"type": "Property", "value": 0.5}]},
    2092           36 :                 {"type": "Property", "value": 9, "datasetId": "urn:ds:2"}
    2093              :             ]
    2094              :         })
    2095           36 :     }
    2096              : 
    2097              :     /// 6.3.11 Table 6.3.11-1: createdAt/modifiedAt are system attributes —
    2098              :     /// they must be absent from the default representation, on the entity AND
    2099              :     /// on every attribute instance, and present with options=sysAttrs.
    2100              :     #[test]
    2101            4 :     fn system_attributes_stay_hidden_without_sysattrs() {
    2102            4 :         let plain = apply(&entity(), &Repr::default());
    2103            4 :         assert!(plain.get("createdAt").is_none(), "entity createdAt leaked");
    2104            4 :         assert!(
    2105            4 :             plain.get("modifiedAt").is_none(),
    2106              :             "entity modifiedAt leaked"
    2107              :         );
    2108            4 :         assert!(
    2109            4 :             plain["https://example.org/temperature"][0]
    2110            4 :                 .get("createdAt")
    2111            4 :                 .is_none(),
    2112              :             "instance createdAt leaked"
    2113              :         );
    2114            4 :         assert_eq!(plain["id"], "urn:ngsi-ld:E:1");
    2115            4 :         let sys = apply(
    2116            4 :             &entity(),
    2117            4 :             &Repr {
    2118            4 :                 sys_attrs: true,
    2119            4 :                 ..Repr::default()
    2120            4 :             },
    2121              :         );
    2122            4 :         assert_eq!(sys["createdAt"], "2026-01-01T00:00:00Z");
    2123            4 :         assert_eq!(
    2124            4 :             sys["https://example.org/temperature"][0]["createdAt"],
    2125              :             "2026-01-01T00:00:00Z"
    2126              :         );
    2127            4 :     }
    2128              : 
    2129              :     /// 4.5.4 concise: the type member is dropped and an instance left with a
    2130              :     /// lone value collapses to that bare value — sub-attributes included.
    2131              :     #[test]
    2132            4 :     fn concise_drops_type_and_collapses_bare_values() {
    2133            4 :         let out = apply(
    2134            4 :             &entity(),
    2135            4 :             &Repr {
    2136            4 :                 concise: true,
    2137            4 :                 ..Repr::default()
    2138            4 :             },
    2139              :         );
    2140            4 :         let inst = &out["https://example.org/temperature"][0];
    2141            4 :         assert!(inst.get("type").is_none(), "concise keeps no type member");
    2142            4 :         assert_eq!(
    2143            4 :             inst["https://example.org/accuracy"],
    2144            4 :             json!([0.5]),
    2145              :             "a value-only sub-attribute collapses to the bare value"
    2146              :         );
    2147              :         // an instance carrying more than value keeps the object form
    2148            4 :         let d = apply(
    2149            4 :             &json!({"https://example.org/a": [{"type": "Property", "value": 1, "unitCode": "CEL"}]}),
    2150            4 :             &Repr {
    2151            4 :                 concise: true,
    2152            4 :                 ..Repr::default()
    2153            4 :             },
    2154              :         );
    2155            4 :         assert_eq!(d["https://example.org/a"][0]["unitCode"], "CEL");
    2156            4 :         assert_eq!(d["https://example.org/a"][0]["value"], 1);
    2157            4 :     }
    2158              : 
    2159              :     /// 4.5.4: with several instances the simplified form is a dataset map
    2160              :     /// keyed by datasetId, "@none" standing for the default instance — a bare
    2161              :     /// array of values would lose which instance is which. The map carries one
    2162              :     /// pair for each datasetId, so a dropped instance is a dropped datasetId.
    2163              :     #[test]
    2164            4 :     fn simplified_multi_instance_uses_the_dataset_map() {
    2165            4 :         let out = apply(
    2166            4 :             &entity(),
    2167            4 :             &Repr {
    2168            4 :                 key_values: true,
    2169            4 :                 ..Repr::default()
    2170            4 :             },
    2171              :         );
    2172            4 :         let t = &out["https://example.org/temperature"];
    2173            4 :         assert!(t.get("dataset").is_some(), "multi-instance needs the map");
    2174            4 :         assert_eq!(t["dataset"]["@none"], 21);
    2175            4 :         assert_eq!(t["dataset"]["urn:ds:2"], 9);
    2176            4 :         assert!(t.as_array().is_none(), "not a bare array");
    2177              :         // one instance stays a bare value
    2178            4 :         let one = apply(
    2179            4 :             &json!({"https://example.org/a": [{"type": "Property", "value": 1}]}),
    2180            4 :             &Repr {
    2181            4 :                 key_values: true,
    2182            4 :                 ..Repr::default()
    2183            4 :             },
    2184              :         );
    2185            4 :         assert_eq!(one["https://example.org/a"], json!(1));
    2186              :         // 4.5.4 EXAMPLE 2 is three instances; the map is built by pairing
    2187              :         // instances with the rows they came from, so an instance lost on the
    2188              :         // way leaves a map one pair short rather than an error
    2189            4 :         let three = apply(
    2190            4 :             &json!({"https://example.org/name": [
    2191            4 :                 {"type": "Property", "value": "David Robert Jones"},
    2192            4 :                 {"type": "Property", "value": "David Bowie",
    2193            4 :                  "datasetId": "urn:ngsi-ld:datasetId:001"},
    2194            4 :                 {"type": "Property", "value": "Ziggy Stardust",
    2195            4 :                  "datasetId": "urn:ngsi-ld:datasetId:002"}
    2196            4 :             ]}),
    2197            4 :             &Repr {
    2198            4 :                 key_values: true,
    2199            4 :                 ..Repr::default()
    2200            4 :             },
    2201              :         );
    2202            4 :         let ds = three["https://example.org/name"]["dataset"]
    2203            4 :             .as_object()
    2204            4 :             .expect("dataset map");
    2205            4 :         assert_eq!(ds.len(), 3, "one pair for each datasetId");
    2206            4 :         assert_eq!(ds["@none"], "David Robert Jones");
    2207            4 :         assert_eq!(ds["urn:ngsi-ld:datasetId:001"], "David Bowie");
    2208            4 :         assert_eq!(ds["urn:ngsi-ld:datasetId:002"], "Ziggy Stardust");
    2209            4 :     }
    2210              : 
    2211              :     /// datasetId= selects instances; "@none" selects the default one. An
    2212              :     /// attribute left with no surviving instance is absent, not empty.
    2213              :     #[test]
    2214            4 :     fn dataset_id_filter_selects_instances_and_drops_empty_attributes() {
    2215           12 :         let sel = |ids: &[&str]| {
    2216           12 :             apply(
    2217           12 :                 &entity(),
    2218              :                 &Repr {
    2219           12 :                     dataset_id: Some(ids.iter().map(|s| (*s).to_owned()).collect()),
    2220           12 :                     ..Repr::default()
    2221              :                 },
    2222              :             )
    2223           12 :         };
    2224            4 :         let out = sel(&["urn:ds:2"]);
    2225            4 :         let insts = out["https://example.org/temperature"]
    2226            4 :             .as_array()
    2227            4 :             .expect("array");
    2228            4 :         assert_eq!(insts.len(), 1);
    2229            4 :         assert_eq!(insts[0]["value"], 9, "the default instance must be gone");
    2230            4 :         let out = sel(&["@none"]);
    2231            4 :         let insts = out["https://example.org/temperature"]
    2232            4 :             .as_array()
    2233            4 :             .expect("array");
    2234            4 :         assert_eq!(insts.len(), 1);
    2235            4 :         assert_eq!(insts[0]["value"], 21);
    2236            4 :         let out = sel(&["urn:ds:absent"]);
    2237            4 :         assert!(
    2238            4 :             out.get("https://example.org/temperature").is_none(),
    2239              :             "an attribute with no matching instance is omitted entirely"
    2240              :         );
    2241            4 :         assert_eq!(out["id"], "urn:ngsi-ld:E:1", "entity members survive");
    2242            4 :     }
    2243              : 
    2244              :     /// The reserved members of an attribute instance carry values, not
    2245              :     /// sub-attributes: an array-valued `value` (or objectList, previousValue…)
    2246              :     /// must be passed through untouched, never walked as a list of instances.
    2247              :     #[test]
    2248            4 :     fn array_valued_reserved_members_are_not_sub_attributes() {
    2249            4 :         let doc = json!({"https://example.org/a": [{
    2250            4 :             "type": "ListProperty",
    2251            4 :             "valueList": [1, 2, 3],
    2252            4 :             "value": [{"type": "Property", "value": 7}],
    2253            4 :             "previousValue": [9],
    2254            4 :             "https://example.org/note": [{"type": "Property", "value": "sub"}]
    2255              :         }]});
    2256            4 :         let out = apply(&doc, &Repr::default());
    2257            4 :         let inst = &out["https://example.org/a"][0];
    2258            4 :         assert_eq!(inst["valueList"], json!([1, 2, 3]));
    2259            4 :         assert_eq!(
    2260            4 :             inst["value"],
    2261            4 :             json!([{"type": "Property", "value": 7}]),
    2262              :             "an array value is data, not an instance list to transform"
    2263              :         );
    2264            4 :         assert_eq!(inst["previousValue"], json!([9]));
    2265              :         // a genuine sub-attribute IS walked
    2266            4 :         assert_eq!(inst["https://example.org/note"][0]["value"], "sub");
    2267              :         // …and the walk applies the representation to it
    2268            4 :         let sys = apply(
    2269            4 :             &json!({"https://example.org/a": [{"type": "Property", "value": 1,
    2270            4 :                 "https://example.org/note": [{"type": "Property", "value": "s",
    2271            4 :                     "modifiedAt": "2026-01-01T00:00:00Z"}]}]}),
    2272            4 :             &Repr::default(),
    2273              :         );
    2274            4 :         assert!(
    2275            4 :             sys["https://example.org/a"][0]["https://example.org/note"][0]
    2276            4 :                 .get("modifiedAt")
    2277            4 :                 .is_none(),
    2278              :             "the sysAttrs gate reaches sub-attributes"
    2279              :         );
    2280            4 :     }
    2281              : 
    2282              :     /// 4.21: pick constrains core members too — an entity member not picked
    2283              :     /// does not survive; omit only drops the heads it names outright.
    2284              :     #[test]
    2285            4 :     fn pick_is_strict_over_core_members_and_omit_is_not() {
    2286            4 :         let ctx = Loader::new().core();
    2287            4 :         let picked = apply(
    2288            4 :             &entity(),
    2289            4 :             &Repr {
    2290            4 :                 pick: Some(parse_projection("id", &ctx).expect("pick")),
    2291            4 :                 ..Repr::default()
    2292            4 :             },
    2293              :         );
    2294            4 :         assert_eq!(picked["id"], "urn:ngsi-ld:E:1");
    2295            4 :         assert!(picked.get("type").is_none(), "type was not picked");
    2296            4 :         assert!(
    2297            4 :             picked.get("https://example.org/temperature").is_none(),
    2298              :             "an unpicked attribute must not survive"
    2299              :         );
    2300            4 :         let omitted = apply(
    2301            4 :             &entity(),
    2302            4 :             &Repr {
    2303            4 :                 omit: Some(parse_projection("scope", &ctx).expect("omit")),
    2304            4 :                 ..Repr::default()
    2305            4 :             },
    2306              :         );
    2307            4 :         assert_eq!(omitted["type"], "T", "omit leaves the rest of the entity");
    2308            4 :     }
    2309              : 
    2310              :     /// 4.5.16.1/4.5.16.2/4.5.16.3: geometry selection (default instance,
    2311              :     /// datasetId-narrowed single, invalid value -> null) and the
    2312              :     /// RFC 7946 3.1.6 on the way out: a clockwise exterior turns counterclockwise, a
    2313              :     /// counterclockwise hole clockwise; a geometry across the antimeridian stays as stored.
    2314              :     #[test]
    2315            4 :     fn geojson_rings_are_wound_and_the_antimeridian_is_not_cut() {
    2316            4 :         let cw = json!([[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]);
    2317            4 :         let ccw = json!([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]);
    2318            4 :         let hole_ccw = json!([[0.2, 0.2], [0.8, 0.2], [0.8, 0.8], [0.2, 0.8], [0.2, 0.2]]);
    2319            4 :         let hole_cw = json!([[0.2, 0.2], [0.2, 0.8], [0.8, 0.8], [0.8, 0.2], [0.2, 0.2]]);
    2320           20 :         let polygon = |rings: Value| json!({"type": "Polygon", "coordinates": rings});
    2321            4 :         assert_eq!(
    2322            4 :             rewound(polygon(json!([cw, hole_ccw]))),
    2323            4 :             polygon(json!([ccw, hole_cw]))
    2324              :         );
    2325            4 :         assert_eq!(rewound(polygon(json!([ccw]))), polygon(json!([ccw])));
    2326            4 :         let multi = json!({"type": "MultiPolygon", "coordinates": [[cw]]});
    2327            4 :         assert_eq!(rewound(multi)["coordinates"], json!([[ccw]]));
    2328            4 :         let across = polygon(json!([[
    2329            4 :             [179, 0],
    2330            4 :             [-179, 0],
    2331            4 :             [-179, 1],
    2332            4 :             [179, 1],
    2333            4 :             [179, 0]
    2334            4 :         ]]));
    2335            4 :         let entity = json!({"location": {"type": "GeoProperty", "value": across}});
    2336            4 :         assert_eq!(geometry_of(&entity, None), across);
    2337              :         // the default instance, not the first, of a multi-instance GeoProperty
    2338            4 :         let two = json!({"place": [
    2339            4 :             {"type": "GeoProperty", "datasetId": "urn:a", "value": {"type": "Point", "coordinates": [1, 1]}},
    2340            4 :             {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [2, 2]}}]});
    2341            4 :         assert_eq!(
    2342            4 :             geometry_of(&two, Some("place"))["coordinates"],
    2343            4 :             json!([2, 2])
    2344              :         );
    2345            4 :     }
    2346              : 
    2347              :     /// Feature/FeatureCollection shapes.
    2348              :     #[test]
    2349            4 :     fn geojson_feature_selection_and_shape() {
    2350              :         use super::{to_geojson_collection, to_geojson_feature};
    2351              :         use serde_json::Value;
    2352            4 :         let entity = json!({
    2353            4 :             "id": "urn:ngsi-ld:V:1", "type": "Vehicle",
    2354            4 :             "location": [
    2355            4 :                 {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [9.0, 9.0]},
    2356            4 :                  "datasetId": "urn:ngsi-ld:Dataset:gps"},
    2357            4 :                 {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [1.0, 2.0]}}
    2358              :             ],
    2359            4 :             "speed": {"type": "Property", "value": 5}
    2360              :         });
    2361            4 :         let f = to_geojson_feature(entity.clone(), None);
    2362            4 :         assert_eq!(f["type"], "Feature");
    2363            4 :         assert_eq!(f["id"], "urn:ngsi-ld:V:1");
    2364              :         // default instance (no datasetId) wins over the first array element
    2365            4 :         assert_eq!(
    2366            4 :             f["geometry"],
    2367            4 :             json!({"type": "Point", "coordinates": [1.0, 2.0]})
    2368              :         );
    2369            4 :         assert_eq!(f["properties"]["type"], "Vehicle");
    2370            4 :         assert!(
    2371            4 :             f["properties"].get("id").is_none(),
    2372              :             "id only at Feature level"
    2373              :         );
    2374            4 :         assert!(f["properties"].get("speed").is_some());
    2375              : 
    2376              :         // geometryProperty naming a non-geometry Property -> null geometry
    2377            4 :         let f2 = to_geojson_feature(entity.clone(), Some(&"speed".to_string()));
    2378            4 :         assert_eq!(f2["geometry"], Value::Null);
    2379              :         // absent GeoProperty -> null geometry
    2380            4 :         let f3 = to_geojson_feature(entity.clone(), Some(&"missing".to_string()));
    2381            4 :         assert_eq!(f3["geometry"], Value::Null);
    2382              : 
    2383              :         // 4.5.17.1: simplified multi-instance GeoProperty = dataset map;
    2384              :         // the "@none" (default) entry is the geometry
    2385            4 :         let simplified = json!({
    2386            4 :             "id": "urn:ngsi-ld:V:2", "type": "Vehicle",
    2387            4 :             "location": {"dataset": {
    2388            4 :                 "urn:ngsi-ld:Dataset:gps": {"type": "Point", "coordinates": [9.0, 9.0]},
    2389            4 :                 "@none": {"type": "Point", "coordinates": [3.0, 4.0]}
    2390              :             }},
    2391            4 :             "speed": 5
    2392              :         });
    2393            4 :         let fs = to_geojson_feature(simplified, None);
    2394            4 :         assert_eq!(
    2395            4 :             fs["geometry"],
    2396            4 :             json!({"type": "Point", "coordinates": [3.0, 4.0]})
    2397              :         );
    2398            4 :         assert_eq!(fs["properties"]["speed"], 5);
    2399              : 
    2400            4 :         let fc = to_geojson_collection(vec![entity], None);
    2401            4 :         assert_eq!(fc["type"], "FeatureCollection");
    2402            4 :         assert_eq!(fc["features"].as_array().map(Vec::len), Some(1));
    2403            4 :         assert!(
    2404            4 :             fc["features"][0].get("@context").is_none(),
    2405              :             "no per-Feature @context"
    2406              :         );
    2407              :         // Table 5.2.30-1: "In the case that no matches are found, features
    2408              :         // will be an empty array"
    2409            4 :         let empty = to_geojson_collection(vec![], None);
    2410            4 :         assert_eq!(empty["type"], "FeatureCollection");
    2411            4 :         assert_eq!(empty["features"], json!([]));
    2412            4 :     }
    2413              : }
    2414              : 
    2415              : #[cfg(test)]
    2416              : mod type_rules_memo_tests {
    2417              :     use super::type_rules;
    2418              :     use crate::policy::{Filter, Rule};
    2419              :     use std::collections::BTreeMap;
    2420              : 
    2421           20 :     fn filter(t: &str, attr: &str) -> Filter {
    2422           20 :         Filter {
    2423           20 :             rules: vec![Rule {
    2424           20 :                 types: vec![t.to_owned()],
    2425           20 :                 attributes: vec![attr.to_owned()],
    2426           20 :                 ..Rule::default()
    2427           20 :             }],
    2428           20 :             ..Filter::default()
    2429           20 :         }
    2430           20 :     }
    2431              : 
    2432              :     const DEFAULT: &str = "https://uri.etsi.org/ngsi-ld/default-context/";
    2433              : 
    2434              :     /// The memo answers only for the rules it was made from: alternating
    2435              :     /// filters (two views, two subjects) each get their own expansion.
    2436              :     #[test]
    2437            4 :     fn the_memo_answers_only_for_its_own_rules() {
    2438            4 :         let (car, user) = (filter("Car", "speed"), filter("User", "name"));
    2439           20 :         for f in [&car, &user, &car, &car, &user] {
    2440           20 :             let got = type_rules(f).unwrap();
    2441           20 :             let r = &f.rules[0];
    2442           20 :             assert_eq!(got.rules[0].types, vec![format!("{DEFAULT}{}", r.types[0])]);
    2443           20 :             assert_eq!(
    2444           20 :                 got.rules[0].attributes,
    2445           20 :                 vec![format!("{DEFAULT}{}", r.attributes[0])]
    2446              :             );
    2447              :         }
    2448            4 :         let omitting = Filter {
    2449            4 :             omit_by_type: BTreeMap::from([("Car".to_owned(), vec!["speed".to_owned()])]),
    2450            4 :             ..car.clone()
    2451            4 :         };
    2452            4 :         let got = type_rules(&omitting).unwrap();
    2453            4 :         assert_eq!(
    2454            4 :             got.omit_by_type.get(&format!("{DEFAULT}Car")),
    2455            4 :             Some(&vec![format!("{DEFAULT}speed")])
    2456              :         );
    2457            4 :         assert!(type_rules(&car).unwrap().omit_by_type.is_empty());
    2458            4 :         assert!(type_rules(&Filter::default()).is_none());
    2459            4 :     }
    2460              : 
    2461              :     /// the reach memo answers for the one `Arc` it holds. The same
    2462              :     /// `Arc` gets the same reach back; another `Arc`, even one with equal
    2463              :     /// content, or a narrowing that was dropped and rebuilt, gets its own,
    2464              :     /// so a reach never outlives the Filter it was made from.
    2465              :     #[test]
    2466            4 :     fn the_reach_memo_answers_only_for_its_own_arc() {
    2467              :         use std::sync::Arc;
    2468           32 :         let reach = |f: &Arc<Filter>| {
    2469           32 :             let mut r = super::Repr::default();
    2470           32 :             super::narrow_repr(&mut r, f);
    2471           32 :             r
    2472           32 :         };
    2473            4 :         let car = Arc::new(filter("Car", "speed"));
    2474            4 :         let user = Arc::new(filter("User", "name"));
    2475            4 :         let (a, b) = (reach(&car), reach(&car));
    2476            4 :         let (ra, rb) = (a.policy_reach.unwrap(), b.policy_reach.unwrap());
    2477            4 :         assert!(Arc::ptr_eq(&ra, &rb), "the same Arc reuses its reach");
    2478           12 :         for (f, t) in [(&user, "User"), (&car, "Car"), (&user, "User")] {
    2479           12 :             let got = reach(f).policy_reach.unwrap();
    2480           12 :             let rules = got.rules.as_ref().unwrap();
    2481           12 :             assert_eq!(rules.rules[0].types, vec![format!("{DEFAULT}{t}")]);
    2482              :         }
    2483            4 :         let twin = Arc::new(filter("Car", "speed"));
    2484            4 :         let own = reach(&twin).policy_reach.unwrap();
    2485            4 :         assert!(
    2486            4 :             !Arc::ptr_eq(&own, &ra),
    2487              :             "an equal Filter in another Arc builds its own"
    2488              :         );
    2489            4 :         assert_eq!(own.rules, ra.rules);
    2490            4 :         let narrowed = Arc::new(Filter {
    2491            4 :             q: Some(Box::new(antares_ql::parse_q("speed>1").expect("q"))),
    2492            4 :             ..Filter::default()
    2493            4 :         });
    2494            4 :         assert!(reach(&narrowed).policy_reach.unwrap().q.is_some());
    2495            4 :         let open = crate::policy::unnarrowed();
    2496            4 :         let none = reach(&open);
    2497            4 :         assert!(none.policy_reach.is_none() && none.policy_types.is_none());
    2498            4 :         assert!(super::narrowing(&car).policy_reach.unwrap().rules.is_some());
    2499            4 :     }
    2500              : }
    2501              : 
    2502              : #[cfg(test)]
    2503              : mod t3932_projection_edges {
    2504              :     use super::*;
    2505              :     use antares_jsonld::Loader;
    2506              : 
    2507              :     /// the projection parser splits at the brace it found and counts
    2508              :     /// depth without overflow; a malformed or multi-byte term is a 400, never
    2509              :     /// a panic.
    2510              :     #[test]
    2511            4 :     fn malformed_and_multibyte_projections_are_refused() {
    2512            4 :         let ctx = Loader::new().core();
    2513            4 :         let deep = parse_projection("a{b{c}}", &ctx).expect("nested");
    2514            4 :         assert_eq!(proj_depth(&deep), 2);
    2515            4 :         assert_eq!(deep[0].raw, "a");
    2516           36 :         for bad in ["a{b", "a}", "a{}", "{", "}", ",", "a,,b", "é{ü|ö}", "a{é}"] {
    2517           36 :             assert!(parse_projection(bad, &ctx).is_err(), "{bad} is refused");
    2518              :         }
    2519            4 :     }
    2520              : }
        

Generated by: LCOV version 2.0-1