LCOV - code coverage report
Current view: top level - antares-api/src - repr.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 95.1 % 1153 1097
Test Date: 2026-09-21 10:31:06 Functions: 54.0 % 387 209

            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              :     pub lang: Option<String>,
      39              :     /// datasetId= instance filter; entry "@none" selects default instances.
      40              :     pub dataset_id: Option<Vec<String>>,
      41              : }
      42              : 
      43         1308 : pub fn parse_repr(params: &HashMap<String, String>, ctx: &Context) -> Result<Repr, NgsiError> {
      44         1308 :     let mut r = Repr::default();
      45         1308 :     let mut format: Option<String> = None;
      46         1308 :     if let Some(opts) = params.get("options") {
      47          114 :         for o in opts.split(',') {
      48          114 :             match o.trim() {
      49          114 :                 "sysAttrs" => r.sys_attrs = true,
      50           46 :                 "keyValues" | "simplified" => {
      51           16 :                     format.get_or_insert("simplified".into());
      52           16 :                 }
      53           30 :                 "concise" => {
      54            4 :                     format.get_or_insert("concise".into());
      55            4 :                 }
      56           26 :                 "normalized" => {
      57            0 :                     format.get_or_insert("normalized".into());
      58            0 :                 }
      59              :                 _ => {
      60           26 :                     return Err(NgsiError::InvalidRequest(format!(
      61           26 :                         "unsupported options value {o:?}"
      62           26 :                     )))
      63              :                 }
      64              :             };
      65              :         }
      66         1206 :     }
      67              :     // format wins over options on conflict (6.3.7)
      68         1282 :     if let Some(f) = params.get("format") {
      69           20 :         match f.as_str() {
      70           20 :             "normalized" | "concise" | "simplified" | "keyValues" => {
      71            8 :                 format = Some(if f == "keyValues" {
      72            0 :                     "simplified".into()
      73              :                 } else {
      74            8 :                     f.clone()
      75              :                 })
      76              :             }
      77              :             _ => {
      78           12 :                 return Err(NgsiError::InvalidRequest(format!(
      79           12 :                     "unsupported format value {f:?}"
      80           12 :                 )))
      81              :             }
      82              :         }
      83         1262 :     }
      84         1270 :     match format.as_deref() {
      85           12 :         Some("simplified") => r.key_values = true,
      86            4 :         Some("concise") => r.concise = true,
      87         1262 :         _ => {}
      88              :     }
      89         1270 :     check_projection_exclusive(params)?;
      90         1254 :     if let Some(a) = params.get("attrs") {
      91           54 :         let mut list = Vec::new();
      92           62 :         for t in a.split(',') {
      93           62 :             let t = t.trim();
      94           62 :             if t.is_empty() || ENTITY_META.contains(&t) {
      95           28 :                 return Err(NgsiError::BadRequestData(format!(
      96           28 :                     "invalid attribute name {t:?} in attrs"
      97           28 :                 )));
      98           34 :             }
      99           34 :             list.push(ctx.expand_key(t));
     100              :         }
     101           26 :         r.attrs = Some(list);
     102         1200 :     }
     103         1226 :     if let Some(pck) = params.get("pick") {
     104           38 :         r.pick = Some(parse_projection(pck, ctx)?);
     105         1188 :     }
     106         1222 :     if let Some(o) = params.get("omit") {
     107           12 :         r.omit = Some(parse_projection(o, ctx)?);
     108         1210 :     }
     109         1218 :     r.lang = params.get("lang").cloned();
     110         1218 :     r.dataset_id = params
     111         1218 :         .get("datasetId")
     112         1218 :         .map(|s| s.split(',').map(|d| d.trim().to_owned()).collect());
     113         1218 :     Ok(r)
     114         1308 : }
     115              : 
     116              : /// 4.5.5.1: the datasetId "is of datatype URI, or equal to the JSON-LD
     117              : /// keyword `@none`", and "if no datasetId is provided, or `"datasetId":
     118              : /// "@none"` is supplied, it is considered as the default Attribute
     119              : /// instance". A default instance carries no datasetId of its own, so the
     120              : /// write paths that select ONE instance by the `?datasetId=` parameter
     121              : /// (5.6.5.4, 5.6.13.4) match `@none` against its absence. Instance members
     122              : /// reach the same rule one layer earlier, in 5.5.7 expansion.
     123          108 : pub fn target_dataset_id(params: &HashMap<String, String>) -> Option<&str> {
     124          108 :     params
     125          108 :         .get("datasetId")
     126          108 :         .map(String::as_str)
     127          108 :         .filter(|d| *d != "@none")
     128          108 : }
     129              : 
     130              : /// Narrow a representation by a policy decision (ADR-0020).
     131              : ///
     132              : /// `omit` is appended to whatever the request asked to omit: removing more
     133              : /// members can only remove more. `pick` intersects with the request's,
     134              : /// because a pick that added a name would serve a member the request did
     135              : /// not ask for and the engine's narrowing would have widened the answer.
     136              : ///
     137              : /// The Entity frame survives a policy pick. 6.5.3.1 makes `pick` reduce an
     138              : /// Entity "down to only contain the listed Entity members", and a client
     139              : /// that wants `id` back names it; but the engine is restricting what may be
     140              : /// seen rather than choosing a representation, and 5.2.4 makes `id` and
     141              : /// `type` the Entity — a document without them is not one.
     142          808 : pub fn narrow_projection(
     143          808 :     pick: &mut Option<Vec<ProjNode>>,
     144          808 :     omit: &mut Option<Vec<ProjNode>>,
     145          808 :     f: &crate::policy::Filter,
     146          808 :     ctx: &Context,
     147          808 : ) -> Result<(), NgsiError> {
     148          808 :     if !f.omit.is_empty() {
     149            6 :         let mut nodes = policy_nodes(&f.omit);
     150            6 :         match omit {
     151            0 :             Some(own) => own.append(&mut nodes),
     152            6 :             None => *omit = Some(nodes),
     153              :         }
     154          802 :     }
     155          808 :     if !f.pick.is_empty() {
     156            0 :         let allowed = policy_nodes(&f.pick);
     157            0 :         let mut kept = match pick.take() {
     158            0 :             None => allowed,
     159            0 :             Some(own) => own
     160            0 :                 .into_iter()
     161            0 :                 .filter(|n| allowed.iter().any(|a| a.iri == n.iri))
     162            0 :                 .collect(),
     163              :         };
     164            0 :         for frame in parse_projection("id,type", ctx)? {
     165            0 :             if !kept.iter().any(|n| n.raw == frame.raw) {
     166            0 :                 kept.push(frame);
     167            0 :             }
     168              :         }
     169            0 :         *pick = Some(kept);
     170          808 :     }
     171          808 :     Ok(())
     172          808 : }
     173              : 
     174              : /// The same narrowing on a current-state [`Repr`], which is the one a join
     175              : /// walk descends. It goes into the policy fields rather than into the
     176              : /// request's own projection, so every document the answer carries is
     177              : /// narrowed and not only the one at the top: `?join=flat` returns Entities
     178              : /// reached over a Relationship, and a member the subject may not see is not
     179              : /// less hidden for being one hop away.
     180              : ///
     181              : /// The Entity frame survives a policy pick — 5.2.4 makes `id` and `type`
     182              : /// the Entity, and a document without them is not one — which is why the
     183              : /// policy pick is applied to Attributes and never to the core members.
     184         1194 : pub fn narrow_repr(r: &mut Repr, f: &crate::policy::Filter) {
     185         1194 :     if !f.omit.is_empty() {
     186           14 :         r.policy_omit = Some(policy_nodes(&f.omit));
     187         1180 :     }
     188         1194 :     if !f.pick.is_empty() {
     189            4 :         r.policy_pick = Some(policy_nodes(&f.pick));
     190         1190 :     }
     191         1194 : }
     192              : 
     193              : /// The same `pick`/`omit` names in the form an already-compacted document
     194              : /// carries them. The query path folds a policy projection into the
     195              : /// request's own representation, where the names are IRIs; a notification
     196              : /// is compacted by `notify::build_data` long before the seam sees it, so
     197              : /// there the names have to travel the other way — parsed against the same
     198              : /// `@context` the document was compacted with, then compacted back. An
     199              : /// engine writes one rule set either way, and a rule written as an IRI
     200              : /// (which is what ADR-0020 asks of an engine) removes the member it names
     201              : /// rather than silently matching nothing.
     202            6 : pub fn compacted_filter(f: &crate::policy::Filter, ctx: &Context) -> crate::policy::Filter {
     203           12 :     let names = |raw: &[String]| -> Vec<String> {
     204           12 :         policy_nodes(raw)
     205           12 :             .iter()
     206           12 :             .map(|n| ctx.compact_iri(&n.iri))
     207           12 :             .collect()
     208           12 :     };
     209            6 :     crate::policy::Filter {
     210            6 :         pick: names(&f.pick),
     211            6 :         omit: names(&f.omit),
     212            6 :         ..f.clone()
     213            6 :     }
     214            6 : }
     215              : 
     216              : /// The `@context` a policy name is read in. Deliberately NOT the request's:
     217              : /// [`antares_jsonld::Context::expand_key`] consults the term map before it
     218              : /// decides a name is already an IRI, so a caller that binds the term a rule
     219              : /// names — or binds the rule's own IRI as a term, which its own inline
     220              : /// `@context` is enough to do — would move the rule off its target and walk
     221              : /// out of the narrowing. A deployment's rule means the same thing whatever
     222              : /// the caller sends. ADR-0020 asks an engine to write its rules as IRIs and
     223              : /// those pass through unchanged; a short name is read here the way a name a
     224              : /// request does not define is read anywhere else.
     225              : static POLICY_CONTEXT: std::sync::LazyLock<Context> =
     226              :     std::sync::LazyLock::new(antares_jsonld::core_context);
     227              : 
     228              : /// A policy's `pick`/`omit` names as projection nodes, expanded against
     229              : /// [`POLICY_CONTEXT`].
     230              : ///
     231              : /// Deliberately NOT the 4.21 parser: 6.5.3.1 lets those members name `"id"`,
     232              : /// `"type"`, `"scope"` or one projected Attribute, and ADR-0020 asks an
     233              : /// engine to write its rules against IRIs — while 4.21 reads a dot as the
     234              : /// sub-attribute path separator, so `https://uri.etsi.org/…/colour` through
     235              : /// that grammar becomes the member `https://uri` and removes nothing. An
     236              : /// engine names one member; it is expanded, and both forms of the name are
     237              : /// kept so the projection matches a document whichever form its keys are in.
     238           36 : fn policy_nodes(names: &[String]) -> Vec<ProjNode> {
     239           36 :     names
     240           36 :         .iter()
     241           36 :         .map(|n| ProjNode {
     242           30 :             raw: n.clone(),
     243           30 :             iri: POLICY_CONTEXT.expand_key(n),
     244           30 :             children: None,
     245           30 :         })
     246           36 :         .collect()
     247           36 : }
     248              : 
     249              : /// Maximum `{…}` selection depth of a projection tree — the number of
     250              : /// Linked Entity hops it implies (5.7.1.4: must not exceed joinLevel).
     251          152 : pub fn proj_depth(nodes: &[ProjNode]) -> usize {
     252          152 :     nodes
     253          152 :         .iter()
     254          174 :         .map(|n| match &n.children {
     255           60 :             Some(c) => 1 + proj_depth(c),
     256          114 :             None => 0,
     257          174 :         })
     258          152 :         .max()
     259          152 :         .unwrap_or(0)
     260          152 : }
     261              : 
     262              : /// The entity-level members of 4.5.1: everything an Entity carries that is
     263              : /// NOT an Attribute. Every layer that has to tell the two apart reads this
     264              : /// one list — `attrs`/`pick`/`omit` validation and projection here, the
     265              : /// notification diff and tombstone in `notify`, the 4.3.6.8 amendment in
     266              : /// `conformance`, the registration-scope narrowing in `federation`. A layer
     267              : /// with its own copy is a layer that will disagree with the others about
     268              : /// what an attribute is.
     269              : pub(crate) const ENTITY_META: &[&str] = antares_model::ENTITY_META_KEYS;
     270              : 
     271              : /// One node of a 4.21 attribute-projection expression; `children` carries a
     272              : /// nested `{…}` selection (applied to linked entities on join).
     273              : #[derive(Debug, Clone)]
     274              : pub struct ProjNode {
     275              :     pub raw: String,
     276              :     pub iri: String,
     277              :     pub children: Option<Vec<ProjNode>>,
     278              : }
     279              : 
     280              : /// Parse + validate a pick=/omit= value (4.21) into a projection tree.
     281          310 : pub(crate) fn parse_projection(s: &str, ctx: &Context) -> Result<Vec<ProjNode>, NgsiError> {
     282          310 :     let bad = || NgsiError::BadRequestData(format!("invalid attribute projection {s:?} (4.21)"));
     283              :     // Each `{…}` level is one Linked Entity hop (5.7.1.4), so a selection
     284              :     // deeper than the joinLevel ceiling can never be satisfied. Bounding it
     285              :     // here, before the recursive descent below, is what keeps the recursion
     286              :     // finite: pick=/omit= reach this parser as plain STRINGS — from the URI,
     287              :     // or from inside a query body, where the JSON nesting wall never sees
     288              :     // their braces.
     289          310 :     if s.is_empty()
     290          302 :         || s.matches('{').count() != s.matches('}').count()
     291          294 :         || crate::bounds::json_depth(s.as_bytes()) > crate::bounds::MAX_JOIN_LEVEL
     292          286 :         || !s
     293          286 :             .chars()
     294         2234 :             .all(|c| c.is_ascii_alphanumeric() || "_,.:{}#/%-+@|".contains(c))
     295              :     {
     296           48 :         return Err(bad());
     297          262 :     }
     298          402 :     fn split_top(s: &str) -> Option<Vec<&str>> {
     299          402 :         let mut out = Vec::new();
     300          402 :         let mut depth = 0usize;
     301          402 :         let mut start = 0usize;
     302         3254 :         for (i, c) in s.char_indices() {
     303           50 :             match c {
     304          328 :                 '{' => depth += 1,
     305          328 :                 '}' => depth = depth.checked_sub(1)?,
     306              :                 // 4.21 orOp = | / , — both split at the same depth
     307           90 :                 ',' | '|' if depth == 0 => {
     308           90 :                     out.push(&s[start..i]);
     309           90 :                     start = i + 1;
     310           90 :                 }
     311         2508 :                 _ => {}
     312              :             }
     313              :         }
     314          402 :         out.push(&s[start..]);
     315          402 :         Some(out)
     316          402 :     }
     317          402 :     fn parse_nodes(s: &str, ctx: &Context) -> Result<Vec<ProjNode>, NgsiError> {
     318          402 :         let bad = |m: &str| NgsiError::BadRequestData(format!("invalid attribute projection: {m}"));
     319          402 :         let parts = split_top(s).ok_or_else(|| bad("unbalanced braces"))?;
     320          402 :         let mut out: Vec<ProjNode> = Vec::new();
     321          472 :         for t in parts {
     322          472 :             let t = t.trim();
     323          472 :             if t.is_empty() {
     324           28 :                 return Err(bad("empty projection member"));
     325          444 :             }
     326          444 :             let (head_part, children) = match t.find('{') {
     327          140 :                 Some(i) => {
     328          140 :                     let inner = t[i + 1..]
     329          140 :                         .strip_suffix('}')
     330          140 :                         .ok_or_else(|| bad("unclosed brace"))?;
     331          140 :                     (&t[..i], Some(parse_nodes(inner, ctx)?))
     332              :                 }
     333          304 :                 None => (t, None),
     334              :             };
     335          440 :             let head = head_part.split('.').next().unwrap_or(head_part);
     336          440 :             if head.is_empty() || head_part.split('.').any(str::is_empty) {
     337            8 :                 return Err(bad("empty path segment"));
     338          432 :             }
     339          432 :             if !head
     340          432 :                 .chars()
     341          432 :                 .next()
     342          432 :                 .is_some_and(|c| c.is_ascii_alphanumeric() || "_:@#".contains(c))
     343              :             {
     344            0 :                 return Err(bad("projection member starts with a special character"));
     345          432 :             }
     346          432 :             if out.iter().any(|n| n.raw == head) {
     347            0 :                 return Err(bad("duplicate projection member"));
     348          432 :             }
     349          432 :             out.push(ProjNode {
     350          432 :                 raw: head.to_owned(),
     351          432 :                 iri: ctx.expand_key(head),
     352          432 :                 children,
     353          432 :             });
     354              :         }
     355          362 :         Ok(out)
     356          402 :     }
     357          262 :     parse_nodes(s, ctx)
     358          310 : }
     359              : 
     360              : /// Apply the representation to an internal doc, producing a new internal doc
     361              : /// 4.21 Projections: "pick, omit and attrs are mutually exclusive" — the one
     362              : /// reading, so an operation cannot accept a combination another rejects.
     363         2142 : pub fn check_projection_exclusive(params: &HashMap<String, String>) -> Result<(), NgsiError> {
     364         2142 :     let excl = ["pick", "omit", "attrs"]
     365         2142 :         .iter()
     366         6426 :         .filter(|k| params.contains_key(**k))
     367         2142 :         .count();
     368         2142 :     if excl > 1 {
     369           32 :         return Err(NgsiError::BadRequestData(
     370           32 :             "pick, omit and attrs are mutually exclusive (4.21)".into(),
     371           32 :         ));
     372         2110 :     }
     373         2110 :     Ok(())
     374         2142 : }
     375              : 
     376              : /// ADR-0020: does an Attribute survive the policy's own projection? Both
     377              : /// forms of the name are tried, because a document reaches this function
     378              : /// expanded on the query path and compacted on the notification path, and a
     379              : /// rule names one Attribute either way.
     380        16698 : pub fn policy_projected(pick: Option<&[ProjNode]>, omit: Option<&[ProjNode]>, k: &str) -> bool {
     381        16698 :     let names = |n: &ProjNode| n.iri == *k || n.raw == *k;
     382        16698 :     if let Some(pick) = pick {
     383            6 :         if !pick.iter().any(names) {
     384            4 :             return false;
     385            2 :         }
     386        16692 :     }
     387        16694 :     if let Some(omit) = omit {
     388           40 :         if omit.iter().any(names) {
     389           20 :             return false;
     390           20 :         }
     391        16654 :     }
     392        16674 :     true
     393        16698 : }
     394              : 
     395              : /// 4.21: does a core Entity member (id, type, scope, the system temporal
     396              : /// Properties) survive the projection? `pick` constrains core members
     397              : /// strictly — only what is named survives; `omit` drops a named member only
     398              : /// when the node carries no children, because a node with children
     399              : /// constrains the linked Entity below the head, not the head itself. The
     400              : /// current-state and temporal representations project core members by the
     401              : /// same rule and differ only below it.
     402        68880 : pub fn meta_projected(pick: Option<&[ProjNode]>, omit: Option<&[ProjNode]>, k: &str) -> bool {
     403        68880 :     if let Some(pick) = pick {
     404          116 :         if !pick.iter().any(|n| n.raw == *k) {
     405           64 :             return false;
     406           36 :         }
     407        68780 :     }
     408        68816 :     if let Some(omit) = omit {
     409          116 :         if omit.iter().any(|n| n.raw == *k && n.children.is_none()) {
     410            4 :             return false;
     411          112 :         }
     412        68700 :     }
     413        68812 :     true
     414        68880 : }
     415              : 
     416              : /// ready for compaction.
     417        13982 : pub fn apply(doc: &Value, r: &Repr) -> Value {
     418        13982 :     let Some(obj) = doc.as_object() else {
     419            0 :         return doc.clone();
     420              :     };
     421        13982 :     let mut out = Map::new();
     422        62244 :     for (k, v) in obj {
     423        62244 :         let is_meta = ENTITY_META.contains(&k.as_str());
     424        62244 :         if is_meta {
     425        47534 :             match k.as_str() {
     426              :                 // 6.3.11 Table 6.3.11-1: expiresAt is a system temporal
     427              :                 // attribute — included only when options=sysAttrs.
     428        47534 :                 "createdAt" | "modifiedAt" | "expiresAt" if !r.sys_attrs => continue,
     429        33738 :                 _ => {}
     430              :             }
     431              :             // the policy pick never reaches a core member: 5.2.4 makes id
     432              :             // and type the Entity, so a narrowing that removed them would
     433              :             // answer something that is not one
     434        33738 :             if !meta_projected(r.pick.as_deref(), r.omit.as_deref(), k)
     435        33690 :                 || !meta_projected(None, r.policy_omit.as_deref(), k)
     436              :             {
     437           48 :                 continue;
     438        33690 :             }
     439        33690 :             out.insert(k.clone(), v.clone());
     440        33690 :             continue;
     441        14710 :         }
     442        14710 :         if let Some(keep) = &r.attrs {
     443            4 :             if !keep.contains(k) {
     444            2 :                 continue;
     445            2 :             }
     446        14706 :         }
     447        14708 :         if let Some(pick) = &r.pick {
     448          104 :             if !pick.iter().any(|n| n.iri == *k) {
     449           24 :                 continue;
     450           40 :             }
     451        14644 :         }
     452        14684 :         if let Some(drop) = &r.omit {
     453           44 :             if drop.iter().any(|n| n.iri == *k && n.children.is_none()) {
     454           18 :                 continue;
     455           26 :             }
     456        14640 :         }
     457        14666 :         if !policy_projected(r.policy_pick.as_deref(), r.policy_omit.as_deref(), k) {
     458           22 :             continue;
     459        14644 :         }
     460        14644 :         let raw: Vec<Value> = v.as_array().cloned().unwrap_or_else(|| vec![v.clone()]);
     461        14644 :         let kept: Vec<&Value> = raw
     462        14644 :             .iter()
     463        14704 :             .filter(|inst| match (&r.dataset_id, inst.get("datasetId")) {
     464        14680 :                 (None, _) => true,
     465           12 :                 (Some(want), Some(Value::String(have))) => want.iter().any(|w| w == have),
     466           12 :                 (Some(want), None) => want.iter().any(|w| w == "@none"),
     467            0 :                 _ => false,
     468        14704 :             })
     469        14644 :             .collect();
     470        14644 :         let mut instances: Vec<Value> = kept
     471        14644 :             .iter()
     472        14688 :             .map(|inst| transform_instance(inst, r))
     473        14644 :             .collect();
     474        14644 :         if instances.is_empty() {
     475            4 :             continue;
     476        14640 :         }
     477        14640 :         if r.key_values {
     478           20 :             if instances.len() == 1 {
     479              :                 // 4.5.4: a lone instance simplifies to its bare value
     480           12 :                 out.extend(instances.pop().map(|one| (k.clone(), one)));
     481              :             } else {
     482              :                 // 4.5.4 multi-attribute case: a "dataset" map holding one
     483              :                 // key-value pair for each datasetId, "@none" for the default
     484              :                 // instance. The pairing below is positional, so `kept` and
     485              :                 // `instances` must both still be whole here.
     486            8 :                 let mut ds = Map::new();
     487           20 :                 for (orig, simple) in kept.iter().zip(instances.iter()) {
     488           20 :                     let key = orig
     489           20 :                         .get("datasetId")
     490           20 :                         .and_then(Value::as_str)
     491           20 :                         .unwrap_or("@none");
     492           20 :                     ds.insert(key.to_owned(), simple.clone());
     493           20 :                 }
     494            8 :                 out.insert(
     495            8 :                     k.clone(),
     496            8 :                     serde_json::json!({ "dataset": Value::Object(ds) }),
     497              :                 );
     498              :             }
     499        14620 :         } else {
     500        14620 :             out.insert(k.clone(), Value::Array(instances));
     501        14620 :         }
     502              :     }
     503        13982 :     Value::Object(out)
     504        13982 : }
     505              : 
     506        14764 : fn transform_instance(inst: &Value, r: &Repr) -> Value {
     507        14764 :     let Some(obj) = inst.as_object() else {
     508            0 :         return inst.clone();
     509              :     };
     510              :     // lang filter first: LanguageProperty → Property under a selected language
     511        14764 :     let mut obj = obj.clone();
     512        14764 :     if let Some(lang) = &r.lang {
     513            4 :         apply_lang(&mut obj, lang);
     514        14760 :     }
     515              : 
     516        14764 :     if r.key_values {
     517           32 :         return simplified_value(&obj);
     518        14732 :     }
     519              : 
     520        14732 :     let mut out = Map::new();
     521        58600 :     for (k, v) in &obj {
     522        58600 :         match k.as_str() {
     523              :             // 6.3.11: expiresAt shares the sysAttrs gate on attribute
     524              :             // instances (current-state and temporal alike).
     525        58600 :             "createdAt" | "modifiedAt" | "expiresAt" if !r.sys_attrs => continue,
     526        29842 :             "type" if r.concise => continue,
     527        42852 :             _ => {}
     528              :         }
     529              :         // sub-attributes recurse
     530        42852 :         if let Some(arr) = v.as_array().filter(|_| !is_reserved_member(k)) {
     531           72 :             let subs: Vec<Value> = arr.iter().map(|i| transform_instance(i, r)).collect();
     532           72 :             out.insert(k.clone(), Value::Array(subs));
     533        42780 :         } else {
     534        42780 :             out.insert(k.clone(), v.clone());
     535        42780 :         }
     536              :     }
     537        14732 :     if r.concise {
     538              :         // bare-value collapse: a Property with only `value` collapses
     539           48 :         if out.len() == 1 {
     540           12 :             if let Some(v) = out.get("value") {
     541            8 :                 return v.clone();
     542            4 :             }
     543           36 :         }
     544        14684 :     }
     545        14724 :     Value::Object(out)
     546        14764 : }
     547              : 
     548              : /// 4.15 Language Filter on one attribute instance (5.7.2.5, and the `lang`
     549              : /// rows of Tables 6.18.3.2-1 / 6.19.3.1 for the temporal forms): a
     550              : /// LanguageProperty "shall be converted into a Property" holding the chosen
     551              : /// languageMap entry, with the non-reified `lang` member naming it.
     552           76 : pub(crate) fn apply_lang(obj: &mut Map<String, Value>, lang: &str) {
     553           76 :     if obj.get("type").and_then(Value::as_str) != Some("LanguageProperty") {
     554           24 :         return;
     555           52 :     }
     556           52 :     let Some(lm) = obj.get("languageMap").and_then(Value::as_object) else {
     557            0 :         return;
     558              :     };
     559           52 :     if let Some((chosen_lang, value)) = select_lang(lm, lang) {
     560           52 :         obj.remove("languageMap");
     561           52 :         obj.insert("type".into(), Value::String("Property".into()));
     562           52 :         obj.insert("value".into(), value);
     563           52 :         obj.insert("lang".into(), Value::String(chosen_lang));
     564           52 :     }
     565           76 : }
     566              : 
     567              : /// 4.15 Language Filter: pick one languageMap entry for a lang priority
     568              : /// list. Ranges are ordered by their q weights (RFC 3282, default 1, list
     569              : /// position breaking ties); tags compare case-insensitively (RFC 5646); a
     570              : /// range matches an exact tag, then a longer tag by prefix (fr → fr-CH),
     571              : /// then a shorter tag by truncation (fr-CH → fr). "*" — or no match at
     572              : /// all — "shall default to any supported language" (@none preferred).
     573           88 : fn select_lang(lm: &Map<String, Value>, lang: &str) -> Option<(String, Value)> {
     574           88 :     let mut ranges: Vec<(f64, usize, &str)> = lang
     575           88 :         .split(',')
     576           88 :         .enumerate()
     577          100 :         .filter_map(|(i, part)| {
     578          100 :             let mut it = part.trim().split(';');
     579          100 :             let tag = it.next()?.trim();
     580          100 :             if tag.is_empty() {
     581            0 :                 return None;
     582          100 :             }
     583          100 :             let q = it
     584          100 :                 .find_map(|p| {
     585           20 :                     p.trim()
     586           20 :                         .strip_prefix("q=")
     587           20 :                         .and_then(|v| v.parse::<f64>().ok())
     588           20 :                 })
     589          100 :                 .unwrap_or(1.0);
     590          100 :             Some((q, i, tag))
     591          100 :         })
     592           88 :         .collect();
     593           88 :     ranges.sort_by(|a, b| {
     594           12 :         b.0.partial_cmp(&a.0)
     595           12 :             .unwrap_or(std::cmp::Ordering::Equal)
     596           12 :             .then(a.1.cmp(&b.1))
     597           12 :     });
     598          160 :     let ci = |k: &str, want: &str| k.eq_ignore_ascii_case(want);
     599           92 :     for (q, _, want) in &ranges {
     600              :         // q=0 = "not acceptable" (RFC 3282); "*" = any → the fallback below
     601           92 :         if *q <= 0.0 || *want == "*" {
     602            4 :             continue;
     603           88 :         }
     604          148 :         if let Some((k, v)) = lm.iter().find(|(k, _)| ci(k, want)) {
     605           60 :             return Some((k.clone(), v.clone()));
     606           28 :         }
     607           48 :         if let Some((k, v)) = lm.iter().find(|(k, _)| {
     608           48 :             k.len() > want.len()
     609            4 :                 && k.as_bytes().get(want.len()) == Some(&b'-')
     610            4 :                 && ci(&k[..want.len()], want)
     611           48 :         }) {
     612            4 :             return Some((k.clone(), v.clone()));
     613           24 :         }
     614           24 :         let mut w = *want;
     615           24 :         while let Some(cut) = w.rfind('-') {
     616            4 :             w = &w[..cut];
     617            8 :             if let Some((k, v)) = lm.iter().find(|(k, _)| ci(k, w)) {
     618            4 :                 return Some((k.clone(), v.clone()));
     619            0 :             }
     620              :         }
     621              :     }
     622              :     // any: prefer @none, then first
     623           20 :     if let Some(v) = lm.get("@none") {
     624            0 :         return Some(("@none".to_owned(), v.clone()));
     625           20 :     }
     626           20 :     lm.iter().next().map(|(k, v)| (k.clone(), v.clone()))
     627           88 : }
     628              : 
     629           90 : fn is_reserved_member(k: &str) -> bool {
     630           18 :     matches!(
     631           90 :         k,
     632           90 :         "type"
     633           90 :             | "value"
     634           84 :             | "object"
     635           84 :             | "objectType"
     636           84 :             | "datasetId"
     637           84 :             | "observedAt"
     638           84 :             | "unitCode"
     639           84 :             | "lang"
     640           84 :             | "languageMap"
     641           84 :             | "vocab"
     642           84 :             | "json"
     643           84 :             | "valueList"
     644           80 :             | "objectList"
     645           76 :             | "createdAt"
     646           76 :             | "modifiedAt"
     647           76 :             | "deletedAt"
     648           76 :             | "instanceId"
     649           76 :             | "previousValue"
     650           72 :             | "previousObject"
     651           72 :             | "previousLanguageMap"
     652           72 :             | "previousJson"
     653           72 :             | "previousVocab"
     654              :     )
     655           90 : }
     656              : 
     657              : /// 4.5.4: the simplified (keyValues) value of one instance — bare value for
     658              : /// Property/GeoProperty, bare URI(s) for a Relationship, bare ordered arrays
     659              : /// for ListProperty/ListRelationship, but the single-key wrapper objects
     660              : /// {"languageMap": …} / {"json": …} / {"vocab": …} for the Language, Json
     661              : /// and Vocab subtypes (Examples 4–6).
     662           60 : fn simplified_value(obj: &Map<String, Value>) -> Value {
     663          132 :     for k in ["value", "object", "valueList", "objectList"] {
     664          132 :         if let Some(v) = obj.get(k) {
     665           44 :             return v.clone();
     666           88 :         }
     667              :     }
     668           28 :     for k in ["languageMap", "json", "vocab"] {
     669           28 :         if let Some(v) = obj.get(k) {
     670           16 :             return serde_json::json!({ k: v.clone() });
     671           12 :         }
     672              :     }
     673            0 :     Value::Object(obj.clone())
     674           60 : }
     675              : 
     676              : /// Compaction for a shaped doc under a representation: keyValues docs get
     677              : /// shallow key renaming only (values are already plain JSON).
     678        13916 : pub fn compact_for(
     679        13916 :     repr: &crate::repr::Repr,
     680        13916 :     shaped: &Value,
     681        13916 :     ctx: &antares_jsonld::Context,
     682        13916 : ) -> Value {
     683        13916 :     if repr.key_values {
     684            4 :         compact_entity_shallow(shaped, ctx)
     685              :     } else {
     686        13912 :         compact_entity(shaped, ctx)
     687              :     }
     688        13916 : }
     689              : 
     690              : /// The child representation for a linked entity under `key` (4.21 nested
     691              : /// projections apply to the joined entity, not the relationship itself).
     692         5692 : fn joined_repr(parent: &crate::repr::Repr, key_compact: &str, key_iri: &str) -> crate::repr::Repr {
     693         5692 :     let mut r = crate::repr::Repr {
     694         5692 :         sys_attrs: parent.sys_attrs,
     695         5692 :         key_values: parent.key_values,
     696         5692 :         concise: parent.concise,
     697         5692 :         lang: parent.lang.clone(),
     698         5692 :         // ADR-0020: the narrowing is not a per-level projection — it
     699         5692 :         // travels to every document the answer carries
     700         5692 :         policy_pick: parent.policy_pick.clone(),
     701         5692 :         policy_omit: parent.policy_omit.clone(),
     702         5692 :         ..Default::default()
     703         5692 :     };
     704         5692 :     if let Some(pick) = &parent.pick {
     705           16 :         if let Some(n) = pick
     706           16 :             .iter()
     707           54 :             .find(|n| n.raw == key_compact || n.iri == key_iri)
     708           16 :         {
     709           16 :             r.pick = n.children.clone();
     710           16 :         }
     711         5676 :     }
     712         5692 :     if let Some(omit) = &parent.omit {
     713            4 :         if let Some(n) = omit
     714            4 :             .iter()
     715            4 :             .find(|n| (n.raw == key_compact || n.iri == key_iri) && n.children.is_some())
     716            2 :         {
     717            2 :             r.omit = n.children.clone();
     718            2 :         }
     719         5688 :     }
     720         5692 :     r
     721         5692 : }
     722              : 
     723              : /// 4.5.23.1: "When retrieving Linked Entities, it is necessary to limit
     724              : /// retrieval to avoid cascades of an excessive length, duplicates or loops."
     725              : /// joinLevel bounds the DEPTH of the walk; this bounds its WIDTH — the total
     726              : /// number of Linked Entity reads a single request may buy, so that a densely
     727              : /// linked graph cannot turn one retrieval into an unbounded store scan.
     728              : pub(crate) const MAX_JOIN_LOOKUPS: usize = 1_000;
     729              : 
     730              : /// State of one Linked Entity Retrieval walk (4.5.23.1): the entity ids
     731              : /// already resolved — a loop or a duplicate is never walked a second time —
     732              : /// and the remaining lookup budget. `complete` goes false as soon as the walk
     733              : /// left something out, which the caller reports as an NGSILD-Warning.
     734              : struct JoinWalk {
     735              :     seen: std::collections::BTreeSet<String>,
     736              :     budget: usize,
     737              :     complete: bool,
     738              : }
     739              : 
     740              : impl JoinWalk {
     741              :     /// The Linking Entity is already part of the response, so it counts as
     742              :     /// resolved before the walk starts — and so does every id the client
     743              :     /// passed in `containedBy`. `budget` is what is LEFT of the request's
     744              :     /// allowance: a page walks one entity at a time and each walk hands the
     745              :     /// remainder to the next, so the ceiling bounds the request rather than
     746              :     /// each of its entities.
     747           48 :     fn rooted(root: Option<&str>, contained_by: &[String], budget: usize) -> Self {
     748           48 :         let mut seen: std::collections::BTreeSet<String> = contained_by.iter().cloned().collect();
     749           48 :         if let Some(id) = root {
     750           48 :             seen.insert(id.to_owned());
     751           48 :         }
     752           48 :         JoinWalk {
     753           48 :             seen,
     754           48 :             budget,
     755           48 :             complete: true,
     756           48 :         }
     757           48 :     }
     758              : }
     759              : 
     760              : /// Linked Entity Retrieval, inline form (4.5.23.2): embed each relationship
     761              : /// target under an "entity" member (normalized) or replace the object URI by
     762              : /// the linked entity representation (simplified). Operates on COMPACTED docs.
     763              : /// Returns false when 4.5.23.1 truncated the walk (loop, duplicate, budget).
     764           12 : pub async fn inline_join(
     765           12 :     st: &AppState,
     766           12 :     tenant: &TenantId,
     767           12 :     ctx: &antares_jsonld::Context,
     768           12 :     repr: &crate::repr::Repr,
     769           12 :     compacted: &mut Value,
     770           12 :     level: usize,
     771           12 : ) -> bool {
     772           12 :     inline_join_beyond(st, tenant, ctx, repr, compacted, level, &[], &mut {
     773           12 :         MAX_JOIN_LOOKUPS
     774           12 :     })
     775           12 :     .await
     776           12 : }
     777              : 
     778              : /// Same, continuing an Entity Graph the client is already holding: the
     779              : /// `containedBy` ids count as encountered (Table 6.4.3.2-1).
     780              : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
     781           28 : pub async fn inline_join_beyond(
     782           28 :     st: &AppState,
     783           28 :     tenant: &TenantId,
     784           28 :     ctx: &antares_jsonld::Context,
     785           28 :     repr: &crate::repr::Repr,
     786           28 :     compacted: &mut Value,
     787           28 :     level: usize,
     788           28 :     contained_by: &[String],
     789           28 :     budget: &mut usize,
     790           28 : ) -> bool {
     791           28 :     let mut walk = JoinWalk::rooted(
     792           28 :         compacted.get("id").and_then(Value::as_str),
     793           28 :         contained_by,
     794           28 :         *budget,
     795              :     );
     796           28 :     inline_join_walk(st, tenant, ctx, repr, compacted, level, &mut walk).await;
     797           28 :     *budget = walk.budget;
     798           28 :     walk.complete
     799           28 : }
     800              : 
     801           36 : async fn inline_join_walk(
     802           36 :     st: &AppState,
     803           36 :     tenant: &TenantId,
     804           36 :     ctx: &antares_jsonld::Context,
     805           36 :     repr: &crate::repr::Repr,
     806           36 :     compacted: &mut Value,
     807           36 :     level: usize,
     808           36 :     walk: &mut JoinWalk,
     809           36 : ) {
     810           36 :     let Some(obj) = compacted.as_object_mut() else {
     811            0 :         return;
     812              :     };
     813           36 :     let metas = ["id", "type", "scope", "createdAt", "modifiedAt", "@context"];
     814         3734 :     for (k, v) in obj.iter_mut() {
     815         3734 :         if metas.contains(&k.as_str()) {
     816           72 :             continue;
     817         3662 :         }
     818         3662 :         let child = joined_repr(repr, k, &ctx.expand_key(k));
     819              :         // boxed: an async fn cannot recurse inline
     820         3662 :         Box::pin(inline_join_value(
     821         3662 :             st, tenant, ctx, repr, &child, v, level, walk,
     822         3662 :         ))
     823         3662 :         .await;
     824              :     }
     825           36 : }
     826              : 
     827         8048 : async fn lookup_joined(
     828         8048 :     st: &AppState,
     829         8048 :     tenant: &TenantId,
     830         8048 :     ctx: &antares_jsonld::Context,
     831         8048 :     child: &crate::repr::Repr,
     832         8048 :     id: &str,
     833         8048 :     level: usize,
     834         8048 :     walk: &mut JoinWalk,
     835         8048 : ) -> Option<Value> {
     836         8048 :     if walk.budget == 0 {
     837          800 :         walk.complete = false;
     838          800 :         return None;
     839         7248 :     }
     840         7248 :     walk.budget -= 1;
     841         7248 :     let target = st
     842         7248 :         .store
     843         7248 :         .get(tenant, Kind::Entity, id)
     844         7248 :         .await
     845         7248 :         .ok()
     846         7248 :         .flatten()?;
     847         7248 :     let shaped = apply(&target, child);
     848         7248 :     let mut c = compact_for(child, &shaped, ctx);
     849         7248 :     if level > 1 {
     850           36 :         if walk.seen.insert(id.to_owned()) {
     851            8 :             inline_join_walk(st, tenant, ctx, child, &mut c, level - 1, walk).await;
     852           28 :         } else {
     853           28 :             // 4.5.23.1: an already-resolved target is a loop or a duplicate —
     854           28 :             // it is still embedded, but its own links are not walked again.
     855           28 :             walk.complete = false;
     856           28 :         }
     857         7212 :     }
     858         7248 :     Some(c)
     859         8048 : }
     860              : 
     861              : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
     862         3662 : async fn inline_join_value(
     863         3662 :     st: &AppState,
     864         3662 :     tenant: &TenantId,
     865         3662 :     ctx: &antares_jsonld::Context,
     866         3662 :     repr: &crate::repr::Repr,
     867         3662 :     child: &crate::repr::Repr,
     868         3662 :     v: &mut Value,
     869         3662 :     level: usize,
     870         3662 :     walk: &mut JoinWalk,
     871         3662 : ) {
     872            0 :     match v {
     873            0 :         Value::Array(items) => {
     874            0 :             for i in items {
     875              :                 // boxed: an async fn cannot recurse inline
     876            0 :                 Box::pin(inline_join_value(
     877            0 :                     st, tenant, ctx, repr, child, i, level, walk,
     878            0 :                 ))
     879            0 :                 .await;
     880              :             }
     881              :         }
     882         3662 :         Value::Object(inst) => {
     883         3662 :             if repr.key_values {
     884            0 :                 return;
     885         3662 :             }
     886              :             // 4.5.22.2: a ListRelationship's targets join under the
     887              :             // output-only "entityList" member (always an array). The
     888              :             // compacted objectList carries {"object": URI} entries.
     889         3662 :             if let Some(Value::Array(ol)) = inst.get("objectList") {
     890            2 :                 let targets: Vec<String> = ol
     891            2 :                     .iter()
     892            4 :                     .filter_map(|e| match e {
     893            0 :                         Value::String(id) => Some(id.clone()),
     894            4 :                         Value::Object(o) => {
     895            4 :                             o.get("object").and_then(Value::as_str).map(str::to_owned)
     896              :                         }
     897            0 :                         _ => None,
     898            4 :                     })
     899            2 :                     .collect();
     900            2 :                 let mut joined: Vec<Value> = Vec::new();
     901            4 :                 for id in &targets {
     902            4 :                     if let Some(j) = lookup_joined(st, tenant, ctx, child, id, level, walk).await {
     903            4 :                         joined.push(j);
     904            4 :                     }
     905              :                 }
     906            2 :                 if !joined.is_empty() {
     907            2 :                     inst.insert("entityList".into(), Value::Array(joined));
     908            2 :                 }
     909            2 :                 return;
     910         3660 :             }
     911         3660 :             let targets: Vec<String> = match inst.get("object") {
     912         3644 :                 Some(Value::String(id)) => vec![id.clone()],
     913            4 :                 Some(Value::Array(a)) => a
     914            4 :                     .iter()
     915            4 :                     .filter_map(Value::as_str)
     916            4 :                     .map(str::to_owned)
     917            4 :                     .collect(),
     918           12 :                 _ => return,
     919              :             };
     920         3648 :             let mut joined: Vec<Value> = Vec::new();
     921         8044 :             for id in &targets {
     922         8044 :                 if let Some(j) = lookup_joined(st, tenant, ctx, child, id, level, walk).await {
     923         7244 :                     joined.push(j);
     924         7244 :                 }
     925              :             }
     926         3648 :             if joined.is_empty() {
     927          400 :                 return;
     928         3248 :             }
     929         3248 :             let e = if joined.len() == 1 {
     930         3244 :                 joined.remove(0)
     931              :             } else {
     932            4 :                 Value::Array(joined)
     933              :             };
     934         3248 :             inst.insert("entity".into(), e);
     935              :         }
     936              :         // simplified: relationship value is the object URI string
     937            0 :         Value::String(id) if repr.key_values => {
     938            0 :             if let Some(joined) = lookup_joined(st, tenant, ctx, child, id, level, walk).await {
     939            0 :                 *v = joined;
     940            0 :             }
     941              :         }
     942            0 :         _ => {}
     943              :     }
     944         3662 : }
     945              : 
     946              : /// Linked Entity Retrieval, flattened form (4.5.23.3): collect targets with
     947              : /// the child representation that applies to each. The Linking Entity is
     948              : /// already in the flattened array, so 4.5.23.1 ("avoid ... duplicates or
     949              : /// loops") keeps it out of `out` even when a Relationship points back at it.
     950              : /// Returns false when the walk was truncated by the lookup budget.
     951            4 : pub async fn collect_flat(
     952            4 :     st: &AppState,
     953            4 :     tenant: &TenantId,
     954            4 :     repr: &crate::repr::Repr,
     955            4 :     internal_doc: &Value,
     956            4 :     level: usize,
     957            4 :     out: &mut std::collections::BTreeMap<String, (Value, crate::repr::Repr)>,
     958            4 : ) -> bool {
     959            4 :     collect_flat_beyond(st, tenant, repr, internal_doc, level, out, &[], &mut {
     960            4 :         MAX_JOIN_LOOKUPS
     961            4 :     })
     962            4 :     .await
     963            4 : }
     964              : 
     965              : /// Same, continuing an Entity Graph the client is already holding: the
     966              : /// `containedBy` ids count as encountered (Table 6.4.3.2-1).
     967              : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
     968           20 : pub async fn collect_flat_beyond(
     969           20 :     st: &AppState,
     970           20 :     tenant: &TenantId,
     971           20 :     repr: &crate::repr::Repr,
     972           20 :     internal_doc: &Value,
     973           20 :     level: usize,
     974           20 :     out: &mut std::collections::BTreeMap<String, (Value, crate::repr::Repr)>,
     975           20 :     contained_by: &[String],
     976           20 :     budget: &mut usize,
     977           20 : ) -> bool {
     978           20 :     let mut walk = JoinWalk::rooted(
     979           20 :         internal_doc.get("id").and_then(Value::as_str),
     980           20 :         contained_by,
     981           20 :         *budget,
     982              :     );
     983           20 :     walk.seen.extend(out.keys().cloned());
     984           20 :     collect_flat_walk(st, tenant, repr, internal_doc, level, out, &mut walk).await;
     985           20 :     *budget = walk.budget;
     986           20 :     walk.complete
     987           20 : }
     988              : 
     989              : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
     990           28 : async fn collect_flat_walk(
     991           28 :     st: &AppState,
     992           28 :     tenant: &TenantId,
     993           28 :     repr: &crate::repr::Repr,
     994           28 :     internal_doc: &Value,
     995           28 :     level: usize,
     996           28 :     out: &mut std::collections::BTreeMap<String, (Value, crate::repr::Repr)>,
     997           28 :     walk: &mut JoinWalk,
     998           28 : ) {
     999           28 :     let Some(obj) = internal_doc.as_object() else {
    1000            0 :         return;
    1001              :     };
    1002         2138 :     for (k, v) in obj {
    1003         2138 :         if is_meta(k) {
    1004          106 :             continue;
    1005         2032 :         }
    1006              :         // only traverse relationships that survive THIS doc's projection
    1007         2032 :         if let Some(pick) = &repr.pick {
    1008            0 :             if !pick.iter().any(|n| n.iri == *k || n.raw == *k) {
    1009            0 :                 continue;
    1010            0 :             }
    1011         2032 :         }
    1012         2032 :         if let Some(omit) = &repr.omit {
    1013            0 :             if omit
    1014            0 :                 .iter()
    1015            0 :                 .any(|n| (n.iri == *k || n.raw == *k) && n.children.is_none())
    1016              :             {
    1017            0 :                 continue;
    1018            0 :             }
    1019         2032 :         }
    1020              :         // a Relationship the subject may not see is not a road either
    1021         2032 :         if !policy_projected(repr.policy_pick.as_deref(), repr.policy_omit.as_deref(), k) {
    1022            2 :             continue;
    1023         2030 :         }
    1024         2030 :         let Some(instances) = v.as_array() else {
    1025            0 :             continue;
    1026              :         };
    1027         2030 :         let child = joined_repr(repr, k, k);
    1028         2030 :         for inst in instances {
    1029              :             // Relationship objects plus ListRelationship objectList targets
    1030              :             // (internal form stores bare URIs) — 4.5.23.3 appends both kinds
    1031              :             // of Linked Entities to the flattened array.
    1032         2030 :             let targets: Vec<&str> = match (inst.get("object"), inst.get("objectList")) {
    1033         2026 :                 (Some(Value::String(id)), _) => vec![id.as_str()],
    1034            0 :                 (Some(Value::Array(a)), _) => a.iter().filter_map(Value::as_str).collect(),
    1035            2 :                 (None, Some(Value::Array(a))) => a.iter().filter_map(Value::as_str).collect(),
    1036            2 :                 _ => continue,
    1037              :             };
    1038         2030 :             for id in targets {
    1039         2030 :                 if walk.seen.contains(id) {
    1040           10 :                     continue;
    1041         2020 :                 }
    1042         2020 :                 if walk.budget == 0 {
    1043            2 :                     walk.complete = false;
    1044            2 :                     return;
    1045         2018 :                 }
    1046         2018 :                 walk.budget -= 1;
    1047         2018 :                 if let Some(target) = st.store.get(tenant, Kind::Entity, id).await.ok().flatten() {
    1048         2018 :                     walk.seen.insert(id.to_owned());
    1049         2018 :                     out.insert(id.to_owned(), (target.clone(), child.clone()));
    1050         2018 :                     if level > 1 {
    1051              :                         // boxed: an async fn cannot recurse inline
    1052            8 :                         Box::pin(collect_flat_walk(
    1053            8 :                             st,
    1054            8 :                             tenant,
    1055            8 :                             &child,
    1056            8 :                             &target,
    1057            8 :                             level - 1,
    1058            8 :                             out,
    1059            8 :                             walk,
    1060            8 :                         ))
    1061            8 :                         .await;
    1062         2010 :                     }
    1063            0 :                 }
    1064              :             }
    1065              :         }
    1066              :     }
    1067           28 : }
    1068              : 
    1069              : /// 4.5.16.2 GeoJSON Feature, members per Table 5.2.29-1 (5.2.29 Feature):
    1070              : /// id = entity id (URI), fixed type "Feature", geometry = the selected
    1071              : /// GeoProperty's value or null (4.5.16.1: geometryProperty parameter,
    1072              : /// default "location"), properties = the 5.2.31 FeatureProperties (entity
    1073              : /// type + attributes). The @context member is added by respond() (6.3.6).
    1074           38 : pub fn to_geojson_feature(entity: Value, geometry_property: Option<&String>) -> Value {
    1075           38 :     let geom_term = geometry_property
    1076           38 :         .cloned()
    1077           38 :         .unwrap_or_else(|| "location".into());
    1078           38 :     let geometry = entity
    1079           38 :         .get(&geom_term)
    1080           38 :         .map(geo_value_of)
    1081           38 :         .unwrap_or(Value::Null);
    1082           38 :     let id = entity.get("id").cloned().unwrap_or(Value::Null);
    1083           38 :     let mut props = entity.as_object().cloned().unwrap_or_default();
    1084           38 :     props.remove("id");
    1085           38 :     let mut feature = Map::new();
    1086           38 :     feature.insert("id".into(), id);
    1087           38 :     feature.insert("type".into(), Value::String("Feature".into()));
    1088           38 :     feature.insert("geometry".into(), geometry);
    1089           38 :     feature.insert("properties".into(), Value::Object(props));
    1090           38 :     Value::Object(feature)
    1091           38 : }
    1092              : 
    1093              : /// 4.5.16.3 GeoJSON FeatureCollection, members per Table 5.2.30-1 (5.2.30
    1094              : /// FeatureCollection): fixed type "FeatureCollection" + features array of
    1095              : /// 4.5.16.2 Feature objects — empty array when no matches, no per-Feature
    1096              : /// @context; the top-level @context is added by respond() (6.3.6).
    1097           24 : pub fn to_geojson_collection(entities: Vec<Value>, geometry_property: Option<&String>) -> Value {
    1098           24 :     let features: Vec<Value> = entities
    1099           24 :         .into_iter()
    1100           24 :         .map(|e| to_geojson_feature(e, geometry_property))
    1101           24 :         .collect();
    1102           24 :     serde_json::json!({"type": "FeatureCollection", "features": features})
    1103           24 : }
    1104              : 
    1105              : /// 4.5.16.1: with multiple instances the default one (no datasetId) is
    1106              : /// selected unless a datasetId filter already narrowed the set to one; a
    1107              : /// missing GeoProperty or a value that "does not hold a valid GeoJSON
    1108              : /// geometry object" yields null — "which is syntactically valid GeoJSON".
    1109           30 : fn geo_value_of(attr: &Value) -> Value {
    1110           30 :     let inst = match attr {
    1111           16 :         Value::Array(a) => match a.iter().find(|i| i.get("datasetId").is_none()) {
    1112            8 :             Some(default) => default.clone(),
    1113            0 :             None if a.len() == 1 => a[0].clone(),
    1114            0 :             None => return Value::Null,
    1115              :         },
    1116           22 :         other => other.clone(),
    1117              :     };
    1118           30 :     let v = inst.get("value").cloned().unwrap_or(inst);
    1119              :     // 4.5.17.1: in the simplified representation a multi-instance GeoProperty
    1120              :     // is the {"dataset": {…}} map — the default ("@none") instance is the
    1121              :     // 4.5.16.1 selection.
    1122           30 :     let v = match v.as_object() {
    1123           26 :         Some(o) if o.len() == 1 && o.contains_key("dataset") => {
    1124            4 :             o["dataset"].get("@none").cloned().unwrap_or(Value::Null)
    1125              :         }
    1126           26 :         _ => v,
    1127              :     };
    1128           30 :     match antares_jsonld::expand::validate_geojson("geometry", &v) {
    1129           26 :         Ok(()) => v,
    1130            4 :         Err(_) => Value::Null,
    1131              :     }
    1132           30 : }
    1133              : 
    1134              : #[cfg(test)]
    1135              : mod tests {
    1136              :     use super::*;
    1137              :     use serde_json::json;
    1138              : 
    1139              :     /// 4.5.4 Examples 1–16: the simplified value of one instance per
    1140              :     /// attribute type — bare for Property/GeoProperty/Relationship/List*,
    1141              :     /// wrapped single-key objects for Language/Json/Vocab subtypes.
    1142              :     #[test]
    1143            4 :     fn simplified_values_per_attribute_type() {
    1144           28 :         let v = |j: Value| simplified_value(j.as_object().unwrap());
    1145            4 :         assert_eq!(v(json!({"type": "Property", "value": 5})), json!(5));
    1146            4 :         assert_eq!(
    1147            4 :             v(json!({"type": "Relationship", "object": "urn:a"})),
    1148            4 :             json!("urn:a")
    1149              :         );
    1150            4 :         assert_eq!(
    1151            4 :             v(json!({"type": "ListProperty", "valueList": [1, 2]})),
    1152            4 :             json!([1, 2])
    1153              :         );
    1154            4 :         assert_eq!(
    1155            4 :             v(json!({"type": "ListRelationship", "objectList": ["urn:a"]})),
    1156            4 :             json!(["urn:a"])
    1157              :         );
    1158            4 :         assert_eq!(
    1159            4 :             v(json!({"type": "LanguageProperty", "languageMap": {"en": "hi"}})),
    1160            4 :             json!({"languageMap": {"en": "hi"}})
    1161              :         );
    1162            4 :         assert_eq!(
    1163            4 :             v(json!({"type": "JsonProperty", "json": {"k": 1}})),
    1164            4 :             json!({"json": {"k": 1}})
    1165              :         );
    1166            4 :         assert_eq!(
    1167            4 :             v(json!({"type": "VocabProperty", "vocab": "V"})),
    1168            4 :             json!({"vocab": "V"})
    1169              :         );
    1170            4 :     }
    1171              : }
    1172              : 
    1173              : #[cfg(test)]
    1174              : mod clause_4_15 {
    1175              :     use super::*;
    1176              :     use serde_json::json;
    1177              : 
    1178           36 :     fn lm(pairs: &[(&str, &str)]) -> Map<String, Value> {
    1179           36 :         pairs
    1180           36 :             .iter()
    1181           60 :             .map(|(k, v)| ((*k).to_owned(), json!(*v)))
    1182           36 :             .collect()
    1183           36 :     }
    1184              : 
    1185              :     /// 4.15 EXAMPLE 4: quality value ranking — entries are ordered by their
    1186              :     /// q weight (default 1), not by list position.
    1187              :     #[test]
    1188            4 :     fn q_values_rank_the_priority_list() {
    1189            4 :         let m = lm(&[("en", "red"), ("fr", "rouge")]);
    1190            4 :         let (l, v) = select_lang(&m, "en;q=0.2,fr;q=0.9").expect("pick");
    1191            4 :         assert_eq!((l.as_str(), &v), ("fr", &json!("rouge")), "fr outranks en");
    1192              :         // default q=1: plain fr-CH beats fr;q=0.9
    1193            4 :         let m = lm(&[("fr-CH", "rouge suisse"), ("fr", "rouge")]);
    1194            4 :         let (l, _) = select_lang(&m, "fr-CH,fr;q=0.9").expect("pick");
    1195            4 :         assert_eq!(l, "fr-CH");
    1196              :         // wildcard with low q still yields a fallback when nothing else fits
    1197            4 :         let m = lm(&[("de", "rot")]);
    1198            4 :         let (l, _) = select_lang(&m, "fr;q=0.9,*;q=0.5").expect("pick");
    1199            4 :         assert_eq!(l, "de");
    1200            4 :     }
    1201              : 
    1202              :     /// RFC 5646 (via 4.15): language tags compare case-insensitively.
    1203              :     #[test]
    1204            4 :     fn langtags_compare_case_insensitively() {
    1205            4 :         let m = lm(&[("en-US", "color")]);
    1206            4 :         let (l, v) = select_lang(&m, "en-us").expect("pick");
    1207            4 :         assert_eq!((l.as_str(), &v), ("en-US", &json!("color")));
    1208            4 :         let m = lm(&[("fr", "rouge"), ("de", "rot")]);
    1209            4 :         let (l, _) = select_lang(&m, "FR").expect("pick");
    1210            4 :         assert_eq!(l, "fr");
    1211            4 :     }
    1212              : 
    1213              :     /// RFC 5646 lookup (via 4.15): a shorter range matches a longer tag
    1214              :     /// (lang=fr picks fr-CH) and a longer range truncates onto a shorter tag
    1215              :     /// (lang=fr-CH picks fr) — the `lang` subproperty reports the ACTUAL tag.
    1216              :     #[test]
    1217            4 :     fn prefix_and_truncation_fallbacks() {
    1218              :         // decoy `de` sorts first — a naive any-fallback would pick it
    1219            4 :         let m = lm(&[("de", "rot"), ("fr-CH", "rouge suisse")]);
    1220            4 :         let (l, _) = select_lang(&m, "fr").expect("pick");
    1221            4 :         assert_eq!(l, "fr-CH", "range fr matches tag fr-CH, not the decoy");
    1222            4 :         let m = lm(&[("de", "rot"), ("fr", "rouge")]);
    1223            4 :         let (l, _) = select_lang(&m, "fr-CH").expect("pick");
    1224            4 :         assert_eq!(l, "fr", "range fr-CH truncates onto tag fr, not the decoy");
    1225              :         // an exact match still beats a prefix match at the same rank
    1226            4 :         let m = lm(&[("fr-CH", "suisse"), ("fr", "rouge")]);
    1227            4 :         let (l, _) = select_lang(&m, "fr").expect("pick");
    1228            4 :         assert_eq!(l, "fr");
    1229            4 :     }
    1230              : 
    1231              :     /// 4.15: "If the Context Broker cannot serve any matching language, it
    1232              :     /// shall default to any supported language" — and the augmented `lang`
    1233              :     /// subproperty carries the actually returned one.
    1234              :     #[test]
    1235            4 :     fn no_match_falls_back_to_any_supported_language() {
    1236            4 :         let m = lm(&[("de", "rot")]);
    1237            4 :         let (l, v) = select_lang(&m, "pt").expect("fallback");
    1238            4 :         assert_eq!((l.as_str(), &v), ("de", &json!("rot")));
    1239              :         // the transform augments with the actual language and converts the
    1240              :         // LanguageProperty to a Property — languageMap must NOT survive
    1241            4 :         let inst = json!({"type": "LanguageProperty",
    1242            4 :             "languageMap": {"en": "red", "fr": "rouge"}});
    1243            4 :         let r = Repr {
    1244            4 :             lang: Some("fr".into()),
    1245            4 :             ..Repr::default()
    1246            4 :         };
    1247            4 :         let out = transform_instance(&inst, &r);
    1248            4 :         assert_eq!(out["type"], "Property");
    1249            4 :         assert_eq!(out["value"], "rouge");
    1250            4 :         assert_eq!(out["lang"], "fr");
    1251            4 :         assert!(
    1252            4 :             out.get("languageMap").is_none(),
    1253              :             "languageMap must not remain after conversion"
    1254              :         );
    1255            4 :     }
    1256              : }
    1257              : 
    1258              : #[cfg(test)]
    1259              : mod clause_4_21 {
    1260              :     use super::*;
    1261              :     use antares_jsonld::Loader;
    1262              : 
    1263              :     /// 4.21: "either a comma or a pipe character can be used as alternative
    1264              :     /// representations of the or operator" — including inside a nested
    1265              :     /// LinkedEntityTerm (EXAMPLE 3).
    1266              :     #[test]
    1267            4 :     fn pipe_and_comma_are_both_or_operators() {
    1268            4 :         let ctx = Loader::new().core();
    1269            4 :         let comma = parse_projection("temperature,humidity", &ctx).expect("comma");
    1270            4 :         let pipe = parse_projection("temperature|humidity", &ctx).expect("pipe");
    1271            4 :         assert_eq!(comma.len(), 2);
    1272            4 :         assert_eq!(pipe.len(), 2);
    1273            4 :         assert_eq!(comma[0].raw, pipe[0].raw);
    1274            4 :         assert_eq!(comma[1].raw, pipe[1].raw);
    1275            4 :         let nested = parse_projection("observation{temperature|humidity}", &ctx).expect("nested");
    1276            4 :         assert_eq!(nested.len(), 1);
    1277            4 :         let kids = nested[0].children.as_ref().expect("children");
    1278            4 :         assert_eq!(kids.len(), 2, "pipe splits inside the braces too");
    1279            4 :     }
    1280              : 
    1281              :     /// 4.21 grammar: an empty member or unbalanced braces are violations.
    1282              :     #[test]
    1283            4 :     fn grammar_rejections_hold_for_both_spellings() {
    1284            4 :         let ctx = Loader::new().core();
    1285           20 :         for bad in ["a||b", "a|,b", "|a", "a|", "a{b|}"] {
    1286           20 :             assert!(
    1287           20 :                 parse_projection(bad, &ctx).is_err(),
    1288              :                 "{bad:?} must be rejected"
    1289              :             );
    1290              :         }
    1291            4 :     }
    1292              : 
    1293              :     /// Each `{…}` level of a projection is one Linked Entity hop (5.7.1.4),
    1294              :     /// so a selection deeper than the joinLevel ceiling can never be
    1295              :     /// satisfied. The depth is bounded BEFORE the recursive descent parses
    1296              :     /// it: pick= arrives as a plain string inside a query body, where the
    1297              :     /// JSON nesting wall never sees its braces.
    1298              :     #[test]
    1299            4 :     fn projection_nesting_is_bounded_before_the_parser_recurses() {
    1300            4 :         let ctx = Loader::new().core();
    1301            4 :         let cap = crate::bounds::MAX_JOIN_LEVEL;
    1302           12 :         let nested = |n: usize| "a{".repeat(n) + "b" + &"}".repeat(n);
    1303              :         // the body path can carry a projection far past any stack budget
    1304            4 :         assert!(
    1305            4 :             parse_projection(&nested(200_000), &ctx).is_err(),
    1306              :             "a body-sized projection must be rejected, not recursed into"
    1307              :         );
    1308            4 :         assert!(parse_projection(&nested(cap), &ctx).is_ok(), "at the cap");
    1309            4 :         assert!(
    1310            4 :             parse_projection(&nested(cap + 1), &ctx).is_err(),
    1311              :             "one level over the cap must be rejected"
    1312              :         );
    1313            4 :     }
    1314              : 
    1315              :     /// 5.7.1.4: proj_depth reports the hops a projection implies, so the
    1316              :     /// joinLevel comparison upstream is made against the deepest branch.
    1317              :     #[test]
    1318            4 :     fn projection_depth_counts_the_deepest_branch() {
    1319            4 :         let ctx = Loader::new().core();
    1320           12 :         let d = |s: &str| proj_depth(&parse_projection(s, &ctx).expect("parse"));
    1321            4 :         assert_eq!(d("a,b"), 0, "a flat selection implies no hop");
    1322            4 :         assert_eq!(d("a{b}"), 1);
    1323            4 :         assert_eq!(d("a,b{c{d}}"), 2, "the deepest branch wins");
    1324            4 :     }
    1325              : }
    1326              : 
    1327              : #[cfg(test)]
    1328              : mod clause_6_3_7 {
    1329              :     use super::*;
    1330              :     use antares_jsonld::Loader;
    1331              :     use serde_json::json;
    1332              : 
    1333           88 :     fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
    1334           88 :         pairs
    1335           88 :             .iter()
    1336          116 :             .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
    1337           88 :             .collect()
    1338           88 :     }
    1339              : 
    1340              :     /// 6.3.7: an unknown options or format value is not silently ignored.
    1341              :     #[test]
    1342            4 :     fn unknown_options_and_format_values_are_rejected() {
    1343            4 :         let ctx = Loader::new().core();
    1344           20 :         for p in [
    1345            4 :             params(&[("options", "sysattrs")]),
    1346            4 :             params(&[("options", "keyValues,bogus")]),
    1347            4 :             params(&[("options", "")]),
    1348            4 :             params(&[("format", "verbose")]),
    1349            4 :             params(&[("format", "KeyValues")]),
    1350            4 :         ] {
    1351           20 :             let e = parse_repr(&p, &ctx).expect_err("must be rejected");
    1352           20 :             assert!(
    1353           20 :                 matches!(e, NgsiError::InvalidRequest(_)),
    1354              :                 "unsupported representation value is InvalidRequest, got {e:?}"
    1355              :             );
    1356              :         }
    1357            4 :     }
    1358              : 
    1359              :     /// 6.3.7: format wins over options when the two disagree, and keyValues
    1360              :     /// is the older spelling of simplified.
    1361              :     #[test]
    1362            4 :     fn format_wins_over_options_on_conflict() {
    1363            4 :         let ctx = Loader::new().core();
    1364            4 :         let r = parse_repr(
    1365            4 :             &params(&[("options", "concise"), ("format", "simplified")]),
    1366            4 :             &ctx,
    1367              :         )
    1368            4 :         .expect("parse");
    1369            4 :         assert!(r.key_values, "format=simplified wins");
    1370            4 :         assert!(!r.concise, "options=concise must NOT survive the conflict");
    1371            4 :         let r = parse_repr(
    1372            4 :             &params(&[("options", "keyValues"), ("format", "normalized")]),
    1373            4 :             &ctx,
    1374              :         )
    1375            4 :         .expect("parse");
    1376            4 :         assert!(!r.key_values && !r.concise, "normalized is neither");
    1377            4 :         let r = parse_repr(&params(&[("options", "sysAttrs,keyValues")]), &ctx).expect("parse");
    1378            4 :         assert!(r.sys_attrs && r.key_values);
    1379            4 :     }
    1380              : 
    1381              :     /// 4.21: pick, omit and attrs are mutually exclusive — any pair is a 400,
    1382              :     /// each one alone is fine.
    1383              :     #[test]
    1384            4 :     fn pick_omit_and_attrs_cannot_be_combined() {
    1385            4 :         let ctx = Loader::new().core();
    1386           16 :         for p in [
    1387            4 :             params(&[("pick", "a"), ("omit", "b")]),
    1388            4 :             params(&[("pick", "a"), ("attrs", "b")]),
    1389            4 :             params(&[("omit", "a"), ("attrs", "b")]),
    1390            4 :             params(&[("pick", "a"), ("omit", "b"), ("attrs", "c")]),
    1391            4 :         ] {
    1392           16 :             let e = parse_repr(&p, &ctx).expect_err("must be rejected");
    1393           16 :             assert!(matches!(e, NgsiError::BadRequestData(_)), "got {e:?}");
    1394              :         }
    1395            4 :         assert!(parse_repr(&params(&[("pick", "a")]), &ctx).is_ok());
    1396            4 :         assert!(parse_repr(&params(&[("attrs", "a")]), &ctx).is_ok());
    1397            4 :     }
    1398              : 
    1399              :     /// attrs= selects ATTRIBUTES: an entity meta member or @context is not an
    1400              :     /// attribute name, and an empty member is a grammar violation.
    1401              :     #[test]
    1402            4 :     fn attrs_rejects_entity_members_and_empty_names() {
    1403            4 :         let ctx = Loader::new().core();
    1404           28 :         for bad in ["id", "type", "scope", "createdAt", "@context", "a,,b", ""] {
    1405           28 :             assert!(
    1406           28 :                 parse_repr(&params(&[("attrs", bad)]), &ctx).is_err(),
    1407              :                 "attrs={bad:?} must be rejected"
    1408              :             );
    1409              :         }
    1410            4 :         let r = parse_repr(&params(&[("attrs", "temperature, humidity")]), &ctx).expect("parse");
    1411            4 :         let list = r.attrs.expect("attrs");
    1412            4 :         assert_eq!(list.len(), 2);
    1413            8 :         assert!(list.iter().all(|a| a.contains("://")), "names are expanded");
    1414            4 :     }
    1415              : 
    1416           36 :     fn entity() -> Value {
    1417           36 :         json!({
    1418           36 :             "id": "urn:ngsi-ld:E:1",
    1419           36 :             "type": "T",
    1420           36 :             "createdAt": "2026-01-01T00:00:00Z",
    1421           36 :             "modifiedAt": "2026-01-02T00:00:00Z",
    1422           36 :             "https://example.org/temperature": [
    1423           36 :                 {"type": "Property", "value": 21,
    1424           36 :                  "createdAt": "2026-01-01T00:00:00Z",
    1425           36 :                  "https://example.org/accuracy": [{"type": "Property", "value": 0.5}]},
    1426           36 :                 {"type": "Property", "value": 9, "datasetId": "urn:ds:2"}
    1427              :             ]
    1428              :         })
    1429           36 :     }
    1430              : 
    1431              :     /// 6.3.11 Table 6.3.11-1: createdAt/modifiedAt are system attributes —
    1432              :     /// they must be absent from the default representation, on the entity AND
    1433              :     /// on every attribute instance, and present with options=sysAttrs.
    1434              :     #[test]
    1435            4 :     fn system_attributes_stay_hidden_without_sysattrs() {
    1436            4 :         let plain = apply(&entity(), &Repr::default());
    1437            4 :         assert!(plain.get("createdAt").is_none(), "entity createdAt leaked");
    1438            4 :         assert!(
    1439            4 :             plain.get("modifiedAt").is_none(),
    1440              :             "entity modifiedAt leaked"
    1441              :         );
    1442            4 :         assert!(
    1443            4 :             plain["https://example.org/temperature"][0]
    1444            4 :                 .get("createdAt")
    1445            4 :                 .is_none(),
    1446              :             "instance createdAt leaked"
    1447              :         );
    1448            4 :         assert_eq!(plain["id"], "urn:ngsi-ld:E:1");
    1449            4 :         let sys = apply(
    1450            4 :             &entity(),
    1451            4 :             &Repr {
    1452            4 :                 sys_attrs: true,
    1453            4 :                 ..Repr::default()
    1454            4 :             },
    1455              :         );
    1456            4 :         assert_eq!(sys["createdAt"], "2026-01-01T00:00:00Z");
    1457            4 :         assert_eq!(
    1458            4 :             sys["https://example.org/temperature"][0]["createdAt"],
    1459              :             "2026-01-01T00:00:00Z"
    1460              :         );
    1461            4 :     }
    1462              : 
    1463              :     /// 4.5.4 concise: the type member is dropped and an instance left with a
    1464              :     /// lone value collapses to that bare value — sub-attributes included.
    1465              :     #[test]
    1466            4 :     fn concise_drops_type_and_collapses_bare_values() {
    1467            4 :         let out = apply(
    1468            4 :             &entity(),
    1469            4 :             &Repr {
    1470            4 :                 concise: true,
    1471            4 :                 ..Repr::default()
    1472            4 :             },
    1473              :         );
    1474            4 :         let inst = &out["https://example.org/temperature"][0];
    1475            4 :         assert!(inst.get("type").is_none(), "concise keeps no type member");
    1476            4 :         assert_eq!(
    1477            4 :             inst["https://example.org/accuracy"],
    1478            4 :             json!([0.5]),
    1479              :             "a value-only sub-attribute collapses to the bare value"
    1480              :         );
    1481              :         // an instance carrying more than value keeps the object form
    1482            4 :         let d = apply(
    1483            4 :             &json!({"https://example.org/a": [{"type": "Property", "value": 1, "unitCode": "CEL"}]}),
    1484            4 :             &Repr {
    1485            4 :                 concise: true,
    1486            4 :                 ..Repr::default()
    1487            4 :             },
    1488              :         );
    1489            4 :         assert_eq!(d["https://example.org/a"][0]["unitCode"], "CEL");
    1490            4 :         assert_eq!(d["https://example.org/a"][0]["value"], 1);
    1491            4 :     }
    1492              : 
    1493              :     /// 4.5.4: with several instances the simplified form is a dataset map
    1494              :     /// keyed by datasetId, "@none" standing for the default instance — a bare
    1495              :     /// array of values would lose which instance is which. The map carries one
    1496              :     /// pair for each datasetId, so a dropped instance is a dropped datasetId.
    1497              :     #[test]
    1498            4 :     fn simplified_multi_instance_uses_the_dataset_map() {
    1499            4 :         let out = apply(
    1500            4 :             &entity(),
    1501            4 :             &Repr {
    1502            4 :                 key_values: true,
    1503            4 :                 ..Repr::default()
    1504            4 :             },
    1505              :         );
    1506            4 :         let t = &out["https://example.org/temperature"];
    1507            4 :         assert!(t.get("dataset").is_some(), "multi-instance needs the map");
    1508            4 :         assert_eq!(t["dataset"]["@none"], 21);
    1509            4 :         assert_eq!(t["dataset"]["urn:ds:2"], 9);
    1510            4 :         assert!(t.as_array().is_none(), "not a bare array");
    1511              :         // one instance stays a bare value
    1512            4 :         let one = apply(
    1513            4 :             &json!({"https://example.org/a": [{"type": "Property", "value": 1}]}),
    1514            4 :             &Repr {
    1515            4 :                 key_values: true,
    1516            4 :                 ..Repr::default()
    1517            4 :             },
    1518              :         );
    1519            4 :         assert_eq!(one["https://example.org/a"], json!(1));
    1520              :         // 4.5.4 EXAMPLE 2 is three instances; the map is built by pairing
    1521              :         // instances with the rows they came from, so an instance lost on the
    1522              :         // way leaves a map one pair short rather than an error
    1523            4 :         let three = apply(
    1524            4 :             &json!({"https://example.org/name": [
    1525            4 :                 {"type": "Property", "value": "David Robert Jones"},
    1526            4 :                 {"type": "Property", "value": "David Bowie",
    1527            4 :                  "datasetId": "urn:ngsi-ld:datasetId:001"},
    1528            4 :                 {"type": "Property", "value": "Ziggy Stardust",
    1529            4 :                  "datasetId": "urn:ngsi-ld:datasetId:002"}
    1530            4 :             ]}),
    1531            4 :             &Repr {
    1532            4 :                 key_values: true,
    1533            4 :                 ..Repr::default()
    1534            4 :             },
    1535              :         );
    1536            4 :         let ds = three["https://example.org/name"]["dataset"]
    1537            4 :             .as_object()
    1538            4 :             .expect("dataset map");
    1539            4 :         assert_eq!(ds.len(), 3, "one pair for each datasetId");
    1540            4 :         assert_eq!(ds["@none"], "David Robert Jones");
    1541            4 :         assert_eq!(ds["urn:ngsi-ld:datasetId:001"], "David Bowie");
    1542            4 :         assert_eq!(ds["urn:ngsi-ld:datasetId:002"], "Ziggy Stardust");
    1543            4 :     }
    1544              : 
    1545              :     /// datasetId= selects instances; "@none" selects the default one. An
    1546              :     /// attribute left with no surviving instance is absent, not empty.
    1547              :     #[test]
    1548            4 :     fn dataset_id_filter_selects_instances_and_drops_empty_attributes() {
    1549           12 :         let sel = |ids: &[&str]| {
    1550           12 :             apply(
    1551           12 :                 &entity(),
    1552              :                 &Repr {
    1553           12 :                     dataset_id: Some(ids.iter().map(|s| (*s).to_owned()).collect()),
    1554           12 :                     ..Repr::default()
    1555              :                 },
    1556              :             )
    1557           12 :         };
    1558            4 :         let out = sel(&["urn:ds:2"]);
    1559            4 :         let insts = out["https://example.org/temperature"]
    1560            4 :             .as_array()
    1561            4 :             .expect("array");
    1562            4 :         assert_eq!(insts.len(), 1);
    1563            4 :         assert_eq!(insts[0]["value"], 9, "the default instance must be gone");
    1564            4 :         let out = sel(&["@none"]);
    1565            4 :         let insts = out["https://example.org/temperature"]
    1566            4 :             .as_array()
    1567            4 :             .expect("array");
    1568            4 :         assert_eq!(insts.len(), 1);
    1569            4 :         assert_eq!(insts[0]["value"], 21);
    1570            4 :         let out = sel(&["urn:ds:absent"]);
    1571            4 :         assert!(
    1572            4 :             out.get("https://example.org/temperature").is_none(),
    1573              :             "an attribute with no matching instance is omitted entirely"
    1574              :         );
    1575            4 :         assert_eq!(out["id"], "urn:ngsi-ld:E:1", "entity members survive");
    1576            4 :     }
    1577              : 
    1578              :     /// The reserved members of an attribute instance carry values, not
    1579              :     /// sub-attributes: an array-valued `value` (or objectList, previousValue…)
    1580              :     /// must be passed through untouched, never walked as a list of instances.
    1581              :     #[test]
    1582            4 :     fn array_valued_reserved_members_are_not_sub_attributes() {
    1583            4 :         let doc = json!({"https://example.org/a": [{
    1584            4 :             "type": "ListProperty",
    1585            4 :             "valueList": [1, 2, 3],
    1586            4 :             "value": [{"type": "Property", "value": 7}],
    1587            4 :             "previousValue": [9],
    1588            4 :             "https://example.org/note": [{"type": "Property", "value": "sub"}]
    1589              :         }]});
    1590            4 :         let out = apply(&doc, &Repr::default());
    1591            4 :         let inst = &out["https://example.org/a"][0];
    1592            4 :         assert_eq!(inst["valueList"], json!([1, 2, 3]));
    1593            4 :         assert_eq!(
    1594            4 :             inst["value"],
    1595            4 :             json!([{"type": "Property", "value": 7}]),
    1596              :             "an array value is data, not an instance list to transform"
    1597              :         );
    1598            4 :         assert_eq!(inst["previousValue"], json!([9]));
    1599              :         // a genuine sub-attribute IS walked
    1600            4 :         assert_eq!(inst["https://example.org/note"][0]["value"], "sub");
    1601              :         // …and the walk applies the representation to it
    1602            4 :         let sys = apply(
    1603            4 :             &json!({"https://example.org/a": [{"type": "Property", "value": 1,
    1604            4 :                 "https://example.org/note": [{"type": "Property", "value": "s",
    1605            4 :                     "modifiedAt": "2026-01-01T00:00:00Z"}]}]}),
    1606            4 :             &Repr::default(),
    1607              :         );
    1608            4 :         assert!(
    1609            4 :             sys["https://example.org/a"][0]["https://example.org/note"][0]
    1610            4 :                 .get("modifiedAt")
    1611            4 :                 .is_none(),
    1612              :             "the sysAttrs gate reaches sub-attributes"
    1613              :         );
    1614            4 :     }
    1615              : 
    1616              :     /// 4.21: pick constrains core members too — an entity member not picked
    1617              :     /// does not survive; omit only drops the heads it names outright.
    1618              :     #[test]
    1619            4 :     fn pick_is_strict_over_core_members_and_omit_is_not() {
    1620            4 :         let ctx = Loader::new().core();
    1621            4 :         let picked = apply(
    1622            4 :             &entity(),
    1623            4 :             &Repr {
    1624            4 :                 pick: Some(parse_projection("id", &ctx).expect("pick")),
    1625            4 :                 ..Repr::default()
    1626            4 :             },
    1627              :         );
    1628            4 :         assert_eq!(picked["id"], "urn:ngsi-ld:E:1");
    1629            4 :         assert!(picked.get("type").is_none(), "type was not picked");
    1630            4 :         assert!(
    1631            4 :             picked.get("https://example.org/temperature").is_none(),
    1632              :             "an unpicked attribute must not survive"
    1633              :         );
    1634            4 :         let omitted = apply(
    1635            4 :             &entity(),
    1636            4 :             &Repr {
    1637            4 :                 omit: Some(parse_projection("scope", &ctx).expect("omit")),
    1638            4 :                 ..Repr::default()
    1639            4 :             },
    1640              :         );
    1641            4 :         assert_eq!(omitted["type"], "T", "omit leaves the rest of the entity");
    1642            4 :     }
    1643              : 
    1644              :     /// 4.5.16.1/4.5.16.2/4.5.16.3: geometry selection (default instance,
    1645              :     /// datasetId-narrowed single, invalid value -> null) and the
    1646              :     /// Feature/FeatureCollection shapes.
    1647              :     #[test]
    1648            4 :     fn geojson_feature_selection_and_shape() {
    1649              :         use super::{to_geojson_collection, to_geojson_feature};
    1650              :         use serde_json::Value;
    1651            4 :         let entity = json!({
    1652            4 :             "id": "urn:ngsi-ld:V:1", "type": "Vehicle",
    1653            4 :             "location": [
    1654            4 :                 {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [9.0, 9.0]},
    1655            4 :                  "datasetId": "urn:ngsi-ld:Dataset:gps"},
    1656            4 :                 {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [1.0, 2.0]}}
    1657              :             ],
    1658            4 :             "speed": {"type": "Property", "value": 5}
    1659              :         });
    1660            4 :         let f = to_geojson_feature(entity.clone(), None);
    1661            4 :         assert_eq!(f["type"], "Feature");
    1662            4 :         assert_eq!(f["id"], "urn:ngsi-ld:V:1");
    1663              :         // default instance (no datasetId) wins over the first array element
    1664            4 :         assert_eq!(
    1665            4 :             f["geometry"],
    1666            4 :             json!({"type": "Point", "coordinates": [1.0, 2.0]})
    1667              :         );
    1668            4 :         assert_eq!(f["properties"]["type"], "Vehicle");
    1669            4 :         assert!(
    1670            4 :             f["properties"].get("id").is_none(),
    1671              :             "id only at Feature level"
    1672              :         );
    1673            4 :         assert!(f["properties"].get("speed").is_some());
    1674              : 
    1675              :         // geometryProperty naming a non-geometry Property -> null geometry
    1676            4 :         let f2 = to_geojson_feature(entity.clone(), Some(&"speed".to_string()));
    1677            4 :         assert_eq!(f2["geometry"], Value::Null);
    1678              :         // absent GeoProperty -> null geometry
    1679            4 :         let f3 = to_geojson_feature(entity.clone(), Some(&"missing".to_string()));
    1680            4 :         assert_eq!(f3["geometry"], Value::Null);
    1681              : 
    1682              :         // 4.5.17.1: simplified multi-instance GeoProperty = dataset map;
    1683              :         // the "@none" (default) entry is the geometry
    1684            4 :         let simplified = json!({
    1685            4 :             "id": "urn:ngsi-ld:V:2", "type": "Vehicle",
    1686            4 :             "location": {"dataset": {
    1687            4 :                 "urn:ngsi-ld:Dataset:gps": {"type": "Point", "coordinates": [9.0, 9.0]},
    1688            4 :                 "@none": {"type": "Point", "coordinates": [3.0, 4.0]}
    1689              :             }},
    1690            4 :             "speed": 5
    1691              :         });
    1692            4 :         let fs = to_geojson_feature(simplified, None);
    1693            4 :         assert_eq!(
    1694            4 :             fs["geometry"],
    1695            4 :             json!({"type": "Point", "coordinates": [3.0, 4.0]})
    1696              :         );
    1697            4 :         assert_eq!(fs["properties"]["speed"], 5);
    1698              : 
    1699            4 :         let fc = to_geojson_collection(vec![entity], None);
    1700            4 :         assert_eq!(fc["type"], "FeatureCollection");
    1701            4 :         assert_eq!(fc["features"].as_array().map(Vec::len), Some(1));
    1702            4 :         assert!(
    1703            4 :             fc["features"][0].get("@context").is_none(),
    1704              :             "no per-Feature @context"
    1705              :         );
    1706              :         // Table 5.2.30-1: "In the case that no matches are found, features
    1707              :         // will be an empty array"
    1708            4 :         let empty = to_geojson_collection(vec![], None);
    1709            4 :         assert_eq!(empty["type"], "FeatureCollection");
    1710            4 :         assert_eq!(empty["features"], json!([]));
    1711            4 :     }
    1712              : }
        

Generated by: LCOV version 2.0-1