LCOV - code coverage report
Current view: top level - antares-api/src - entities.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 97.3 % 3493 3398
Test Date: 2026-11-02 13:21:27 Functions: 59.0 % 1201 708

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! /entities resource (CIM 009 6.4–6.7; operations 5.6.1–5.6.6, 5.6.17,
       3              : //! 5.6.18, 5.6.19, 5.6.21, 5.7.1, 5.7.2).
       4              : 
       5              : use crate::history::mirror_delete_entity;
       6              : use crate::negotiate::*;
       7              : use crate::paging::{attach_warnings, order_entities, page_params, paginate, paginate_pre};
       8              : use crate::repr::{apply, parse_repr};
       9              : use crate::repr::{
      10              :     collect_flat_beyond, compact_for, inline_join_beyond, to_geojson_collection,
      11              :     to_geojson_feature, MAX_JOIN_LOOKUPS,
      12              : };
      13              : use crate::stamp::stamp_new;
      14              : use crate::state::{now_iso, AppState};
      15              : use antares_jsonld::{expand_entity, is_ngsi_null, ExpandOpts};
      16              : use antares_model::{NgsiError, TenantId};
      17              : use antares_ql::eval::eval_q;
      18              : use antares_ql::parse_q;
      19              : use antares_store::CurrentStateDriverExt;
      20              : use antares_store::Kind;
      21              : use axum::body::Bytes;
      22              : use axum::extract::{Path, State};
      23              : use axum::http::{HeaderMap, StatusCode};
      24              : use axum::response::{IntoResponse, Response};
      25              : use serde_json::{json, Map, Value};
      26              : use std::collections::HashMap;
      27              : 
      28              : use crate::negotiate::CleanParams;
      29              : 
      30              : use antares_model::is_meta;
      31              : 
      32              : // ---------- temporal mirroring (auto-recording; Scorpio ENTITY-topic parity) ----------
      33              : //
      34              : // Append-side auto-recording (create/update/partial/merge/replace/batch) is
      35              : // driven centrally off the store's change hook — see
      36              : // `notify::record_temporal_change`. Only the DELETION mirrors below stay as
      37              : // explicit handler calls (their typed-null deletion shape is not derivable
      38              : // from a plain before/after append).
      39              : 
      40              : // ---------- POST /entities/ (5.6.1) ----------
      41              : 
      42              : /// 5.6.1.5: the output of a successful Create Entity is "the URI of the
      43              : /// created Entity" — the resource URL carried in the Location header. The id
      44              : /// is one path segment (RFC 3986 clause 3.3), so it is percent-encoded:
      45              : /// spliced raw, a `#` in the id turns the rest of it into a fragment and the
      46              : /// client is handed a URL addressing a different resource.
      47        12250 : fn entity_location(id: &str) -> String {
      48        12250 :     format!(
      49              :         "/ngsi-ld/v1/entities/{}",
      50        12250 :         crate::federation::path_segment(id)
      51              :     )
      52        12250 : }
      53              : 
      54              : /// The selector of Entity types is input data of Delete (5.6.6.3), Merge
      55              : /// (5.6.17.3) and Replace Entity (5.6.18.3), and each of those clauses
      56              : /// forwards "matching input data ... to the Registration endpoint". A
      57              : /// registration may cover several Entity types, so a forward that drops the
      58              : /// selector lets the peer act on an entity the client's selector excluded.
      59          170 : fn type_selector_query(params: &HashMap<String, String>) -> Vec<(String, String)> {
      60          170 :     params
      61          170 :         .get("type")
      62          170 :         .map(|v| ("type".to_owned(), v.clone()))
      63          170 :         .into_iter()
      64          170 :         .collect()
      65          170 : }
      66              : 
      67        12682 : pub async fn create_entity(
      68        12682 :     State(st): State<AppState>,
      69        12682 :     CleanParams(params): CleanParams,
      70        12682 :     headers: HeaderMap,
      71        12682 :     body: Bytes,
      72        12682 : ) -> Response {
      73        12682 :     match create_entity_inner(&st, &params, &headers, &body).await {
      74        12250 :         Ok(r) => r,
      75          432 :         Err(e) => e.into_response(),
      76              :     }
      77        12682 : }
      78              : 
      79        12682 : async fn create_entity_inner(
      80        12682 :     st: &AppState,
      81        12682 :     params: &HashMap<String, String>,
      82        12682 :     headers: &HeaderMap,
      83        12682 :     body: &[u8],
      84        12682 : ) -> ApiResult<Response> {
      85        12682 :     let tenant = tenant_from(headers)?;
      86        12682 :     check_params(params, &["local"])?;
      87        12678 :     let parsed = parse_body(st, headers, body, BodyKind::Standard).await?;
      88        12608 :     let obj = parsed.object(NgsiError::InvalidRequest(
      89        12608 :         "entity document must be a JSON object".into(),
      90        12608 :     ))?;
      91        12608 :     let mut expanded = expand_entity(obj, &parsed.ctx, ExpandOpts::default())?;
      92        12278 :     let id = antares_jsonld::new_entity_id(&expanded)?.to_owned();
      93              : 
      94              :     // distributed create (4.3.6, 6.4.3.1)
      95        12274 :     let types: Option<Vec<String>> = expanded.get("type").and_then(Value::as_array).map(|a| {
      96        12274 :         a.iter()
      97        12274 :             .filter_map(Value::as_str)
      98        12274 :             .map(str::to_owned)
      99        12274 :             .collect()
     100        12274 :     });
     101        12274 :     let attr_iris: Vec<String> = expanded
     102        12274 :         .as_object()
     103        46098 :         .map(|o| o.keys().filter(|k| !is_meta(k)).cloned().collect())
     104        12274 :         .unwrap_or_default();
     105        12274 :     let spec = crate::registry::CsrSpec {
     106        12274 :         ids: Some(vec![id.clone()]),
     107        12274 :         types,
     108        12274 :         attrs: (!attr_iris.is_empty()).then_some(attr_iris),
     109        12274 :         ..Default::default()
     110        12274 :     };
     111              :     // ADR-0020: the policy seam, once per request, after expansion and
     112              :     // before the operation or any fan-out. Everything the engine is given
     113              :     // is the expanded form the store would see.
     114        12274 :     gate!(
     115              :         st, &tenant, headers, "5.6.1",
     116              :         ids: &[&id],
     117              :         types: spec.types.as_deref().unwrap_or(&[]),
     118              :         attrs: spec.attrs.as_deref().unwrap_or(&[]),
     119              :         body: Some(&expanded),
     120              :     )
     121        12274 :     .await?;
     122        12266 :     let regs = match crate::federation::write_plan(st, &tenant, &spec, &parsed.ctx, params, headers)
     123        12266 :         .await?
     124              :     {
     125            0 :         crate::federation::WritePlan::Answered(r) => return Ok(*r),
     126        12266 :         crate::federation::WritePlan::Forward(regs) => regs,
     127              :     };
     128        12266 :     if !regs.is_empty() {
     129              :         // 5.6.1.4: exclusive/redirect registrations not supporting the
     130              :         // Create Entity operation yield an error of type Conflict (and are
     131              :         // never contacted); an inclusive one is simply not forwarded.
     132          160 :         let (conflicts, fwd) =
     133          160 :             crate::federation::scoped_fragments(&regs, "createEntity", obj, &parsed.ctx);
     134          160 :         let proxies: Vec<&crate::federation::FedReg> =
     135          166 :             regs.iter().filter(|r| r.is_proxy()).collect();
     136          160 :         let (rest, has_attrs) = crate::federation::strip_proxied(obj, &proxies, &parsed.ctx);
     137          160 :         let mut parts = Vec::new();
     138              :         // local part only when something non-proxied remains (4.3.6.3)
     139          160 :         if has_attrs || proxies.is_empty() {
     140          106 :             let mut local_exp = expand_entity(&rest, &parsed.ctx, ExpandOpts::default())?;
     141          106 :             stamp_new(&mut local_exp, &now_iso());
     142          106 :             if st
     143          106 :                 .store
     144          106 :                 .create(&tenant, Kind::Entity, &id, local_exp.clone())
     145          106 :                 .await?
     146           98 :             {
     147           98 :                 parts.push(crate::federation::Part::local(201, "created locally"));
     148           98 :             } else {
     149            8 :                 parts.push(crate::federation::Part::local(
     150            8 :                     409,
     151            8 :                     format!("entity {id} already exists"),
     152            8 :                 ));
     153            8 :             }
     154           54 :         }
     155          160 :         parts.extend(conflicts);
     156          160 :         let ctx_url = crate::federation::ctx_link_url(headers, &parsed.ctx.source);
     157          160 :         for (reg, frag) in fwd {
     158           78 :             parts.push(
     159           78 :                 crate::federation::forward_part(
     160           78 :                     st,
     161           78 :                     reqwest::Method::POST,
     162           78 :                     format!("{}/ngsi-ld/v1/entities", reg.endpoint),
     163           78 :                     &[],
     164           78 :                     headers,
     165           78 :                     &tenant,
     166           78 :                     &reg,
     167           78 :                     &ctx_url,
     168           78 :                     Some(frag),
     169           78 :                 )
     170           78 :                 .await,
     171              :             );
     172              :         }
     173          160 :         return Ok(crate::federation::combine(
     174          160 :             st.profile,
     175          160 :             Some(&id),
     176          160 :             parts,
     177          160 :             created(entity_location(&id), &tenant),
     178          160 :             &tenant,
     179          160 :         ));
     180        12106 :     }
     181              : 
     182        12106 :     stamp_new(&mut expanded, &now_iso());
     183        12106 :     if !st
     184        12106 :         .store
     185        12106 :         .create(&tenant, Kind::Entity, &id, expanded.clone())
     186        12106 :         .await?
     187              :     {
     188           16 :         return Err(NgsiError::AlreadyExists(format!("entity {id} already exists")).into());
     189        12090 :     }
     190        12090 :     Ok(created(entity_location(&id), &tenant))
     191        12682 : }
     192              : 
     193              : // ---------- GET /entities/{id} (5.7.1) ----------
     194              : 
     195         2224 : pub async fn retrieve_entity(
     196         2224 :     State(st): State<AppState>,
     197         2224 :     Path(id): Path<String>,
     198         2224 :     CleanParams(params): CleanParams,
     199         2224 :     headers: HeaderMap,
     200         2224 : ) -> Response {
     201         2224 :     match retrieve_entity_outer(&st, &id, &params, &headers).await {
     202         2056 :         Ok(r) => r,
     203          168 :         Err(e) => e.into_response(),
     204              :     }
     205         2224 : }
     206              : 
     207              : /// 5.7.1.4 Retrieve Entity: the EntityMap half of the clause is the shared
     208              : /// rule (`entity_maps::retrieve_with_map`); this is the retrieve it wraps.
     209         2224 : async fn retrieve_entity_outer(
     210         2224 :     st: &AppState,
     211         2224 :     id: &str,
     212         2224 :     params: &HashMap<String, String>,
     213         2224 :     headers: &HeaderMap,
     214         2224 : ) -> ApiResult<Response> {
     215         2224 :     crate::entity_map::retrieve_with_map(st, id, params, headers, false, |map| async move {
     216         2222 :         retrieve_entity_inner(st, id, params, headers, map.as_ref()).await
     217         4444 :     })
     218         2224 :     .await
     219         2224 : }
     220              : 
     221         2222 : async fn retrieve_entity_inner(
     222         2222 :     st: &AppState,
     223         2222 :     id: &str,
     224         2222 :     params: &HashMap<String, String>,
     225         2222 :     headers: &HeaderMap,
     226         2222 :     map: Option<&Value>,
     227         2222 : ) -> ApiResult<Response> {
     228         2222 :     let tenant = tenant_from(headers)?;
     229         2222 :     check_params(
     230         2222 :         params,
     231         2222 :         &[
     232         2222 :             "attrs",
     233         2222 :             "pick",
     234         2222 :             "omit",
     235         2222 :             "options",
     236         2222 :             "format",
     237         2222 :             "lang",
     238         2222 :             "type",
     239         2222 :             "geometryProperty",
     240         2222 :             "datasetId",
     241         2222 :             "containedBy",
     242         2222 :             "join",
     243         2222 :             "joinLevel",
     244         2222 :             "local",
     245         2222 :             "entityMap",
     246         2222 :         ],
     247            0 :     )?;
     248         2222 :     let accept = parse_accept_geo(headers)?;
     249              :     // 5.7.1.4: geometryProperty is only meaningful for the GeoJSON
     250              :     // representation — any other Accept is BadRequestData
     251         2218 :     if params.contains_key("geometryProperty") && accept != Accept::GeoJson {
     252           12 :         return Err(NgsiError::BadRequestData(
     253           12 :             "geometryProperty requires Accept: application/geo+json (5.7.1.4)".into(),
     254           12 :         )
     255           12 :         .into());
     256         2206 :     }
     257         2206 :     let ctx = request_context(&st.loader, headers).await?;
     258         2206 :     let named = ReadMembers::with_ctx(st, params, &ctx);
     259         2206 :     let filter = gate!(
     260              :         st, &tenant, headers, "5.7.1",
     261              :         ids: &[id],
     262              :         types: &named.types,
     263              :         attrs: &named.attrs,
     264              :     )
     265         2206 :     .await?;
     266         2202 :     let mut repr = parse_repr(params, &ctx)?;
     267         2146 :     crate::repr::narrow_repr(&mut repr, &filter);
     268         2146 :     let join = parse_join(params)?;
     269         2146 :     check_linked_projection(&repr, &join)?;
     270         2130 :     antares_model::EntityId::new(id)?;
     271         2126 :     let local_doc = st.store.get(&tenant, Kind::Entity, id).await?;
     272         2122 :     let looped = crate::federation::via_loop(
     273         2122 :         headers,
     274         2122 :         &crate::federation::alias_for(&st.host_alias, &tenant),
     275              :     );
     276         2122 :     let fed_on = crate::federation::active(params) && !looped;
     277              :     // 6.3.17: abnormal distributed-GET outcomes surface as NGSILD-Warning
     278         2122 :     let mut warnings: Vec<String> = Vec::new();
     279         2122 :     if crate::federation::active(params) && looped {
     280            8 :         let spec = crate::registry::CsrSpec {
     281            8 :             ids: Some(vec![id.to_owned()]),
     282            8 :             ..Default::default()
     283            8 :         };
     284              :         // only a loop that suppressed a real forward is abnormal behaviour
     285            8 :         if !crate::federation::matching_regs(st, &tenant, &spec, &ctx, headers)
     286            8 :             .await?
     287            8 :             .is_empty()
     288            8 :         {
     289            8 :             warnings.push(crate::federation::warning(
     290            8 :                 199,
     291            8 :                 &crate::federation::alias_for(&st.host_alias, &tenant),
     292            8 :                 "a registration loop has been detected",
     293            8 :             ));
     294            8 :         }
     295         2114 :     }
     296         2122 :     let doc = if fed_on {
     297         1982 :         let mut sole_failure = None;
     298         1982 :         let fed = crate::federation::fed_retrieve(
     299         1982 :             st,
     300         1982 :             &tenant,
     301         1982 :             headers,
     302         1982 :             &ctx,
     303         1982 :             id,
     304         1982 :             map,
     305         1982 :             None,
     306         1982 :             &mut warnings,
     307         1982 :             &mut sole_failure,
     308         1982 :         )
     309         1982 :         .await?;
     310         1980 :         match local_doc {
     311         1694 :             Some(mut base) => {
     312         3388 :                 for aux_pass in [false, true] {
     313         3388 :                     for (aux, d) in &fed {
     314           24 :                         if *aux == aux_pass {
     315           12 :                             crate::federation::merge_docs(&mut base, d, *aux);
     316           12 :                         }
     317              :                     }
     318              :                 }
     319         1694 :                 base
     320              :             }
     321              :             None => {
     322          286 :                 let first = fed
     323          286 :                     .iter()
     324          286 :                     .find(|(aux, _)| !aux)
     325          286 :                     .map(|(_, d)| d.clone())
     326          286 :                     .or_else(|| fed.first().map(|(_, d)| d.clone()));
     327          286 :                 let Some(mut base) = first else {
     328              :                     // 6.3.17 p.278: "In the case of an exclusive or redirect
     329              :                     // registration, where all of the data is held outside of
     330              :                     // the Context Broker and held in a single registered
     331              :                     // source, the following errors shall be returned": 504
     332              :                     // when it "fails to respond in time", 502 when "the
     333              :                     // single forwarded request fails for any other reason".
     334              :                     // Its 404 stays the plain 404 below.
     335          234 :                     if let Some(p) = sole_failure.filter(|p| p.status != 404) {
     336           10 :                         let mut resp = crate::federation::source_error(&p, &tenant);
     337           10 :                         attach_warnings(&mut resp, &warnings);
     338           10 :                         return Ok(resp);
     339          224 :                     }
     340              :                     // 6.3.17: abnormal distributed outcomes surface as
     341              :                     // NGSILD-Warning even when the retrieve ends 404
     342          224 :                     let mut resp = ApiError::from(NgsiError::ResourceNotFound(format!(
     343          224 :                         "entity {id} not found"
     344          224 :                     )))
     345          224 :                     .into_response();
     346          224 :                     attach_warnings(&mut resp, &warnings);
     347          224 :                     echo_tenant(&tenant, &mut resp);
     348          224 :                     return Ok(resp);
     349              :                 };
     350          104 :                 for aux_pass in [false, true] {
     351          112 :                     for (aux, d) in &fed {
     352          112 :                         if *aux == aux_pass {
     353           56 :                             crate::federation::merge_docs(&mut base, d, *aux);
     354           56 :                         }
     355              :                     }
     356              :                 }
     357           52 :                 base
     358              :             }
     359              :         }
     360              :     } else {
     361          140 :         local_doc.ok_or_else(|| NgsiError::ResourceNotFound(format!("entity {id} not found")))?
     362              :     };
     363              :     // 5.7.1.4: no entity "whose id (URI), and where specified type, is
     364              :     // equivalent" — the optional ?type selector (4.17) narrows the target
     365         1854 :     if !crate::negotiate::matches_type_param(&doc, params, &ctx) {
     366            8 :         return Err(NgsiError::ResourceNotFound(format!(
     367            8 :             "entity {id} does not match the type selector"
     368            8 :         ))
     369            8 :         .into());
     370         1846 :     }
     371              :     // ADR-0020: an Entity outside the engine's narrowing answers the way
     372              :     // 5.7.1.4 answers an absent one — "If the NGSI-LD Entity does not
     373              :     // exist, an error of type ResourceNotFound shall be raised" — because a
     374              :     // refusal here would tell the caller the Entity is there.
     375         1846 :     if !crate::repr::policy_admits(st, &tenant, &ctx, repr.policy_reach.as_deref(), &doc).await {
     376            8 :         return Err(NgsiError::ResourceNotFound(format!("entity {id} not found")).into());
     377         1838 :     }
     378              :     // 5.7.1: attrs projection with no matching attribute ⇒ 404, read on the
     379              :     // Entity as the narrowing shows it: a hidden Attribute is absent, or
     380              :     // the 404 would tell the caller it is there
     381         1838 :     if let Some(want) = &repr.attrs {
     382           12 :         let shown = crate::repr::policy_view(&doc, &repr);
     383           12 :         if !want.iter().any(|a| shown.get(a).is_some()) {
     384            6 :             return Err(NgsiError::ResourceNotFound(format!(
     385            6 :                 "entity {id} has none of the requested attributes"
     386            6 :             ))
     387            6 :             .into());
     388            6 :         }
     389         1826 :     }
     390         1832 :     let shaped = apply(&doc, &repr);
     391         1832 :     if (repr.pick.is_some() || repr.omit.is_some())
     392           88 :         && shaped.as_object().is_some_and(|o| o.is_empty())
     393              :     {
     394           10 :         return Err(NgsiError::ResourceNotFound(format!(
     395           10 :             "projection matches nothing on entity {id}"
     396           10 :         ))
     397           10 :         .into());
     398         1822 :     }
     399         1822 :     let mut payload = compact_for(&repr, &shaped, &ctx);
     400         1822 :     if let Some((mode, level)) = &join {
     401          134 :         let held = contained_by(params);
     402          134 :         let complete = match mode.as_str() {
     403          134 :             "inline" => {
     404           72 :                 inline_join_beyond(
     405           72 :                     st,
     406           72 :                     &tenant,
     407           72 :                     &ctx,
     408           72 :                     &repr,
     409           72 :                     &mut payload,
     410           72 :                     &doc,
     411           72 :                     *level,
     412           72 :                     &held,
     413           72 :                     &mut { MAX_JOIN_LOOKUPS },
     414           72 :                     fed_on.then_some(headers),
     415           72 :                 )
     416           72 :                 .await
     417              :             }
     418           62 :             "flat" => {
     419           62 :                 let mut linked = std::collections::BTreeMap::new();
     420           62 :                 let complete = collect_flat_beyond(
     421           62 :                     st,
     422           62 :                     &tenant,
     423           62 :                     &ctx,
     424           62 :                     &repr,
     425           62 :                     &doc,
     426           62 :                     *level,
     427           62 :                     &mut linked,
     428           62 :                     &held,
     429           62 :                     &mut { MAX_JOIN_LOOKUPS },
     430           62 :                 )
     431           62 :                 .await;
     432           62 :                 if !linked.is_empty() {
     433           56 :                     let mut arr = vec![payload];
     434           84 :                     for (_, (ldoc, lrepr)) in linked {
     435           84 :                         arr.push(compact_for(&lrepr, &apply(&ldoc, &lrepr), &ctx));
     436           84 :                     }
     437           56 :                     payload = Value::Array(arr);
     438            6 :                 }
     439           62 :                 complete
     440              :             }
     441            0 :             _ => true,
     442              :         };
     443          134 :         if !complete {
     444            8 :             warnings.push(crate::federation::warning(
     445            8 :                 199,
     446            8 :                 &crate::federation::alias_for(&st.host_alias, &tenant),
     447            8 :                 "the linked entity retrieval was truncated",
     448            8 :             ));
     449          126 :         }
     450         1688 :     }
     451         1822 :     let payload = if accept == Accept::GeoJson {
     452           42 :         to_geojson_feature(payload, params.get("geometryProperty"))
     453              :     } else {
     454         1780 :         payload
     455              :     };
     456         1822 :     let mut resp = respond_prefer(StatusCode::OK, payload, &ctx, accept, &tenant, headers);
     457         1822 :     attach_warnings(&mut resp, &warnings);
     458         1822 :     filter.mark(&st.host_alias, resp.headers_mut());
     459         1822 :     Ok(resp)
     460         2222 : }
     461              : 
     462              : /// 5.7.1.4 / 5.7.2.4: a `{…}` projection selects into Linked Entities —
     463              : /// it must be requested via join, and may not select deeper than joinLevel.
     464         4426 : pub(crate) fn check_linked_projection(
     465         4426 :     repr: &crate::repr::Repr,
     466         4426 :     join: &Option<(String, usize)>,
     467         4426 : ) -> ApiResult<()> {
     468         4426 :     let depth = repr
     469         4426 :         .pick
     470         4426 :         .as_deref()
     471         4426 :         .map(crate::repr::proj_depth)
     472         4426 :         .unwrap_or(0)
     473         4426 :         .max(
     474         4426 :             repr.omit
     475         4426 :                 .as_deref()
     476         4426 :                 .map(crate::repr::proj_depth)
     477         4426 :                 .unwrap_or(0),
     478              :         );
     479         4426 :     if depth == 0 {
     480         4310 :         return Ok(());
     481          116 :     }
     482           96 :     match join {
     483           96 :         Some((mode, level)) if mode != "@none" => {
     484           96 :             if depth > *level {
     485           12 :                 return Err(NgsiError::BadRequestData(format!(
     486           12 :                     "projected attribute depth {depth} exceeds joinLevel {level} (5.7.1.4/5.7.2.4)"
     487           12 :                 ))
     488           12 :                 .into());
     489           84 :             }
     490           84 :             Ok(())
     491              :         }
     492           20 :         _ => Err(NgsiError::BadRequestData(
     493           20 :             "projection uses Linked Entity selection but join is not specified (5.7.1.4/5.7.2.4)"
     494           20 :                 .into(),
     495           20 :         )
     496           20 :         .into()),
     497              :     }
     498         4426 : }
     499              : 
     500              : /// join/joinLevel params (4.5.23). Returns (mode, level).
     501         4434 : pub fn parse_join(params: &HashMap<String, String>) -> ApiResult<Option<(String, usize)>> {
     502         4434 :     let Some(mode) = params.get("join") else {
     503         4094 :         return Ok(None);
     504              :     };
     505          340 :     if !["inline", "flat", "@none"].contains(&mode.as_str()) {
     506            4 :         return Err(NgsiError::BadRequestData(format!("invalid join {mode:?}")).into());
     507          336 :     }
     508          336 :     let level = match params.get("joinLevel") {
     509          312 :         Some(l) => l
     510          312 :             .parse::<usize>()
     511          312 :             .ok()
     512              :             // Bounded traversal depth
     513          312 :             .filter(|l| *l >= 1 && *l <= crate::bounds::MAX_JOIN_LEVEL)
     514          312 :             .ok_or_else(|| {
     515            4 :                 NgsiError::BadRequestData(format!(
     516            4 :                     "invalid joinLevel {l:?} (1..={})",
     517            4 :                     crate::bounds::MAX_JOIN_LEVEL
     518            4 :                 ))
     519            4 :             })?,
     520           24 :         None => 1,
     521              :     };
     522          332 :     if mode == "@none" {
     523            4 :         return Ok(None);
     524          328 :     }
     525          328 :     Ok(Some((mode.clone(), level)))
     526         4434 : }
     527              : 
     528              : /// Table 6.4.3.2-1 `containedBy`: "List of entity ids which have previously
     529              : /// been encountered whilst retrieving the Entity Graph. Only applicable if
     530              : /// joinLevel is present." They are already in the graph the client is
     531              : /// assembling, so 4.5.23.1's "avoid ... duplicates or loops" counts them as
     532              : /// resolved and the walk does not follow them again.
     533          308 : pub fn contained_by(params: &HashMap<String, String>) -> Vec<String> {
     534          308 :     params
     535          308 :         .get("containedBy")
     536          308 :         .map(|s| {
     537            4 :             s.split(',')
     538            4 :                 .map(str::trim)
     539            4 :                 .filter(|t| !t.is_empty())
     540            4 :                 .map(str::to_owned)
     541            4 :                 .collect()
     542            4 :         })
     543          308 :         .unwrap_or_default()
     544          308 : }
     545              : 
     546              : // ---------- GET /entities/ (5.7.2) ----------
     547              : 
     548         2278 : pub async fn query_entities(
     549         2278 :     State(st): State<AppState>,
     550         2278 :     CleanParams(params): CleanParams,
     551         2278 :     headers: HeaderMap,
     552         2278 : ) -> Response {
     553         2278 :     match query_entities_outer(&st, params, &headers).await {
     554         1966 :         Ok(r) => r,
     555          312 :         Err(e) => e.into_response(),
     556              :     }
     557         2278 : }
     558              : 
     559              : /// 5.5.14 / 5.5.9.3: a query referencing an EntityMap (NGSILD-EntityMap
     560              : /// request header, 6.4.3.2-2) is fixed to the map's Entities; the filters
     561              : /// are re-checked at processing time and local entries that no longer match
     562              : /// are removed from the map by its creator. An expired or unknown map means
     563              : /// "no inference can be made … a new one shall be created".
     564         2278 : async fn query_entities_outer(
     565         2278 :     st: &AppState,
     566         2278 :     mut params: HashMap<String, String>,
     567         2278 :     headers: &HeaderMap,
     568         2278 : ) -> ApiResult<Response> {
     569         2278 :     let tenant = tenant_from(headers)?;
     570         2278 :     let q_ast = params.get("q").map(|q| parse_q(q)).transpose()?;
     571         2270 :     let named = ReadMembers::of(st, headers, &params).await;
     572         2270 :     let filter = gate!(
     573              :         st, &tenant, headers, "5.7.2",
     574              :         q: q_ast.as_ref(),
     575              :         scope_q: params.get("scopeQ").map(String::as_str),
     576              :         types: &named.types,
     577              :         attrs: &named.attrs,
     578              :         geo: named.geo.as_ref(),
     579              :     )
     580         2270 :     .await?;
     581         2266 :     let Some(map_ref) = single_header(headers, "NGSILD-EntityMap")? else {
     582         2218 :         return query_entities_inner(st, &params, headers, &filter).await;
     583              :     };
     584              :     // 5.7.2.4: an unknown parameter and a too-wide query are BadRequestData
     585              :     // for this request, whether or not it carries an EntityMap reference —
     586              :     // and the paged fetch below walks the whole map, locally and forwarded,
     587              :     // before the inner call would reach these same two checks.
     588           44 :     check_params(&params, crate::negotiate::QUERY_PARAMS)?;
     589           44 :     if !qualifies_non_wide(&params, q_ast.as_ref()) {
     590            0 :         return Err(NgsiError::BadRequestData(
     591            0 :             "query needs at least one of type, attrs, q, georel (5.7.2)".into(),
     592            0 :         )
     593            0 :         .into());
     594           44 :     }
     595           44 :     let map_id = map_ref.rsplit('/').next().unwrap_or(&map_ref).to_owned();
     596           44 :     let Some(mut map) = crate::entity_map::map_if_accessible(st, &tenant, headers, &map_id).await
     597              :     else {
     598              :         // 5.5.14: expired or inaccessible → a new EntityMap is created
     599            6 :         params.insert("entityMap".into(), "true".into());
     600            6 :         return query_entities_inner(st, &params, headers, &filter).await;
     601              :     };
     602           38 :     let ctx = request_context(&st.loader, headers).await?;
     603              :     // a request that references a live map does not create a new one
     604           38 :     params.remove("entityMap");
     605           38 :     let (offset, limit, count) = page_params(st, &params)?;
     606              :     // pagination links carry the ORIGINAL query, never the page's id list
     607           38 :     let link_params = params.clone();
     608           38 :     let accept = parse_accept_geo(headers)?;
     609              : 
     610              :     // 5.5.9.3 paged fetch: the map fixes the candidate id set; candidates
     611              :     // are fetched (locally + forwarded) chunk by chunk, "filters shall be
     612              :     // rechecked before returning results" per chunk, and visited entries
     613              :     // that no longer match are removed from the map — "Entities not or no
     614              :     // longer fitting the query shall be removed from the Entity map during
     615              :     // pagination". Pruning is judgeable only for "@none" (local) entries: a
     616              :     // remote-backed id may merely have an unreachable source right now
     617              :     // (5.5.14). Memory per request is O(chunk), never O(map) — the reason
     618              :     // EntityMaps exist for the distributed case. count=true walks every
     619              :     // candidate (the total needs each id checked), still chunk-bounded.
     620           38 :     let ids: Vec<String> = crate::entity_map::candidate_ids(&map, &params);
     621           38 :     let narrow = crate::repr::narrowing_reading(&filter, &params, &ctx)?;
     622           38 :     let looped = crate::federation::via_loop(
     623           38 :         headers,
     624           38 :         &crate::federation::alias_for(&st.host_alias, &tenant),
     625              :     );
     626           38 :     let chunk_size = limit.max(20);
     627           38 :     let mut page_ids: Vec<String> = Vec::new();
     628           38 :     let mut stale: Vec<String> = Vec::new();
     629           38 :     let (mut skipped, mut total, mut more, mut visited) = (0usize, 0usize, false, 0usize);
     630           38 :     for chunk in ids.chunks(chunk_size) {
     631           38 :         let mut p = params.clone();
     632           38 :         p.remove("limit");
     633           38 :         p.remove("offset");
     634           38 :         p.remove("count");
     635           38 :         p.insert("id".into(), chunk.join(","));
     636              :         // the final page fetch below re-surfaces the same peers' warnings
     637           38 :         let mut chunk_warnings = Vec::new();
     638           38 :         let fed = if crate::federation::active(&p) && !looped {
     639           38 :             crate::federation::fed_query(st, &tenant, headers, &ctx, &p, &mut chunk_warnings)
     640           38 :                 .await?
     641              :         } else {
     642            0 :             Vec::new()
     643              :         };
     644           38 :         let docs = filter_entities_fed(st, &tenant, &p, &ctx, fed, Some(&narrow)).await?;
     645           38 :         let matched: std::collections::HashSet<&str> = docs
     646           38 :             .iter()
     647          172 :             .filter_map(|d| d.get("id").and_then(Value::as_str))
     648           38 :             .collect();
     649           38 :         if let Some(emap) = map.get_mut("entityMap").and_then(Value::as_object_mut) {
     650          180 :             for id in chunk {
     651          180 :                 let local_only = emap
     652          180 :                     .get(id)
     653          180 :                     .and_then(Value::as_array)
     654          180 :                     .is_some_and(|a| matches!(a.as_slice(), [only] if only == "@none"));
     655          180 :                 if local_only && !matched.contains(id.as_str()) {
     656            8 :                     emap.remove(id);
     657            8 :                     stale.push(id.clone());
     658          172 :                 }
     659              :             }
     660            0 :         }
     661           38 :         visited = visited.saturating_add(chunk.len());
     662          180 :         for id in chunk {
     663          180 :             if !matched.contains(id.as_str()) {
     664            8 :                 continue;
     665          172 :             }
     666          172 :             total = total.saturating_add(1);
     667          172 :             if skipped < offset {
     668           24 :                 skipped = skipped.saturating_add(1);
     669          148 :             } else if page_ids.len() < limit {
     670           76 :                 page_ids.push(id.clone());
     671           80 :             } else {
     672           72 :                 more = true;
     673           72 :             }
     674              :         }
     675           38 :         if !count && page_ids.len() == limit {
     676              :             // page full — next exists if an extra match was seen or
     677              :             // unvisited candidates remain ("pages shall always be filled to
     678              :             // the maximum, as long as Entities are available")
     679           14 :             more = more || visited < ids.len();
     680           14 :             break;
     681           24 :         }
     682              :     }
     683           38 :     if count {
     684            6 :         more = total > offset.saturating_add(limit);
     685           32 :     }
     686           38 :     crate::entity_map::map_prune(st, &tenant, &map_id, &stale).await?;
     687              :     // fix the final fetch to exactly the page's survivors (5.5.14: an empty
     688              :     // set is fixed to nothing); one extra page-sized fetch keeps the whole
     689              :     // repr pipeline shared instead of forked
     690           38 :     params.insert(
     691           38 :         "id".into(),
     692           38 :         if page_ids.is_empty() {
     693            0 :             "urn:ngsi-ld:entitymap:empty".to_owned()
     694              :         } else {
     695           38 :             page_ids.join(",")
     696              :         },
     697              :     );
     698           38 :     params.remove("offset");
     699           38 :     params.remove("count");
     700           38 :     if limit == 0 {
     701            0 :         // count-only shape: the inner default limit is irrelevant against
     702            0 :         // the empty id sentinel
     703            0 :         params.remove("limit");
     704           38 :     }
     705              :     // The narrowing reaches the answer here; the map's own contents are
     706              :     // still the ones the query built, which is what P5's per-subject map is
     707              :     // for.
     708           38 :     let mut resp = query_entities_inner(st, &params, headers, &filter).await?;
     709              :     // "The location of the EntityMap used in the query operation is
     710              :     // returned in the response" (6.4.3.2-2)
     711           38 :     if let Ok(v) = format!("/ngsi-ld/v1/entityMaps/{map_id}").parse() {
     712           38 :         resp.headers_mut().insert("NGSILD-EntityMap", v);
     713           38 :     }
     714           38 :     if count {
     715            6 :         if let Ok(v) = total.to_string().parse() {
     716            6 :             resp.headers_mut().insert("NGSILD-Results-Count", v);
     717            6 :         }
     718           32 :     }
     719              :     // 6.3.10 links from the original query — the inner call saw offset 0
     720              :     // over exactly one page, so it emitted none
     721           76 :     for (off, rel, cond) in [
     722           38 :         (offset.saturating_add(limit), "next", more && limit > 0),
     723           38 :         (offset.saturating_sub(limit.max(1)), "prev", offset > 0),
     724              :     ] {
     725           76 :         if !cond {
     726           50 :             continue;
     727           26 :         }
     728           26 :         let mut qp: Vec<String> = link_params
     729           26 :             .iter()
     730           64 :             .filter(|(k, _)| k.as_str() != "offset")
     731           58 :             .map(|(k, v)| format!("{k}={}", crate::paging::query_value(v)))
     732           26 :             .collect();
     733           26 :         qp.push(format!("offset={off}"));
     734           26 :         qp.sort(); // deterministic order — the suite string-compares links
     735           26 :         let ty = match accept {
     736            0 :             Accept::LdJson => ";type=\"application/ld+json\"",
     737           26 :             Accept::Json => ";type=\"application/json\"",
     738            0 :             Accept::GeoJson => ";type=\"application/geo+json\"",
     739              :         };
     740           26 :         if let Ok(v) = format!("</ngsi-ld/v1/entities?{}>; rel=\"{rel}\"{ty}", qp.join("&")).parse()
     741           26 :         {
     742           26 :             resp.headers_mut().append(axum::http::header::LINK, v);
     743           26 :         }
     744              :     }
     745           38 :     Ok(resp)
     746         2278 : }
     747              : 
     748         2262 : async fn query_entities_inner(
     749         2262 :     st: &AppState,
     750         2262 :     params: &HashMap<String, String>,
     751         2262 :     headers: &HeaderMap,
     752         2262 :     filter: &std::sync::Arc<crate::policy::Filter>,
     753         2262 : ) -> ApiResult<Response> {
     754         2262 :     let tenant = tenant_from(headers)?;
     755         2262 :     check_params(params, crate::negotiate::QUERY_PARAMS)?;
     756         2210 :     let accept = parse_accept_geo(headers)?;
     757         2206 :     let ctx = request_context(&st.loader, headers).await?;
     758              : 
     759              :     // 5.7.2.4 a-e: id/idPattern alone are NOT sufficient, and the attrs
     760              :     // list / q must include "at least one non-system Attribute" to qualify.
     761              :     // The judgement is about what the CLIENT asked for, so it reads the
     762              :     // request's own `q` — a policy condition is not the client's filter and
     763              :     // does not make a wide query narrow (ADR-0020).
     764         2206 :     let has_filter = qualifies_non_wide(
     765         2206 :         params,
     766         2206 :         params.get("q").map(|q| parse_q(q)).transpose()?.as_ref(),
     767              :     );
     768              :     // what the client's own query reads, before the engine's q joins it
     769              :     //
     770         2206 :     let client_params = params;
     771              :     // Everything below reads the narrowed query: the store push-down, the
     772              :     // local re-check 5.7.2.4 runs over merged results, and the query the
     773              :     // request is forwarded with.
     774         2206 :     let narrowed = filter.narrow_params(params, &ctx)?;
     775         2198 :     let params = &narrowed;
     776         2198 :     let q_ast = params
     777         2198 :         .get("q")
     778         2198 :         .map(|q| antares_ql::parse_narrowed_q(q))
     779         2198 :         .transpose()?;
     780              :     // 5.7.2.4 validation bullets (p.201), in the spec's own order.
     781         2198 :     star_type_local(params)?;
     782         2190 :     if params.contains_key("geometryProperty") && accept != Accept::GeoJson {
     783            8 :         return Err(NgsiError::BadRequestData(
     784            8 :             "geometryProperty requires Accept: application/geo+json (5.7.2.4)".into(),
     785            8 :         )
     786            8 :         .into());
     787         2182 :     }
     788              :     // "If the ordering parameter is present and the execution of the operation
     789              :     // is not limited to the local scope then BadRequestData" — reinforced by
     790              :     // 4.23.1: "Sort ordering is never applied to distributed operations."
     791              :     // The subject is the EXECUTION: a query nothing federates to runs locally
     792              :     // regardless of `local=true`, which is why this asks would_federate rather
     793              :     // than active (ETSI 019_19 orders without local).
     794         2182 :     crate::paging::check_collation(params)?;
     795         2174 :     if params.contains_key("orderBy")
     796          282 :         && crate::federation::would_federate(st, &tenant, &ctx, params, headers).await?
     797              :     {
     798            8 :         return Err(NgsiError::BadRequestData(
     799            8 :             "orderBy requires local scope — ordering is never applied to \
     800            8 :              distributed operations (5.7.2.4, 4.23.1)"
     801            8 :                 .into(),
     802            8 :         )
     803            8 :         .into());
     804         2166 :     }
     805         2166 :     if !has_filter {
     806           32 :         return Err(NgsiError::BadRequestData(
     807           32 :             "query needs at least one of type, attrs, q, georel (5.7.2)".into(),
     808           32 :         )
     809           32 :         .into());
     810         2134 :     }
     811              : 
     812         2134 :     let mut repr = parse_repr(params, &ctx)?;
     813         2082 :     crate::repr::narrow_repr(&mut repr, filter);
     814         2082 :     crate::repr::note_reads(&mut repr, client_params, &ctx)?;
     815         2082 :     let join = parse_join(params)?;
     816         2076 :     check_linked_projection(&repr, &join)?;
     817              :     // 5.7.2.4: filter conditions using Linked Entity attributes need join,
     818              :     // and their hop depth may not exceed joinLevel ("too deep query")
     819         2060 :     let link_depth = q_ast
     820         2060 :         .as_ref()
     821         2060 :         .map(antares_ql::QNode::max_link_depth)
     822         2060 :         .unwrap_or(0);
     823         2060 :     if link_depth > 0 {
     824           32 :         match &join {
     825           32 :             Some((mode, level)) if mode != "@none" => {
     826           32 :                 if link_depth > *level {
     827            8 :                     return Err(NgsiError::BadRequestData(format!(
     828            8 :                         "linked attribute query depth {link_depth} exceeds joinLevel {level} \
     829            8 :                          (5.7.2.4 — too deep query)"
     830            8 :                     ))
     831            8 :                     .into());
     832           24 :                 }
     833              :             }
     834              :             _ => {
     835            8 :                 return Err(NgsiError::BadRequestData(
     836            8 :                     "q references Linked Entity attributes but join is not specified \
     837            8 :                      (5.7.2.4 — too deep query)"
     838            8 :                         .into(),
     839            8 :                 )
     840            8 :                 .into());
     841              :             }
     842              :         }
     843         2020 :     }
     844              :     // 5.7.2.4: a syntactically invalid context source filter is 400.
     845         2044 :     if let Some(csf) = params.get("csf") {
     846           24 :         parse_q(csf)?;
     847         2020 :     }
     848              :     // 6.3.17: abnormal distributed-GET outcomes surface as NGSILD-Warning
     849         2032 :     let mut warnings: Vec<String> = Vec::new();
     850         2032 :     let looped = crate::federation::via_loop(
     851         2032 :         headers,
     852         2032 :         &crate::federation::alias_for(&st.host_alias, &tenant),
     853              :     );
     854         2032 :     let fed = if crate::federation::active(params) && !looped {
     855         1906 :         crate::federation::fed_query(st, &tenant, headers, &ctx, params, &mut warnings).await?
     856              :     } else {
     857          126 :         if crate::federation::active(params)
     858            2 :             && looped
     859            2 :             && crate::federation::would_federate(st, &tenant, &ctx, params, headers).await?
     860            2 :         {
     861            2 :             warnings.push(crate::federation::warning(
     862            2 :                 199,
     863            2 :                 &crate::federation::alias_for(&st.host_alias, &tenant),
     864            2 :                 "a registration loop has been detected",
     865            2 :             ));
     866          124 :         }
     867          126 :         Vec::new()
     868              :     };
     869              :     // Pushdown gates: pagination per page_pushdown_allowed (no federation
     870              :     // candidates, no idPattern, no orderBy). Projection additionally excludes
     871              :     // join (linked-entity walks read page docs) and GeoJSON output.
     872         2028 :     let (p_offset, p_limit, _) = page_params(st, params)?;
     873         2020 :     let push_page = page_pushdown_allowed(fed.is_empty(), params);
     874         2020 :     let push_proj = join.is_none() && accept != Accept::GeoJson;
     875              :     // an ordered read sorts on what its keys read and reads its page whole
     876              :     // afterwards
     877         2020 :     let order_keys = params
     878         2020 :         .get("orderBy")
     879         2020 :         .map(|spec| crate::paging::order_heads(spec, &ctx));
     880         2020 :     let pushdown = Pushdown {
     881         2020 :         page: push_page.then_some((p_offset, p_limit)),
     882         2020 :         proj: push_proj.then_some(&repr),
     883         2020 :         order_keys: order_keys.as_deref(),
     884         2020 :     };
     885         1972 :     let mut filtered =
     886         2020 :         filter_entities_paged(st, &tenant, params, &ctx, fed, pushdown, Some(&repr)).await?;
     887              :     // the page is read back by `id`, a comma-separated list
     888         3296 :     let listable = |d: &Value| {
     889         2888 :         d.get("id")
     890         2888 :             .and_then(Value::as_str)
     891         2888 :             .is_some_and(|id| !id.contains(','))
     892         2888 :     };
     893         1972 :     if filtered.trimmed && !filtered.docs.iter().all(listable) {
     894           80 :         filtered = filter_entities_paged(
     895           80 :             st,
     896           80 :             &tenant,
     897           80 :             params,
     898           80 :             &ctx,
     899           80 :             Vec::new(),
     900           80 :             Pushdown {
     901           80 :                 order_keys: None,
     902           80 :                 ..pushdown
     903           80 :             },
     904           80 :             Some(&repr),
     905           80 :         )
     906           80 :         .await?;
     907         1892 :     }
     908         1972 :     let trimmed = filtered.trimmed;
     909         1972 :     let mut matches = filtered.docs;
     910         1972 :     if let Some(spec) = params.get("orderBy") {
     911          274 :         crate::repr::order_as_shown(&mut matches, Some(&repr), |m| {
     912          274 :             order_entities(m, spec, params, &ctx)
     913          274 :         })?;
     914         1698 :     }
     915         1966 :     let keyset = push_page && crate::paging::keyset_allowed(params);
     916         1966 :     let (page, count_hdr, links) = if keyset && params.contains_key(crate::paging::CURSOR) {
     917              :         // the rows start after the cursor's id, at the client's offset
     918            4 :         let total = match filtered.paged {
     919            4 :             true => filtered.total.unwrap_or(matches.len()),
     920            0 :             false => matches.len(),
     921              :         };
     922            4 :         matches.truncate(p_limit);
     923            4 :         let total = p_offset.saturating_add(total);
     924            4 :         paginate_pre(st, params, matches, "/ngsi-ld/v1/entities", total)?
     925         1962 :     } else if filtered.paged {
     926         1398 :         let total = filtered.total.unwrap_or(matches.len());
     927         1398 :         paginate_pre(st, params, matches, "/ngsi-ld/v1/entities", total)?
     928              :     } else {
     929          564 :         paginate(st, params, matches, "/ngsi-ld/v1/entities")?
     930              :     };
     931         1966 :     let page = match trimmed {
     932          184 :         true => whole_page(st, &tenant, params, &ctx, &repr, page).await?,
     933         1782 :         false => page,
     934              :     };
     935         1966 :     let links = match keyset {
     936         1274 :         true => crate::paging::with_next_cursor(
     937         1274 :             links,
     938         1274 :             &crate::paging::view_of(st),
     939         1274 :             tenant.as_str(),
     940         1274 :             params,
     941         1274 :             page.last()
     942         1274 :                 .and_then(|d| d.get("id"))
     943         1274 :                 .and_then(Value::as_str),
     944         1274 :             p_offset.saturating_add(p_limit),
     945              :         ),
     946          692 :         false => links,
     947              :     };
     948              : 
     949              :     // each rendered Entity beside the one it was rendered from, which the
     950              :     // inline join reads its Relationships off
     951         1966 :     let (rendered_from, mut payload): (Vec<&Value>, Vec<Value>) = page
     952         1966 :         .iter()
     953         6516 :         .filter_map(|doc| {
     954         6516 :             let shaped = apply(doc, &repr);
     955              :             // pick projections that match nothing drop the entity entirely
     956         6516 :             if repr.pick.is_some() && shaped.as_object().is_some_and(|o| o.is_empty()) {
     957            4 :                 return None;
     958         6512 :             }
     959         6512 :             Some((doc, compact_for(&repr, &shaped, &ctx)))
     960         6516 :         })
     961         1966 :         .unzip();
     962         1966 :     if let Some((mode, level)) = &join {
     963          156 :         let mut complete = true;
     964          156 :         let held = contained_by(params);
     965              :         // 4.5.23.1 bounds the WIDTH of the retrieval per request, so one
     966              :         // allowance is spent across the whole page: minting a fresh budget per
     967              :         // payload Entity multiplied it by the page size, and a page of
     968              :         // max_limit densely linked Entities bought MAX_JOIN_LOOKUPS lookups
     969              :         // each.
     970          156 :         let mut budget = MAX_JOIN_LOOKUPS;
     971          156 :         match mode.as_str() {
     972          156 :             "inline" => {
     973          140 :                 for (p, stored) in payload.iter_mut().zip(&rendered_from) {
     974          140 :                     complete &= inline_join_beyond(
     975          140 :                         st,
     976          140 :                         &tenant,
     977          140 :                         &ctx,
     978          140 :                         &repr,
     979          140 :                         p,
     980          140 :                         stored,
     981          140 :                         *level,
     982          140 :                         &held,
     983          140 :                         &mut budget,
     984          140 :                         crate::federation::active(params).then_some(headers),
     985          140 :                     )
     986          140 :                     .await;
     987              :                 }
     988              :             }
     989           64 :             "flat" => {
     990           64 :                 let mut linked = std::collections::BTreeMap::new();
     991          116 :                 for doc in &page {
     992          116 :                     complete &= collect_flat_beyond(
     993          116 :                         st,
     994          116 :                         &tenant,
     995          116 :                         &ctx,
     996          116 :                         &repr,
     997          116 :                         doc,
     998          116 :                         *level,
     999          116 :                         &mut linked,
    1000          116 :                         &held,
    1001          116 :                         &mut budget,
    1002          116 :                     )
    1003          116 :                     .await;
    1004              :                 }
    1005          116 :                 let page_ids: Vec<&str> = page.iter().filter_map(|d| d["id"].as_str()).collect();
    1006          148 :                 for (id, (ldoc, lrepr)) in linked {
    1007          148 :                     if !page_ids.contains(&id.as_str()) {
    1008          148 :                         payload.push(compact_for(&lrepr, &apply(&ldoc, &lrepr), &ctx));
    1009          148 :                     }
    1010              :                 }
    1011              :             }
    1012            0 :             _ => {}
    1013              :         }
    1014          156 :         if !complete {
    1015            4 :             warnings.push(crate::federation::warning(
    1016            4 :                 199,
    1017            4 :                 &crate::federation::alias_for(&st.host_alias, &tenant),
    1018            4 :                 "the linked entity retrieval was truncated",
    1019            4 :             ));
    1020          152 :         }
    1021         1810 :     }
    1022         1966 :     let mut resp = if accept == Accept::GeoJson {
    1023           24 :         let fc = to_geojson_collection(payload, params.get("geometryProperty"));
    1024           24 :         respond_prefer(StatusCode::OK, fc, &ctx, accept, &tenant, headers)
    1025              :     } else {
    1026         1942 :         crate::negotiate::respond_list(StatusCode::OK, payload, &ctx, accept, &tenant)
    1027              :     };
    1028         1966 :     attach_paging(&mut resp, count_hdr, &links);
    1029         1966 :     attach_warnings(&mut resp, &warnings);
    1030              :     // 6.4.3.2: entityMap=true — the EntityMap for this query is (re)created;
    1031              :     // the response carries NGSILD-EntityMap and 201 Created.
    1032         1966 :     if params.get("entityMap").map(String::as_str) == Some("true") {
    1033           28 :         let map = build_query_map(st, &tenant, headers, &ctx, params, filter).await?;
    1034           28 :         *resp.status_mut() = StatusCode::CREATED;
    1035           28 :         if let Some(id) = map.get("id").and_then(Value::as_str) {
    1036           28 :             if let Ok(v) = format!("/ngsi-ld/v1/entityMaps/{id}").parse() {
    1037           28 :                 resp.headers_mut().insert("NGSILD-EntityMap", v);
    1038           28 :             }
    1039            0 :         }
    1040         1938 :     }
    1041         1966 :     filter.mark(&st.host_alias, resp.headers_mut());
    1042         1966 :     Ok(resp)
    1043         2262 : }
    1044              : 
    1045              : /// Table 6.4.3.2-1: `type=*` implies local scope and shall not be combined
    1046              : /// with `local=false`.
    1047         3628 : pub(crate) fn star_type_local(params: &HashMap<String, String>) -> Result<(), NgsiError> {
    1048         3628 :     if params.get("type").map(String::as_str) == Some("*")
    1049           24 :         && params.get("local").map(String::as_str) == Some("false")
    1050              :     {
    1051           12 :         return Err(NgsiError::BadRequestData(
    1052           12 :             "type=* implies local and shall not be combined with local=false \
    1053           12 :              (Table 6.4.3.2-1)"
    1054           12 :                 .into(),
    1055           12 :         ));
    1056         3616 :     }
    1057         3616 :     Ok(())
    1058         3628 : }
    1059              : 
    1060              : /// 5.7.2.4 / 5.7.4.4 / 5.14.4.4 a-e: a query qualifies (is not "too wide")
    1061              : /// only with a type selector, an attrs list or q naming at least one
    1062              : /// non-system Attribute, a geoquery, or local scope.
    1063         3900 : pub(crate) fn qualifies_non_wide(
    1064         3900 :     params: &HashMap<String, String>,
    1065         3900 :     q_ast: Option<&antares_ql::QNode>,
    1066         3900 : ) -> bool {
    1067         3900 :     let attrs_qualify = params.get("attrs").is_some_and(|a| {
    1068          192 :         a.split(',')
    1069          196 :             .any(|n| antares_ql::is_non_system_attr(n.trim()))
    1070          192 :     });
    1071         3900 :     let q_qualifies = q_ast.is_some_and(|ast| {
    1072          758 :         ast.attribute_paths()
    1073          758 :             .iter()
    1074          758 :             .any(|h| antares_ql::is_non_system_attr(h))
    1075          758 :     });
    1076         3900 :     params.contains_key("type")
    1077          212 :         || attrs_qualify
    1078          160 :         || q_qualifies
    1079          116 :         || params.contains_key("georel")
    1080          112 :         || params.get("local").map(String::as_str) == Some("true")
    1081         3900 : }
    1082              : 
    1083              : /// Same, with federated candidate docs merged in before filtering (4.3.6.7).
    1084          264 : pub async fn filter_entities_fed(
    1085          264 :     st: &AppState,
    1086          264 :     tenant: &TenantId,
    1087          264 :     params: &HashMap<String, String>,
    1088          264 :     ctx: &antares_jsonld::Context,
    1089          264 :     fed: Vec<(bool, Value)>,
    1090          264 :     narrow: Option<&crate::repr::Repr>,
    1091          264 : ) -> ApiResult<Vec<Value>> {
    1092              :     Ok(
    1093          264 :         filter_entities_paged(st, tenant, params, ctx, fed, Pushdown::default(), narrow)
    1094          264 :             .await?
    1095              :             .docs,
    1096              :     )
    1097          264 : }
    1098              : 
    1099              : /// What the paged variant produced. `paged` = the store already applied
    1100              : /// ORDER BY id + LIMIT/OFFSET (and `total` is the pre-LIMIT match count), so
    1101              : /// the caller must NOT slice again.
    1102              : pub struct Filtered {
    1103              :     pub docs: Vec<Value>,
    1104              :     pub paged: bool,
    1105              :     pub total: Option<usize>,
    1106              :     /// How far a chunked walk steps past this page, in the unit its offset
    1107              :     /// counts. With a pushed page that is the store rows the SQL page read,
    1108              :     /// before the evaluator below dropped any — `docs` undercounts it
    1109              :     /// whenever idPattern (invisible to the store filter) removed rows. Where
    1110              :     /// the offset counts matches instead (a window or a count the loop
    1111              :     /// decides), it is the matches of the page: stepping by the rows read
    1112              :     /// there skipped matches, and a window of rows that never match kept the
    1113              :     /// walk going forever.
    1114              :     pub rows: usize,
    1115              :     /// The store decided the match set and kept only the core members and
    1116              :     /// [`Pushdown::order_keys`] of each document: enough to order and page
    1117              :     /// it, and the page is then read whole by id.
    1118              :     pub trimmed: bool,
    1119              : }
    1120              : 
    1121              : /// Whether the store may be asked to project members away. Only when its
    1122              : /// answer is the final answer: 5.7.2.4 applies the query, geoquery, Scope
    1123              : /// query and Attribute filters after remote parts have been aggregated, so
    1124              : /// with federated candidates present those filters still run over documents
    1125              : /// the store would have stripped; and 4.23 orders Entities by the value of
    1126              : /// the ordering member, which a projection may have removed — leaving the
    1127              : /// comparator nothing to compare and the client id order.
    1128         1932 : fn proj_pushdown_allowed(fed_is_empty: bool, params: &HashMap<String, String>) -> bool {
    1129         1932 :     fed_is_empty && !params.contains_key("orderBy")
    1130         1932 : }
    1131              : 
    1132              : /// Whether `q` reads only Attributes that every rule of a type in `groups`
    1133              : /// shows and the narrowing's own projection keeps, in the form the store
    1134              : /// compiles exactly: one Attribute per term, no Linked Entity hop, no system
    1135              : /// member. An Entity of those types shows at least what one such rule shows,
    1136              : /// so on those Attributes it answers `q` as stored and as shown alike.
    1137           32 : fn reads_only_shown(
    1138           32 :     q: &antares_ql::QNode,
    1139           32 :     rules: &crate::policy::Filter,
    1140           32 :     groups: &[Vec<String>],
    1141           32 :     narrow: Option<&crate::repr::Repr>,
    1142           32 :     expand: &dyn Fn(&str) -> String,
    1143           32 : ) -> bool {
    1144           32 :     antares_ql::sql::compile_q(q, "entity", 1, expand).is_some()
    1145           28 :         && shows_alike(q.attribute_paths(), rules, groups, narrow, expand)
    1146           32 : }
    1147              : 
    1148              : /// Whether every rule of a type in `groups` shows each of `names` (terms) and
    1149              : /// the narrowing's own projection keeps it: on those Attributes the Entity as
    1150              : /// stored and as shown are alike.
    1151           36 : fn shows_alike<'n>(
    1152           36 :     names: impl IntoIterator<Item = &'n str>,
    1153           36 :     rules: &crate::policy::Filter,
    1154           36 :     groups: &[Vec<String>],
    1155           36 :     narrow: Option<&crate::repr::Repr>,
    1156           36 :     expand: &dyn Fn(&str) -> String,
    1157           36 : ) -> bool {
    1158           36 :     if !rules.omit_by_type.is_empty() {
    1159            0 :         return false;
    1160           36 :     }
    1161              :     // every rule naming a type the request selects
    1162           36 :     let selected: Vec<&crate::policy::Rule> = rules
    1163           36 :         .rules
    1164           36 :         .iter()
    1165           58 :         .filter(|r| {
    1166           58 :             r.types
    1167           58 :                 .iter()
    1168           76 :                 .any(|t| groups.iter().flatten().any(|g| g == t))
    1169           58 :         })
    1170           36 :         .collect();
    1171           72 :     let heads = |nodes: &Option<Vec<crate::repr::ProjNode>>| {
    1172           72 :         nodes
    1173           72 :             .as_ref()
    1174           72 :             .map(|n| n.iter().map(|n| n.iri.clone()).collect::<Vec<_>>())
    1175           72 :     };
    1176           36 :     let pick = narrow.and_then(|r| heads(&r.policy_pick));
    1177           36 :     let omit = narrow
    1178           36 :         .and_then(|r| heads(&r.policy_omit))
    1179           36 :         .unwrap_or_default();
    1180           36 :     !selected.is_empty()
    1181           36 :         && names.into_iter().all(|name| {
    1182           36 :             let iri = expand(name);
    1183           36 :             antares_ql::is_non_system_attr(name)
    1184           36 :                 && !omit.contains(&iri)
    1185           36 :                 && pick.as_ref().is_none_or(|p| p.contains(&iri))
    1186           36 :                 && selected
    1187           36 :                     .iter()
    1188           54 :                     .all(|r| r.every_attribute || r.attributes.contains(&iri))
    1189           36 :         })
    1190           36 : }
    1191              : 
    1192              : /// The client's type selection (OR of AND groups, `None` = any type) with
    1193              : /// one of the rules' types required in every group: `(a;b)|c` under rules
    1194              : /// of `T` and `U` selects `(a;b;T)|(a;b;U)|(c;T)|(c;U)`, and a group that
    1195              : /// already names one of them stays as it is (`(a;T)` selects no more than
    1196              : /// `(a;T;U)`). `None` when the rules name no type, so a selection is never
    1197              : /// dropped into "any".
    1198          124 : fn judged_types<'a>(
    1199          124 :     client: Option<&'a [Vec<String>]>,
    1200          124 :     rules: &crate::policy::Filter,
    1201          124 : ) -> Option<std::borrow::Cow<'a, [Vec<String>]>> {
    1202          124 :     let mut union: Vec<&String> = rules.rules.iter().flat_map(|r| &r.types).collect();
    1203          124 :     union.sort();
    1204          124 :     union.dedup();
    1205          124 :     if union.is_empty() {
    1206           10 :         return None;
    1207          114 :     }
    1208          246 :     let named = |g: &Vec<String>| g.iter().any(|t| union.contains(&t));
    1209          114 :     if let Some(groups) = client.filter(|c| c.iter().all(named)) {
    1210           86 :         return Some(std::borrow::Cow::Borrowed(groups));
    1211           28 :     }
    1212           28 :     let any = [Vec::new()];
    1213              :     Some(std::borrow::Cow::Owned(
    1214           28 :         client
    1215           28 :             .unwrap_or(&any)
    1216           28 :             .iter()
    1217           58 :             .flat_map(|g| -> Vec<Vec<String>> {
    1218           58 :                 if named(g) {
    1219           30 :                     return vec![g.clone()];
    1220           28 :                 }
    1221           28 :                 union
    1222           28 :                     .iter()
    1223           50 :                     .map(|t| {
    1224           50 :                         let mut g = g.clone();
    1225           50 :                         g.push((*t).clone());
    1226           50 :                         g
    1227           50 :                     })
    1228           28 :                     .collect()
    1229           58 :             })
    1230           28 :             .collect(),
    1231              :     ))
    1232          124 : }
    1233              : 
    1234              : /// The documents of a page ordered over trimmed ones ([`Filtered::trimmed`]),
    1235              : /// read whole by id under the same filter, in the page's order. An Entity
    1236              : /// deleted between the two reads drops out of the page.
    1237          184 : async fn whole_page(
    1238          184 :     st: &AppState,
    1239          184 :     tenant: &TenantId,
    1240          184 :     params: &HashMap<String, String>,
    1241          184 :     ctx: &antares_jsonld::Context,
    1242          184 :     repr: &crate::repr::Repr,
    1243          184 :     page: Vec<Value>,
    1244          184 : ) -> ApiResult<Vec<Value>> {
    1245          474 :     let ids: Vec<&str> = page.iter().filter_map(|d| d.get("id")?.as_str()).collect();
    1246          184 :     if ids.is_empty() {
    1247           36 :         return Ok(Vec::new());
    1248          148 :     }
    1249          148 :     let mut by_id = params.clone();
    1250          148 :     by_id.insert("id".to_owned(), ids.join(","));
    1251          148 :     let whole = filter_entities_paged(
    1252          148 :         st,
    1253          148 :         tenant,
    1254          148 :         &by_id,
    1255          148 :         ctx,
    1256          148 :         Vec::new(),
    1257          148 :         Pushdown::default(),
    1258          148 :         Some(repr),
    1259          148 :     )
    1260          148 :     .await?;
    1261          148 :     let mut found: HashMap<String, Value> = whole
    1262          148 :         .docs
    1263          148 :         .into_iter()
    1264          474 :         .filter_map(|d| Some((d.get("id")?.as_str()?.to_owned(), d)))
    1265          148 :         .collect();
    1266          474 :     Ok(ids.iter().filter_map(|id| found.remove(*id)).collect())
    1267          184 : }
    1268              : 
    1269              : /// Whether the store may be asked to cut the page (ORDER BY id +
    1270              : /// LIMIT/OFFSET). Only when nothing outside it still narrows or reorders the
    1271              : /// match set:
    1272              : ///
    1273              : /// * federated candidates are merged in afterwards, and 5.7.2.4 applies the
    1274              : ///   query, geoquery, Scope query and Attribute filters only after that
    1275              : ///   aggregation — so a SQL page would be cut from the wrong set;
    1276              : /// * `idPattern` is not part of the store filter, so it drops rows the SQL
    1277              : ///   page already counted;
    1278              : /// * `orderBy` orders the whole match set by the value of the ordering member
    1279              : ///   (4.23) before the page is cut, and that comparison order is the
    1280              : ///   evaluator's.
    1281              : ///
    1282              : /// `limit=0` — the count-only shape of 6.3.10, where `count=true` is
    1283              : /// mandatory — IS pushed: the store returns no rows and counts the match set,
    1284              : /// which is the same count the scan derives from a materialized one, without
    1285              : /// materializing 100 million documents to throw them away.
    1286         2040 : fn page_pushdown_allowed(fed_is_empty: bool, params: &HashMap<String, String>) -> bool {
    1287         2040 :     fed_is_empty && !params.contains_key("idPattern") && !params.contains_key("orderBy")
    1288         2040 : }
    1289              : 
    1290              : /// Entity Type Selection Language (4.17): `,`/`|` = OR, `(a;b)` = AND.
    1291              : /// Table 6.4.3.2-1: `"*"` selects every Entity Type, i.e. no type predicate
    1292              : /// at all. Expanding it as a term yields an IRI nothing matches, which is
    1293              : /// how `type=*` silently returned an empty array.
    1294         8435 : pub(crate) fn type_selection(
    1295         8435 :     params: &HashMap<String, String>,
    1296         8435 :     ctx: &antares_jsonld::Context,
    1297         8435 : ) -> Option<Vec<Vec<String>>> {
    1298         8435 :     params.get("type").filter(|s| *s != "*").map(|s| {
    1299         4633 :         s.split([',', '|'])
    1300         5445 :             .map(|alt| {
    1301         5445 :                 alt.trim()
    1302         5445 :                     .trim_start_matches('(')
    1303         5445 :                     .trim_end_matches(')')
    1304         5445 :                     .split(';')
    1305         5465 :                     .map(|t| ctx.expand_key(t.trim()))
    1306         5445 :                     .collect()
    1307         5445 :             })
    1308         4633 :             .collect()
    1309         4633 :     })
    1310         8435 : }
    1311              : 
    1312              : /// The expanded `attrs` list of a query.
    1313         3859 : fn attr_list(
    1314         3859 :     params: &HashMap<String, String>,
    1315         3859 :     ctx: &antares_jsonld::Context,
    1316         3859 : ) -> Option<Vec<String>> {
    1317         3859 :     params
    1318         3859 :         .get("attrs")
    1319         3859 :         .map(|s| s.split(',').map(|t| ctx.expand_key(t.trim())).collect())
    1320         3859 : }
    1321              : 
    1322              : /// What a read names, for the policy gate (ADR-0020: "`Operation` is the
    1323              : /// expanded request"): the types of its type selector, its `attrs`, its
    1324              : /// `q` and its GeoQuery, expanded with the request @context exactly as the
    1325              : /// store query uses them. Built only with an engine attached. A member
    1326              : /// that does not parse is left out, and the read refuses it after the gate
    1327              : /// as before.
    1328              : #[derive(Default)]
    1329              : pub(crate) struct ReadMembers {
    1330              :     pub types: Vec<String>,
    1331              :     pub attrs: Vec<String>,
    1332              :     pub q: Option<antares_ql::QNode>,
    1333              :     pub geo: Option<antares_ql::geo::GeoQuery>,
    1334              : }
    1335              : 
    1336              : impl ReadMembers {
    1337         3424 :     pub(crate) fn with_ctx(
    1338         3424 :         st: &AppState,
    1339         3424 :         params: &HashMap<String, String>,
    1340         3424 :         ctx: &antares_jsonld::Context,
    1341         3424 :     ) -> Self {
    1342         3424 :         if st.policy.is_none() {
    1343         2584 :             return Self::default();
    1344          840 :         }
    1345          840 :         let mut types: Vec<String> = Vec::new();
    1346         1004 :         for t in type_selection(params, ctx).into_iter().flatten().flatten() {
    1347         1004 :             if !types.contains(&t) {
    1348         1004 :                 types.push(t);
    1349         1004 :             }
    1350              :         }
    1351              :         Self {
    1352          840 :             types,
    1353          840 :             attrs: attr_list(params, ctx).unwrap_or_default(),
    1354          840 :             q: params
    1355          840 :                 .get("q")
    1356          840 :                 .and_then(|q| antares_ql::parse_narrowed_q(q).ok()),
    1357          840 :             geo: antares_ql::geo::GeoQuery::from_params(params)
    1358          840 :                 .ok()
    1359          840 :                 .flatten(),
    1360              :         }
    1361         3424 :     }
    1362              : 
    1363              :     /// Same, for a gate taken before the request @context is resolved.
    1364         3514 :     pub(crate) async fn of(
    1365         3514 :         st: &AppState,
    1366         3514 :         headers: &HeaderMap,
    1367         3514 :         params: &HashMap<String, String>,
    1368         3514 :     ) -> Self {
    1369         3514 :         if st.policy.is_none() {
    1370         2904 :             return Self::default();
    1371          610 :         }
    1372          610 :         match request_context(&st.loader, headers).await {
    1373          610 :             Ok(ctx) => Self::with_ctx(st, params, &ctx),
    1374            0 :             Err(_) => Self::default(),
    1375              :         }
    1376         3514 :     }
    1377              : }
    1378              : 
    1379              : /// What the store may be asked to do beyond filtering; see
    1380              : /// [`filter_entities_paged`].
    1381              : #[derive(Debug, Default, Clone, Copy)]
    1382              : pub struct Pushdown<'a> {
    1383              :     pub page: Option<(usize, usize)>,
    1384              :     pub proj: Option<&'a crate::repr::Repr>,
    1385              :     /// The expanded Attributes an `orderBy` reads: an ordered read sorts
    1386              :     /// the whole match set, and these alone are what the sort needs
    1387              :     /// ([`Filtered::trimmed`]).
    1388              :     pub order_keys: Option<&'a [String]>,
    1389              : }
    1390              : 
    1391              : /// The full filtering path (5.7.2). `push.page` = (offset, limit) to push into the
    1392              : /// store — pass it ONLY when every filter the store cannot see is absent
    1393              : /// (idPattern, federation, orderBy); the store still refuses unless its own
    1394              : /// predicates compiled exactly. `push.proj` = the parsed representation,
    1395              : /// offered for projection pushdown (pick/omit/attrs top-level heads) under
    1396              : /// the same exactness gate. `narrow` = the caller's policy narrowing, which
    1397              : /// a linked q term reads its targets through (ADR-0020).
    1398         3033 : pub async fn filter_entities_paged(
    1399         3033 :     st: &AppState,
    1400         3033 :     tenant: &TenantId,
    1401         3033 :     params: &HashMap<String, String>,
    1402         3033 :     ctx: &antares_jsonld::Context,
    1403         3033 :     fed: Vec<(bool, Value)>,
    1404         3033 :     push: Pushdown<'_>,
    1405         3033 :     narrow: Option<&crate::repr::Repr>,
    1406         3033 : ) -> ApiResult<Filtered> {
    1407              :     let Pushdown {
    1408         3033 :         page,
    1409         3033 :         proj,
    1410         3033 :         order_keys,
    1411         3033 :     } = push;
    1412              :     // a keyset cursor: the store seeks past its id, so the rows
    1413              :     // already start at the client's offset and every page below is counted
    1414              :     // from there
    1415         3033 :     let after = crate::paging::cursor_after(&crate::paging::view_of(st), tenant.as_str(), params)?;
    1416         3033 :     let page = page.map(|(offset, limit)| (if after.is_some() { 0 } else { offset }, limit));
    1417              :     // a pushed page over local rows cannot be merged with federated
    1418              :     // candidates — refuse here so no caller can create that page
    1419         3033 :     let page = if fed.is_empty() { page } else { None };
    1420              :     // and neither can a pushed projection: the store's answer is only the
    1421              :     // final answer when nothing downstream still needs the stripped members
    1422         3033 :     let proj = proj.filter(|_| proj_pushdown_allowed(fed.is_empty(), params));
    1423              :     // under rules the store answers the policy's own
    1424              :     // predicates over the stored Entity, and the loop below decides which
    1425              :     // rules hold and evaluates the client's predicates on the Entity as it
    1426              :     // is shown, so the client's page is cut in the store only where the
    1427              :     // store can judge the rules itself (`judged` below).
    1428         3033 :     let rules = narrow.and_then(|r| r.policy_types.as_deref());
    1429         3033 :     let client_page = page;
    1430         3033 :     let page = if rules.is_some() { None } else { page };
    1431         3033 :     let proj = if rules.is_some() { None } else { proj };
    1432         3033 :     let ids: Option<Vec<&str>> = params.get("id").map(|s| s.split(',').collect());
    1433         3033 :     if let Some(ids) = &ids {
    1434         1254 :         for id in ids {
    1435         1254 :             antares_model::EntityId::new(id)?;
    1436              :         }
    1437         2521 :     }
    1438         3027 :     let id_pattern = match params.get("idPattern") {
    1439          130 :         Some(p) => {
    1440          508 :             if ["**", "++", "*+", "+*"].iter().any(|q| p.contains(q)) {
    1441            4 :                 return Err(NgsiError::BadRequestData(format!("invalid idPattern {p:?}")).into());
    1442          126 :             }
    1443              :             Some(
    1444          126 :                 antares_ql::regex::compile(p)
    1445          126 :                     .map_err(|_| NgsiError::BadRequestData(format!("invalid idPattern {p:?}")))?,
    1446              :             )
    1447              :         }
    1448         2897 :         None => None,
    1449              :     };
    1450         3019 :     let type_sel = type_selection(params, ctx);
    1451         3019 :     let attr_filter = attr_list(params, ctx);
    1452         3019 :     let q_ast = match params.get("q") {
    1453              :         // 4.9 expandValues: "attributes whose values should be expanded
    1454              :         // against the supplied @context using JSON-LD type coercion prior to
    1455              :         // executing the query" (EXAMPLE 12), less the Attributes jsonKeys
    1456              :         // declares uninterpretable as JSON-LD.
    1457              :         // the query may be narrowed: every caller parsed its own `q` with
    1458              :         // `parse_q` before (threat V23)
    1459          652 :         Some(q) => Some(antares_ql::eval::apply_expand_values(
    1460          652 :             antares_ql::parse_narrowed_q(q)?,
    1461          652 :             antares_ql::eval::expansion_list(
    1462          652 :                 params.get("expandValues").map(String::as_str),
    1463          652 :                 params.get("jsonKeys").map(String::as_str),
    1464          652 :             )
    1465          652 :             .as_deref(),
    1466          652 :             ctx,
    1467              :         )),
    1468         2367 :         None => None,
    1469              :     };
    1470         3019 :     let scope_q = params.get("scopeQ");
    1471              :     // The request's own geoquery (7.2.5 allows one) and the narrowing's
    1472              :     // area: every one must hold, so a caller's area
    1473              :     // cannot widen the policy's.
    1474         3019 :     let own_geo = antares_ql::geo::GeoQuery::from_params(params)?;
    1475         2983 :     let policy_geo = narrow
    1476         2983 :         .and_then(|r| r.policy_reach.as_deref())
    1477         2983 :         .map(|reach| reach.areas.as_slice())
    1478         2983 :         .unwrap_or_default();
    1479         2983 :     let geos: Vec<&antares_ql::geo::GeoQuery> = own_geo.iter().chain(policy_geo).collect();
    1480              :     // under rules the client's q, attrs and geoQ read the shown Entity, and
    1481              :     // only the engine's own q and area reach the store and the stored one
    1482         2983 :     let client_q = narrow.and_then(|r| r.policy_client_q.as_ref());
    1483         2983 :     let engine_q = narrow
    1484         2983 :         .and_then(|r| r.policy_reach.as_deref())
    1485         2983 :         .and_then(|reach| reach.q.as_ref());
    1486         2983 :     let client_and_engine_q = q_ast.clone();
    1487         2983 :     let (q_ast, shown_q) = if rules.is_some() {
    1488          310 :         (engine_q.cloned(), client_q)
    1489              :     } else {
    1490         2673 :         (q_ast, None)
    1491              :     };
    1492         2983 :     let (geos, shown_geos): (Vec<_>, Vec<_>) = if rules.is_some() {
    1493          310 :         (policy_geo.iter().collect(), own_geo.iter().collect())
    1494              :     } else {
    1495         2673 :         (geos, Vec::new())
    1496              :     };
    1497         2983 :     let (attr_filter, shown_attrs) = if rules.is_some() {
    1498          310 :         (None, attr_filter)
    1499              :     } else {
    1500         2673 :         (attr_filter, None)
    1501              :     };
    1502         2983 :     let reads_shown = shown_attrs.is_some() || !shown_geos.is_empty() || shown_q.is_some();
    1503              :     // Rules that select by type alone admit exactly the Entities of their
    1504              :     // types. When the client reads nothing on the shown Entity either, the
    1505              :     // store takes those types as one more selection, and pages and counts
    1506              :     // as at the root (a page of one read every Entity the policy
    1507              :     // admits).
    1508              :     // So too rules that narrow their types only by one set of `idPattern`s
    1509              :     // the store applies exactly: it takes them as a selection too.
    1510         2983 :     let rule_patterns = rules.and_then(|f| crate::policy::shared_id_patterns(&f.rules));
    1511         2983 :     let by_type = rules
    1512         2983 :         .filter(|f| {
    1513          310 :             rule_patterns.is_some()
    1514          296 :                 || f.rules
    1515          296 :                     .iter()
    1516          296 :                     .all(crate::policy::Rule::admits_by_type_alone)
    1517          310 :         })
    1518         2983 :         .and_then(|f| Some((f, judged_types(type_sel.as_deref(), f)?)));
    1519              :     // So too when the client's `q` reads only Attributes that every rule of
    1520              :     // the types it selects shows: on those, the Entity as stored and as shown
    1521              :     // answer it alike (`count=true` with a `q`, which every paged
    1522              :     // façade read sends, read every Entity the policy admits and hit the
    1523              :     // store's TooManyResults ceiling).
    1524              :     // So too the client's geoquery on the default GeoProperty when every rule
    1525              :     // of those types shows it: the store's spatial pushdown answers it as the
    1526              :     // shown Entity would (a geo read under
    1527              :     // a view read every Entity of the type in doubling pages).
    1528         2983 :     let geos_alike = shown_attrs.is_none()
    1529         2977 :         && by_type.as_ref().is_some_and(|(f, groups)| {
    1530           98 :             shown_geos.iter().all(|g| {
    1531            8 :                 let name = Some(g.geoproperty.as_str()).filter(|n| !n.is_empty());
    1532            8 :                 g.to_sql_spec(ctx).is_some()
    1533            8 :                     && shows_alike(
    1534            8 :                         [name.unwrap_or("location")],
    1535            8 :                         f,
    1536            8 :                         groups,
    1537            8 :                         narrow,
    1538            8 :                         &|t: &str| ctx.expand_key(t),
    1539              :                     )
    1540            8 :             })
    1541           98 :         });
    1542         2983 :     let q_alike = geos_alike
    1543           94 :         && by_type
    1544           94 :             .as_ref()
    1545           94 :             .zip(shown_q)
    1546           94 :             .is_some_and(|((f, groups), q)| {
    1547           60 :                 reads_only_shown(q, f, groups, narrow, &|t: &str| ctx.expand_key(t))
    1548           32 :             });
    1549         2983 :     let judged = by_type
    1550         2983 :         .filter(|_| !reads_shown || (geos_alike && (shown_q.is_none() || q_alike)))
    1551         2983 :         .map(|(_, groups)| groups);
    1552         2983 :     let (geos, shown_geos) = if judged.is_some() && geos_alike {
    1553           68 :         (geos.into_iter().chain(shown_geos).collect(), Vec::new())
    1554              :     } else {
    1555         2915 :         (geos, shown_geos)
    1556              :     };
    1557         2983 :     let reads_shown = shown_attrs.is_some() || !shown_geos.is_empty() || shown_q.is_some();
    1558              :     // the client's own `q` (with the engine's, as the parameters carry them)
    1559              :     // reaches the store only where it reads the shown Entity alike
    1560         2983 :     let q_ast = if judged.is_some() && q_alike {
    1561            6 :         client_and_engine_q
    1562              :     } else {
    1563         2977 :         q_ast
    1564              :     };
    1565         2983 :     let page = if judged.is_some() { client_page } else { page };
    1566              :     // An ordered read sorts every match, so the store keeps only what the
    1567              :     // sort reads: only where nothing below reads more of the stored
    1568              :     // Entity, that is no rules or rules that select by type alone (a client
    1569              :     // `q` they leave to the store is decided there), and no federated rows.
    1570         2983 :     let order_keys = order_keys.filter(|_| fed.is_empty() && (rules.is_none() || judged.is_some()));
    1571              :     // Otherwise, without `count` the answer needs the matches up to the
    1572              :     // page's end and one more (whether a next page exists): the store is
    1573              :     // read in pages of its own, doubling, until that many held.
    1574              :     // `count=true` under conditional rules needs every match counted: the
    1575              :     // store is read in pages of its own to the end, only the client's window
    1576              :     // is kept, and the scan stops at MAX_FOLD_DOCS. One flat read
    1577              :     // refused a dataset's count once its type held MAX_UNDECIDED_ROWS.
    1578              :     // Still reads every Entity the policy admits; compile the
    1579              :     // rules' OR and the shown-set predicates to SQL if that shows up
    1580         2983 :     let counting = client_page.filter(|_| {
    1581         2063 :         rules.is_some()
    1582          302 :             && judged.is_none()
    1583          236 :             && params.get("count").map(String::as_str) == Some("true")
    1584         2063 :     });
    1585              :     // So too a geoquery or a scopeQ the store narrows but cannot decide: it
    1586              :     // pages the candidates (`query_candidates`) and the loop below decides
    1587              :     // them, where one flat read took up to MAX_UNDECIDED_ROWS for a page of
    1588              :     // 20 (~1 000 rows, 0.6-1.1 s a statement).
    1589         2983 :     let undecidable = !geos.is_empty() || scope_q.is_some();
    1590         2983 :     let enough = match client_page {
    1591          310 :         Some((offset, limit))
    1592         2063 :             if ((rules.is_some() && judged.is_none()) || undecidable)
    1593          442 :                 && params.get("count").map(String::as_str) != Some("true") =>
    1594              :         {
    1595          310 :             offset.checked_add(limit).and_then(|n| n.checked_add(1))
    1596              :         }
    1597         2673 :         _ => None,
    1598              :     };
    1599              : 
    1600              :     // Hand the store what it can filter on. A backend that can push
    1601              :     // the predicate down (postgres/timescale) returns fewer rows — and says
    1602              :     // via `decided` whether it applied EVERY present predicate exactly, which
    1603              :     // is what licenses pagination/projection pushdown and lets the loop below
    1604              :     // skip re-deciding. A backend that cannot (memory/file) returns the
    1605              :     // snapshot and the loop stays the arbiter.
    1606         2983 :     let expand = |t: &str| ctx.expand_key(t);
    1607         2983 :     let geo_specs: Vec<_> = geos.iter().filter_map(|g| g.to_sql_spec(ctx)).collect();
    1608              :     // A geo query whose spec declined to compile (non-default geoproperty) is
    1609              :     // INVISIBLE to the store — the store would truthfully claim `decided`
    1610              :     // about what it saw, projection would strip the very member the evaluator
    1611              :     // still needs, and a pushed page would page over the wrong set. Forfeit
    1612              :     // every pushdown up front and mask `decided` after. The specs that did
    1613              :     // compile still narrow the rows.
    1614         2983 :     let geo_uncompiled = geo_specs.len() < geos.len();
    1615         2983 :     let page = if geo_uncompiled { None } else { page };
    1616         2983 :     let enough = enough.filter(|_| !geo_uncompiled);
    1617         2983 :     let counting = counting.filter(|_| !geo_uncompiled);
    1618         2983 :     let proj = proj.filter(|_| !geo_uncompiled);
    1619         2983 :     let order_keys = order_keys.filter(|_| !geo_uncompiled);
    1620              :     // pick (or attrs) heads to keep / whole-attr omit heads to drop; core
    1621              :     // members are never SQL-dropped (only `://` IRIs qualify) — repr::apply
    1622              :     // stays the decider for those.
    1623         2983 :     let keep_attrs: Option<Vec<String>> = proj
    1624         2983 :         .and_then(|r| {
    1625         1106 :             r.pick
    1626         1106 :                 .as_ref()
    1627         1106 :                 .map(|nodes| nodes.iter().map(|n| n.iri.clone()).collect())
    1628         1106 :                 .or_else(|| r.attrs.clone())
    1629         1106 :         })
    1630         2983 :         .or_else(|| order_keys.map(<[String]>::to_vec));
    1631         2983 :     let drop_attrs: Option<Vec<String>> = proj
    1632         2983 :         .and_then(|r| {
    1633         1106 :             r.omit.as_ref().map(|nodes| {
    1634           16 :                 nodes
    1635           16 :                     .iter()
    1636           20 :                     .filter(|n| n.children.is_none() && n.iri.contains("://"))
    1637           16 :                     .map(|n| n.iri.clone())
    1638           16 :                     .collect::<Vec<_>>()
    1639           16 :             })
    1640         1106 :         })
    1641         2983 :         .filter(|v| !v.is_empty());
    1642              :     // 5.7.2.4 split entities (p.202): the filters (q, geoquery, Scope query,
    1643              :     // Attributes) apply only AFTER remote parts and local information have
    1644              :     // been aggregated — so with federated candidates present the store must
    1645              :     // not drop (or pre-project) the LOCAL half of a split entity. The
    1646              :     // post-merge loop below applies them instead (`decided` is already
    1647              :     // false whenever `fed` is non-empty).
    1648         2983 :     let split_agg = crate::federation::split_entities(params) && !fed.is_empty();
    1649              :     // the id list is client-sized (a POST query body carries an array with no
    1650              :     // count of its own) and the candidate set is store-sized, so the two are
    1651              :     // never multiplied together
    1652         2983 :     let id_set: Option<std::collections::HashSet<&str>> =
    1653         2983 :         ids.as_ref().map(|v| v.iter().copied().collect());
    1654         2983 :     let mut out = Vec::new();
    1655              :     let (mut paged, mut total): (bool, Option<usize>);
    1656              :     let mut trimmed: bool;
    1657         2983 :     let mut rows = 0usize;
    1658         2983 :     let mut fed_once = Some(fed);
    1659              :     // the store page under `enough` or `counting`: (offset, limit), the limit
    1660              :     // doubling up to COUNT_CHUNK. Under `enough` only the client's window is
    1661              :     // kept, as under `counting`: holding every match up to the page's end made
    1662              :     // a deep page of a large dataset hold the whole dataset, read in one
    1663              :     // statement, and four of them took the broker past its limit.
    1664              :     const COUNT_CHUNK: usize = 2048;
    1665         2983 :     let window = client_page.filter(|_| enough.is_some());
    1666         2983 :     let mut read = (
    1667              :         0usize,
    1668         2983 :         enough
    1669         2983 :             .map(|n| n.min(COUNT_CHUNK))
    1670         2983 :             .unwrap_or(if counting.is_some() { 1024 } else { 0 }),
    1671              :     );
    1672         2983 :     let mut matched = 0usize;
    1673              :     // the walk's keyset: each store page starts after the last id
    1674              :     // the one before read, where a growing OFFSET re-read every row it
    1675              :     // skipped (80 pages over 164 000 Trees)
    1676         2983 :     let mut walk_after: Option<String> = None;
    1677              :     // Rules the loop below decides row by row, each narrowing by one
    1678              :     // `idPattern`, hand the store the id prefixes those patterns force: a
    1679              :     // dataset view of 70 KPIs walked the Tenant's 128 678 and timed out at
    1680              :     // the gateway, even for `limit=1`. The rules still decide.
    1681              :     // Rules the store decides by their patterns keep the ranges too: the
    1682              :     // regex filters only the rows the prefixes' index range found.
    1683         2983 :     let rule_prefixes =
    1684         2983 :         match rules.filter(|_| (judged.is_none() || rule_patterns.is_some()) && ids.is_none()) {
    1685          256 :             Some(f) => crate::policy::rules_id_prefixes(st, tenant, &f.rules).await?,
    1686         2727 :             None => None,
    1687              :         };
    1688              :     'scan: loop {
    1689         3011 :         let f = antares_store::filter::EntityFilter {
    1690         3011 :             ids: ids.as_deref(),
    1691              :             // 5.2.33: id takes precedence over idPattern, so the literal
    1692              :             // narrows only when no id selector was given
    1693         3011 :             id_literal: if ids.is_none() {
    1694         2505 :                 params
    1695         2505 :                     .get("idPattern")
    1696         2505 :                     .and_then(|p| antares_store::filter::id_pattern_literal(p))
    1697              :             } else {
    1698          506 :                 None
    1699              :             },
    1700         3011 :             id_prefixes: rule_prefixes.as_deref(),
    1701         3011 :             id_patterns: rule_patterns,
    1702         3011 :             types: judged.as_deref().or(type_sel.as_deref()),
    1703         3011 :             attrs: if split_agg {
    1704            6 :                 None
    1705              :             } else {
    1706         3005 :                 attr_filter.as_deref()
    1707              :             },
    1708         3011 :             q: if split_agg { None } else { q_ast.as_ref() },
    1709         3011 :             scope_q: if split_agg {
    1710            6 :                 None
    1711              :             } else {
    1712         3005 :                 scope_q.map(String::as_str)
    1713              :             },
    1714         3011 :             geo: if split_agg { &[] } else { &geo_specs },
    1715         3011 :             expand: &expand,
    1716         3011 :             ctx: Some(ctx),
    1717         3011 :             page: match enough.or(counting.map(|_| 0)) {
    1718          372 :                 Some(_) => Some(store_page(read)),
    1719         2639 :                 None => page.map(|(offset, limit)| antares_store::filter::Page {
    1720         1715 :                     offset: offset as i64,
    1721         1715 :                     limit: limit as i64,
    1722         1715 :                     count: params.get("count").map(String::as_str) == Some("true"),
    1723         1715 :                 }),
    1724              :             },
    1725         3011 :             keep_attrs: keep_attrs.as_deref(),
    1726         3011 :             drop_attrs: drop_attrs.as_deref(),
    1727         3011 :             after_id: walk_after.as_deref().or(after.as_deref()),
    1728              :         };
    1729              :         // a walk in pages decides each row itself, so the store may page
    1730              :         // what it cannot decide
    1731         3011 :         let outcome = match enough.or(counting.map(|_| 0)) {
    1732          372 :             Some(_) => st.store.query_candidates(tenant, &f).await?,
    1733         2639 :             None => st.store.query_entities(tenant, &f).await?,
    1734              :         };
    1735         3011 :         let fed = fed_once.take().unwrap_or_default();
    1736         3011 :         let store_paged = outcome.paged;
    1737         3011 :         let decided = outcome.decided && fed.is_empty() && !geo_uncompiled;
    1738         3011 :         trimmed = order_keys.is_some() && decided;
    1739         3011 :         paged = store_paged && fed.is_empty() && enough.is_none();
    1740         3011 :         total = outcome.total.map(|t| t as usize);
    1741         3011 :         let got = outcome.rows.len();
    1742         3011 :         rows = rows.saturating_add(got);
    1743         3011 :         walk_after = outcome
    1744         3011 :             .rows
    1745         3011 :             .last()
    1746         3011 :             .and_then(|d| d.get("id")?.as_str())
    1747         3011 :             .map(str::to_owned);
    1748         3011 :         let all = crate::federation::merge_candidates(outcome.rows, fed);
    1749        32476 :         for doc in all {
    1750        32476 :             let id = doc.get("id").and_then(Value::as_str).unwrap_or("");
    1751        32476 :             if let Some(ids) = &id_set {
    1752         1164 :                 if !decided && !ids.contains(id) {
    1753            0 :                     continue;
    1754         1164 :                 }
    1755        31312 :             }
    1756              :             // 5.2.33: "id takes precedence over idPattern" — the pattern only
    1757              :             // filters when no id selector was given.
    1758        32476 :             if ids.is_none() {
    1759        31312 :                 if let Some(re) = &id_pattern {
    1760              :                     // idPattern is invisible to the store — applied even when
    1761              :                     // decided
    1762         2628 :                     if !re.is_match(id) {
    1763         1214 :                         continue;
    1764         1414 :                     }
    1765        28684 :                 }
    1766         1164 :             }
    1767        31262 :             if !decided {
    1768         6872 :                 if let Some(sel) = &type_sel {
    1769         6872 :                     let etypes: Vec<&str> = doc
    1770         6872 :                         .get("type")
    1771         6872 :                         .and_then(Value::as_array)
    1772         6872 :                         .map(|a| a.iter().filter_map(Value::as_str).collect())
    1773         6872 :                         .unwrap_or_default();
    1774         6872 :                     let matched = sel
    1775         6872 :                         .iter()
    1776         6878 :                         .any(|and_group| and_group.iter().all(|w| etypes.contains(&w.as_str())));
    1777         6872 :                     if !matched {
    1778            0 :                         continue;
    1779         6872 :                     }
    1780            0 :                 }
    1781         6872 :                 if let Some(attrs) = &attr_filter {
    1782           16 :                     if !attrs.iter().any(|a| doc.get(a).is_some()) {
    1783            0 :                         continue;
    1784           16 :                     }
    1785         6856 :                 }
    1786         6872 :                 if let Some(ast) = &q_ast {
    1787              :                     // 4.9 linked-entity subqueries (attr{path}) resolve through
    1788              :                     // the local store, same tenant, under the caller's narrowing
    1789          138 :                     let narrow = narrow.map(|r| (r, ctx));
    1790          174 :                     if !crate::notify::linked_eval(st, tenant, narrow, |l| {
    1791          174 :                         eval_q(ast, &doc, ctx, l)
    1792          174 :                     })
    1793          138 :                     .await
    1794              :                     {
    1795           68 :                         continue;
    1796           70 :                     }
    1797         6734 :                 }
    1798         6804 :                 if let Some(sq) = scope_q {
    1799         6340 :                     if !crate::scope_matches(sq, &doc) {
    1800         2842 :                         continue;
    1801         3498 :                     }
    1802          464 :                 }
    1803         3962 :                 if !geos.iter().all(|g| g.matches(&doc, ctx)) {
    1804          164 :                     continue;
    1805         3798 :                 }
    1806        24390 :             }
    1807        28188 :             if let (Some(rules), Some(r)) = (rules, narrow) {
    1808         1676 :                 if !rules.admits(&doc, crate::repr::policy_context()) {
    1809          440 :                     continue;
    1810         1236 :                 }
    1811              :                 // the Entity as shown is built only for a client predicate
    1812              :                 // that reads it
    1813         1236 :                 if reads_shown {
    1814          176 :                     let view = crate::repr::policy_view(&doc, r);
    1815          176 :                     if let Some(attrs) = &shown_attrs {
    1816           12 :                         if !attrs.iter().any(|a| view.get(a).is_some()) {
    1817            6 :                             continue;
    1818            6 :                         }
    1819          164 :                     }
    1820          170 :                     if !shown_geos.iter().all(|g| g.matches(&view, ctx)) {
    1821           10 :                         continue;
    1822          160 :                     }
    1823              :                     // a `q` the store took because it reads the shown Entity
    1824              :                     // alike is decided there
    1825          160 :                     if let Some(ast) = shown_q.filter(|_| !(q_alike && decided)) {
    1826          148 :                         let narrow = narrow.map(|r| (r, ctx));
    1827          148 :                         if !crate::notify::linked_eval(st, tenant, narrow, |l| {
    1828          148 :                             eval_q(ast, &view, ctx, l)
    1829          148 :                         })
    1830          148 :                         .await
    1831              :                         {
    1832           64 :                             continue;
    1833           84 :                         }
    1834           12 :                     }
    1835         1060 :                 }
    1836        26512 :             }
    1837        27668 :             if let Some((offset, limit)) = counting {
    1838              :                 // ORDER BY id is the store's order, so the k-th match is the
    1839              :                 // k-th Entity of the client's page
    1840           88 :                 if matched.checked_sub(offset).is_some_and(|k| k < limit) {
    1841           28 :                     out.push(doc);
    1842           60 :                 }
    1843           88 :                 matched = matched.saturating_add(1);
    1844           88 :                 continue;
    1845        27580 :             }
    1846        27580 :             if let Some((offset, limit)) = window {
    1847              :                 // the window and one match past it, which says a next page exists
    1848         3880 :                 if matched >= offset {
    1849         3840 :                     out.push(doc);
    1850         3840 :                 }
    1851         3880 :                 matched = matched.saturating_add(1);
    1852         3880 :                 if out.len() > limit {
    1853           14 :                     break 'scan;
    1854         3866 :                 }
    1855         3866 :                 continue;
    1856        23700 :             }
    1857        23700 :             out.push(doc);
    1858              :         }
    1859              :         // a store that could not page answered with every row it holds
    1860         2997 :         if (enough.is_none() && counting.is_none()) || !store_paged || got < read.1 {
    1861         2967 :             break;
    1862           30 :         }
    1863              :         // a walk over candidates is bounded as the flat read it replaces was,
    1864              :         // a window that never fills (rules admitting nothing) too
    1865           30 :         if rows >= *crate::bounds::MAX_FOLD_DOCS {
    1866            2 :             return Err(NgsiError::TooManyResults(format!(
    1867            2 :                 "this query read more than {} entities; narrow it",
    1868            2 :                 *crate::bounds::MAX_FOLD_DOCS
    1869            2 :             ))
    1870            2 :             .into());
    1871           28 :         }
    1872           28 :         read = (0, read.1.saturating_mul(2).min(COUNT_CHUNK));
    1873              :     }
    1874         2981 :     if let Some((offset, limit)) = window {
    1875              :         // the smallest total consistent with "a next page exists"
    1876          308 :         let more = out.len() > limit;
    1877          308 :         out.truncate(limit);
    1878          308 :         return Ok(Filtered {
    1879          308 :             total: Some(
    1880          308 :                 offset
    1881          308 :                     .saturating_add(out.len())
    1882          308 :                     .saturating_add(usize::from(more)),
    1883          308 :             ),
    1884          308 :             rows: out.len(),
    1885          308 :             docs: out,
    1886          308 :             paged: true,
    1887          308 :             trimmed,
    1888          308 :         });
    1889         2673 :     }
    1890         2673 :     if counting.is_some() {
    1891              :         // the store paged to the end, or answered every row it holds at once
    1892           34 :         return Ok(Filtered {
    1893           34 :             rows: out.len(),
    1894           34 :             docs: out,
    1895           34 :             paged: true,
    1896           34 :             total: Some(matched),
    1897           34 :             trimmed,
    1898           34 :         });
    1899         2639 :     }
    1900         2639 :     Ok(Filtered {
    1901         2639 :         docs: out,
    1902         2639 :         paged,
    1903         2639 :         total,
    1904         2639 :         rows,
    1905         2639 :         trimmed,
    1906         2639 :     })
    1907         3033 : }
    1908              : 
    1909              : // ---------- DELETE /entities/{id} (5.6.6) ----------
    1910              : 
    1911         3106 : pub async fn delete_entity(
    1912         3106 :     State(st): State<AppState>,
    1913         3106 :     Path(id): Path<String>,
    1914         3106 :     CleanParams(params): CleanParams,
    1915         3106 :     headers: HeaderMap,
    1916         3106 : ) -> Response {
    1917         3106 :     let go = async {
    1918         3106 :         let tenant = tenant_from(&headers)?;
    1919         3106 :         antares_model::EntityId::new(&id)?;
    1920         3102 :         check_params(&params, &["local", "type"])?;
    1921              :         // 5.5.7: `type` is a term, so the request's own @context expands it.
    1922              :         // Under the core context the same word names a different type than
    1923              :         // the client meant, and the delete then removes an Entity the
    1924              :         // client's selector excluded.
    1925         3102 :         let ctx = request_context(&st.loader, &headers).await?;
    1926         3102 :         let pre = crate::policy::pre_image(&st, &tenant, &id, None).await?;
    1927         3102 :         let granted = gate!(
    1928              :             st, &tenant, &headers, "5.6.6",
    1929              :             ids: &[&id],
    1930              :             types: &pre.types,
    1931              :             stored: pre.stored.as_ref(),
    1932              :         )
    1933         3102 :         .await?;
    1934         3092 :         let cond = crate::policy::Condition::of(&granted);
    1935              :         // 4.17/5.6.6.4: the type selector gates the target — a registration
    1936              :         // for a different type must not receive the forwarded delete.
    1937              :         // every type the 4.17 selection names: the forward carries the
    1938              :         // selector and the Context Source judges it
    1939         3092 :         let spec = crate::registry::CsrSpec {
    1940         3092 :             ids: Some(vec![id.clone()]),
    1941         3092 :             types: type_selection(&params, &ctx).map(|sel| sel.into_iter().flatten().collect()),
    1942         3092 :             ..Default::default()
    1943              :         };
    1944         3092 :         let regs = match crate::federation::write_plan(&st, &tenant, &spec, &ctx, &params, &headers)
    1945         3092 :             .await?
    1946              :         {
    1947           20 :             crate::federation::WritePlan::Answered(r) => return Ok(*r),
    1948         3072 :             crate::federation::WritePlan::Forward(regs) => regs,
    1949              :         };
    1950         3072 :         crate::policy::Condition::unforwarded(cond.as_ref(), !regs.is_empty())?;
    1951         3072 :         let keep = |d: &Value| {
    1952              :             // An Entity the engine's condition does not select is
    1953              :             // not found
    1954         2844 :             if cond.as_ref().is_some_and(|c| !c.holds(d)) {
    1955            4 :                 return false;
    1956         2840 :             }
    1957         2840 :             crate::negotiate::matches_type_param(d, &params, &ctx)
    1958         2844 :         };
    1959         3072 :         if !regs.is_empty() {
    1960          126 :             let proxy_match = regs.iter().any(|r| r.is_proxy());
    1961          126 :             let mut parts = Vec::new();
    1962          126 :             if st.store.delete_entity_if(&tenant, &id, &keep).await? {
    1963           66 :                 mirror_delete_entity(&st, &tenant, &id).await;
    1964           66 :                 parts.push(crate::federation::Part::local(204, "deleted locally"));
    1965           60 :             } else if !proxy_match {
    1966           32 :                 // nothing local to delete, and no proxy that owns it: the
    1967           32 :                 // local half of this operation is the 404
    1968           32 :                 parts.push(crate::federation::Part::local(
    1969           32 :                     404,
    1970           32 :                     format!("entity {id} not found locally"),
    1971           32 :                 ));
    1972           34 :             }
    1973          126 :             let ctx_url = crate::federation::ctx_link_url(&headers, &ctx.source);
    1974          126 :             let seg = crate::federation::path_segment(&id);
    1975          126 :             let fwd_q = type_selector_query(&params);
    1976          126 :             for reg in &regs {
    1977              :                 // 5.6.6.4: proxy modes not supporting Delete Entity are an
    1978              :                 // error of type Conflict; inclusive ones are not forwarded.
    1979          126 :                 if !reg.supports("deleteEntity") {
    1980           12 :                     if reg.is_proxy() {
    1981            4 :                         parts.push(crate::federation::conflict_part("deleteEntity", reg));
    1982            8 :                     }
    1983           12 :                     continue;
    1984          114 :                 }
    1985          114 :                 parts.push(
    1986          114 :                     crate::federation::forward_part(
    1987          114 :                         &st,
    1988          114 :                         reqwest::Method::DELETE,
    1989          114 :                         format!("{}/ngsi-ld/v1/entities/{seg}", reg.endpoint),
    1990          114 :                         &fwd_q,
    1991          114 :                         &headers,
    1992          114 :                         &tenant,
    1993          114 :                         reg,
    1994          114 :                         &ctx_url,
    1995          114 :                         None,
    1996          114 :                     )
    1997          114 :                     .await,
    1998              :                 );
    1999              :             }
    2000          126 :             return Ok(crate::federation::combine(
    2001          126 :                 st.profile,
    2002          126 :                 Some(&id),
    2003          126 :                 parts,
    2004          126 :                 no_content(&tenant),
    2005          126 :                 &tenant,
    2006          126 :             ));
    2007         2946 :         }
    2008         2946 :         if st.store.delete_entity_if(&tenant, &id, &keep).await? {
    2009         2762 :             mirror_delete_entity(&st, &tenant, &id).await;
    2010         2762 :             Ok::<_, ApiError>(no_content(&tenant))
    2011              :         } else {
    2012          184 :             Err(NgsiError::ResourceNotFound(format!("entity {id} not found")).into())
    2013              :         }
    2014         3106 :     };
    2015         3106 :     go.await.unwrap_or_else(|e| e.into_response())
    2016         3106 : }
    2017              : 
    2018              : // ---------- DELETE /entities/ — Purge (5.6.21) ----------
    2019              : 
    2020              : /// 5.6.21.3 Input data of a Purge: the Entity type selector, the identifier
    2021              : /// list and id pattern, the restrictive and exclusionary Attribute-name
    2022              : /// lists, the NGSI-LD Query, the GeoQuery, the Scope query and the context
    2023              : /// source filter. 5.6.21.4 forwards "matching input data ... to the
    2024              : /// Registration endpoint", so every one of them travels: a forward that
    2025              : /// carries fewer restrictions than the client issued makes the peer execute
    2026              : /// a strictly wider purge than the one requested. `local` is absent by
    2027              : /// design — it selects local scope, which is what stops the forward
    2028              : /// happening at all (5.5.13).
    2029              : const PURGE_FORWARD_PARAMS: &[&str] = &[
    2030              :     "type",
    2031              :     "id",
    2032              :     "idPattern",
    2033              :     "attrs",
    2034              :     "q",
    2035              :     "georel",
    2036              :     "geometry",
    2037              :     "coordinates",
    2038              :     "geoproperty",
    2039              :     "scopeQ",
    2040              :     "csf",
    2041              :     "keep",
    2042              :     "drop",
    2043              : ];
    2044              : 
    2045           30 : fn forwarded_purge_query(params: &HashMap<String, String>) -> Vec<(String, String)> {
    2046           30 :     PURGE_FORWARD_PARAMS
    2047           30 :         .iter()
    2048          390 :         .filter_map(|k| params.get(*k).map(|v| ((*k).to_owned(), v.clone())))
    2049           30 :         .collect()
    2050           30 : }
    2051              : 
    2052              : /// How many matched Entities one round of a Purge fetches and applies. The
    2053              : /// match set of 5.6.21.4 is deliberately unbounded — `DELETE /entities?type=T`
    2054              : /// matches every Entity of that type — so it is walked page by page and
    2055              : /// applied in batches rather than materialized whole.
    2056              : const PURGE_CHUNK: usize = 500;
    2057              : 
    2058              : /// Where the next chunk of a walked match set starts, or None when the set is
    2059              : /// exhausted. `rows` = documents the store returned for this chunk (not the
    2060              : /// subset that survived the evaluator: idPattern is applied after the store,
    2061              : /// so a full chunk can arrive narrowed or even empty and the walk must still
    2062              : /// go on — 5.6.21.4 deletes ALL matched Entities, not the first page of
    2063              : /// them). `left_the_set` = how many of those rows no longer match the query
    2064              : /// afterwards: a Purge deletes or prunes them, a read leaves every row where
    2065              : /// it was. The rows still in the set have to be stepped over, or the walk
    2066              : /// re-reads them forever.
    2067              : ///
    2068              : /// Termination: every round either removes rows from the match set or
    2069              : /// advances the offset by a full chunk, and both are bounded by the number of
    2070              : /// stored Entities.
    2071          539 : pub(crate) fn next_scan_offset(
    2072          539 :     offset: usize,
    2073          539 :     rows: usize,
    2074          539 :     left_the_set: usize,
    2075          539 :     paged: bool,
    2076          539 :     chunk: usize,
    2077          539 : ) -> Option<usize> {
    2078              :     // an unpaged answer IS the whole match set, which this round just applied
    2079          539 :     if !paged || rows < chunk {
    2080          475 :         return None;
    2081           64 :     }
    2082           64 :     Some(offset.saturating_add(rows.saturating_sub(left_the_set)))
    2083          539 : }
    2084              : 
    2085              : /// 5.6.21.4 "And thereafter": with no Attribute-name list, delete every
    2086              : /// matched Entity found locally; with a restrictive list, delete those
    2087              : /// Attributes from them; with an exclusionary list, delete all but those.
    2088          160 : async fn purge_locally(
    2089          160 :     st: &AppState,
    2090          160 :     tenant: &TenantId,
    2091          160 :     params: &HashMap<String, String>,
    2092          160 :     ctx: &antares_jsonld::Context,
    2093          160 :     keep: &Option<Vec<String>>,
    2094          160 :     drop: &Option<Vec<String>>,
    2095          160 : ) -> ApiResult<()> {
    2096          160 :     let prune = keep.is_some() || drop.is_some();
    2097          160 :     let mut offset = 0usize;
    2098              :     loop {
    2099          192 :         let batch = filter_entities_paged(
    2100          192 :             st,
    2101          192 :             tenant,
    2102          192 :             params,
    2103          192 :             ctx,
    2104          192 :             Vec::new(),
    2105          192 :             Pushdown {
    2106          192 :                 page: Some((offset, PURGE_CHUNK)),
    2107          192 :                 proj: None,
    2108          192 :                 order_keys: None,
    2109          192 :             },
    2110          192 :             // 5.6.21.4 refuses linked q terms before a purge runs
    2111          192 :             None,
    2112          192 :         )
    2113          192 :         .await?;
    2114          192 :         let rows = batch.rows;
    2115          192 :         let ids: Vec<String> = batch
    2116          192 :             .docs
    2117          192 :             .iter()
    2118        16146 :             .filter_map(|d| d["id"].as_str().map(str::to_owned))
    2119          192 :             .collect();
    2120          192 :         let left_the_set = if ids.is_empty() {
    2121          100 :             0
    2122           92 :         } else if prune {
    2123           28 :             let mut changed = 0usize;
    2124           28 :             st.store
    2125         8036 :                 .batch_mutate(tenant, &ids, |_, doc| {
    2126         8036 :                     let target = antares_store::stored_object(doc)?;
    2127         8036 :                     let attrs: Vec<String> =
    2128        28176 :                         target.keys().filter(|k| !is_meta(k)).cloned().collect();
    2129         8036 :                     let before = target.len();
    2130        12088 :                     for a in attrs {
    2131        12088 :                         let purge = match (keep, drop) {
    2132        12072 :                             (Some(keep), _) => !keep.contains(&a),
    2133           16 :                             (_, Some(drop)) => drop.contains(&a),
    2134            0 :                             _ => true,
    2135              :                         };
    2136        12088 :                         if purge {
    2137         4044 :                             target.remove(&a);
    2138         8044 :                         }
    2139              :                     }
    2140         8036 :                     if target.len() != before {
    2141         4036 :                         changed = changed.saturating_add(1);
    2142         4036 :                     }
    2143         8036 :                     Ok::<(), NgsiError>(())
    2144         8036 :                 })
    2145           28 :                 .await?;
    2146              :             // A prune keeps the Entity, but it may have removed the very
    2147              :             // Attribute the query matched on (`attrs=speed&drop=speed`), which
    2148              :             // takes it out of the match set and shifts the rest down. Whether
    2149              :             // it did is only observable by reading the window again, so a round
    2150              :             // that changed something re-reads it; the re-read finds those
    2151              :             // Entities unchanged and steps over them.
    2152           28 :             if changed > 0 {
    2153           20 :                 rows
    2154              :             } else {
    2155            8 :                 0
    2156              :             }
    2157              :         } else {
    2158           64 :             let mut gone = 0usize;
    2159         8110 :             for (id, deleted) in ids.iter().zip(st.store.batch_delete(tenant, &ids).await?) {
    2160         8110 :                 if deleted {
    2161         8110 :                     gone = gone.saturating_add(1);
    2162         8110 :                     mirror_delete_entity(st, tenant, id).await;
    2163            0 :                 }
    2164              :             }
    2165           64 :             gone
    2166              :         };
    2167          192 :         match next_scan_offset(offset, rows, left_the_set, batch.paged, PURGE_CHUNK) {
    2168           32 :             Some(next) => offset = next,
    2169          160 :             None => return Ok(()),
    2170              :         }
    2171              :     }
    2172          160 : }
    2173              : 
    2174              : /// 5.6.21 Purge Entities: delete (or keep=/drop=-prune) all entities matched
    2175              : /// by the query; output data is none — 204 (5.6.21.5). Too-wide queries,
    2176              : /// Linked Entity paths and invalid id/q/geo/csf are BadRequestData
    2177              : /// (5.6.21.4); matched registrations forward only with purgeEntity support.
    2178          208 : pub async fn purge_entities(
    2179          208 :     State(st): State<AppState>,
    2180          208 :     CleanParams(params): CleanParams,
    2181          208 :     headers: HeaderMap,
    2182          208 : ) -> Response {
    2183          208 :     match purge_inner(&st, &params, &headers).await {
    2184          140 :         Ok(r) => r,
    2185           68 :         Err(e) => e.into_response(),
    2186              :     }
    2187          208 : }
    2188              : 
    2189          230 : async fn purge_inner(
    2190          230 :     st: &AppState,
    2191          230 :     params: &HashMap<String, String>,
    2192          230 :     headers: &HeaderMap,
    2193          230 : ) -> ApiResult<Response> {
    2194          230 :     let tenant = tenant_from(headers)?;
    2195          230 :     check_params(
    2196          230 :         params,
    2197          230 :         &[
    2198          230 :             "id",
    2199          230 :             "idPattern",
    2200          230 :             "type",
    2201          230 :             "attrs",
    2202          230 :             "q",
    2203          230 :             "georel",
    2204          230 :             "geometry",
    2205          230 :             "coordinates",
    2206          230 :             "geoproperty",
    2207          230 :             "scopeQ",
    2208          230 :             "csf",
    2209          230 :             "keep",
    2210          230 :             "drop",
    2211          230 :             "local",
    2212          230 :         ],
    2213            4 :     )?;
    2214          226 :     let ctx = request_context(&st.loader, headers).await?;
    2215              :     // The engine sees the whole request; 5.6.21 stays one
    2216              :     // tenant-wide grant (ADR-0020), so nothing narrows the purge itself.
    2217          226 :     let named = ReadMembers::with_ctx(st, params, &ctx);
    2218          226 :     let ids: Vec<&str> = params
    2219          226 :         .get("id")
    2220          226 :         .map(|s| s.split(',').map(str::trim).collect())
    2221          226 :         .unwrap_or_default();
    2222          226 :     gate!(
    2223              :         st, &tenant, headers, "5.6.21",
    2224              :         ids: &ids,
    2225              :         q: named.q.as_ref(),
    2226              :         scope_q: params.get("scopeQ").map(String::as_str),
    2227              :         types: &named.types,
    2228              :         attrs: &named.attrs,
    2229              :         geo: named.geo.as_ref(),
    2230              :     )
    2231          226 :     .await?;
    2232              :     // 5.6.21.4: exactly five qualifying conditions —
    2233              :     //   a) selector of Entity Types
    2234              :     //   b) list of Attribute names, including at least one non-system Attribute
    2235              :     //   c) NGSI-LD Query, including at least one non-system Attribute
    2236              :     //   d) NGSI-LD GeoQuery
    2237              :     //   e) local scope (5.5.13)
    2238              :     // "If none of the above is provided, then an error of type BadRequestData
    2239              :     // shall be raised (too wide query)."
    2240              :     //
    2241              :     // id/idPattern are legal input data (5.6.21.3) and DO filter, but they are
    2242              :     // never sufficient on their own: "it is not possible to purge a set of
    2243              :     // entities by only specifying desired Entity identifiers". Listing them
    2244              :     // here is how `DELETE /entities?idPattern=.*` became a tenant wipe.
    2245          224 :     let attrs_qualify = params.get("attrs").is_some_and(|a| {
    2246           22 :         a.split(',')
    2247           22 :             .any(|n| antares_ql::is_non_system_attr(n.trim()))
    2248           22 :     });
    2249          224 :     let q_ast = params.get("q").map(|q| parse_q(q)).transpose()?;
    2250          224 :     let q_qualifies = q_ast.as_ref().is_some_and(|ast| {
    2251           26 :         ast.attribute_paths()
    2252           26 :             .iter()
    2253           26 :             .any(|h| antares_ql::is_non_system_attr(h))
    2254           26 :     });
    2255              :     // 5.6.21.4: Linked Entity retrieval in the projection attributes, or
    2256              :     // Linked Entity attributes in the filter conditions → BadRequestData.
    2257          224 :     if q_ast
    2258          224 :         .as_ref()
    2259          224 :         .is_some_and(antares_ql::QNode::has_linked_paths)
    2260              :     {
    2261            8 :         return Err(NgsiError::BadRequestData(
    2262            8 :             "purge q must not reference Linked Entity attributes (5.6.21.4)".into(),
    2263            8 :         )
    2264            8 :         .into());
    2265          216 :     }
    2266          216 :     if params.get("attrs").is_some_and(|a| a.contains('{')) {
    2267            8 :         return Err(NgsiError::BadRequestData(
    2268            8 :             "purge attrs must not use Linked Entity retrieval (5.6.21.4)".into(),
    2269            8 :         )
    2270            8 :         .into());
    2271          208 :     }
    2272              :     // Table 6.4.3.2-1, as on the entity query (5.7.2.4)
    2273          208 :     star_type_local(params)?;
    2274              :     // 5.6.21.4: a syntactically invalid context source filter is
    2275              :     // BadRequestData.
    2276          206 :     if let Some(csf) = params.get("csf") {
    2277            8 :         parse_q(csf)?;
    2278          198 :     }
    2279          202 :     let has_filter = params.contains_key("type")
    2280           96 :         || attrs_qualify
    2281           92 :         || q_qualifies
    2282           84 :         || params.contains_key("georel")
    2283           84 :         || params.get("local").map(String::as_str) == Some("true");
    2284          202 :     if !has_filter {
    2285           36 :         return Err(NgsiError::BadRequestData(
    2286           36 :             "purge needs at least one of: type, attrs or q naming a non-system \
    2287           36 :              Attribute, georel, or local=true (5.6.21.4 — too wide query)"
    2288           36 :                 .into(),
    2289           36 :         )
    2290           36 :         .into());
    2291          166 :     }
    2292          166 :     if params.contains_key("keep") && params.contains_key("drop") {
    2293            4 :         return Err(NgsiError::BadRequestData(
    2294            4 :             "keep and drop are mutually exclusive (5.6.21)".into(),
    2295            4 :         )
    2296            4 :         .into());
    2297          162 :     }
    2298          162 :     let keep: Option<Vec<String>> = params
    2299          162 :         .get("keep")
    2300          162 :         .map(|s| s.split(',').map(|t| ctx.expand_key(t.trim())).collect());
    2301          162 :     let drop: Option<Vec<String>> = params
    2302          162 :         .get("drop")
    2303          162 :         .map(|s| s.split(',').map(|t| ctx.expand_key(t.trim())).collect());
    2304              :     // distributed purge (5.6.21 / 6.4.3.3). Matching and the 6.3.17/6.3.18
    2305              :     // loop check come first: 508 Loop Detected is an error status, so the
    2306              :     // request it answers must not have deleted a page of Entities already.
    2307          162 :     let spec = crate::registry::CsrSpec {
    2308              :         // every type the 4.17 selection names, as for 5.6.6
    2309          162 :         types: type_selection(params, &ctx).map(|sel| sel.into_iter().flatten().collect()),
    2310          162 :         ids: params
    2311          162 :             .get("id")
    2312          162 :             .map(|s| s.split(',').map(str::to_owned).collect()),
    2313              :         // 5.12: the purge's idPattern is part of the Entity specification too
    2314          162 :         id_pattern: params.get("idPattern").cloned(),
    2315          162 :         csf: params.get("csf").and_then(|c| antares_ql::parse_q(c).ok()),
    2316          162 :         ..Default::default()
    2317              :     };
    2318          160 :     let regs =
    2319          162 :         match crate::federation::write_plan(st, &tenant, &spec, &ctx, params, headers).await? {
    2320            2 :             crate::federation::WritePlan::Answered(r) => return Ok(*r),
    2321          160 :             crate::federation::WritePlan::Forward(regs) => regs,
    2322              :         };
    2323          160 :     purge_locally(st, &tenant, params, &ctx, &keep, &drop).await?;
    2324          160 :     if !regs.is_empty() {
    2325           22 :         let mut parts = vec![crate::federation::Part::local(204, "purged locally")];
    2326           22 :         let ctx_url = crate::federation::ctx_link_url(headers, &ctx.source);
    2327           22 :         let query = forwarded_purge_query(params);
    2328           26 :         for reg in &regs {
    2329              :             // 5.6.21.4: matching input data is forwarded only when the
    2330              :             // registration supports Purge Entity; an unsupported matched
    2331              :             // registration — any mode — is an error of type Conflict
    2332              :             // (partial success when other parts succeeded).
    2333           26 :             if !reg.supports("purgeEntity") {
    2334            2 :                 parts.push(crate::federation::conflict_part("purgeEntity", reg));
    2335            2 :                 continue;
    2336           24 :             }
    2337           24 :             parts.push(
    2338           24 :                 crate::federation::forward_part(
    2339           24 :                     st,
    2340           24 :                     reqwest::Method::DELETE,
    2341           24 :                     format!("{}/ngsi-ld/v1/entities", reg.endpoint),
    2342           24 :                     &query,
    2343           24 :                     headers,
    2344           24 :                     &tenant,
    2345           24 :                     reg,
    2346           24 :                     &ctx_url,
    2347           24 :                     None,
    2348           24 :                 )
    2349           24 :                 .await,
    2350              :             );
    2351              :         }
    2352           22 :         return Ok(crate::federation::combine(
    2353           22 :             st.profile,
    2354           22 :             None,
    2355           22 :             parts,
    2356           22 :             no_content(&tenant),
    2357           22 :             &tenant,
    2358           22 :         ));
    2359          138 :     }
    2360          138 :     Ok(no_content(&tenant))
    2361          230 : }
    2362              : 
    2363              : // ---------- PATCH /entities/{id} — Merge (5.6.17 / 5.5.12) ----------
    2364              : 
    2365          248 : pub async fn merge_entity(
    2366          248 :     State(st): State<AppState>,
    2367          248 :     Path(id): Path<String>,
    2368          248 :     CleanParams(params): CleanParams,
    2369          248 :     headers: HeaderMap,
    2370          248 :     body: Bytes,
    2371          248 : ) -> Response {
    2372          248 :     match merge_entity_inner(&st, &id, &params, &headers, &body).await {
    2373          202 :         Ok(r) => r,
    2374           46 :         Err(e) => e.into_response(),
    2375              :     }
    2376          248 : }
    2377              : 
    2378          248 : async fn merge_entity_inner(
    2379          248 :     st: &AppState,
    2380          248 :     id: &str,
    2381          248 :     params: &HashMap<String, String>,
    2382          248 :     headers: &HeaderMap,
    2383          248 :     body: &[u8],
    2384          248 : ) -> ApiResult<Response> {
    2385          248 :     let tenant = tenant_from(headers)?;
    2386          248 :     antares_model::EntityId::new(id)?;
    2387          244 :     check_params(
    2388          244 :         params,
    2389          244 :         &["options", "format", "observedAt", "lang", "local", "type"],
    2390            0 :     )?;
    2391          244 :     let parsed = parse_body(st, headers, body, BodyKind::MergePatch).await?;
    2392          244 :     let obj = parsed.object(NgsiError::BadRequestData(
    2393          244 :         "fragment must be a JSON object".into(),
    2394          244 :     ))?;
    2395          244 :     if let Some(bid) = obj.get("id").and_then(Value::as_str) {
    2396          116 :         if bid != id {
    2397            0 :             return Err(NgsiError::BadRequestData("fragment id mismatch".into()).into());
    2398          116 :         }
    2399          128 :     }
    2400          244 :     let mut fragment = expand_entity(
    2401          244 :         obj,
    2402          244 :         &parsed.ctx,
    2403          244 :         ExpandOpts {
    2404          244 :             fragment: true,
    2405          244 :             allow_null: true,
    2406          244 :             merge: true,
    2407          244 :             temporal: false,
    2408          244 :             ..Default::default()
    2409          244 :         },
    2410            6 :     )?;
    2411          238 :     let pre = crate::policy::pre_image(st, &tenant, id, Some(&fragment)).await?;
    2412          238 :     let granted = gate!(
    2413              :         st, &tenant, headers, "5.6.17",
    2414              :         ids: &[id],
    2415              :         types: &pre.types,
    2416              :         stored: pre.stored.as_ref(),
    2417              :         attrs: &crate::attrs::attr_iris_of(&fragment),
    2418              :         body: Some(&fragment),
    2419              :     )
    2420          238 :     .await?;
    2421          236 :     let cond = crate::policy::Condition::of(&granted);
    2422              :     // 5.6.17.3: a common observedAt timestamp to use across merged
    2423              :     // Attributes, and a common language tag for merged LanguageMaps.
    2424          236 :     let observed_at = params.get("observedAt").map(String::as_str);
    2425          236 :     if let Some(t) = observed_at {
    2426            8 :         if !antares_jsonld::parse_datetime(t) {
    2427            4 :             return Err(
    2428            4 :                 NgsiError::BadRequestData("observedAt must be a DateTime (4.8)".into()).into(),
    2429            4 :             );
    2430            4 :         }
    2431          228 :     }
    2432          232 :     let lang = params.get("lang").map(String::as_str);
    2433          232 :     let simplified = simplified_input(params)?;
    2434          230 :     apply_common_observed_at(&mut fragment, observed_at);
    2435          230 :     let ts = now_iso();
    2436              : 
    2437              :     // 5.6.17.4: the ?type selector narrows the Context Sources as it
    2438              :     // narrows the local target, as for 5.6.6
    2439          230 :     let spec = crate::registry::CsrSpec {
    2440          230 :         ids: Some(vec![id.to_owned()]),
    2441          230 :         types: type_selection(params, &parsed.ctx).map(|sel| sel.into_iter().flatten().collect()),
    2442          230 :         ..Default::default()
    2443              :     };
    2444          230 :     let regs = match crate::federation::write_plan(st, &tenant, &spec, &parsed.ctx, params, headers)
    2445          230 :         .await?
    2446              :     {
    2447            0 :         crate::federation::WritePlan::Answered(r) => return Ok(*r),
    2448          230 :         crate::federation::WritePlan::Forward(regs) => regs,
    2449              :     };
    2450          230 :     crate::policy::Condition::unforwarded(cond.as_ref(), !regs.is_empty())?;
    2451          230 :     if !regs.is_empty() {
    2452           18 :         let proxies: Vec<&crate::federation::FedReg> =
    2453           22 :             regs.iter().filter(|r| r.is_proxy()).collect();
    2454           18 :         let mut parts = Vec::new();
    2455           18 :         let (rest, has_attrs) = crate::federation::strip_proxied(obj, &proxies, &parsed.ctx);
    2456              :         // 5.6.17.4: the target is "an existing Entity whose id (URI), and
    2457              :         // where specified type, is equivalent held locally" — the ?type
    2458              :         // selector narrows it on this path exactly as on the local-only one.
    2459           18 :         let local_exists = st
    2460           18 :             .store
    2461           18 :             .get(&tenant, Kind::Entity, id)
    2462           18 :             .await?
    2463           18 :             .is_some_and(|d| crate::negotiate::matches_type_param(&d, params, &parsed.ctx));
    2464           18 :         if (local_exists || proxies.is_empty()) && has_attrs {
    2465           10 :             let mut local_frag = expand_entity(
    2466           10 :                 &rest,
    2467           10 :                 &parsed.ctx,
    2468           10 :                 ExpandOpts {
    2469           10 :                     fragment: true,
    2470           10 :                     allow_null: true,
    2471           10 :                     merge: true,
    2472           10 :                     temporal: false,
    2473           10 :                     ..Default::default()
    2474           10 :                 },
    2475            0 :             )?;
    2476           10 :             apply_common_observed_at(&mut local_frag, observed_at);
    2477           10 :             let res = st
    2478           10 :                 .store
    2479           10 :                 .mutate(&tenant, Kind::Entity, id, |doc| {
    2480           10 :                     crate::history::restage(id);
    2481           10 :                     if !crate::negotiate::matches_type_param(doc, params, &parsed.ctx) {
    2482            0 :                         return Err(NgsiError::ResourceNotFound(format!(
    2483            0 :                             "entity {id} does not match the type selector"
    2484            0 :                         )));
    2485           10 :                     }
    2486           10 :                     let mut frag = local_frag.clone();
    2487           10 :                     let raw = KeptTypes {
    2488           10 :                         raw: &rest,
    2489           10 :                         ctx: &parsed.ctx,
    2490           10 :                         simplified,
    2491           10 :                         lang,
    2492           10 :                         observed_at,
    2493           10 :                     };
    2494           10 :                     raw.apply(doc, &mut frag)?;
    2495           10 :                     apply_common_lang(doc, &mut frag, lang);
    2496           10 :                     merge_into(doc, &frag, &ts);
    2497           10 :                     Ok::<(), NgsiError>(())
    2498           10 :                 })
    2499           10 :                 .await?;
    2500           10 :             crate::history::settle(id, matches!(res, Some(Ok(()))));
    2501           10 :             parts.push(match res {
    2502           10 :                 Some(Ok(())) => crate::federation::Part::local(204, "merged locally"),
    2503            0 :                 _ => crate::federation::Part::local(404, format!("entity {id} not found locally")),
    2504              :             });
    2505            8 :         }
    2506           18 :         let ctx_url = crate::federation::ctx_link_url(headers, &parsed.ctx.source);
    2507           18 :         let seg = crate::federation::path_segment(id);
    2508           18 :         let fwd_q = type_selector_query(params);
    2509           22 :         for reg in &regs {
    2510              :             // 5.6.17.4: proxy modes without Merge Entity support are an
    2511              :             // error of type Conflict; inclusive ones are not forwarded.
    2512           22 :             if !reg.supports("mergeEntity") {
    2513            4 :                 if reg.is_proxy() {
    2514            2 :                     parts.push(crate::federation::conflict_part("mergeEntity", reg));
    2515            2 :                 }
    2516            4 :                 continue;
    2517           18 :             }
    2518           18 :             let Some(frag) = crate::federation::reduce_to_scope(obj, reg, &parsed.ctx) else {
    2519            0 :                 continue;
    2520              :             };
    2521           18 :             parts.push(
    2522           18 :                 crate::federation::forward_part(
    2523           18 :                     st,
    2524           18 :                     reqwest::Method::PATCH,
    2525           18 :                     format!("{}/ngsi-ld/v1/entities/{seg}", reg.endpoint),
    2526           18 :                     &fwd_q,
    2527           18 :                     headers,
    2528           18 :                     &tenant,
    2529           18 :                     reg,
    2530           18 :                     &ctx_url,
    2531           18 :                     Some(frag),
    2532           18 :                 )
    2533           18 :                 .await,
    2534              :             );
    2535              :         }
    2536           18 :         return Ok(crate::federation::combine(
    2537           18 :             st.profile,
    2538           18 :             Some(id),
    2539           18 :             parts,
    2540           18 :             no_content(&tenant),
    2541           18 :             &tenant,
    2542           18 :         ));
    2543          212 :     }
    2544              : 
    2545          212 :     let res = st
    2546          212 :         .store
    2547          212 :         .mutate(&tenant, Kind::Entity, id, |doc| {
    2548          198 :             crate::history::restage(id);
    2549          198 :             crate::policy::Condition::pre(cond.as_ref(), id, doc)?;
    2550              :             // 5.6.17.4: the ?type selector narrows the merge target
    2551          194 :             if !crate::negotiate::matches_type_param(doc, params, &parsed.ctx) {
    2552            0 :                 return Err(NgsiError::ResourceNotFound(format!(
    2553            0 :                     "entity {id} does not match the type selector"
    2554            0 :                 )));
    2555          194 :             }
    2556          194 :             let mut frag = fragment.clone();
    2557          194 :             let raw = KeptTypes {
    2558          194 :                 raw: obj,
    2559          194 :                 ctx: &parsed.ctx,
    2560          194 :                 simplified,
    2561          194 :                 lang,
    2562          194 :                 observed_at,
    2563          194 :             };
    2564          194 :             raw.apply(doc, &mut frag)?;
    2565          184 :             apply_common_lang(doc, &mut frag, lang);
    2566          184 :             merge_into(doc, &frag, &ts);
    2567          184 :             Ok::<(), NgsiError>(())
    2568          198 :         })
    2569          212 :         .await?;
    2570          212 :     crate::history::settle(id, matches!(res, Some(Ok(()))));
    2571          198 :     match res {
    2572           14 :         None => Err(NgsiError::ResourceNotFound(format!("entity {id} not found")).into()),
    2573           14 :         Some(Err(e)) => Err(e.into()),
    2574          184 :         Some(Ok(())) => Ok(no_content(&tenant)),
    2575              :     }
    2576          248 : }
    2577              : 
    2578              : /// 5.6.17.3: "An optional parameter indicating a common observedAt timestamp
    2579              : /// to use across merged Attributes." It applies to the Attribute instances of
    2580              : /// the Fragment that do not carry an observedAt of their own; a deletion
    2581              : /// instance (5.5.12 NGSI-LD Null) removes the Attribute and takes none.
    2582          268 : fn apply_common_observed_at(fragment: &mut Value, observed_at: Option<&str>) {
    2583          268 :     let (Some(ts), Some(obj)) = (observed_at, fragment.as_object_mut()) else {
    2584          264 :         return;
    2585              :     };
    2586            8 :     for (k, v) in obj.iter_mut() {
    2587            8 :         if is_meta(k) {
    2588            0 :             continue;
    2589            8 :         }
    2590            8 :         let Some(instances) = v.as_array_mut() else {
    2591            0 :             continue;
    2592              :         };
    2593            8 :         for inst in instances {
    2594            8 :             if antares_jsonld::is_deletion_instance(inst) {
    2595            0 :                 continue;
    2596            8 :             }
    2597            8 :             if let Some(o) = inst.as_object_mut() {
    2598            8 :                 o.entry("observedAt".to_owned())
    2599            8 :                     .or_insert_with(|| Value::String(ts.to_owned()));
    2600            0 :             }
    2601              :         }
    2602              :     }
    2603          268 : }
    2604              : 
    2605              : /// 5.6.17.4: "If a common language tag is defined and a LanguageProperty
    2606              : /// Attribute to be merged is represented as a string, the pre-existing
    2607              : /// languageMap JSON object shall be preserved. The string value shall only
    2608              : /// replace the value associated to the language tag key found within the
    2609              : /// languageMap." The string instance is rewritten into a one-key languageMap
    2610              : /// patch, which 5.5.12 then merges into the stored map key by key.
    2611          194 : fn apply_common_lang(target: &Value, fragment: &mut Value, lang: Option<&str>) {
    2612          194 :     let (Some(lang), Some(frag)) = (lang, fragment.as_object_mut()) else {
    2613          188 :         return;
    2614              :     };
    2615            6 :     for (k, v) in frag.iter_mut() {
    2616            6 :         if is_meta(k) {
    2617            0 :             continue;
    2618            6 :         }
    2619            6 :         let pre_existing_langmap = target
    2620            6 :             .get(k)
    2621            6 :             .and_then(Value::as_array)
    2622            6 :             .is_some_and(|insts| insts.iter().any(|i| i.get("languageMap").is_some()));
    2623            6 :         if !pre_existing_langmap {
    2624            0 :             continue;
    2625            6 :         }
    2626            6 :         let Some(instances) = v.as_array_mut() else {
    2627            0 :             continue;
    2628              :         };
    2629            6 :         for inst in instances {
    2630            6 :             if antares_jsonld::is_deletion_instance(inst) {
    2631            0 :                 continue;
    2632            6 :             }
    2633            6 :             let Some(o) = inst.as_object_mut() else {
    2634            0 :                 continue;
    2635              :             };
    2636            6 :             let Some(s) = o.get("value").and_then(Value::as_str).map(str::to_owned) else {
    2637            2 :                 continue;
    2638              :             };
    2639            4 :             o.remove("value");
    2640            4 :             o.insert("type".into(), Value::String("LanguageProperty".into()));
    2641            4 :             let mut map = Map::new();
    2642            4 :             map.insert(lang.to_owned(), Value::String(s));
    2643            4 :             o.insert("languageMap".into(), Value::Object(map));
    2644              :         }
    2645              :     }
    2646          194 : }
    2647              : 
    2648              : /// TS 104 176 7.3.3.2 Table 7.3.3.2-1: `format` "shall be one of:
    2649              : /// "simplified" (or its synonym "keyValues")", and so is the deprecated
    2650              : /// `options=keyValues` spelling (6.3.7). Either says the Fragment holds the
    2651              : /// simplified representation of ETSI TS 104 175 clause 5.3.2.4.
    2652          232 : fn simplified_input(params: &HashMap<String, String>) -> Result<bool, NgsiError> {
    2653          232 :     let by_options = params.get("options").is_some_and(|o| {
    2654           12 :         o.split(',')
    2655           12 :             .any(|o| matches!(o.trim(), "keyValues" | "simplified"))
    2656           12 :     });
    2657          232 :     match params.get("format").map(String::as_str) {
    2658          208 :         None => Ok(by_options),
    2659           24 :         Some("simplified" | "keyValues") => Ok(true),
    2660            2 :         Some("normalized" | "concise") => Ok(by_options),
    2661            2 :         Some(f) => Err(NgsiError::BadRequestData(format!(
    2662            2 :             "format {f:?}: Merge Entity takes only \"simplified\" or \"keyValues\" \
    2663            2 :              (TS 104 176 7.3.3.2)"
    2664            2 :         ))),
    2665              :     }
    2666          232 : }
    2667              : 
    2668              : /// The Fragment members a Merge Entity reads by the type of the Attribute it
    2669              : /// lands on, re-expanded inside the store transaction against the stored
    2670              : /// Entity:
    2671              : /// - 10.2.9.4: "If the Attribute to be merged is represented in a simplified
    2672              : ///   representation, the type of any pre-existing Attribute in the target
    2673              : ///   entity shall be preserved" (TS 104 176 7.3.3.2: "any attempt to modify
    2674              : ///   the type of an Attribute shall result in a BadRequest error"). A bare
    2675              : ///   value goes into the member the stored type holds it in, and a value
    2676              : ///   that type cannot hold is BadRequestData from the same expansion a
    2677              : ///   normalized write runs.
    2678              : /// - 4.7.1: a GeoProperty's Value "shall be a GeoJSON Geometry". A type-less
    2679              : ///   `{"value": ...}` onto a stored GeoProperty is validated as one; read as
    2680              : ///   a plain Property it stored `{"type":"Polygon"}` with no coordinates.
    2681              : struct KeptTypes<'a> {
    2682              :     raw: &'a Map<String, Value>,
    2683              :     ctx: &'a antares_jsonld::Context,
    2684              :     simplified: bool,
    2685              :     lang: Option<&'a str>,
    2686              :     observed_at: Option<&'a str>,
    2687              : }
    2688              : 
    2689              : impl KeptTypes<'_> {
    2690          204 :     fn apply(&self, doc: &Value, frag: &mut Value) -> Result<(), NgsiError> {
    2691          204 :         let mut kept = Map::new();
    2692          204 :         let mut iris = Vec::new();
    2693          558 :         for (k, v) in self.raw {
    2694          558 :             if k == "@context" || is_meta(k) || is_ngsi_null(v) {
    2695          382 :                 continue;
    2696          176 :             }
    2697          176 :             let iri = antares_jsonld::expand_attr_name(k, self.ctx)?;
    2698          176 :             let Some(stored) = doc
    2699          176 :                 .get(&iri)
    2700          176 :                 .and_then(Value::as_array)
    2701          176 :                 .and_then(|a| a.first())
    2702          176 :                 .and_then(|i| i.get("type"))
    2703          176 :                 .and_then(Value::as_str)
    2704              :             else {
    2705           12 :                 continue;
    2706              :             };
    2707          164 :             let typeless = v
    2708          164 :                 .as_object()
    2709          164 :                 .filter(|o| !o.contains_key("type") && !o.contains_key("@type"));
    2710          164 :             let normalized = if self.simplified {
    2711           34 :                 self.simplified_as(&iri, stored, v)?
    2712            4 :             } else if let Some(o) =
    2713          130 :                 typeless.filter(|o| stored == "GeoProperty" && o.contains_key("value"))
    2714              :             {
    2715            4 :                 let mut o = o.clone();
    2716            4 :                 o.insert("type".into(), Value::String(stored.to_owned()));
    2717            4 :                 Value::Object(o)
    2718              :             } else {
    2719          126 :                 continue;
    2720              :             };
    2721           36 :             kept.insert(k.clone(), normalized);
    2722           36 :             iris.push(iri);
    2723              :         }
    2724          202 :         if kept.is_empty() {
    2725          166 :             return Ok(());
    2726           36 :         }
    2727           36 :         let mut expanded = expand_entity(
    2728           36 :             &kept,
    2729           36 :             self.ctx,
    2730           36 :             ExpandOpts {
    2731           36 :                 fragment: true,
    2732           36 :                 allow_null: true,
    2733           36 :                 merge: true,
    2734           36 :                 temporal: false,
    2735           36 :                 ..Default::default()
    2736           36 :             },
    2737            8 :         )?;
    2738           28 :         apply_common_observed_at(&mut expanded, self.observed_at);
    2739           28 :         if let (Some(f), Some(e)) = (frag.as_object_mut(), expanded.as_object_mut()) {
    2740           28 :             for iri in iris {
    2741           28 :                 if let Some(v) = e.remove(&iri) {
    2742           28 :                     f.insert(iri, v);
    2743           28 :                 }
    2744              :             }
    2745            0 :         }
    2746           28 :         Ok(())
    2747          204 :     }
    2748              : 
    2749              :     /// The normalized Attribute a simplified value of a stored `stored`
    2750              :     /// Attribute stands for (TS 104 175 5.3.2.4: a LanguageProperty,
    2751              :     /// JsonProperty or VocabProperty may arrive as `{"languageMap": ...}`,
    2752              :     /// `{"json": ...}` or `{"vocab": ...}`; every other type as its bare
    2753              :     /// value). 10.2.9.4: a LanguageProperty sent "as a string" updates the
    2754              :     /// key of the common language tag, so without `lang` there is no key.
    2755           34 :     fn simplified_as(&self, iri: &str, stored: &str, v: &Value) -> Result<Value, NgsiError> {
    2756           34 :         let member = match stored {
    2757           34 :             "Relationship" => "object",
    2758           28 :             "ListProperty" => "valueList",
    2759           22 :             "ListRelationship" => "objectList",
    2760           18 :             "LanguageProperty" => "languageMap",
    2761           14 :             "VocabProperty" => "vocab",
    2762           10 :             "JsonProperty" => "json",
    2763            6 :             _ => "value",
    2764              :         };
    2765           34 :         let self_described = matches!(
    2766           34 :             stored,
    2767           34 :             "LanguageProperty" | "VocabProperty" | "JsonProperty"
    2768           12 :         ) && v
    2769           12 :             .as_object()
    2770           12 :             .is_some_and(|o| o.len() == 1 && o.contains_key(member));
    2771           34 :         let mut out = if self_described {
    2772            4 :             v.as_object().cloned().unwrap_or_default()
    2773           30 :         } else if stored == "LanguageProperty" {
    2774            4 :             let Some(tag) = self.lang else {
    2775            2 :                 return Err(NgsiError::BadRequestData(format!(
    2776            2 :                     "attribute {iri}: a simplified value for a LanguageProperty needs the \
    2777            2 :                      lang URL parameter to name its languageMap key (TS 104 176 7.3.3.2)"
    2778            2 :                 )));
    2779              :             };
    2780            2 :             let mut m = Map::new();
    2781            2 :             m.insert(member.into(), json!({ tag: v }));
    2782            2 :             m
    2783              :         } else {
    2784           26 :             let mut m = Map::new();
    2785           26 :             m.insert(member.into(), v.clone());
    2786           26 :             m
    2787              :         };
    2788           32 :         out.insert("type".into(), Value::String(stored.to_owned()));
    2789           32 :         Ok(Value::Object(out))
    2790           34 :     }
    2791              : }
    2792              : 
    2793              : /// JSON Merge-Patch over internal docs (5.5.12).
    2794          302 : pub fn merge_into(doc: &mut Value, fragment: &Value, ts: &str) {
    2795          302 :     merge_with(doc, fragment, ts, false);
    2796          302 : }
    2797              : 
    2798              : /// 5.6.9 Update (5.6.3 per Entity): as [`merge_into`], but an instance the
    2799              : /// fragment names replaces the stored one whole (its `createdAt` kept), so a
    2800              : /// member the update leaves out (`unitCode`) is gone, never merged over.
    2801          112 : pub fn update_into(doc: &mut Value, fragment: &Value, ts: &str) {
    2802          112 :     merge_with(doc, fragment, ts, true);
    2803          112 : }
    2804              : 
    2805          414 : fn merge_with(doc: &mut Value, fragment: &Value, ts: &str, replace: bool) {
    2806              :     // The Entity as a backfill names it: id and type, before the merge.
    2807          414 :     let shell = serde_json::json!({"id": doc.get("id"), "type": doc.get("type")});
    2808          414 :     let (Some(target), Some(frag)) = (doc.as_object_mut(), fragment.as_object()) else {
    2809            0 :         return;
    2810              :     };
    2811         1188 :     for (k, v) in frag {
    2812         1188 :         match k.as_str() {
    2813         1188 :             "id" | "createdAt" | "modifiedAt" => continue,
    2814          900 :             "type" => {
    2815              :                 // union of types
    2816          310 :                 let mut cur: Vec<Value> = target
    2817          310 :                     .get("type")
    2818          310 :                     .and_then(Value::as_array)
    2819          310 :                     .cloned()
    2820          310 :                     .unwrap_or_default();
    2821          314 :                 for t in v.as_array().cloned().unwrap_or_default() {
    2822          314 :                     if !cur.contains(&t) {
    2823           20 :                         cur.push(t);
    2824          294 :                     }
    2825              :                 }
    2826          310 :                 target.insert("type".into(), Value::Array(cur));
    2827              :             }
    2828              :             // 5.5.12: "For each member of the Fragment, whose value is an
    2829              :             // NGSI-LD Null, contained by the target, the target member is
    2830              :             // removed." 4.18 admits the sentinel as a scope for exactly this
    2831              :             // reason, and expansion hands scopes over as an array, so the
    2832              :             // deletion arrives as its single entry. Storing it instead left
    2833              :             // the Entity scoped to a string no 4.18 grammar accepts.
    2834          590 :             "scope" => {
    2835           34 :                 if antares_jsonld::is_ngsi_null_list(v) {
    2836            8 :                     target.remove("scope");
    2837           26 :                 } else {
    2838           26 :                     target.insert("scope".into(), v.clone());
    2839           26 :                 }
    2840              :             }
    2841              :             // 4.22: expiresAt is a settable Entity member (5.2.4, not in the
    2842              :             // read-only Table 5.2.2-1) — merge updates the storage expiry;
    2843              :             // an NGSI-LD Null removes it (5.5.12). Without this arm it fell
    2844              :             // through to the attribute path, where a bare string has no
    2845              :             // instances and the member was silently dropped.
    2846          556 :             "expiresAt" => {
    2847           32 :                 if is_ngsi_null(v) {
    2848            8 :                     target.remove("expiresAt");
    2849           24 :                 } else {
    2850           24 :                     target.insert("expiresAt".into(), v.clone());
    2851           24 :                 }
    2852              :             }
    2853              :             _ => {
    2854          524 :                 let frag_instances = v.as_array().cloned().unwrap_or_default();
    2855          524 :                 let mut cur: Vec<Value> = target
    2856          524 :                     .get(k)
    2857          524 :                     .and_then(Value::as_array)
    2858          524 :                     .cloned()
    2859          524 :                     .unwrap_or_default();
    2860          540 :                 for fi in frag_instances {
    2861          540 :                     let is_delete = antares_jsonld::is_deletion_instance(&fi);
    2862          540 :                     let want_ds = fi.get("datasetId").and_then(Value::as_str);
    2863          540 :                     let pos = cur
    2864          540 :                         .iter()
    2865          540 :                         .position(|ci| ci.get("datasetId").and_then(Value::as_str) == want_ds);
    2866          540 :                     match (is_delete, pos) {
    2867           68 :                         (true, Some(p)) => {
    2868           68 :                             cur.remove(p);
    2869           68 :                         }
    2870            0 :                         (true, None) => {}
    2871          354 :                         (false, Some(p)) => {
    2872          354 :                             if let Some(ci) = cur.get_mut(p) {
    2873          354 :                                 if crate::history::backfilled(&shell, k, ci, &fi) {
    2874            4 :                                     // older than the stored one: history only
    2875          350 :                                 } else if replace {
    2876          142 :                                     let created = ci.get("createdAt").cloned();
    2877          142 :                                     let mut ni = Value::Array(vec![fi.clone()]);
    2878          142 :                                     crate::stamp::stamp_instances(&mut ni, ts);
    2879          142 :                                     if let Some(Value::Object(mut o)) =
    2880          142 :                                         ni.as_array_mut().and_then(Vec::pop)
    2881              :                                     {
    2882          142 :                                         if let Some(c) = created {
    2883          142 :                                             o.insert("createdAt".into(), c);
    2884          142 :                                         }
    2885          142 :                                         *ci = Value::Object(o);
    2886            0 :                                     }
    2887          208 :                                 } else {
    2888          208 :                                     merge_instance(ci, &fi, ts);
    2889          208 :                                 }
    2890            0 :                             }
    2891              :                         }
    2892              :                         (false, None) => {
    2893          118 :                             let mut ni = fi.clone();
    2894          118 :                             if let Some(o) = ni.as_object_mut() {
    2895          118 :                                 o.insert("createdAt".into(), Value::String(ts.to_owned()));
    2896          118 :                                 o.insert("modifiedAt".into(), Value::String(ts.to_owned()));
    2897          118 :                             }
    2898          118 :                             cur.push(ni);
    2899              :                         }
    2900              :                     }
    2901              :                 }
    2902          524 :                 if cur.is_empty() {
    2903           36 :                     target.remove(k);
    2904          488 :                 } else {
    2905          488 :                     target.insert(k.clone(), Value::Array(cur));
    2906          488 :                 }
    2907              :             }
    2908              :         }
    2909              :     }
    2910          414 :     target.insert("modifiedAt".into(), Value::String(ts.to_owned()));
    2911          414 : }
    2912              : 
    2913          208 : fn merge_instance(target: &mut Value, frag: &Value, ts: &str) {
    2914          208 :     let (Some(t), Some(f)) = (target.as_object_mut(), frag.as_object()) else {
    2915            0 :         return;
    2916              :     };
    2917              :     // An instance of another attribute type (a LanguageProperty sent over a
    2918              :     // stored Property) is a new attribute, not a patch of the old one: a key
    2919              :     // by key merge kept the Property's value beside the new languageMap,
    2920              :     // a shape no NGSI-LD type allows. It replaces the instance and
    2921              :     // keeps only the instant the attribute was created (4.8).
    2922          208 :     let changes_type = f
    2923          208 :         .get("type")
    2924          208 :         .is_some_and(|ft| t.get("type").is_some_and(|tt| tt != ft));
    2925          208 :     if changes_type {
    2926            8 :         let created = t.get("createdAt").cloned();
    2927            8 :         t.clear();
    2928           16 :         for (k, v) in f {
    2929           16 :             if k != "createdAt" && k != "modifiedAt" && !antares_jsonld::removes_member(v) {
    2930           16 :                 t.insert(k.clone(), v.clone());
    2931           16 :             }
    2932              :         }
    2933            8 :         if let Some(c) = created {
    2934            8 :             t.insert("createdAt".into(), c);
    2935            8 :         }
    2936            8 :         t.insert("modifiedAt".into(), Value::String(ts.to_owned()));
    2937            8 :         return;
    2938          200 :     }
    2939          528 :     for (k, v) in f {
    2940          528 :         if k == "createdAt" || k == "modifiedAt" {
    2941            0 :             continue;
    2942          528 :         }
    2943              :         // a sub-Attribute whose instances are all deletions leaves too (10.2.9.4)
    2944          528 :         if antares_jsonld::removes_member(v) {
    2945            4 :             t.remove(k);
    2946           24 :         } else if let (Some(cur), Some(patch)) =
    2947          524 :             (t.get_mut(k).and_then(Value::as_object_mut), v.as_object())
    2948           24 :         {
    2949           24 :             // 5.5.12: the merge goes "into JSON objects representing a
    2950           24 :             // Property value" — RFC 7396 with the NGSI-LD Null as removal.
    2951           24 :             merge_value_object(cur, patch);
    2952          500 :         } else {
    2953          500 :             t.insert(k.clone(), v.clone());
    2954          500 :         }
    2955              :     }
    2956          200 :     t.insert("modifiedAt".into(), Value::String(ts.to_owned()));
    2957          208 : }
    2958              : 
    2959              : /// RFC 7396 merge patch over a compound (JSON object) member value, with
    2960              : /// "urn:ngsi-ld:null" / JSON null as the key-removal marker (5.5.12); the
    2961              : /// sentinel itself is never stored (5.5.4).
    2962           24 : fn merge_value_object(target: &mut Map<String, Value>, patch: &Map<String, Value>) {
    2963           34 :     for (k, v) in patch {
    2964           34 :         if v.is_null() || is_ngsi_null(v) {
    2965            8 :             target.remove(k);
    2966            8 :         } else if let (Some(cur), Some(po)) = (
    2967           26 :             target.get_mut(k).and_then(Value::as_object_mut),
    2968           26 :             v.as_object(),
    2969            0 :         ) {
    2970            0 :             merge_value_object(cur, po);
    2971           26 :         } else {
    2972           26 :             target.insert(k.clone(), v.clone());
    2973           26 :         }
    2974              :     }
    2975           24 : }
    2976              : 
    2977              : // ---------- PUT /entities/{id} — Replace (5.6.18) ----------
    2978              : 
    2979           82 : pub async fn replace_entity(
    2980           82 :     State(st): State<AppState>,
    2981           82 :     Path(id): Path<String>,
    2982           82 :     CleanParams(params): CleanParams,
    2983           82 :     headers: HeaderMap,
    2984           82 :     body: Bytes,
    2985           82 : ) -> Response {
    2986           82 :     let go = async {
    2987           82 :         let tenant = tenant_from(&headers)?;
    2988           82 :         antares_model::EntityId::new(&id)?;
    2989           78 :         check_params(&params, &["local", "type"])?;
    2990              :         // 5.5.7 again: the selector is expanded with the request's @context,
    2991              :         // not the core one. The target is judged before the body is parsed
    2992              :         // (5.6.18: an unknown target is 404 before body validation), so the
    2993              :         // header context is resolved on its own here.
    2994           78 :         let ctx0 = request_context(&st.loader, &headers).await?;
    2995              :         // The engine is shown the replacement. The body is read for it
    2996              :         // leniently: a body that does not parse is not the engine's to
    2997              :         // judge, and the replace below refuses it as before, after the
    2998              :         // 404 an unknown target answers first (057_03).
    2999           78 :         let shown = match &st.policy {
    3000           30 :             Some(_) => parse_body(&st, &headers, &body, BodyKind::Standard)
    3001           30 :                 .await
    3002           30 :                 .ok()
    3003           30 :                 .and_then(|p| {
    3004           30 :                     let obj = p.object(NgsiError::BadRequestData(String::new())).ok()?;
    3005           30 :                     expand_entity(obj, &p.ctx, ExpandOpts::default()).ok()
    3006           30 :                 }),
    3007           48 :             None => None,
    3008              :         };
    3009           78 :         let pre = crate::policy::pre_image(&st, &tenant, &id, shown.as_ref()).await?;
    3010           78 :         let granted = gate!(
    3011              :             st, &tenant, &headers, "5.6.18",
    3012              :             ids: &[&id],
    3013              :             types: &pre.types,
    3014              :             stored: pre.stored.as_ref(),
    3015              :             attrs: &shown.as_ref().map(crate::attrs::attr_iris_of).unwrap_or_default(),
    3016              :             body: shown.as_ref(),
    3017              :         )
    3018           78 :         .await?;
    3019           68 :         let cond = crate::policy::Condition::of(&granted);
    3020              :         // 5.6.18.4: the ?type selector narrows the target — a non-matching
    3021              :         // entity is "not known" for this replace.
    3022           68 :         let local_doc = st
    3023           68 :             .store
    3024           68 :             .get(&tenant, Kind::Entity, &id)
    3025           68 :             .await?
    3026           68 :             .filter(|d| crate::negotiate::matches_type_param(d, &params, &ctx0))
    3027              :             // An Entity the engine's condition does not select is
    3028              :             // not found, before the body is read, as an absent target is
    3029           68 :             .filter(|d| cond.as_ref().is_none_or(|c| c.holds(d)));
    3030              :         // the same selector narrows the Context Sources the replace goes to
    3031           68 :         let spec = crate::registry::CsrSpec {
    3032           68 :             ids: Some(vec![id.clone()]),
    3033           68 :             types: type_selection(&params, &ctx0).map(|sel| sel.into_iter().flatten().collect()),
    3034           68 :             ..Default::default()
    3035              :         };
    3036           68 :         let regs =
    3037           68 :             match crate::federation::write_plan(&st, &tenant, &spec, &ctx0, &params, &headers)
    3038           68 :                 .await?
    3039              :             {
    3040            0 :                 crate::federation::WritePlan::Answered(r) => return Ok(*r),
    3041           68 :                 crate::federation::WritePlan::Forward(regs) => regs,
    3042              :             };
    3043           68 :         crate::policy::Condition::unforwarded(cond.as_ref(), !regs.is_empty())?;
    3044           68 :         if regs.is_empty() {
    3045              :             // 5.6.18: an unknown target is 404 before body validation (057_03).
    3046              :             // The read above answers that; the write below decides again
    3047              :             // under the row lock, because between the two the target can be
    3048              :             // deleted and a replace that writes anyway puts it back.
    3049           50 :             if local_doc.is_none() {
    3050           24 :                 return Err(NgsiError::ResourceNotFound(format!("entity {id} not found")).into());
    3051           26 :             }
    3052           26 :             let parsed = parse_body(&st, &headers, &body, BodyKind::Standard).await?;
    3053           26 :             let obj = parsed.object(NgsiError::BadRequestData(
    3054           26 :                 "entity must be a JSON object".into(),
    3055           26 :             ))?;
    3056           26 :             let mut expanded = expand_entity(obj, &parsed.ctx, ExpandOpts::default())?;
    3057           26 :             if expanded.get("id").and_then(Value::as_str) != Some(id.as_str()) {
    3058            0 :                 return Err(NgsiError::BadRequestData("entity id mismatch".into()).into());
    3059           26 :             }
    3060           26 :             let ts = now_iso();
    3061           26 :             stamp_new(&mut expanded, &ts);
    3062           26 :             let res = st
    3063           26 :                 .store
    3064           26 :                 .mutate(&tenant, Kind::Entity, &id, |doc| {
    3065           24 :                     crate::policy::Condition::pre(cond.as_ref(), &id, doc)?;
    3066              :                     // 5.6.18.4: the ?type selector narrows the target here too —
    3067              :                     // the type of the row being written is the one that counts
    3068           24 :                     if !crate::negotiate::matches_type_param(doc, &params, &ctx0) {
    3069            0 :                         return Err(NgsiError::ResourceNotFound(format!(
    3070            0 :                             "entity {id} does not match the type selector"
    3071            0 :                         )));
    3072           24 :                     }
    3073              :                     // 4.8: "createdAt ... shall be the date and time at which the
    3074              :                     // Entity was created" — the target's own stamp, read under
    3075              :                     // the lock rather than from a snapshot another write may
    3076              :                     // already have replaced.
    3077           24 :                     if let (Some(o), Some(created)) =
    3078           24 :                         (expanded.as_object_mut(), doc.get("createdAt").cloned())
    3079           24 :                     {
    3080           24 :                         o.insert("createdAt".into(), created);
    3081           24 :                     }
    3082           24 :                     *doc = expanded.clone();
    3083           24 :                     Ok::<(), NgsiError>(())
    3084           24 :                 })
    3085           26 :                 .await?;
    3086           24 :             return match res {
    3087            2 :                 None => Err(NgsiError::ResourceNotFound(format!("entity {id} not found")).into()),
    3088            0 :                 Some(Err(e)) => Err(e.into()),
    3089           24 :                 Some(Ok(())) => Ok::<_, ApiError>(no_content(&tenant)),
    3090              :             };
    3091           18 :         }
    3092           18 :         let parsed = parse_body(&st, &headers, &body, BodyKind::Standard).await?;
    3093           18 :         let obj = parsed.object(NgsiError::BadRequestData(
    3094           18 :             "entity must be a JSON object".into(),
    3095           18 :         ))?;
    3096           18 :         let expanded = expand_entity(obj, &parsed.ctx, ExpandOpts::default())?;
    3097           18 :         if expanded.get("id").and_then(Value::as_str) != Some(id.as_str()) {
    3098            0 :             return Err(NgsiError::BadRequestData("entity id mismatch".into()).into());
    3099           18 :         }
    3100           18 :         let mut parts = Vec::new();
    3101           18 :         let proxies: Vec<&crate::federation::FedReg> =
    3102           22 :             regs.iter().filter(|r| r.is_proxy()).collect();
    3103           18 :         let proxy_match = !proxies.is_empty();
    3104           18 :         if local_doc.is_some() || !proxy_match {
    3105           12 :             let gone =
    3106           12 :                 crate::federation::Part::local(404, format!("entity {id} not found locally"));
    3107           12 :             if local_doc.is_none() {
    3108            0 :                 parts.push(gone);
    3109            0 :             } else {
    3110           12 :                 let (rest, _) = crate::federation::strip_proxied(obj, &proxies, &parsed.ctx);
    3111           12 :                 let mut local_exp = expand_entity(&rest, &parsed.ctx, ExpandOpts::default())?;
    3112           12 :                 let ts = now_iso();
    3113           12 :                 stamp_new(&mut local_exp, &ts);
    3114              :                 // the same row lock as the local-only path above: the read
    3115              :                 // that found the target is not the write that replaces it
    3116           12 :                 let res = st
    3117           12 :                     .store
    3118           12 :                     .mutate(&tenant, Kind::Entity, &id, |doc| {
    3119           12 :                         if !crate::negotiate::matches_type_param(doc, &params, &ctx0) {
    3120            0 :                             return Err(NgsiError::ResourceNotFound(format!(
    3121            0 :                                 "entity {id} does not match the type selector"
    3122            0 :                             )));
    3123           12 :                         }
    3124           12 :                         if let (Some(o), Some(created)) =
    3125           12 :                             (local_exp.as_object_mut(), doc.get("createdAt").cloned())
    3126           12 :                         {
    3127           12 :                             o.insert("createdAt".into(), created);
    3128           12 :                         }
    3129           12 :                         *doc = local_exp.clone();
    3130           12 :                         Ok::<(), NgsiError>(())
    3131           12 :                     })
    3132           12 :                     .await?;
    3133           12 :                 match res {
    3134              :                     Some(Ok(())) => {
    3135           12 :                         parts.push(crate::federation::Part::local(204, "replaced locally"))
    3136              :                     }
    3137            0 :                     _ => parts.push(gone),
    3138              :                 }
    3139              :             }
    3140            6 :         }
    3141           18 :         let ctx_url = crate::federation::ctx_link_url(&headers, &parsed.ctx.source);
    3142           18 :         let seg = crate::federation::path_segment(&id);
    3143           18 :         let fwd_q = type_selector_query(&params);
    3144           22 :         for reg in &regs {
    3145              :             // 5.6.18.4: proxy modes without Replace Entity support are an
    3146              :             // error of type Conflict; inclusive ones are not forwarded.
    3147           22 :             if !reg.supports("replaceEntity") {
    3148            2 :                 if reg.is_proxy() {
    3149            2 :                     parts.push(crate::federation::conflict_part("replaceEntity", reg));
    3150            2 :                 }
    3151            2 :                 continue;
    3152           20 :             }
    3153           20 :             let Some(frag) = crate::federation::reduce_to_scope(obj, reg, &parsed.ctx) else {
    3154            0 :                 continue;
    3155              :             };
    3156           20 :             parts.push(
    3157           20 :                 crate::federation::forward_part(
    3158           20 :                     &st,
    3159           20 :                     reqwest::Method::PUT,
    3160           20 :                     format!("{}/ngsi-ld/v1/entities/{seg}", reg.endpoint),
    3161           20 :                     &fwd_q,
    3162           20 :                     &headers,
    3163           20 :                     &tenant,
    3164           20 :                     reg,
    3165           20 :                     &ctx_url,
    3166           20 :                     Some(frag),
    3167           20 :                 )
    3168           20 :                 .await,
    3169              :             );
    3170              :         }
    3171           18 :         Ok(crate::federation::combine(
    3172           18 :             st.profile,
    3173           18 :             Some(&id),
    3174           18 :             parts,
    3175           18 :             no_content(&tenant),
    3176           18 :             &tenant,
    3177           18 :         ))
    3178           82 :     };
    3179           82 :     go.await.unwrap_or_else(|e| e.into_response())
    3180           82 : }
    3181              : 
    3182              : // ---------- Entity Ordering (4.23) ----------
    3183              : 
    3184              : // ---------- GeoJSON output (6.3.15) ----------
    3185              : 
    3186              : // ---------- GET /entities/{id}/attrs/{attrId} [+ /value] ----------
    3187              : // NGSI-LD 2.0 pre-adoptions #14/#15: retrieve a single
    3188              : // attribute of an entity, and its bare value. Additive-only: 2.0 defines the
    3189              : // resources, 1.9.1 clients never see them unless asked.
    3190              : 
    3191           42 : pub async fn retrieve_entity_attr(
    3192           42 :     State(st): State<AppState>,
    3193           42 :     Path((id, attr)): Path<(String, String)>,
    3194           42 :     CleanParams(params): CleanParams,
    3195           42 :     headers: HeaderMap,
    3196           42 : ) -> Response {
    3197           42 :     match retrieve_attr_inner(&st, &id, &attr, false, &params, &headers).await {
    3198           14 :         Ok(r) => r,
    3199           28 :         Err(e) => e.into_response(),
    3200              :     }
    3201           42 : }
    3202              : 
    3203           30 : pub async fn retrieve_entity_attr_value(
    3204           30 :     State(st): State<AppState>,
    3205           30 :     Path((id, attr)): Path<(String, String)>,
    3206           30 :     CleanParams(params): CleanParams,
    3207           30 :     headers: HeaderMap,
    3208           30 : ) -> Response {
    3209           30 :     match retrieve_attr_inner(&st, &id, &attr, true, &params, &headers).await {
    3210            8 :         Ok(r) => r,
    3211           22 :         Err(e) => e.into_response(),
    3212              :     }
    3213           30 : }
    3214              : 
    3215           72 : async fn retrieve_attr_inner(
    3216           72 :     st: &AppState,
    3217           72 :     id: &str,
    3218           72 :     attr: &str,
    3219           72 :     value_only: bool,
    3220           72 :     params: &HashMap<String, String>,
    3221           72 :     headers: &HeaderMap,
    3222           72 : ) -> ApiResult<Response> {
    3223           72 :     let tenant = tenant_from(headers)?;
    3224           72 :     check_params(params, &["options", "format", "lang", "datasetId", "local"])?;
    3225           72 :     let ctx = request_context(&st.loader, headers).await?;
    3226           72 :     let filter = gate!(
    3227              :         st, &tenant, headers, "5.7.1",
    3228              :         ids: &[id],
    3229              :         attrs: &antares_jsonld::expand_attr_name(attr, &ctx).ok().into_iter().collect::<Vec<_>>(),
    3230              :     )
    3231           72 :     .await?;
    3232           72 :     let mut repr = parse_repr(params, &ctx)?;
    3233           72 :     crate::repr::narrow_repr(&mut repr, &filter);
    3234           72 :     antares_model::EntityId::new(id)?;
    3235           72 :     antares_model::check_attr_name(attr)?;
    3236           72 :     let doc = st
    3237           72 :         .store
    3238           72 :         .get(&tenant, Kind::Entity, id)
    3239           72 :         .await?
    3240           72 :         .ok_or_else(|| NgsiError::ResourceNotFound(format!("entity {id} not found")))?;
    3241              :     // ADR-0020: the Attribute of an Entity outside the
    3242              :     // narrowing is the Attribute of an absent one, as Retrieve Entity
    3243              :     // answers it, whatever the Attribute's own visibility.
    3244           36 :     if !crate::repr::policy_admits(st, &tenant, &ctx, repr.policy_reach.as_deref(), &doc).await {
    3245            4 :         return Err(NgsiError::ResourceNotFound(format!("entity {id} not found")).into());
    3246           32 :     }
    3247           32 :     let attr_iri = antares_jsonld::expand_attr_name(attr, &ctx)?;
    3248              :     // One answer for an Attribute the Entity lacks and one the narrowing
    3249              :     // hides.
    3250           28 :     let lacks = || NgsiError::ResourceNotFound(format!("entity {id} has no attribute {attr}"));
    3251           28 :     let node = doc.get(&attr_iri).ok_or_else(lacks)?;
    3252              :     // Compact through the entity pipeline so the attribute serializes exactly
    3253              :     // as it would inside a full retrieve.
    3254           22 :     let mini = serde_json::json!({
    3255           22 :         "id": doc.get("id").cloned().unwrap_or_default(),
    3256           22 :         "type": doc.get("type").cloned().unwrap_or_default(),
    3257           22 :         attr_iri.clone(): node.clone(),
    3258              :     });
    3259           22 :     let shaped = crate::repr::apply(&mini, &repr);
    3260           22 :     let compacted = compact_for(&repr, &shaped, &ctx);
    3261           22 :     let key = ctx.compact_iri(&attr_iri);
    3262           22 :     let member = compacted.get(&key).cloned().ok_or_else(lacks)?;
    3263           22 :     let body = if value_only {
    3264              :         // #15: the bare value — value / object / languageMap, whichever the
    3265              :         // attribute type carries; multi-instance attributes yield an array.
    3266           12 :         fn bare(v: &Value) -> Value {
    3267           12 :             match v {
    3268            2 :                 Value::Array(a) => Value::Array(a.iter().map(bare).collect()),
    3269           10 :                 Value::Object(o) => o
    3270           10 :                     .get("value")
    3271           10 :                     .or_else(|| o.get("object"))
    3272           10 :                     .or_else(|| o.get("languageMap"))
    3273           10 :                     .or_else(|| o.get("json"))
    3274           10 :                     .or_else(|| o.get("vocab"))
    3275           10 :                     .or_else(|| o.get("valueList"))
    3276           10 :                     .or_else(|| o.get("objectList"))
    3277           10 :                     .cloned()
    3278           10 :                     .unwrap_or(Value::Null),
    3279            0 :                 other => other.clone(),
    3280              :             }
    3281           12 :         }
    3282            8 :         bare(&member)
    3283              :     } else {
    3284           14 :         member
    3285              :     };
    3286           22 :     let accept = parse_accept(headers)?;
    3287           22 :     let mut resp = respond(StatusCode::OK, body, &ctx, accept, &tenant);
    3288           22 :     filter.mark(&st.host_alias, resp.headers_mut());
    3289           22 :     Ok(resp)
    3290           72 : }
    3291              : 
    3292              : /// 5.14.4.4: run the (split-reduced when applicable) local query and record
    3293              : /// each matching id under the "@none" local marker; forward to matching
    3294              : /// registrations supporting createEntityMapQueryEntity and merge each
    3295              : /// returned EntityMap (ids → registration id, linkedMaps → remote map id);
    3296              : /// store the local EntityMap and return it.
    3297              : /// Known ceiling: the local candidate ids are the first max_limit matches —
    3298              : /// the query is paged into the store instead of materializing every matching
    3299              : /// Entity document, so one request cannot pull a whole tenant into memory.
    3300              : /// Raise the cap if local candidate sets outgrow it.
    3301          192 : pub(crate) async fn build_query_map(
    3302          192 :     st: &AppState,
    3303          192 :     tenant: &TenantId,
    3304          192 :     headers: &HeaderMap,
    3305          192 :     ctx: &antares_jsonld::Context,
    3306          192 :     params: &HashMap<String, String>,
    3307          192 :     filter: &crate::policy::Filter,
    3308          192 : ) -> ApiResult<Value> {
    3309          192 :     let q_ast = params
    3310          192 :         .get("q")
    3311          192 :         .map(|q| antares_ql::parse_q(q))
    3312          192 :         .transpose()?;
    3313              :     // 5.14.4.4 a-e: too wide query
    3314          192 :     if !qualifies_non_wide(params, q_ast.as_ref()) {
    3315            6 :         return Err(NgsiError::BadRequestData(
    3316            6 :             "EntityMap query needs at least one of type, attrs, q, georel, or local=true \
    3317            6 :              (5.14.4.4 — too wide query)"
    3318            6 :                 .into(),
    3319            6 :         )
    3320            6 :         .into());
    3321          186 :     }
    3322              :     // the candidate set is the NARROWED query's (ADR-0020)
    3323          186 :     let narrowed = filter.narrow_params(params, ctx)?;
    3324          186 :     let narrow = crate::repr::narrowing_reading(filter, params, ctx)?;
    3325          186 :     let params = &narrowed;
    3326              :     // 5.14.4.4: invalid entity ids / csf are BadRequestData
    3327          186 :     if let Some(ids) = params.get("id") {
    3328            0 :         for id in ids.split(',') {
    3329            0 :             antares_model::EntityId::new(id.trim())?;
    3330              :         }
    3331          186 :     }
    3332          186 :     if let Some(csf) = params.get("csf") {
    3333            0 :         antares_ql::parse_q(csf)?;
    3334          186 :     }
    3335          186 :     let local_scope = params.get("local").map(String::as_str) == Some("true");
    3336          186 :     let split = params.get("splitEntities").map(String::as_str) == Some("true");
    3337              :     // Split entities: only id/type/idPattern narrow the local candidate set —
    3338              :     // value/geo/scope filters cannot be judged on a fragment (5.14.4.4).
    3339          186 :     let eff: HashMap<String, String> = if split && !local_scope {
    3340            0 :         params
    3341            0 :             .iter()
    3342            0 :             .filter(|(k, _)| ["id", "idPattern", "type", "local"].contains(&k.as_str()))
    3343            0 :             .map(|(k, v)| (k.clone(), v.clone()))
    3344            0 :             .collect()
    3345              :     } else {
    3346          186 :         params.clone()
    3347              :     };
    3348              :     // idPattern is invisible to the store, so a pushed page cannot BE the
    3349              :     // candidate set's page — it is still the window a walk steps through, and
    3350              :     // the literal the pattern forces has already narrowed the rows (5.2.33).
    3351              :     // Without the walk a tenant-wide pattern arrives as one allocation of
    3352              :     // every Entity the rest of the filter matched.
    3353          186 :     let mut emap = Map::new();
    3354          186 :     let mut offset = 0usize;
    3355          196 :     while emap.len() < st.max_limit {
    3356          192 :         let batch = filter_entities_paged(
    3357          192 :             st,
    3358          192 :             tenant,
    3359          192 :             &eff,
    3360          192 :             ctx,
    3361          192 :             Vec::new(),
    3362          192 :             Pushdown {
    3363          192 :                 page: Some((offset, st.max_limit)),
    3364          192 :                 proj: None,
    3365          192 :                 order_keys: None,
    3366          192 :             },
    3367          192 :             Some(&narrow),
    3368          192 :         )
    3369          192 :         .await?;
    3370          310 :         for d in &batch.docs {
    3371          310 :             if emap.len() == st.max_limit {
    3372            0 :                 break;
    3373          310 :             }
    3374          310 :             if let Some(id) = d.get("id").and_then(Value::as_str) {
    3375          310 :                 // "@none" refers to an Entity held locally (5.2.39)
    3376          310 :                 emap.insert(id.to_owned(), json!(["@none"]));
    3377          310 :             }
    3378              :         }
    3379          192 :         match next_scan_offset(offset, batch.rows, 0, batch.paged, st.max_limit) {
    3380           10 :             Some(next) => offset = next,
    3381          182 :             None => break,
    3382              :         }
    3383              :     }
    3384          186 :     crate::entity_map::merge_and_store_map(st, tenant, headers, ctx, params, false, emap).await
    3385          192 : }
    3386              : 
    3387              : /// The store page of the conditional-rules scan as the store's `i64` binds,
    3388              : /// saturating: `enough` is the client's offset plus limit, so an offset near
    3389              : /// `i64::MAX` (which `page_params` admits) and the doubling limit pass it,
    3390              : /// and an `as` cast wraps negative, a LIMIT postgres refuses (500).
    3391          380 : fn store_page((offset, limit): (usize, usize)) -> antares_store::filter::Page {
    3392          380 :     antares_store::filter::Page {
    3393          380 :         offset: i64::try_from(offset).unwrap_or(i64::MAX),
    3394          380 :         limit: i64::try_from(limit).unwrap_or(i64::MAX),
    3395          380 :         count: false,
    3396          380 :     }
    3397          380 : }
    3398              : 
    3399              : #[cfg(test)]
    3400              : mod tests {
    3401              :     #[test]
    3402            4 :     fn the_scan_page_past_i64_saturates_and_never_wraps_negative() {
    3403            4 :         let past = usize::try_from(i64::MAX).unwrap_or(usize::MAX) + 11;
    3404            4 :         let p = super::store_page((past, usize::MAX));
    3405            4 :         assert_eq!((p.offset, p.limit), (i64::MAX, i64::MAX));
    3406            4 :         let p = super::store_page((20, 41));
    3407            4 :         assert_eq!((p.offset, p.limit, p.count), (20, 41, false));
    3408            4 :     }
    3409              : 
    3410              :     use super::merge_into;
    3411              :     use serde_json::json;
    3412              : 
    3413              :     /// 5.5.12 EXAMPLE 1 + the datasetId/type bullets: a merge updates the
    3414              :     /// named sub-attributes and leaves the others untouched; a fragment
    3415              :     /// instance with an unknown datasetId is ADDED (not replacing the
    3416              :     /// default); entity types are unioned.
    3417              :     #[test]
    3418            4 :     fn clause_5_5_12_merge_algorithm() {
    3419            4 :         let mut doc = json!({"id": "urn:x", "type": ["https://uri.etsi.org/ngsi-ld/default-context/T"],
    3420            4 :             "https://uri.etsi.org/ngsi-ld/default-context/temperature": [{
    3421            4 :                 "type": "Property", "value": 25, "unitCode": "CEL",
    3422            4 :                 "observedAt": "2022-03-14T01:59:26.535Z"}]});
    3423            4 :         merge_into(
    3424            4 :             &mut doc,
    3425            4 :             &json!({
    3426            4 :             "type": ["https://uri.etsi.org/ngsi-ld/default-context/T",
    3427            4 :                      "https://uri.etsi.org/ngsi-ld/default-context/U"],
    3428            4 :             "https://uri.etsi.org/ngsi-ld/default-context/temperature": [
    3429            4 :                 {"type": "Property", "value": 100,
    3430            4 :                  "observedAt": "2022-03-14T13:00:00.000Z"},
    3431            4 :                 {"type": "Property", "value": 7,
    3432            4 :                  "datasetId": "urn:ngsi-ld:Dataset:extra"}
    3433            4 :             ]}),
    3434            4 :             "2026-08-11T00:00:00Z",
    3435              :         );
    3436              :         // EXAMPLE 1: value/observedAt updated, unitCode untouched
    3437            4 :         let t = &doc["https://uri.etsi.org/ngsi-ld/default-context/temperature"];
    3438            4 :         let default = t
    3439            4 :             .as_array()
    3440            4 :             .unwrap()
    3441            4 :             .iter()
    3442            4 :             .find(|i| i.get("datasetId").is_none())
    3443            4 :             .expect("default instance");
    3444            4 :         assert_eq!(default["value"], 100);
    3445            4 :         assert_eq!(default["observedAt"], "2022-03-14T13:00:00.000Z");
    3446            4 :         assert_eq!(
    3447            4 :             default["unitCode"], "CEL",
    3448              :             "unmentioned sub-attribute survives"
    3449              :         );
    3450              :         // unknown datasetId is added as a NEW instance
    3451            4 :         assert_eq!(t.as_array().unwrap().len(), 2);
    3452              :         // entity types are unioned, no duplicates
    3453            4 :         assert_eq!(
    3454            4 :             doc["type"],
    3455            4 :             json!([
    3456              :                 "https://uri.etsi.org/ngsi-ld/default-context/T",
    3457              :                 "https://uri.etsi.org/ngsi-ld/default-context/U"
    3458              :             ])
    3459              :         );
    3460            4 :     }
    3461              : 
    3462              :     /// 5.5.12: merge "merges the provided information with the existing
    3463              :     /// information up to an arbitrary depth, e.g. including going into JSON
    3464              :     /// objects representing a Property value" (RFC 7396 with the NGSI-LD
    3465              :     /// Null) — untouched keys survive, null-valued keys are removed, and the
    3466              :     /// null sentinel never lands in the stored document (5.5.4).
    3467              :     #[test]
    3468            4 :     fn merge_goes_into_compound_property_values() {
    3469            4 :         let mut doc = json!({"id": "urn:x", "type": ["T"],
    3470            4 :             "https://uri.etsi.org/ngsi-ld/default-context/address": [{
    3471            4 :                 "type": "Property",
    3472            4 :                 "value": {"street": "Straße des 17. Juni", "city": "Berlin",
    3473            4 :                           "country": "Germany"}}]});
    3474            4 :         merge_into(
    3475            4 :             &mut doc,
    3476            4 :             &json!({"https://uri.etsi.org/ngsi-ld/default-context/address": [{
    3477            4 :                 "type": "Property",
    3478            4 :                 "value": {"street": "Pariser Platz",
    3479            4 :                           "country": "urn:ngsi-ld:null"}}]}),
    3480            4 :             "2026-08-11T00:00:00Z",
    3481              :         );
    3482            4 :         let v = &doc["https://uri.etsi.org/ngsi-ld/default-context/address"][0]["value"];
    3483            4 :         assert_eq!(v["street"], "Pariser Platz");
    3484            4 :         assert_eq!(v["city"], "Berlin", "untouched keys survive the merge");
    3485            4 :         assert!(v.get("country").is_none(), "null removes the key");
    3486            4 :         assert!(
    3487            4 :             !doc.to_string().contains("urn:ngsi-ld:null"),
    3488              :             "the null sentinel must never be stored"
    3489              :         );
    3490            4 :     }
    3491              : 
    3492              :     /// An Entity kept `value` beside a `languageMap` after a
    3493              :     /// writer changed `name` from one attribute type to the other.
    3494              :     #[test]
    3495            4 :     fn merge_replaces_an_instance_whose_attribute_type_changes() {
    3496            4 :         let name = "https://schema.org/name";
    3497            4 :         let mut doc = json!({"id": "urn:x", "type": ["T"],
    3498            4 :             name: [{"type": "Property", "value": "M1020",
    3499            4 :                     "createdAt": "2026-10-01T00:00:00Z", "modifiedAt": "2026-10-01T00:00:00Z"}]});
    3500            4 :         merge_into(
    3501            4 :             &mut doc,
    3502            4 :             &json!({name: [{"type": "LanguageProperty", "languageMap": {"sk": "Most"}}]}),
    3503            4 :             "2026-10-22T00:02:58Z",
    3504              :         );
    3505            4 :         assert_eq!(
    3506            4 :             doc[name],
    3507            4 :             json!([{"type": "LanguageProperty", "languageMap": {"sk": "Most"},
    3508            4 :                     "createdAt": "2026-10-01T00:00:00Z", "modifiedAt": "2026-10-22T00:02:58Z"}])
    3509              :         );
    3510            4 :         merge_into(
    3511            4 :             &mut doc,
    3512            4 :             &json!({name: [{"type": "Property", "value": "M1020"}]}),
    3513            4 :             "2026-10-22T01:00:00Z",
    3514              :         );
    3515            4 :         assert_eq!(
    3516            4 :             doc[name],
    3517            4 :             json!([{"type": "Property", "value": "M1020",
    3518            4 :                     "createdAt": "2026-10-01T00:00:00Z", "modifiedAt": "2026-10-22T01:00:00Z"}])
    3519              :         );
    3520            4 :     }
    3521              : 
    3522              :     /// The same attribute type still merges key by key (5.5.12).
    3523              :     #[test]
    3524            4 :     fn merge_keeps_the_keys_of_an_instance_of_the_same_type() {
    3525            4 :         let name = "https://schema.org/name";
    3526            4 :         let mut doc = json!({"id": "urn:x", "type": ["T"],
    3527            4 :             name: [{"type": "LanguageProperty", "languageMap": {"sk": "Most", "en": "Bridge"},
    3528            4 :                     "observedAt": "2026-10-01T00:00:00Z"}]});
    3529            4 :         merge_into(
    3530            4 :             &mut doc,
    3531            4 :             &json!({name: [{"type": "LanguageProperty", "languageMap": {"sk": "Most M1020"}}]}),
    3532            4 :             "2026-10-22T00:00:00Z",
    3533              :         );
    3534            4 :         assert_eq!(
    3535            4 :             doc[name][0]["languageMap"],
    3536            4 :             json!({"sk": "Most M1020", "en": "Bridge"})
    3537              :         );
    3538            4 :         assert_eq!(doc[name][0]["observedAt"], "2026-10-01T00:00:00Z");
    3539            4 :     }
    3540              : 
    3541              :     #[test]
    3542            4 :     fn merge_sets_and_null_removes_expires_at() {
    3543            4 :         let mut doc = json!({"id": "urn:x", "type": ["T"]});
    3544            4 :         merge_into(
    3545            4 :             &mut doc,
    3546            4 :             &json!({"expiresAt": "2030-01-01T00:00:00Z"}),
    3547            4 :             "2026-08-08T00:00:00Z",
    3548              :         );
    3549            4 :         assert_eq!(doc["expiresAt"], "2030-01-01T00:00:00Z");
    3550            4 :         merge_into(
    3551            4 :             &mut doc,
    3552            4 :             &json!({"expiresAt": "urn:ngsi-ld:null"}),
    3553            4 :             "2026-08-08T00:00:01Z",
    3554              :         );
    3555            4 :         assert!(doc.get("expiresAt").is_none(), "NGSI-LD Null removes it");
    3556            4 :     }
    3557              : }
    3558              : 
    3559              : #[cfg(test)]
    3560              : mod clause_4_16 {
    3561              :     use super::*;
    3562              :     use serde_json::json;
    3563              : 
    3564              :     /// 4.16: "Entity Types can be implicitly added by all operations that
    3565              :     /// update or append attributes. There is no operation to remove Entity
    3566              :     /// Types from an Entity."
    3567              :     #[test]
    3568            4 :     fn merge_unions_types_and_never_removes() {
    3569            4 :         let mut doc = json!({"id": "urn:x", "type": ["A"],
    3570            4 :             "https://ex/p": [{"type": "Property", "value": 1}]});
    3571            4 :         merge_into(
    3572            4 :             &mut doc,
    3573            4 :             &json!({"type": ["B"]}),
    3574            4 :             "2026-08-11T00:00:00.000Z",
    3575              :         );
    3576            4 :         assert_eq!(doc["type"], json!(["A", "B"]), "types union");
    3577              :         // a fragment naming FEWER types must not shrink the set
    3578            4 :         merge_into(
    3579            4 :             &mut doc,
    3580            4 :             &json!({"type": ["A"]}),
    3581            4 :             "2026-08-11T00:00:00.000Z",
    3582              :         );
    3583            4 :         assert_eq!(
    3584            4 :             doc["type"],
    3585            4 :             json!(["A", "B"]),
    3586              :             "no operation removes Entity Types"
    3587              :         );
    3588              :         // duplicates are not accumulated
    3589            4 :         merge_into(
    3590            4 :             &mut doc,
    3591            4 :             &json!({"type": ["B"]}),
    3592            4 :             "2026-08-11T00:00:00.000Z",
    3593              :         );
    3594            4 :         assert_eq!(doc["type"], json!(["A", "B"]));
    3595            4 :     }
    3596              : }
    3597              : 
    3598              : #[cfg(test)]
    3599              : mod clause_4_17 {
    3600              :     use antares_jsonld::Loader;
    3601              :     use antares_ql::type_selection_matches;
    3602              : 
    3603              :     /// 4.17: disjunction via `|` or `,`, conjunction via `(a;b)`; short
    3604              :     /// names expand against the @context.
    3605              :     #[test]
    3606            4 :     fn selection_language_semantics() {
    3607            4 :         let ctx = Loader::new().core();
    3608              :         const D: &str = "https://uri.etsi.org/ngsi-ld/default-context/";
    3609            4 :         let home = format!("{D}Home");
    3610            4 :         let vehicle = format!("{D}Vehicle");
    3611            4 :         let motorhome = format!("{D}Motorhome");
    3612            4 :         let both: Vec<&str> = vec![&home, &vehicle];
    3613            4 :         let only_home: Vec<&str> = vec![&home];
    3614            4 :         let only_motor: Vec<&str> = vec![&motorhome];
    3615              :         // EXAMPLE 1: OR, both spellings
    3616            4 :         assert!(type_selection_matches("Building|Home", &only_home, &ctx));
    3617            4 :         assert!(type_selection_matches("Building,Home", &only_home, &ctx));
    3618            4 :         assert!(!type_selection_matches("Building|House", &only_home, &ctx));
    3619              :         // EXAMPLE 2: conjunction — ALL listed types required
    3620            4 :         assert!(type_selection_matches("(Home;Vehicle)", &both, &ctx));
    3621            4 :         assert!(
    3622            4 :             !type_selection_matches("(Home;Vehicle)", &only_home, &ctx),
    3623              :             "an entity with only Home must NOT match the conjunction"
    3624              :         );
    3625              :         // EXAMPLE 3: (Home;Vehicle)|Motorhome in both alternative spellings
    3626            8 :         for sel in ["(Home;Vehicle)|Motorhome", "(Home;Vehicle),Motorhome"] {
    3627            8 :             assert!(type_selection_matches(sel, &both, &ctx), "{sel}");
    3628            8 :             assert!(type_selection_matches(sel, &only_motor, &ctx), "{sel}");
    3629            8 :             assert!(!type_selection_matches(sel, &only_home, &ctx), "{sel}");
    3630              :         }
    3631            4 :     }
    3632              : }
    3633              : 
    3634              : #[cfg(test)]
    3635              : mod clause_5_2_2 {
    3636              :     use super::*;
    3637              :     use antares_jsonld::{ExpandOpts, Loader};
    3638              :     use serde_json::json;
    3639              : 
    3640              :     /// 5.2.2: createdAt/modifiedAt/deletedAt "shall not be provided by
    3641              :     /// Context Producers. In the event that they are provided ... NGSI-LD
    3642              :     /// implementations shall ignore them" — server stamps win, no error.
    3643              :     #[test]
    3644            4 :     fn client_provided_system_timestamps_are_ignored() {
    3645            4 :         let ctx = Loader::new().core();
    3646            4 :         let doc = json!({"id": "urn:x", "type": "T",
    3647            4 :             "createdAt": "1999-01-01T00:00:00Z",
    3648            4 :             "modifiedAt": "1999-01-01T00:00:00Z",
    3649            4 :             "deletedAt": "1999-01-01T00:00:00Z",
    3650            4 :             "p": {"type": "Property", "value": 1,
    3651            4 :                   "createdAt": "1999-01-01T00:00:00Z"}});
    3652            4 :         let mut out = antares_jsonld::expand_entity(
    3653            4 :             doc.as_object().expect("obj"),
    3654            4 :             &ctx,
    3655            4 :             ExpandOpts::default(),
    3656              :         )
    3657            4 :         .expect("providing common members is not an error");
    3658            4 :         stamp_new(&mut out, "2026-08-11T00:00:00.000Z");
    3659            4 :         assert_eq!(out["createdAt"], "2026-08-11T00:00:00.000Z");
    3660            4 :         assert_eq!(out["modifiedAt"], "2026-08-11T00:00:00.000Z");
    3661            4 :         assert!(out.get("deletedAt").is_none(), "deletedAt never creatable");
    3662            4 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
    3663            4 :         assert_eq!(
    3664            4 :             inst["createdAt"], "2026-08-11T00:00:00.000Z",
    3665              :             "instance-level client timestamp ignored too"
    3666              :         );
    3667            4 :     }
    3668              : 
    3669              :     /// 5.2.2: common members are only generated "when the Context Consumer
    3670              :     /// explicitly asks for their inclusion" (sysAttrs, 6.3.11).
    3671              :     #[test]
    3672            4 :     fn common_members_appear_only_on_request() {
    3673            4 :         let doc = json!({"id": "urn:x", "type": ["T"],
    3674            4 :             "createdAt": "2026-08-11T00:00:00.000Z",
    3675            4 :             "modifiedAt": "2026-08-11T00:00:00.000Z",
    3676            4 :             "https://uri.etsi.org/ngsi-ld/default-context/p": [
    3677            4 :                 {"type": "Property", "value": 1,
    3678            4 :                  "createdAt": "2026-08-11T00:00:00.000Z",
    3679            4 :                  "modifiedAt": "2026-08-11T00:00:00.000Z"}]});
    3680            4 :         let plain = crate::repr::apply(&doc, &crate::repr::Repr::default());
    3681            4 :         assert!(plain.get("createdAt").is_none());
    3682            4 :         assert!(plain["https://uri.etsi.org/ngsi-ld/default-context/p"][0]
    3683            4 :             .get("modifiedAt")
    3684            4 :             .is_none());
    3685            4 :         let sys = crate::repr::apply(
    3686            4 :             &doc,
    3687            4 :             &crate::repr::Repr {
    3688            4 :                 sys_attrs: true,
    3689            4 :                 ..Default::default()
    3690            4 :             },
    3691              :         );
    3692            4 :         assert!(sys.get("createdAt").is_some());
    3693            4 :         assert!(sys["https://uri.etsi.org/ngsi-ld/default-context/p"][0]
    3694            4 :             .get("modifiedAt")
    3695            4 :             .is_some());
    3696            4 :     }
    3697              : }
    3698              : 
    3699              : /// 4.5.23.1: "When retrieving Linked Entities, it is necessary to limit
    3700              : /// retrieval to avoid cascades of an excessive length, duplicates or loops."
    3701              : #[cfg(test)]
    3702              : mod clause_4_5_23_bounds {
    3703              :     use super::*;
    3704              :     use crate::repr::inline_join;
    3705              :     use axum::body::Body;
    3706              :     use axum::http::Request;
    3707              :     use http_body_util::BodyExt;
    3708              :     use serde_json::json;
    3709              :     use tower::ServiceExt;
    3710              : 
    3711            8 :     fn app() -> axum::Router {
    3712            8 :         crate::router(AppState::new("antares-test".into()))
    3713            8 :     }
    3714              : 
    3715           20 :     async fn create(app: &axum::Router, body: Value) {
    3716           20 :         let payload = body.to_string();
    3717           20 :         let resp = app
    3718           20 :             .clone()
    3719           20 :             .oneshot(
    3720           20 :                 Request::post("/ngsi-ld/v1/entities")
    3721           20 :                     .header("Content-Type", "application/json")
    3722           20 :                     .header("Content-Length", payload.len().to_string())
    3723           20 :                     .body(Body::from(payload))
    3724           20 :                     .expect("req"),
    3725           20 :             )
    3726           20 :             .await
    3727           20 :             .expect("resp");
    3728           20 :         assert_eq!(resp.status(), StatusCode::CREATED, "create failed");
    3729           20 :     }
    3730              : 
    3731            8 :     async fn get(app: &axum::Router, uri: &str) -> (axum::http::response::Parts, Value) {
    3732            8 :         let resp = app
    3733            8 :             .clone()
    3734            8 :             .oneshot(Request::get(uri).body(Body::empty()).expect("req"))
    3735            8 :             .await
    3736            8 :             .expect("resp");
    3737            8 :         let (parts, body) = resp.into_parts();
    3738            8 :         let bytes = body.collect().await.expect("body").to_bytes();
    3739            8 :         let json: Value = serde_json::from_slice(&bytes).expect("json");
    3740            8 :         (parts, json)
    3741            8 :     }
    3742              : 
    3743              :     /// 4.5.23.1/4.5.23.3: the flattened array carries the Linking Entity and
    3744              :     /// its Linked Entities — a Relationship pointing back at the root is a
    3745              :     /// loop, so the root shall appear exactly ONCE, not once as the Linking
    3746              :     /// Entity and again as its own Linked Entity.
    3747              :     #[tokio::test]
    3748            4 :     async fn flat_join_never_repeats_the_root_entity() {
    3749            4 :         let app = app();
    3750            4 :         let root = "urn:ngsi-ld:Loop:root";
    3751            4 :         let leaf = "urn:ngsi-ld:Loop:leaf";
    3752            4 :         create(&app, json!({"id": leaf, "type": "Loop"})).await;
    3753            4 :         create(
    3754            4 :             &app,
    3755            4 :             json!({"id": root, "type": "Loop",
    3756            4 :                    "self": {"type": "Relationship", "object": root},
    3757            4 :                    "other": {"type": "Relationship", "object": leaf}}),
    3758            4 :         )
    3759            4 :         .await;
    3760              : 
    3761            4 :         let (_, body) = get(
    3762            4 :             &app,
    3763            4 :             &format!("/ngsi-ld/v1/entities/{root}?join=flat&joinLevel=3"),
    3764              :         )
    3765            4 :         .await;
    3766            4 :         let arr = match body {
    3767            4 :             Value::Array(a) => a,
    3768            0 :             other => vec![other],
    3769              :         };
    3770            4 :         let (mut roots, mut leaves) = (0usize, 0usize);
    3771            8 :         for e in &arr {
    3772            8 :             match e["id"].as_str() {
    3773            8 :                 Some(id) if id == root => roots += 1,
    3774            4 :                 Some(id) if id == leaf => leaves += 1,
    3775            0 :                 _ => {}
    3776              :             }
    3777              :         }
    3778            4 :         assert_eq!(
    3779              :             roots, 1,
    3780              :             "the root must appear exactly once in the flattened array: {arr:?}"
    3781              :         );
    3782            4 :         assert_eq!(
    3783              :             leaves, 1,
    3784              :             "the genuine Linked Entity is still there once: {arr:?}"
    3785              :         );
    3786            4 :         assert_eq!(arr.len(), 2, "no other entity is in the array: {arr:?}");
    3787            4 :     }
    3788              : 
    3789              :     /// 4.5.23.1: a cyclic graph at a high joinLevel shall not cascade — the
    3790              :     /// walk stops at entities it already resolved and says so with an
    3791              :     /// NGSILD-Warning (6.3.17) instead of expanding fan-out^joinLevel.
    3792              :     #[tokio::test]
    3793            4 :     async fn cyclic_inline_join_stops_instead_of_cascading() {
    3794            4 :         let app = app();
    3795            4 :         let ids = [
    3796            4 :             "urn:ngsi-ld:Cyc:a",
    3797            4 :             "urn:ngsi-ld:Cyc:b",
    3798            4 :             "urn:ngsi-ld:Cyc:c",
    3799            4 :         ];
    3800              :         // complete graph: every entity links to every entity, itself included
    3801           12 :         for id in ids {
    3802           12 :             create(
    3803           12 :                 &app,
    3804           12 :                 json!({"id": id, "type": "Cyc",
    3805           12 :                        "toA": {"type": "Relationship", "object": ids[0]},
    3806           12 :                        "toB": {"type": "Relationship", "object": ids[1]},
    3807           12 :                        "toC": {"type": "Relationship", "object": ids[2]}}),
    3808           12 :             )
    3809           12 :             .await;
    3810              :         }
    3811              : 
    3812            4 :         let (parts, body) = get(
    3813            4 :             &app,
    3814            4 :             &format!("/ngsi-ld/v1/entities/{}?join=inline&joinLevel=9", ids[0]),
    3815              :         )
    3816            4 :         .await;
    3817            4 :         assert_eq!(parts.status, StatusCode::OK);
    3818              :         // 3^9 embeddings if the walk is unbounded; a handful if it is not
    3819            4 :         let embedded = body.to_string().matches("urn:ngsi-ld:Cyc:").count();
    3820            4 :         assert!(
    3821            4 :             embedded < 64,
    3822              :             "the cyclic walk cascaded: {embedded} entity references embedded"
    3823              :         );
    3824            4 :         assert!(
    3825            4 :             parts.headers.get("NGSILD-Warning").is_some(),
    3826            4 :             "a truncated Linked Entity Retrieval must be reported (6.3.17)"
    3827            4 :         );
    3828            4 :     }
    3829              : 
    3830              :     /// 4.5.23.1: joinLevel bounds the depth, not the width — one retrieval
    3831              :     /// may only buy MAX_JOIN_LOOKUPS Linked Entity reads, and the truncation
    3832              :     /// is reported back to the caller.
    3833              :     #[tokio::test]
    3834            4 :     async fn wide_inline_join_stops_at_the_lookup_budget() {
    3835            4 :         let st = AppState::new("antares-test".into());
    3836            4 :         let tenant = TenantId::default();
    3837            4 :         let ctx = antares_jsonld::Loader::new().core();
    3838            4 :         let mut targets: Vec<Value> = Vec::new();
    3839         4400 :         for n in 0..MAX_JOIN_LOOKUPS + 100 {
    3840         4400 :             let id = format!("urn:ngsi-ld:Wide:{n}");
    3841         4400 :             let doc = json!({"id": &id, "type":
    3842              :                 ["https://uri.etsi.org/ngsi-ld/default-context/Wide"]});
    3843         4400 :             st.store
    3844         4400 :                 .upsert(&tenant, Kind::Entity, &id, doc)
    3845         4400 :                 .await
    3846         4400 :                 .expect("seed");
    3847         4400 :             targets.push(Value::String(id));
    3848              :         }
    3849            4 :         let mut compacted = json!({"id": "urn:ngsi-ld:Wide:root", "type": "Wide",
    3850            4 :             "links": {"type": "Relationship", "object": Value::Array(targets)}});
    3851            4 :         let complete = inline_join(
    3852            4 :             &st,
    3853            4 :             &tenant,
    3854            4 :             &ctx,
    3855            4 :             &crate::repr::Repr::default(),
    3856            4 :             &mut compacted,
    3857            4 :             &Value::Null,
    3858            4 :             1,
    3859            4 :         )
    3860            4 :         .await;
    3861            4 :         assert!(!complete, "the budget was hit, so the walk is incomplete");
    3862            4 :         assert_eq!(
    3863            4 :             compacted["links"]["entity"]
    3864            4 :                 .as_array()
    3865            4 :                 .expect("entity array")
    3866            4 :                 .len(),
    3867            4 :             MAX_JOIN_LOOKUPS,
    3868            4 :             "no more Linked Entities than the budget are resolved"
    3869            4 :         );
    3870            4 :     }
    3871              : }
    3872              : 
    3873              : /// 5.6.6.4 / 5.6.17.4 / 5.6.18.4 / 5.6.21.4: "matching input data is
    3874              : /// forwarded to the Registration endpoint" — the forward may narrow what the
    3875              : /// peer does, never widen it.
    3876              : #[cfg(test)]
    3877              : mod forwarded_input_data {
    3878              :     use super::*;
    3879              : 
    3880           44 :     fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
    3881           44 :         pairs
    3882           44 :             .iter()
    3883          128 :             .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
    3884           44 :             .collect()
    3885           44 :     }
    3886              : 
    3887              :     /// 5.6.21.3 lists the type selector, the id list, the id pattern, the
    3888              :     /// restrictive and exclusionary Attribute-name lists, the query, the
    3889              :     /// geoquery, the Scope query and the context source filter as Purge
    3890              :     /// input data. Every one of them restricts the purge, so every one of
    3891              :     /// them travels.
    3892              :     #[test]
    3893            4 :     fn purge_forward_carries_every_restriction_the_client_issued() {
    3894            4 :         let p = params(&[
    3895            4 :             ("type", "Vehicle"),
    3896            4 :             ("id", "urn:ngsi-ld:Vehicle:A1"),
    3897            4 :             ("idPattern", "^urn:ngsi-ld:Vehicle:"),
    3898            4 :             ("attrs", "speed"),
    3899            4 :             ("q", "speed>5"),
    3900            4 :             ("georel", "near;maxDistance==100"),
    3901            4 :             ("geometry", "Point"),
    3902            4 :             ("coordinates", "[0,0]"),
    3903            4 :             ("geoproperty", "location"),
    3904            4 :             ("scopeQ", "/x"),
    3905            4 :             ("csf", "name==p"),
    3906            4 :             ("keep", "name"),
    3907            4 :             ("local", "false"),
    3908            4 :         ]);
    3909            4 :         let q = forwarded_purge_query(&p);
    3910           48 :         for k in [
    3911            4 :             "type",
    3912            4 :             "id",
    3913            4 :             "idPattern",
    3914            4 :             "attrs",
    3915            4 :             "q",
    3916            4 :             "georel",
    3917            4 :             "geometry",
    3918            4 :             "coordinates",
    3919            4 :             "geoproperty",
    3920            4 :             "scopeQ",
    3921            4 :             "csf",
    3922            4 :             "keep",
    3923            4 :         ] {
    3924           48 :             assert!(
    3925          312 :                 q.iter().any(|(a, _)| a == k),
    3926              :                 "{k} was dropped, so the peer executes a wider purge than the \
    3927              :                  client issued: {q:?}"
    3928              :             );
    3929              :         }
    3930            4 :         assert!(
    3931           48 :             !q.iter().any(|(k, _)| k == "local"),
    3932              :             "local scope is the reason a forward happens at all — it is not \
    3933              :              itself forwarded (5.5.13): {q:?}"
    3934              :         );
    3935            4 :         assert_eq!(q.len(), 12, "nothing else is invented: {q:?}");
    3936            4 :     }
    3937              : 
    3938              :     /// The exclusionary list travels on its own too — `drop=` alone must not
    3939              :     /// reach the peer as a bare, entity-deleting purge.
    3940              :     #[test]
    3941            4 :     fn purge_forward_carries_drop_and_omits_absent_members() {
    3942            4 :         let q = forwarded_purge_query(&params(&[("type", "Vehicle"), ("drop", "speed")]));
    3943            4 :         assert!(
    3944            4 :             q.contains(&("drop".to_owned(), "speed".to_owned())),
    3945              :             "{q:?}"
    3946              :         );
    3947            4 :         assert_eq!(q.len(), 2, "absent parameters are not forwarded: {q:?}");
    3948            4 :     }
    3949              : 
    3950              :     /// 5.6.6.3 / 5.6.17.3 / 5.6.18.3: the selector of Entity types is input
    3951              :     /// data of Delete, Merge and Replace Entity. A registration may cover
    3952              :     /// several types, so the peer needs the selector to reach the same
    3953              :     /// verdict this broker reached locally.
    3954              :     #[test]
    3955            4 :     fn write_forwards_carry_the_type_selector() {
    3956            4 :         assert_eq!(
    3957            4 :             type_selector_query(&params(&[("type", "Vehicle"), ("local", "false")])),
    3958            4 :             vec![("type".to_owned(), "Vehicle".to_owned())]
    3959              :         );
    3960            4 :         assert!(
    3961            4 :             type_selector_query(&params(&[("local", "false")])).is_empty(),
    3962              :             "no selector, nothing to forward"
    3963              :         );
    3964            4 :     }
    3965              : 
    3966              :     /// 5.7.2.4 applies q/geoquery/Scope query/Attributes only after remote
    3967              :     /// parts have been aggregated, and 4.23 orders by the value of a member —
    3968              :     /// neither survives a store-side projection of that member.
    3969              :     #[test]
    3970            4 :     fn projection_is_pushed_down_only_when_the_store_answer_is_final() {
    3971            4 :         let plain = params(&[("type", "T"), ("pick", "name")]);
    3972            4 :         assert!(
    3973            4 :             proj_pushdown_allowed(true, &plain),
    3974              :             "a purely local query keeps the pushdown"
    3975              :         );
    3976            4 :         assert!(
    3977            4 :             !proj_pushdown_allowed(false, &plain),
    3978              :             "federated candidates mean the filters run again after the merge"
    3979              :         );
    3980            4 :         assert!(
    3981            4 :             !proj_pushdown_allowed(true, &params(&[("attrs", "name"), ("orderBy", "age")])),
    3982              :             "the ordering member must survive to be compared"
    3983              :         );
    3984            4 :     }
    3985              : 
    3986              :     /// 6.3.10 makes `limit=0` legal only with `count=true` — an answer that is
    3987              :     /// a count and no rows. That shape is pushed to the store; the filters the
    3988              :     /// store cannot see (idPattern, 4.23 ordering, federated candidates merged
    3989              :     /// per 5.7.2.4) still forfeit the page.
    3990              :     #[test]
    3991            4 :     fn the_count_only_page_is_pushed_down_but_store_blind_filters_are_not() {
    3992            4 :         assert!(
    3993            4 :             page_pushdown_allowed(
    3994              :                 true,
    3995            4 :                 &params(&[("type", "T"), ("limit", "0"), ("count", "true")])
    3996              :             ),
    3997              :             "a count is answered by counting, not by materializing the match set"
    3998              :         );
    3999            4 :         assert!(
    4000            4 :             page_pushdown_allowed(true, &params(&[("type", "T"), ("limit", "10")])),
    4001              :             "a plain local query keeps the pushdown"
    4002              :         );
    4003            4 :         assert!(
    4004            4 :             !page_pushdown_allowed(true, &params(&[("type", "T"), ("idPattern", "^urn:")])),
    4005              :             "idPattern is applied after the store, so it drops rows the SQL \
    4006              :              page already counted"
    4007              :         );
    4008            4 :         assert!(
    4009            4 :             !page_pushdown_allowed(true, &params(&[("type", "T"), ("orderBy", "speed")])),
    4010              :             "4.23 orders the whole match set before the page is cut"
    4011              :         );
    4012            4 :         assert!(
    4013            4 :             !page_pushdown_allowed(false, &params(&[("type", "T")])),
    4014              :             "federated candidates are merged after the store answered"
    4015              :         );
    4016            4 :     }
    4017              : }
    4018              : 
    4019              : /// 5.6.1 Create Entity and 5.6.21 Purge Entities, end to end over the store.
    4020              : #[cfg(test)]
    4021              : mod clause_5_6_1_and_5_6_21 {
    4022              :     use super::*;
    4023              :     use axum::body::Body;
    4024              :     use axum::http::Request;
    4025              :     use http_body_util::BodyExt;
    4026              :     use serde_json::json;
    4027              :     use tower::ServiceExt;
    4028              : 
    4029              :     /// 5.6.1.5: the output is "the URI of the created Entity", returned in
    4030              :     /// the Location header. An id is one path segment (RFC 3986 clause 3.3),
    4031              :     /// so a `#` in it must not be able to end the segment.
    4032              :     #[tokio::test]
    4033            4 :     async fn location_header_percent_encodes_the_entity_id() {
    4034            4 :         let app = crate::router(AppState::new("antares-test".into()));
    4035            4 :         let id = "urn:ngsi-ld:Vehicle:A#4567";
    4036            4 :         let payload = json!({"id": id, "type": "Vehicle"}).to_string();
    4037            4 :         let resp = app
    4038            4 :             .oneshot(
    4039            4 :                 Request::post("/ngsi-ld/v1/entities")
    4040            4 :                     .header("Content-Type", "application/json")
    4041            4 :                     .header("Content-Length", payload.len().to_string())
    4042            4 :                     .body(Body::from(payload))
    4043            4 :                     .expect("req"),
    4044            4 :             )
    4045            4 :             .await
    4046            4 :             .expect("resp");
    4047            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    4048            4 :         let loc = resp
    4049            4 :             .headers()
    4050            4 :             .get("Location")
    4051            4 :             .expect("Location header")
    4052            4 :             .to_str()
    4053            4 :             .expect("ascii");
    4054            4 :         assert_eq!(loc, "/ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:A%234567");
    4055            4 :         assert!(
    4056            4 :             !loc.contains('#'),
    4057            4 :             "a raw # truncates the URL at the fragment and addresses another \
    4058            4 :              resource: {loc}"
    4059            4 :         );
    4060            4 :     }
    4061              : 
    4062              :     /// 5.7.2 names Entities by a comma-separated `id` list, and
    4063              :     /// the EntityMap, temporal and federation pages rebuild that list, so an
    4064              :     /// id holding a comma is refused on every create door and nothing is
    4065              :     /// stored.
    4066              :     #[tokio::test]
    4067            4 :     async fn an_entity_id_holding_a_comma_is_refused_at_create() {
    4068            4 :         let app = crate::router(AppState::new("antares-test".into()));
    4069            4 :         let id = "urn:ngsi-ld:Vehicle:a,b";
    4070            4 :         let one = json!({"id": id, "type": "Vehicle"});
    4071            4 :         let temporal = json!({"id": id, "type": "Vehicle",
    4072            4 :             "speed": [{"type": "Property", "value": 1, "observedAt": "2026-10-26T00:00:00Z"}]});
    4073              :         // a batch answers 207 with the member's 400 in `errors` (5.6.7)
    4074           16 :         for (path, body, want) in [
    4075            4 :             ("/ngsi-ld/v1/entities", one.clone(), StatusCode::BAD_REQUEST),
    4076            4 :             (
    4077            4 :                 "/ngsi-ld/v1/entityOperations/create",
    4078            4 :                 json!([one.clone()]),
    4079            4 :                 StatusCode::MULTI_STATUS,
    4080            4 :             ),
    4081            4 :             (
    4082            4 :                 "/ngsi-ld/v1/entityOperations/upsert",
    4083            4 :                 json!([one.clone()]),
    4084            4 :                 StatusCode::MULTI_STATUS,
    4085            4 :             ),
    4086            4 :             (
    4087            4 :                 "/ngsi-ld/v1/temporal/entities",
    4088            4 :                 temporal,
    4089            4 :                 StatusCode::BAD_REQUEST,
    4090            4 :             ),
    4091            4 :         ] {
    4092           16 :             let payload = body.to_string();
    4093           16 :             let resp = app
    4094           16 :                 .clone()
    4095           16 :                 .oneshot(
    4096           16 :                     Request::post(path)
    4097           16 :                         .header("Content-Type", "application/json")
    4098           16 :                         .header("Content-Length", payload.len().to_string())
    4099           16 :                         .body(Body::from(payload))
    4100           16 :                         .expect("req"),
    4101           16 :                 )
    4102           16 :                 .await
    4103           16 :                 .expect("resp");
    4104           16 :             let status = resp.status();
    4105           16 :             let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    4106           16 :             let text = String::from_utf8_lossy(&bytes);
    4107           16 :             assert_eq!(status, want, "{path}: {text}");
    4108           16 :             assert!(text.contains("holds a comma"), "{path}: {text}");
    4109              :         }
    4110            4 :         let resp = app
    4111            4 :             .oneshot(
    4112            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:a,b")
    4113            4 :                     .body(Body::empty())
    4114            4 :                     .expect("req"),
    4115            4 :             )
    4116            4 :             .await
    4117            4 :             .expect("resp");
    4118            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    4119            4 :     }
    4120              : 
    4121              :     /// an upsert refuses a comma only for an id it would create;
    4122              :     /// an Entity stored under one before the rule is still upserted, so a
    4123              :     /// source that keeps writing it does not turn red.
    4124              :     #[tokio::test]
    4125            4 :     async fn an_upsert_still_reaches_a_stored_comma_id() {
    4126            4 :         let st = AppState::new("antares-test".into());
    4127            4 :         let tenant = TenantId::default();
    4128            4 :         let id = "urn:ngsi-ld:Vehicle:a,b";
    4129            4 :         let vehicle = "https://uri.etsi.org/ngsi-ld/default-context/Vehicle";
    4130            4 :         let doc = json!({"id": id, "type": [vehicle]});
    4131            4 :         st.store
    4132            4 :             .upsert(&tenant, Kind::Entity, id, doc.clone())
    4133            4 :             .await
    4134            4 :             .expect("seed entity");
    4135            4 :         assert!(st
    4136            4 :             .temporal
    4137            4 :             .create(&tenant, id, doc)
    4138            4 :             .await
    4139            4 :             .expect("seed temporal"));
    4140            4 :         let app = crate::router(st);
    4141            4 :         let one = json!({"id": id, "type": "Vehicle",
    4142            4 :             "speed": {"type": "Property", "value": 2}});
    4143            4 :         let temporal = json!({"id": id, "type": "Vehicle",
    4144            4 :             "speed": [{"type": "Property", "value": 2, "observedAt": "2026-10-26T00:00:00Z"}]});
    4145            8 :         for (path, body) in [
    4146            4 :             ("/ngsi-ld/v1/entityOperations/upsert", json!([one])),
    4147            4 :             ("/ngsi-ld/v1/temporal/entities", temporal),
    4148            4 :         ] {
    4149            8 :             let payload = body.to_string();
    4150            8 :             let resp = app
    4151            8 :                 .clone()
    4152            8 :                 .oneshot(
    4153            8 :                     Request::post(path)
    4154            8 :                         .header("Content-Type", "application/json")
    4155            8 :                         .header("Content-Length", payload.len().to_string())
    4156            8 :                         .body(Body::from(payload))
    4157            8 :                         .expect("req"),
    4158            8 :                 )
    4159            8 :                 .await
    4160            8 :                 .expect("resp");
    4161            8 :             let status = resp.status();
    4162            8 :             let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    4163            8 :             assert_eq!(
    4164            4 :                 status,
    4165            4 :                 StatusCode::NO_CONTENT,
    4166            4 :                 "{path}: {}",
    4167            4 :                 String::from_utf8_lossy(&bytes)
    4168            4 :             );
    4169            4 :         }
    4170            4 :     }
    4171              : 
    4172           18 :     async fn seed(st: &AppState, tenant: &TenantId, n: usize) {
    4173        12580 :         for i in 0..n {
    4174        12580 :             let id = format!("urn:ngsi-ld:Purge:{i:05}");
    4175        12580 :             let doc = json!({"id": &id,
    4176        12580 :                 "type": ["https://uri.etsi.org/ngsi-ld/default-context/Purge"],
    4177        12580 :                 "https://uri.etsi.org/ngsi-ld/default-context/name":
    4178        12580 :                     [{"type": "Property", "value": "n"}],
    4179        12580 :                 "https://uri.etsi.org/ngsi-ld/default-context/speed":
    4180        12580 :                     [{"type": "Property", "value": 1}]});
    4181        12580 :             st.store
    4182        12580 :                 .upsert(tenant, Kind::Entity, &id, doc)
    4183        12580 :                 .await
    4184        12580 :                 .expect("seed");
    4185              :         }
    4186           18 :     }
    4187              : 
    4188           22 :     fn purge_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
    4189           22 :         pairs
    4190           22 :             .iter()
    4191           40 :             .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
    4192           22 :             .collect()
    4193           22 :     }
    4194              : 
    4195              :     /// 5.6.21.4: the implementation "shall delete all Entities that can be
    4196              :     /// found locally using retrieved list of Entity ids" — all of them, not
    4197              :     /// one page of them, however many rounds that takes.
    4198              :     #[tokio::test]
    4199            4 :     async fn purge_deletes_every_match_across_page_boundaries() {
    4200            4 :         let st = AppState::new("antares-test".into());
    4201            4 :         let tenant = TenantId::default();
    4202            4 :         let n = PURGE_CHUNK * 2 + 7;
    4203            4 :         seed(&st, &tenant, n).await;
    4204            4 :         let resp = purge_inner(&st, &purge_params(&[("type", "Purge")]), &HeaderMap::new())
    4205            4 :             .await
    4206            4 :             .expect("purge");
    4207            4 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    4208            4 :         assert!(
    4209            4 :             st.store
    4210            4 :                 .list(&tenant, Kind::Entity)
    4211            4 :                 .await
    4212            4 :                 .expect("list")
    4213            4 :                 .is_empty(),
    4214            4 :             "entities survived the purge"
    4215            4 :         );
    4216            4 :     }
    4217              : 
    4218              :     /// 5.6.21.4: with an exclusionary list the implementation "shall delete
    4219              :     /// all but the given set of Attributes" — the Entities themselves
    4220              :     /// survive, again for the whole match set and not just its first page.
    4221              :     #[tokio::test]
    4222            4 :     async fn purge_with_keep_prunes_every_match_and_deletes_no_entity() {
    4223            4 :         let st = AppState::new("antares-test".into());
    4224            4 :         let tenant = TenantId::default();
    4225            4 :         let n = PURGE_CHUNK * 2 + 7;
    4226            4 :         seed(&st, &tenant, n).await;
    4227            4 :         let resp = purge_inner(
    4228            4 :             &st,
    4229            4 :             &purge_params(&[("type", "Purge"), ("keep", "name")]),
    4230            4 :             &HeaderMap::new(),
    4231            4 :         )
    4232            4 :         .await
    4233            4 :         .expect("purge");
    4234            4 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    4235            4 :         let left = st.store.list(&tenant, Kind::Entity).await.expect("list");
    4236            4 :         assert_eq!(left.len(), n, "keep= prunes attributes, not entities");
    4237         4028 :         for doc in &left {
    4238         4028 :             assert!(
    4239         4028 :                 doc.get("https://uri.etsi.org/ngsi-ld/default-context/name")
    4240         4028 :                     .is_some(),
    4241            4 :                 "the kept attribute is still there: {doc}"
    4242            4 :             );
    4243         4028 :             assert!(
    4244         4028 :                 doc.get("https://uri.etsi.org/ngsi-ld/default-context/speed")
    4245         4028 :                     .is_none(),
    4246            4 :                 "every other attribute is gone: {doc}"
    4247            4 :             );
    4248            4 :         }
    4249            4 :     }
    4250              : 
    4251              :     /// 5.6.21.4: "id matches the id pattern passed as a parameter" — the
    4252              :     /// pattern narrows the match set, and only that set is deleted.
    4253              :     #[tokio::test]
    4254            4 :     async fn purge_deletes_the_id_pattern_matches_and_nothing_else() {
    4255            4 :         let st = AppState::new("antares-test".into());
    4256            4 :         let tenant = TenantId::default();
    4257            4 :         seed(&st, &tenant, 30).await;
    4258            4 :         let resp = purge_inner(
    4259            4 :             &st,
    4260            4 :             &purge_params(&[("type", "Purge"), ("idPattern", "^urn:ngsi-ld:Purge:0000")]),
    4261            4 :             &HeaderMap::new(),
    4262            4 :         )
    4263            4 :         .await
    4264            4 :         .expect("purge");
    4265            4 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    4266            4 :         let left: Vec<String> = st
    4267            4 :             .store
    4268            4 :             .list(&tenant, Kind::Entity)
    4269            4 :             .await
    4270            4 :             .expect("list")
    4271            4 :             .iter()
    4272           80 :             .filter_map(|d| d["id"].as_str().map(str::to_owned))
    4273            4 :             .collect();
    4274            4 :         assert_eq!(
    4275            4 :             left.len(),
    4276              :             20,
    4277              :             "only the ten pattern matches went: {left:?}"
    4278              :         );
    4279            4 :         assert!(
    4280            4 :             !left
    4281            4 :                 .iter()
    4282           80 :                 .any(|id| id.starts_with("urn:ngsi-ld:Purge:0000")),
    4283              :             "a pattern match survived: {left:?}"
    4284              :         );
    4285            4 :         assert!(
    4286            4 :             left.contains(&"urn:ngsi-ld:Purge:00010".to_owned()),
    4287            4 :             "an Entity the pattern does not match must not be purged: {left:?}"
    4288            4 :         );
    4289            4 :     }
    4290              : 
    4291              :     /// 5.6.21.4 deletes "all Entities that can be found locally using
    4292              :     /// retrieved list of Entity ids". The retrieval is chunked, and idPattern
    4293              :     /// is applied after the store — so a chunk can come back narrowed, or
    4294              :     /// empty, while matches still wait behind it. The walk continues on the
    4295              :     /// store's row count, never on the surviving subset.
    4296              :     #[test]
    4297            4 :     fn purge_walks_on_the_rows_the_store_returned_not_the_narrowed_subset() {
    4298            4 :         assert_eq!(
    4299            4 :             next_scan_offset(0, 500, 0, true, 500),
    4300              :             Some(500),
    4301              :             "a full chunk the pattern narrowed to nothing still has a successor"
    4302              :         );
    4303            4 :         assert_eq!(
    4304            4 :             next_scan_offset(500, 500, 120, true, 500),
    4305              :             Some(880),
    4306              :             "the 380 rows the round did not remove are stepped over"
    4307              :         );
    4308            4 :         assert_ne!(
    4309            4 :             next_scan_offset(0, 500, 0, true, 500),
    4310              :             None,
    4311              :             "stopping here leaves every match behind the first chunk alive"
    4312              :         );
    4313            4 :     }
    4314              : 
    4315              :     /// 5.6.21.4 against a store that really pages (memory answers every query
    4316              :     /// with the whole match set, so it cannot exercise the walk): with an
    4317              :     /// idPattern spread across the match set, every chunk arrives narrowed —
    4318              :     /// and the purge still has to delete "all Entities that can be found
    4319              :     /// locally using retrieved list of Entity ids", not just the first chunk's
    4320              :     /// share. Skips without ANTARES_TEST_DATABASE_URL.
    4321              :     #[cfg(feature = "postgres")]
    4322              :     #[tokio::test(flavor = "multi_thread")]
    4323            4 :     async fn purge_over_a_paging_store_deletes_every_id_pattern_match() {
    4324            4 :         let url = match std::env::var("ANTARES_TEST_DATABASE_URL") {
    4325            4 :             Ok(u) => u,
    4326              :             Err(_) => {
    4327            0 :                 eprintln!("SKIP: ANTARES_TEST_DATABASE_URL not set");
    4328            0 :                 return;
    4329              :             }
    4330              :         };
    4331            4 :         let pool = antares_sql::store::pg::connect(&url, 5)
    4332            4 :             .await
    4333            4 :             .expect("connect");
    4334            4 :         let tenant = TenantId::new("purgepaging").expect("tenant");
    4335            4 :         antares_sql::store::pg::ensure_tenant(&pool, &tenant)
    4336            4 :             .await
    4337            2 :             .expect("tenant row");
    4338            2 :         let st = AppState::with_store(
    4339            2 :             "antares-test".into(),
    4340            2 :             std::sync::Arc::new(antares_sql::store::any::AnyStore::Pg(
    4341            2 :                 antares_sql::store::any::PgBackend::new(pool),
    4342            2 :             )),
    4343            2 :             "postgres",
    4344              :         );
    4345            2 :         for doc in st.store.list(&tenant, Kind::Entity).await.expect("list") {
    4346            0 :             if let Some(id) = doc["id"].as_str() {
    4347            0 :                 st.store
    4348            0 :                     .delete(&tenant, Kind::Entity, id)
    4349            0 :                     .await
    4350            0 :                     .expect("clean");
    4351            0 :             }
    4352              :         }
    4353              :         // more than two chunks, and the pattern matches every second id — so
    4354              :         // no chunk the store pages is full once the pattern filtered it
    4355            2 :         let n = PURGE_CHUNK * 2 + 200;
    4356            2 :         seed(&st, &tenant, n).await;
    4357              :         // the purge must run AS the seeded tenant — with no NGSILD-Tenant
    4358              :         // header it correctly purges the default tenant's (empty) match set
    4359              :         // and every seeded row survives (5.5.10)
    4360            2 :         let mut headers = HeaderMap::new();
    4361            2 :         headers.insert("NGSILD-Tenant", "purgepaging".parse().expect("header"));
    4362            2 :         let resp = purge_inner(
    4363            2 :             &st,
    4364            2 :             &purge_params(&[("type", "Purge"), ("idPattern", "[02468]$")]),
    4365            2 :             &headers,
    4366            2 :         )
    4367            2 :         .await
    4368            2 :         .expect("purge");
    4369            2 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    4370            2 :         let left: Vec<String> = st
    4371            2 :             .store
    4372            2 :             .list(&tenant, Kind::Entity)
    4373            2 :             .await
    4374            2 :             .expect("list")
    4375            2 :             .iter()
    4376         1200 :             .filter_map(|d| d["id"].as_str().map(str::to_owned))
    4377            2 :             .collect();
    4378            2 :         assert!(
    4379            2 :             !left
    4380            2 :                 .iter()
    4381         1200 :                 .any(|id| id.ends_with(['0', '2', '4', '6', '8'])),
    4382              :             "{} of {} pattern matches survived the chunked walk",
    4383            0 :             left.iter()
    4384            0 :                 .filter(|id| id.ends_with(['0', '2', '4', '6', '8']))
    4385            0 :                 .count(),
    4386            0 :             n / 2
    4387              :         );
    4388            2 :         assert_eq!(
    4389            2 :             left.len(),
    4390            2 :             n / 2,
    4391              :             "an Entity the pattern does not match must not be purged"
    4392              :         );
    4393         1202 :         for id in &left {
    4394         1202 :             st.store
    4395         1200 :                 .delete(&tenant, Kind::Entity, id)
    4396         1200 :                 .await
    4397         1202 :                 .expect("clean");
    4398            4 :         }
    4399            4 :     }
    4400              : 
    4401              :     /// 5.6.21.4 under a scopeQ, which the store narrows but cannot decide:
    4402              :     /// the window it is walked in counts matches, so the walk steps in
    4403              :     /// matches too. Stepping by the store rows read skipped every match past
    4404              :     /// the first window and, with a full window of non-matching rows behind
    4405              :     /// it, never stopped.
    4406              :     #[tokio::test]
    4407            4 :     async fn purge_with_scope_q_deletes_every_match_past_the_first_window() {
    4408            4 :         let st = AppState::new("antares-test".into());
    4409            4 :         let tenant = TenantId::default();
    4410         3600 :         for i in 0..900usize {
    4411         3600 :             let id = format!("urn:ngsi-ld:Purge:{i:05}");
    4412         3600 :             let mut doc = json!({"id": &id,
    4413         3600 :                 "type": ["https://uri.etsi.org/ngsi-ld/default-context/Purge"]});
    4414         3600 :             if i < 700 {
    4415         2800 :                 doc["scope"] = json!(["/a"]);
    4416         2800 :             }
    4417         3600 :             st.store
    4418         3600 :                 .upsert(&tenant, Kind::Entity, &id, doc)
    4419         3600 :                 .await
    4420         3600 :                 .expect("seed");
    4421              :         }
    4422            4 :         let resp = purge_inner(
    4423            4 :             &st,
    4424            4 :             &purge_params(&[("type", "Purge"), ("scopeQ", "/a")]),
    4425            4 :             &HeaderMap::new(),
    4426            4 :         )
    4427            4 :         .await
    4428            4 :         .expect("purge");
    4429            4 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    4430            4 :         let left = st.store.list(&tenant, Kind::Entity).await.expect("list");
    4431          800 :         let scoped = left.iter().filter(|d| d.get("scope").is_some()).count();
    4432            4 :         assert_eq!(scoped, 0, "{scoped} scoped matches survived the purge");
    4433            4 :         assert_eq!(left.len(), 200, "an Entity outside the scope was purged");
    4434            4 :     }
    4435              : 
    4436              :     /// The same walk ends when nothing matches: a full window of rows the
    4437              :     /// scopeQ refuses is no match to step over. Run on a thread of its own,
    4438              :     /// so a walk that never yields fails the test instead of hanging it.
    4439              :     #[test]
    4440            4 :     fn purge_with_scope_q_matching_nothing_terminates() {
    4441            4 :         let (tx, rx) = std::sync::mpsc::channel();
    4442            4 :         std::thread::spawn(move || {
    4443            4 :             let rt = tokio::runtime::Builder::new_current_thread()
    4444            4 :                 .enable_all()
    4445            4 :                 .build()
    4446            4 :                 .expect("runtime");
    4447            4 :             let left = rt.block_on(async {
    4448            4 :                 let st = AppState::new("antares-test".into());
    4449            4 :                 let tenant = TenantId::default();
    4450            4 :                 seed(&st, &tenant, PURGE_CHUNK + 1).await;
    4451            4 :                 let params = purge_params(&[("type", "Purge"), ("scopeQ", "/none")]);
    4452            4 :                 let resp = purge_inner(&st, &params, &HeaderMap::new())
    4453            4 :                     .await
    4454            4 :                     .expect("purge");
    4455            4 :                 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    4456            4 :                 st.store
    4457            4 :                     .list(&tenant, Kind::Entity)
    4458            4 :                     .await
    4459            4 :                     .expect("list")
    4460            4 :                     .len()
    4461            4 :             });
    4462            4 :             let _ = tx.send(left);
    4463            4 :         });
    4464            4 :         let left = rx
    4465            4 :             .recv_timeout(std::time::Duration::from_secs(60))
    4466            4 :             .expect("the purge walk ended");
    4467            4 :         assert_eq!(left, PURGE_CHUNK + 1, "nothing matched, nothing is purged");
    4468            4 :     }
    4469              : 
    4470              :     /// The same walk terminates: a chunk the store did not page IS the whole
    4471              :     /// match set, a short chunk is the last one, and a round that deleted its
    4472              :     /// whole chunk re-reads the window the deletions shifted down.
    4473              :     #[test]
    4474            4 :     fn purge_stops_when_the_match_set_is_exhausted() {
    4475            4 :         assert_eq!(
    4476            4 :             next_scan_offset(0, 500, 500, false, 500),
    4477              :             None,
    4478              :             "an unpaged answer is the whole match set"
    4479              :         );
    4480            4 :         assert_eq!(
    4481            4 :             next_scan_offset(0, 7, 7, true, 500),
    4482              :             None,
    4483              :             "a chunk shorter than the page size is the last one"
    4484              :         );
    4485            4 :         assert_eq!(
    4486            4 :             next_scan_offset(0, 500, 500, true, 500),
    4487              :             Some(0),
    4488              :             "everything deleted — the rest of the match set shifted to the front"
    4489              :         );
    4490            4 :     }
    4491              : }
    4492              : 
    4493              : /// 5.6.17 Merge Entity and the Linked Entity Retrieval parameters of
    4494              : /// Table 6.4.3.2-1, over the HTTP surface.
    4495              : #[cfg(test)]
    4496              : mod clause_5_6_17_and_6_4_3_2 {
    4497              :     use super::*;
    4498              :     use axum::body::Body;
    4499              :     use axum::http::Request;
    4500              :     use http_body_util::BodyExt;
    4501              :     use serde_json::json;
    4502              :     use tower::ServiceExt;
    4503              : 
    4504           16 :     fn app() -> axum::Router {
    4505           16 :         crate::router(AppState::new("antares-test".into()))
    4506           16 :     }
    4507              : 
    4508           24 :     async fn create(app: &axum::Router, body: Value) {
    4509           24 :         let payload = body.to_string();
    4510           24 :         let resp = app
    4511           24 :             .clone()
    4512           24 :             .oneshot(
    4513           24 :                 Request::post("/ngsi-ld/v1/entities")
    4514           24 :                     .header("Content-Type", "application/json")
    4515           24 :                     .header("Content-Length", payload.len().to_string())
    4516           24 :                     .body(Body::from(payload))
    4517           24 :                     .expect("req"),
    4518           24 :             )
    4519           24 :             .await
    4520           24 :             .expect("resp");
    4521           24 :         assert_eq!(resp.status(), StatusCode::CREATED, "create failed");
    4522           24 :     }
    4523              : 
    4524           12 :     async fn patch(app: &axum::Router, uri: &str, body: Value) -> StatusCode {
    4525           12 :         let payload = body.to_string();
    4526           12 :         app.clone()
    4527           12 :             .oneshot(
    4528           12 :                 Request::patch(uri)
    4529           12 :                     .header("Content-Type", "application/json")
    4530           12 :                     .header("Content-Length", payload.len().to_string())
    4531           12 :                     .body(Body::from(payload))
    4532           12 :                     .expect("req"),
    4533           12 :             )
    4534           12 :             .await
    4535           12 :             .expect("resp")
    4536           12 :             .status()
    4537           12 :     }
    4538              : 
    4539           12 :     async fn get(app: &axum::Router, uri: &str) -> Value {
    4540           12 :         let resp = app
    4541           12 :             .clone()
    4542           12 :             .oneshot(Request::get(uri).body(Body::empty()).expect("req"))
    4543           12 :             .await
    4544           12 :             .expect("resp");
    4545           12 :         let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    4546           12 :         serde_json::from_slice(&bytes).expect("json")
    4547           12 :     }
    4548              : 
    4549              :     /// 5.6.17.3: "An optional parameter indicating a common observedAt
    4550              :     /// timestamp to use across merged Attributes." It is the timestamp of
    4551              :     /// every Attribute this merge touches; an Attribute the Fragment gives an
    4552              :     /// observedAt of its own keeps that one, and an Attribute the Fragment
    4553              :     /// does not mention is not a merged Attribute and is left alone.
    4554              :     #[tokio::test]
    4555            4 :     async fn merge_applies_the_common_observed_at() {
    4556            4 :         let app = app();
    4557            4 :         let id = "urn:ngsi-ld:Obs:1";
    4558            4 :         create(
    4559            4 :             &app,
    4560            4 :             json!({"id": id, "type": "T",
    4561            4 :                    "a": {"type": "Property", "value": 1},
    4562            4 :                    "b": {"type": "Property", "value": 1},
    4563            4 :                    "untouched": {"type": "Property", "value": 1,
    4564            4 :                                  "observedAt": "2020-01-01T00:00:00Z"}}),
    4565            4 :         )
    4566            4 :         .await;
    4567            4 :         let uri = format!("/ngsi-ld/v1/entities/{id}?observedAt=2026-08-17T10:00:00Z");
    4568            4 :         assert_eq!(
    4569            4 :             patch(
    4570            4 :                 &app,
    4571            4 :                 &uri,
    4572            4 :                 json!({"a": {"value": 2},
    4573            4 :                        "b": {"value": 2, "observedAt": "2021-01-01T00:00:00Z"}}),
    4574            4 :             )
    4575            4 :             .await,
    4576              :             StatusCode::NO_CONTENT
    4577              :         );
    4578            4 :         let body = get(&app, &format!("/ngsi-ld/v1/entities/{id}")).await;
    4579            4 :         assert_eq!(
    4580            4 :             body["a"]["observedAt"], "2026-08-17T10:00:00Z",
    4581              :             "the merged Attribute takes the common timestamp: {body}"
    4582              :         );
    4583            4 :         assert_eq!(
    4584            4 :             body["b"]["observedAt"], "2021-01-01T00:00:00Z",
    4585              :             "a Fragment instance carrying its own observedAt keeps it: {body}"
    4586              :         );
    4587            4 :         assert_eq!(
    4588            4 :             body["untouched"]["observedAt"], "2020-01-01T00:00:00Z",
    4589            4 :             "an Attribute this merge does not touch is not restamped: {body}"
    4590            4 :         );
    4591            4 :     }
    4592              : 
    4593              :     /// 4.8 makes observedAt a DateTime, so a value that is not one cannot be
    4594              :     /// stamped across the merged Attributes.
    4595              :     #[tokio::test]
    4596            4 :     async fn merge_rejects_an_observed_at_that_is_not_a_datetime() {
    4597            4 :         let app = app();
    4598            4 :         let id = "urn:ngsi-ld:Obs:2";
    4599            4 :         create(&app, json!({"id": id, "type": "T"})).await;
    4600            4 :         assert_eq!(
    4601            4 :             patch(
    4602            4 :                 &app,
    4603            4 :                 &format!("/ngsi-ld/v1/entities/{id}?observedAt=yesterday"),
    4604            4 :                 json!({"a": {"type": "Property", "value": 1}}),
    4605            4 :             )
    4606            4 :             .await,
    4607            4 :             StatusCode::BAD_REQUEST
    4608            4 :         );
    4609            4 :     }
    4610              : 
    4611              :     /// 5.6.17.4: "If a common language tag is defined and a LanguageProperty
    4612              :     /// Attribute to be merged is represented as a string, the pre-existing
    4613              :     /// languageMap JSON object shall be preserved. The string value shall
    4614              :     /// only replace the value associated to the language tag key found
    4615              :     /// within the languageMap."
    4616              :     #[tokio::test]
    4617            4 :     async fn merge_with_a_common_lang_replaces_only_that_language_key() {
    4618            4 :         let app = app();
    4619            4 :         let id = "urn:ngsi-ld:Lang:1";
    4620            4 :         create(
    4621            4 :             &app,
    4622            4 :             json!({"id": id, "type": "T",
    4623            4 :                    "greeting": {"type": "LanguageProperty",
    4624            4 :                                 "languageMap": {"en": "hello", "es": "adios"}}}),
    4625            4 :         )
    4626            4 :         .await;
    4627            4 :         assert_eq!(
    4628            4 :             patch(
    4629            4 :                 &app,
    4630            4 :                 &format!("/ngsi-ld/v1/entities/{id}?lang=es"),
    4631            4 :                 json!({"greeting": "hola"}),
    4632            4 :             )
    4633            4 :             .await,
    4634              :             StatusCode::NO_CONTENT
    4635              :         );
    4636            4 :         let body = get(&app, &format!("/ngsi-ld/v1/entities/{id}")).await;
    4637            4 :         assert_eq!(
    4638            4 :             body["greeting"]["languageMap"],
    4639            4 :             json!({"en": "hello", "es": "hola"}),
    4640              :             "the pre-existing languageMap survives, only the tagged key moves: {body}"
    4641              :         );
    4642            4 :         assert!(
    4643            4 :             body["greeting"].get("value").is_none(),
    4644              :             "the string never lands as a plain Property value: {body}"
    4645              :         );
    4646            4 :         assert_eq!(body["greeting"]["type"], "LanguageProperty", "{body}");
    4647            4 :     }
    4648              : 
    4649              :     /// Table 6.4.3.2-1 containedBy: "List of entity ids which have previously
    4650              :     /// been encountered whilst retrieving the Entity Graph" — 4.5.23.1 keeps
    4651              :     /// the walk from retrieving them a second time.
    4652              :     #[tokio::test]
    4653            4 :     async fn contained_by_ids_are_not_retrieved_again() {
    4654            4 :         let app = app();
    4655            4 :         let (root, held, fresh) = (
    4656            4 :             "urn:ngsi-ld:Graph:root",
    4657            4 :             "urn:ngsi-ld:Graph:held",
    4658            4 :             "urn:ngsi-ld:Graph:fresh",
    4659            4 :         );
    4660            4 :         create(&app, json!({"id": held, "type": "G"})).await;
    4661            4 :         create(&app, json!({"id": fresh, "type": "G"})).await;
    4662            4 :         create(
    4663            4 :             &app,
    4664            4 :             json!({"id": root, "type": "G",
    4665            4 :                    "toHeld": {"type": "Relationship", "object": held},
    4666            4 :                    "toFresh": {"type": "Relationship", "object": fresh}}),
    4667            4 :         )
    4668            4 :         .await;
    4669              : 
    4670            4 :         let body = get(
    4671            4 :             &app,
    4672            4 :             &format!("/ngsi-ld/v1/entities/{root}?join=flat&joinLevel=2&containedBy={held}"),
    4673            4 :         )
    4674            4 :         .await;
    4675            4 :         let arr = match body {
    4676            4 :             Value::Array(a) => a,
    4677            0 :             other => vec![other],
    4678              :         };
    4679            8 :         let ids: Vec<&str> = arr.iter().filter_map(|e| e["id"].as_str()).collect();
    4680            4 :         assert!(
    4681            4 :             !ids.contains(&held),
    4682              :             "an id the client already holds is not retrieved again: {ids:?}"
    4683              :         );
    4684            4 :         assert!(
    4685            4 :             ids.contains(&fresh),
    4686              :             "the Linked Entity it does not hold is still returned: {ids:?}"
    4687              :         );
    4688            4 :         assert!(ids.contains(&root), "the Linking Entity is there: {ids:?}");
    4689            4 :     }
    4690              : }
    4691              : 
    4692              : #[cfg(test)]
    4693              : mod clause_4_8_system_attributes {
    4694              :     use super::*;
    4695              :     use crate::stamp::stamp_instances;
    4696              :     use axum::body::Body;
    4697              :     use axum::http::Request;
    4698              :     use serde_json::json;
    4699              :     use tower::ServiceExt;
    4700              : 
    4701              :     /// 4.8 stamps a sub-Attribute because "a sub-Property is a Property".
    4702              :     /// The members an Attribute instance carries under 4.5 are NOT
    4703              :     /// sub-Attributes, so none of them may be descended into and stamped —
    4704              :     /// several of them (`previousJson` and `previousVocab` hold uninterpreted
    4705              :     /// JSON per 4.5.20/4.5.21, `entityList` holds Linked Entities) can be an
    4706              :     /// array of objects, which is exactly the shape the walk mistakes for an
    4707              :     /// instance array. The list the expander keeps verbatim is the one list;
    4708              :     /// a second copy is what drifts.
    4709              :     #[test]
    4710            4 :     fn no_reserved_instance_member_is_walked_as_a_sub_attribute() {
    4711            4 :         let carrier = json!([{"probe": "untouched"}]);
    4712          120 :         for member in antares_jsonld::RESERVED_MEMBERS {
    4713              :             // the two the stamp itself writes; every other member is the
    4714              :             // instance's own and is left exactly as it was found
    4715          120 :             if matches!(*member, "createdAt" | "modifiedAt") {
    4716            8 :                 continue;
    4717          112 :             }
    4718          112 :             let mut inst = json!({"type": "Property", "value": 1});
    4719          112 :             inst[*member] = carrier.clone();
    4720          112 :             let mut attr = json!([inst]);
    4721          112 :             stamp_instances(&mut attr, "2020-01-01T00:00:00Z");
    4722          112 :             let held = attr[0].get(*member).expect("the member survives");
    4723          112 :             assert_eq!(
    4724          112 :                 held, &carrier,
    4725              :                 "{member} was walked as a sub-Attribute and stamped"
    4726              :             );
    4727              :         }
    4728            4 :     }
    4729              : 
    4730              :     /// 4.8: createdAt is "the temporal Property at which the Entity,
    4731              :     /// Property or Relationship was entered into an NGSI-LD system" and
    4732              :     /// modifiedAt the one at which it "was last modified". Both are
    4733              :     /// generated by the system, at every level: an Entity, an Attribute
    4734              :     /// instance and a sub-Attribute (a sub-Property is a Property). A client
    4735              :     /// that could set them would rewrite the provenance of its own data, and
    4736              :     /// a subscriber filtering on modifiedAt would never see the write.
    4737              :     #[tokio::test]
    4738            4 :     async fn the_client_cannot_write_its_own_created_and_modified_stamps() {
    4739            4 :         let st = AppState::new("antares-sysattrs".into());
    4740            4 :         let forged = "1970-01-01T00:00:00Z";
    4741            4 :         let payload = json!({
    4742            4 :             "id": "urn:ngsi-ld:Vehicle:stamped",
    4743            4 :             "type": "Vehicle",
    4744            4 :             "createdAt": forged,
    4745            4 :             "modifiedAt": forged,
    4746            4 :             "speed": {
    4747            4 :                 "type": "Property",
    4748            4 :                 "value": 10,
    4749            4 :                 "createdAt": forged,
    4750            4 :                 "modifiedAt": forged,
    4751            4 :                 "accuracy": {"type": "Property", "value": 1,
    4752            4 :                              "createdAt": forged, "modifiedAt": forged},
    4753              :             },
    4754              :         })
    4755            4 :         .to_string();
    4756            4 :         let resp = crate::router(st.clone())
    4757            4 :             .oneshot(
    4758            4 :                 Request::post("/ngsi-ld/v1/entities")
    4759            4 :                     .header("Content-Type", "application/json")
    4760            4 :                     .header("Content-Length", payload.len().to_string())
    4761            4 :                     .body(Body::from(payload))
    4762            4 :                     .expect("req"),
    4763            4 :             )
    4764            4 :             .await
    4765            4 :             .expect("resp");
    4766            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    4767              : 
    4768            4 :         let resp = crate::router(st)
    4769            4 :             .oneshot(
    4770            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:stamped?options=sysAttrs")
    4771            4 :                     .body(Body::empty())
    4772            4 :                     .expect("req"),
    4773            4 :             )
    4774            4 :             .await
    4775            4 :             .expect("resp");
    4776            4 :         assert_eq!(resp.status(), StatusCode::OK);
    4777            4 :         let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
    4778            4 :             .await
    4779            4 :             .expect("body");
    4780            4 :         let served = String::from_utf8_lossy(&body);
    4781            4 :         assert!(
    4782            4 :             served.contains("createdAt") && served.contains("modifiedAt"),
    4783              :             "sysAttrs serves the stamps: {served}"
    4784              :         );
    4785            4 :         assert!(
    4786            4 :             !served.contains(forged),
    4787            4 :             "no level of the document may carry the stamp the client sent: {served}"
    4788            4 :         );
    4789            4 :     }
    4790              : 
    4791              :     /// 4.8 again, on every route that writes an Attribute. Create is the
    4792              :     /// obvious way in; a client that is refused there and accepted on Append,
    4793              :     /// Merge, Partial Update or Replace has the same forged provenance one
    4794              :     /// request later. Each of the four carries the stamps at Entity,
    4795              :     /// Attribute and sub-Attribute level.
    4796              :     #[tokio::test]
    4797            4 :     async fn no_write_route_lets_the_client_stamp_its_own_attributes() {
    4798            4 :         let st = AppState::new("antares-sysattrs-writes".into());
    4799            4 :         let forged = "1970-01-01T00:00:00Z";
    4800            4 :         let id = "urn:ngsi-ld:Vehicle:writes";
    4801           44 :         let stamped = |v: Value| {
    4802           44 :             let mut o = v;
    4803           44 :             if let Some(m) = o.as_object_mut() {
    4804           44 :                 m.insert("createdAt".into(), json!(forged));
    4805           44 :                 m.insert("modifiedAt".into(), json!(forged));
    4806           44 :             }
    4807           44 :             o
    4808           44 :         };
    4809           36 :         let send = |req: Request<Body>| {
    4810           36 :             let st = st.clone();
    4811           36 :             async move { crate::router(st).oneshot(req).await.expect("resp") }
    4812           36 :         };
    4813              : 
    4814            4 :         let seed = json!({"id": id, "type": "Vehicle"}).to_string();
    4815            4 :         let resp = send(
    4816            4 :             Request::post("/ngsi-ld/v1/entities")
    4817            4 :                 .header("Content-Type", "application/json")
    4818            4 :                 .header("Content-Length", seed.len().to_string())
    4819            4 :                 .body(Body::from(seed))
    4820            4 :                 .expect("req"),
    4821            4 :         )
    4822            4 :         .await;
    4823            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    4824              : 
    4825           16 :         let attr = || {
    4826           16 :             stamped(json!({
    4827           16 :                 "type": "Property",
    4828           16 :                 "value": 10,
    4829           16 :                 "accuracy": stamped(json!({"type": "Property", "value": 1})),
    4830           16 :             }))
    4831           16 :         };
    4832              :         // Append (5.6.3), Partial Update (5.6.4), Merge (5.6.17), Replace
    4833              :         // (5.6.16) — the whole write surface that reaches expand_entity.
    4834            4 :         let calls: Vec<(&str, String, Value)> = vec![
    4835            4 :             (
    4836            4 :                 "POST",
    4837            4 :                 format!("/ngsi-ld/v1/entities/{id}/attrs"),
    4838            4 :                 stamped(json!({"speed": attr()})),
    4839            4 :             ),
    4840            4 :             (
    4841            4 :                 "PATCH",
    4842            4 :                 format!("/ngsi-ld/v1/entities/{id}/attrs/speed"),
    4843            4 :                 attr(),
    4844            4 :             ),
    4845            4 :             (
    4846            4 :                 "PATCH",
    4847            4 :                 format!("/ngsi-ld/v1/entities/{id}"),
    4848            4 :                 stamped(json!({"speed": attr()})),
    4849            4 :             ),
    4850            4 :             (
    4851            4 :                 "PUT",
    4852            4 :                 format!("/ngsi-ld/v1/entities/{id}"),
    4853            4 :                 stamped(json!({"id": id, "type": "Vehicle", "speed": attr()})),
    4854            4 :             ),
    4855              :         ];
    4856           16 :         for (method, path, payload) in calls {
    4857           16 :             let body = payload.to_string();
    4858           16 :             let resp = send(
    4859           16 :                 Request::builder()
    4860           16 :                     .method(method)
    4861           16 :                     .uri(&path)
    4862           16 :                     .header("Content-Type", "application/json")
    4863           16 :                     .header("Content-Length", body.len().to_string())
    4864           16 :                     .body(Body::from(body))
    4865           16 :                     .expect("req"),
    4866           16 :             )
    4867           16 :             .await;
    4868           16 :             assert!(
    4869           16 :                 resp.status().is_success(),
    4870            4 :                 "{method} {path} answered {}",
    4871            4 :                 resp.status()
    4872            4 :             );
    4873           16 :             let resp = send(
    4874           16 :                 Request::get(format!("/ngsi-ld/v1/entities/{id}?options=sysAttrs"))
    4875           16 :                     .body(Body::empty())
    4876           16 :                     .expect("req"),
    4877           16 :             )
    4878           16 :             .await;
    4879           16 :             let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    4880           16 :                 .await
    4881           16 :                 .expect("body");
    4882           16 :             let served = String::from_utf8_lossy(&bytes);
    4883           16 :             assert!(
    4884           16 :                 !served.contains(forged),
    4885            4 :                 "{method} {path} let the client stamp the document: {served}"
    4886            4 :             );
    4887            4 :         }
    4888            4 :     }
    4889              : }
    4890              : 
    4891              : #[cfg(test)]
    4892              : mod judged_types_tests {
    4893              :     use super::judged_types;
    4894              :     use crate::policy::{Filter, Rule};
    4895              :     use std::borrow::Cow;
    4896              : 
    4897           16 :     fn rules(types: &[&[&str]]) -> Filter {
    4898              :         Filter {
    4899           16 :             rules: types
    4900           16 :                 .iter()
    4901           16 :                 .map(|t| Rule {
    4902           28 :                     types: t.iter().map(|s| (*s).to_owned()).collect(),
    4903           28 :                     ..Rule::default()
    4904           28 :                 })
    4905           16 :                 .collect(),
    4906           16 :             ..Filter::default()
    4907              :         }
    4908           16 :     }
    4909              : 
    4910           20 :     fn sel(groups: &[&[&str]]) -> Vec<Vec<String>> {
    4911           20 :         groups
    4912           20 :             .iter()
    4913           64 :             .map(|g| g.iter().map(|s| (*s).to_owned()).collect())
    4914           20 :             .collect()
    4915           20 :     }
    4916              : 
    4917              :     #[test]
    4918            4 :     fn a_selection_naming_a_rule_type_in_every_group_is_kept_as_is() {
    4919            4 :         let client = sel(&[&["a", "T"], &["U"]]);
    4920            4 :         let got = judged_types(Some(&client), &rules(&[&["T"], &["U"]]));
    4921            4 :         assert!(matches!(got, Some(Cow::Borrowed(g)) if g == client.as_slice()));
    4922            4 :     }
    4923              : 
    4924              :     #[test]
    4925            4 :     fn a_group_naming_no_rule_type_takes_each_in_turn() {
    4926            4 :         let client = sel(&[&["a", "b"], &["T"]]);
    4927            4 :         let got = judged_types(Some(&client), &rules(&[&["U", "T"], &["T"]]));
    4928            4 :         assert_eq!(
    4929            4 :             got.as_deref(),
    4930            4 :             Some(sel(&[&["a", "b", "T"], &["a", "b", "U"], &["T"]]).as_slice())
    4931              :         );
    4932            4 :     }
    4933              : 
    4934              :     #[test]
    4935            4 :     fn no_client_selection_selects_the_rule_types() {
    4936            4 :         let got = judged_types(None, &rules(&[&["T"], &["U"]]));
    4937            4 :         assert_eq!(got.as_deref(), Some(sel(&[&["T"], &["U"]]).as_slice()));
    4938            4 :     }
    4939              : 
    4940              :     #[test]
    4941            4 :     fn rules_without_types_judge_nothing() {
    4942            4 :         assert!(judged_types(None, &rules(&[&[]])).is_none());
    4943            4 :         assert!(judged_types(Some(&sel(&[&["a"]])), &Filter::default()).is_none());
    4944            4 :     }
    4945              : }
        

Generated by: LCOV version 2.0-1