LCOV - code coverage report
Current view: top level - antares-api/src/temporal - query.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 96.2 % 895 861
Test Date: 2026-11-02 13:21:27 Functions: 61.0 % 392 239

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Query temporal evolution (5.7.4) and the batch temporal query (6.24).
       3              : 
       4              : use super::*;
       5              : 
       6              : // ---------- GET /temporal/entities/ (5.7.4) ----------
       7              : 
       8         1034 : pub async fn query_temporal(
       9         1034 :     State(st): State<AppState>,
      10         1034 :     CleanParams(params): CleanParams,
      11         1034 :     headers: HeaderMap,
      12         1034 : ) -> Response {
      13         1034 :     match query_temporal_outer(&st, params, &headers).await {
      14          854 :         Ok(r) => r,
      15          180 :         Err(e) => e.into_response(),
      16              :     }
      17         1034 : }
      18              : 
      19              : /// 5.7.4.4 EntityMap usage on the temporal query: a live map referenced by
      20              : /// the NGSILD-EntityMap header fixes the result set to the map's Entities
      21              : /// (5.5.14) and its location is echoed; an unknown or expired reference
      22              : /// means "a new EntityMap shall be created" (the entityMap=true branch,
      23              : /// answering 201 + the fresh location).
      24         1034 : pub(super) async fn query_temporal_outer(
      25         1034 :     st: &AppState,
      26         1034 :     mut params: HashMap<String, String>,
      27         1034 :     headers: &HeaderMap,
      28         1034 : ) -> ApiResult<Response> {
      29         1034 :     let tenant = tenant_from(headers)?;
      30         1034 :     let named = crate::entities::ReadMembers::of(st, headers, &params).await;
      31         1034 :     let filter = gate!(
      32              :         st, &tenant, headers, "5.7.4",
      33              :         scope_q: params.get("scopeQ").map(String::as_str),
      34              :         q: named.q.as_ref(),
      35              :         types: &named.types,
      36              :         attrs: &named.attrs,
      37              :         geo: named.geo.as_ref(),
      38              :     )
      39         1034 :     .await?;
      40         1032 :     let Some(map_ref) = single_header(headers, "NGSILD-EntityMap")? else {
      41         1010 :         return query_temporal_inner(st, &params, headers, &filter).await;
      42              :     };
      43           20 :     let map_id = map_ref.rsplit('/').next().unwrap_or(&map_ref).to_owned();
      44           20 :     let Some(mut map) = crate::entity_map::map_if_accessible(st, &tenant, headers, &map_id).await
      45              :     else {
      46            6 :         params.insert("entityMap".into(), "true".into());
      47            6 :         return query_temporal_inner(st, &params, headers, &filter).await;
      48              :     };
      49           14 :     params.remove("entityMap");
      50              :     // 5.5.9.3: the map fixes the candidate set and the request's own filters
      51              :     // narrow it, so `id=` on this request selects from the map rather than
      52              :     // replacing it.
      53           14 :     let candidates = crate::entity_map::candidate_ids(&map, &params);
      54              :     // "filters shall be rechecked before returning results" and "Entities not
      55              :     // or no longer fitting the query shall be removed from the Entity map
      56              :     // during pagination" — so the recheck asks about the map's OWN Entities,
      57              :     // in bounded chunks. Asking the whole Tenant instead judged, and then
      58              :     // deleted, entries this request never asked about, and lost every
      59              :     // candidate past the first page of the recheck. Pruning is judgeable only
      60              :     // for "@none" (local) entries: a remote-backed id may merely have an
      61              :     // unreachable source right now (5.5.14). Known cost: this recheck is a
      62              :     // second temporal query per map-using request, same shape as the entity
      63              :     // query's filter re-run.
      64           14 :     let mut matching: std::collections::HashSet<String> = std::collections::HashSet::new();
      65           14 :     let id_headers = ids_shown(headers);
      66           14 :     for chunk in candidates.chunks(st.max_limit.max(1)) {
      67           14 :         let mut eff = params.clone();
      68           42 :         for k in ["limit", "offset", "count"] {
      69           42 :             eff.remove(k);
      70           42 :         }
      71           14 :         eff.insert("limit".into(), st.max_limit.to_string());
      72           14 :         eff.insert("id".into(), chunk.join(","));
      73           14 :         show_ids(&mut eff);
      74           14 :         let resp = query_temporal_inner(st, &eff, &id_headers, &filter).await?;
      75           14 :         let bytes = axum::body::to_bytes(resp.into_body(), st.max_answer_bytes)
      76           14 :             .await
      77           14 :             .map_err(|_| NgsiError::TooManyResults(crate::bounds::ANSWER_TOO_LARGE.into()))?;
      78           12 :         matching.extend(
      79           12 :             serde_json::from_slice::<Value>(&bytes)
      80           12 :                 .ok()
      81           12 :                 .and_then(|v| v.as_array().cloned())
      82           12 :                 .unwrap_or_default()
      83           12 :                 .iter()
      84           12 :                 .filter_map(|d| d.get("id").and_then(Value::as_str).map(str::to_owned)),
      85              :         );
      86              :     }
      87           12 :     let mut stale: Vec<String> = Vec::new();
      88           12 :     if let Some(emap) = map.get_mut("entityMap").and_then(Value::as_object_mut) {
      89           12 :         stale = candidates
      90           12 :             .iter()
      91           12 :             .filter(|eid| {
      92           12 :                 emap.get(eid.as_str())
      93           12 :                     .and_then(Value::as_array)
      94           12 :                     .is_some_and(|a| matches!(a.as_slice(), [only] if only == "@none"))
      95           12 :                     && !matching.contains(eid.as_str())
      96           12 :             })
      97           12 :             .cloned()
      98           12 :             .collect();
      99           12 :         for k in &stale {
     100            0 :             emap.remove(k);
     101            0 :         }
     102            0 :     }
     103           12 :     crate::entity_map::map_prune(st, &tenant, &map_id, &stale).await?;
     104              :     // fix the query to the candidates that survived the recheck (5.5.14)
     105           12 :     let ids: Vec<&str> = candidates
     106           12 :         .iter()
     107           12 :         .filter(|id| {
     108           12 :             map.get("entityMap")
     109           12 :                 .and_then(|m| m.get(id.as_str()))
     110           12 :                 .is_some()
     111           12 :         })
     112           12 :         .map(String::as_str)
     113           12 :         .collect();
     114           12 :     params.insert(
     115           12 :         "id".into(),
     116           12 :         if ids.is_empty() {
     117            0 :             "urn:ngsi-ld:entitymap:empty".to_owned()
     118              :         } else {
     119           12 :             ids.join(",")
     120              :         },
     121              :     );
     122           12 :     let mut resp = query_temporal_inner(st, &params, headers, &filter).await?;
     123           12 :     if let Ok(v) = format!("/ngsi-ld/v1/entityMaps/{map_id}").parse() {
     124           12 :         resp.headers_mut().insert("NGSILD-EntityMap", v);
     125           12 :     }
     126           12 :     Ok(resp)
     127         1034 : }
     128              : 
     129              : /// The EntityMap walks above read the matching Entities' ids off a rendered
     130              : /// answer, so it has to show them: a `pick` without `id` or an `omit=id`
     131              : /// rendered Entities with none, and the walk read the map as empty. `id`
     132              : /// is added to `pick` and taken out of `omit`; every other term stays,
     133              : /// since `pick`'s and `omit`'s Attributes decide which Entities the window
     134              : /// keeps.
     135           66 : fn show_ids(params: &mut HashMap<String, String>) {
     136           66 :     if let Some(pick) = params.get_mut("pick") {
     137            6 :         if !top_level_terms(pick).contains(&"id") {
     138            4 :             *pick = format!("id,{pick}");
     139            4 :         }
     140           60 :     }
     141           66 :     if let Some(omit) = params.get("omit") {
     142            4 :         let rest: Vec<&str> = top_level_terms(omit)
     143            4 :             .into_iter()
     144            6 :             .filter(|t| *t != "id")
     145            4 :             .collect();
     146            4 :         if rest.is_empty() {
     147            2 :             params.remove("omit");
     148            2 :         } else {
     149            2 :             let rest = rest.join(",");
     150            2 :             params.insert("omit".into(), rest);
     151            2 :         }
     152           62 :     }
     153           66 : }
     154              : 
     155              : /// The terms of a 4.21 projection outside any `{…}`, trimmed. 4.21 writes
     156              : /// the disjunction as `,` or `|` (`orOp`), and `parse_projection` splits on
     157              : /// both: split here on `,` alone, `pick=id|speed` was one term, gained a
     158              : /// second `id` and was refused as a duplicate member.
     159           10 : fn top_level_terms(projection: &str) -> Vec<&str> {
     160           10 :     let (mut terms, mut depth, mut start) = (Vec::new(), 0usize, 0usize);
     161           56 :     for (i, c) in projection.char_indices() {
     162            0 :         match c {
     163            0 :             '{' => depth = depth.saturating_add(1),
     164            0 :             '}' => depth = depth.saturating_sub(1),
     165            4 :             ',' | '|' if depth == 0 => {
     166            4 :                 terms.push(projection.get(start..i).unwrap_or_default().trim());
     167            4 :                 start = i.saturating_add(1);
     168            4 :             }
     169           52 :             _ => {}
     170              :         }
     171              :     }
     172           10 :     terms.push(projection.get(start..).unwrap_or_default().trim());
     173           10 :     terms
     174           10 : }
     175              : 
     176              : /// The request's headers with the answer asked for as plain JSON, whose
     177              : /// Entities carry their `id` at the top: a GeoJSON FeatureCollection is no
     178              : /// array, and the walk read it as no Entity at all.
     179           66 : fn ids_shown(headers: &HeaderMap) -> HeaderMap {
     180           66 :     let mut h = headers.clone();
     181           66 :     h.insert(
     182           66 :         axum::http::header::ACCEPT,
     183           66 :         axum::http::HeaderValue::from_static("application/json"),
     184              :     );
     185           66 :     h
     186           66 : }
     187              : 
     188              : /// The 5.7.4.4 preconditions of a temporal query, before any store is
     189              : /// touched: the filter must qualify, Linked Entity conditions are not
     190              : /// defined for it, a context source filter must parse, and ordering may
     191              : /// name only `id` and only where the execution stays local (4.23.1).
     192         1222 : pub(super) async fn check_temporal_query(
     193         1222 :     st: &AppState,
     194         1222 :     params: &HashMap<String, String>,
     195         1222 :     headers: &HeaderMap,
     196         1222 :     tenant: &TenantId,
     197         1222 :     ctx: &Context,
     198         1222 :     q_ast: Option<&antares_ql::QNode>,
     199         1222 : ) -> ApiResult<()> {
     200              :     // Table 6.4.3.2-1, as on the entity query (5.7.2.4)
     201         1222 :     crate::entities::star_type_local(params)?;
     202         1220 :     if !crate::entities::qualifies_non_wide(params, q_ast) {
     203           16 :         return Err(NgsiError::BadRequestData(
     204           16 :             "temporal query needs at least one of type, attrs, q, georel (5.7.4)".into(),
     205           16 :         )
     206           16 :         .into());
     207         1204 :     }
     208              :     // 5.7.4.4: Linked Entity retrieval is not defined for temporal queries —
     209              :     // linked filter conditions are an unconditional BadRequestData
     210         1204 :     if q_ast.map(antares_ql::QNode::max_link_depth).unwrap_or(0) > 0 {
     211            8 :         return Err(NgsiError::BadRequestData(
     212            8 :             "temporal q must not reference Linked Entity attributes (5.7.4.4)".into(),
     213            8 :         )
     214            8 :         .into());
     215         1196 :     }
     216              :     // 5.7.4.4: a syntactically invalid context source filter is 400; the
     217              :     // filter itself gates registrations in federation::reg_matches.
     218         1196 :     if let Some(csf) = params.get("csf") {
     219            8 :         parse_q(csf)?;
     220         1188 :     }
     221         1188 :     crate::paging::check_collation(params)?;
     222              :     // 5.7.4.4: temporal ordering may only refer to the "id" entity member,
     223              :     // and only where the execution "is limited to the local scope (see
     224              :     // clause 5.5.13)" — 4.23.1 gives the reason: "Sort ordering is never
     225              :     // applied to distributed operations." The subject is the EXECUTION, so a
     226              :     // query nothing would federate to orders without `local=true`.
     227         1186 :     if let Some(spec) = params.get("orderBy") {
     228           40 :         if crate::federation::would_federate(st, tenant, ctx, params, headers).await? {
     229            2 :             return Err(NgsiError::BadRequestData(
     230            2 :                 "orderBy requires local scope — ordering is never applied to \
     231            2 :                  distributed operations (5.7.4.4, 4.23.1)"
     232            2 :                     .into(),
     233            2 :             )
     234            2 :             .into());
     235           38 :         }
     236           42 :         let non_id = spec.split(',').any(|part| {
     237           42 :             let m = part.trim().split(';').next().unwrap_or("").trim();
     238           42 :             m.split('[').next().unwrap_or(m) != "id"
     239           42 :         });
     240           38 :         if non_id {
     241           24 :             return Err(NgsiError::BadRequestData(
     242           24 :                 "temporal orderBy may only name \"id\" (5.7.4.4)".into(),
     243           24 :             )
     244           24 :             .into());
     245           14 :         }
     246         1146 :     }
     247         1160 :     Ok(())
     248         1222 : }
     249              : 
     250              : /// 4.5.19: the aggregated answer of a temporal query. The store computed
     251              : /// the bucket matrix, so the page is presented with the core members the
     252              : /// instance path renders and the aggregated attribute objects copied
     253              : /// under their compacted names.
     254              : #[allow(clippy::too_many_arguments)]
     255           20 : pub(super) fn respond_aggregated(
     256           20 :     st: &AppState,
     257           20 :     params: &HashMap<String, String>,
     258           20 :     tenant: &TenantId,
     259           20 :     ctx: &Context,
     260           20 :     accept: Accept,
     261           20 :     trepr: &TRepr,
     262           20 :     tq: Option<&TemporalQ>,
     263           20 :     attrs_filter: Option<&Vec<String>>,
     264           20 :     outcome: antares_store::filter::TemporalOutcome,
     265           20 : ) -> ApiResult<Response> {
     266           20 :     let total = outcome
     267           20 :         .total
     268           20 :         .map(|t| t as usize)
     269           20 :         .unwrap_or(outcome.rows.len());
     270           20 :     let (page, count_hdr, links) = crate::paging::paginate_pre(
     271           20 :         st,
     272           20 :         params,
     273           20 :         outcome.rows,
     274           20 :         "/ngsi-ld/v1/temporal/entities",
     275           20 :         total,
     276            0 :     )?;
     277           20 :     let timeprop = tq.map_or("observedAt", |t| t.timeproperty.as_str());
     278           20 :     let none = Windowed {
     279           20 :         attrs: Default::default(),
     280           20 :         max_per_attr: 0,
     281           20 :         ts_min: None,
     282           20 :         ts_max: None,
     283           20 :         truncated: false,
     284           20 :         cut_served: None,
     285           20 :         more: false,
     286           20 :         held: Default::default(),
     287           20 :     };
     288           20 :     let mut payload: Vec<Value> = Vec::new();
     289           22 :     for d in &page {
     290              :         // core members exactly as the instance path presents them; the
     291              :         // aggregated attribute objects are copied under compacted names
     292           22 :         let mut presented = present_temporal(d, &none, ctx, trepr, tq, timeprop)?;
     293           22 :         let Some(out) = presented.as_object_mut() else {
     294            0 :             continue;
     295              :         };
     296           22 :         let mut any = false;
     297          132 :         for (k, v) in d.as_object().into_iter().flatten() {
     298          132 :             if is_meta(k) || attrs_filter.is_some_and(|a| !a.contains(k)) {
     299           92 :                 continue;
     300           40 :             }
     301           40 :             out.insert(ctx.compact_iri(k), v.clone());
     302           40 :             any = true;
     303              :         }
     304           22 :         if any {
     305           22 :             payload.push(presented);
     306           22 :         }
     307              :     }
     308           20 :     let mut resp = crate::negotiate::respond_list(StatusCode::OK, payload, ctx, accept, tenant);
     309           20 :     attach_paging(&mut resp, count_hdr, &links);
     310           20 :     Ok(resp)
     311           20 : }
     312              : 
     313              : /// The two Table 5.2.33-1 selectors that need no compiled pattern: the
     314              : /// type set, and the `attrs` list, which excludes an entity carrying
     315              : /// none of the named Attributes. Each drops an entity the store could
     316              : /// not narrow away itself.
     317         2322 : pub(super) fn type_and_attrs_match(
     318         2322 :     doc: &Value,
     319         2322 :     type_sel: Option<&Vec<Vec<String>>>,
     320         2322 :     entity_attr_filter: Option<&Vec<String>>,
     321         2322 : ) -> bool {
     322         2322 :     if let Some(sel) = type_sel {
     323              :         // 4.17: one alternative whose every type the Entity has
     324         2226 :         let etypes = doc["type"].as_array().cloned().unwrap_or_default();
     325         2314 :         if !sel.iter().any(|and_group| {
     326         2314 :             and_group
     327         2314 :                 .iter()
     328         2336 :                 .all(|w| etypes.iter().any(|t| t.as_str() == Some(w.as_str())))
     329         2314 :         }) {
     330           74 :             return false;
     331         2152 :         }
     332           96 :     }
     333         2248 :     if let Some(want) = entity_attr_filter {
     334           66 :         if !want.iter().any(|a| doc.get(a).is_some()) {
     335            2 :             return false;
     336           64 :         }
     337         2182 :     }
     338         2246 :     true
     339         2322 : }
     340              : 
     341              : /// the narrowing rules, for every narrowing: a history is served while the
     342              : /// narrowing's own predicates (its `q`, `scopeQ` and areas) hold for the
     343              : /// Entity as it is now, so an Entity that left the narrowing takes its
     344              : /// history with it. Its rules' conditions are not read here: rule 11 reads
     345              : /// them per instance. An Entity held only temporally (11.2.2) has no state
     346              : /// now, and its history `held` is the only state it has: the predicates
     347              : /// hold when some instance satisfies them, as the query's window check
     348              : /// reads them; `held` absent leaves that to the caller's own check.
     349          934 : pub(super) async fn member_now(
     350          934 :     st: &AppState,
     351          934 :     tenant: &TenantId,
     352          934 :     ctx: &Context,
     353          934 :     filter: &crate::policy::Filter,
     354          934 :     id: &str,
     355          934 :     held: Option<&Value>,
     356          934 : ) -> ApiResult<bool> {
     357          934 :     let Some(reach) = crate::repr::narrowing(filter).policy_reach else {
     358          886 :         return Ok(true);
     359              :     };
     360           48 :     if reach.q.is_none() && reach.scope_q.is_none() && reach.areas.is_empty() {
     361           28 :         return Ok(true);
     362           20 :     }
     363           20 :     let reach = crate::repr::PolicyReach {
     364           20 :         rules: None,
     365           20 :         ..(*reach).clone()
     366           20 :     };
     367           20 :     match st
     368           20 :         .store
     369           20 :         .get(tenant, antares_store::Kind::Entity, id)
     370           20 :         .await?
     371              :     {
     372            8 :         Some(doc) => Ok(crate::repr::policy_admits(st, tenant, ctx, Some(&reach), &doc).await),
     373           12 :         None => Ok(held.is_none_or(|h| {
     374            4 :             let areas: Vec<&antares_ql::geo::GeoQuery> = reach.areas.iter().collect();
     375            4 :             window_conditions_match(h, None, reach.q.as_ref(), &areas, ctx)
     376            2 :                 && reach
     377            2 :                     .scope_q
     378            2 :                     .as_deref()
     379            2 :                     .is_none_or(|sq| scope_window_retain(&mut h.clone(), sq, None, "observedAt"))
     380            4 :         })),
     381              :     }
     382          934 : }
     383              : 
     384         1094 : pub(crate) async fn query_temporal_inner(
     385         1094 :     st: &AppState,
     386         1094 :     params: &HashMap<String, String>,
     387         1094 :     headers: &HeaderMap,
     388         1094 :     filter: &crate::policy::Filter,
     389         1094 : ) -> ApiResult<Response> {
     390         1094 :     query_temporal_collected(st, params, headers, filter, None, None).await
     391         1094 : }
     392              : 
     393              : /// The same query, with the merged documents of each page handed to `merged`
     394              : /// as well as rendered into the response. 5.16.1.4 fills a Snapshot from this
     395              : /// query and has to STORE what it matched: for an Evolution held by a
     396              : /// registered Context Source there is nothing else in this broker to store —
     397              : /// the fan-out's answer is the only copy, and the response body is a
     398              : /// presentation of it that compaction does not undo. `ctx`, when given,
     399              : /// stands for the Link header's: a Snapshot's own @context, which may be
     400              : /// inline and so has no Link form.
     401         1248 : pub(crate) async fn query_temporal_collected(
     402         1248 :     st: &AppState,
     403         1248 :     params: &HashMap<String, String>,
     404         1248 :     headers: &HeaderMap,
     405         1248 :     filter: &crate::policy::Filter,
     406         1248 :     merged: Option<&mut Vec<Value>>,
     407         1248 :     ctx: Option<std::sync::Arc<Context>>,
     408         1248 : ) -> ApiResult<Response> {
     409         1248 :     let tenant = tenant_from(headers)?;
     410         1248 :     check_params(params, QUERY_PARAMS)?;
     411         1234 :     let accept = parse_accept(headers)?;
     412         1226 :     let ctx = match ctx {
     413          154 :         Some(ctx) => ctx,
     414         1072 :         None => request_context(&st.loader, headers).await?,
     415              :     };
     416              :     // 5.7.4.4 a-e: id/idPattern alone are NOT sufficient, and the attrs
     417              :     // list / q must include at least one non-system Attribute to qualify.
     418         1226 :     let q_ast = params.get("q").map(|q| parse_q(q)).transpose()?;
     419         1222 :     let q_ast = q_ast.map(|ast| expand_q_values(ast, params, &ctx));
     420         1222 :     check_temporal_query(st, params, headers, &tenant, &ctx, q_ast.as_ref()).await?;
     421              :     // ADR-0020: the engine's narrowing joins the request's own filters after
     422              :     // 5.7.4.4's "too wide" judgement, which is about what the client asked
     423              :     // for. Everything below reads the narrowed query, the forward included.
     424              :     // the rules, and the client's own q apart from the
     425              :     // engine's, read on each Entity as its rules show it
     426         1160 :     let types_rule = crate::repr::type_rules(filter);
     427         1160 :     let client_q = if types_rule.is_some() {
     428           54 :         crate::repr::client_q(params, &ctx)?
     429              :     } else {
     430         1106 :         None
     431              :     };
     432         1160 :     let narrowed = filter.narrow_window(&filter.narrow_params(params, &ctx)?, true)?;
     433         1160 :     let req = QueryReq {
     434         1160 :         st,
     435         1160 :         tenant: &tenant,
     436         1160 :         headers,
     437         1160 :         ctx: &ctx,
     438         1160 :         params: &narrowed,
     439         1160 :         filter,
     440         1160 :     };
     441         1160 :     let qry = Parsed::new(&req, types_rule, client_q)?;
     442         1024 :     let (geos, shown_geos) = qry.geos(filter);
     443         1024 :     let plan = Pushdown::plan(&req, &qry, geos.is_empty()).await?;
     444         1024 :     let outcome = fetch_temporal(&req, &qry, &geos, &plan).await?;
     445         1020 :     if outcome.aggregated {
     446           20 :         return respond_aggregated(
     447           20 :             st,
     448           20 :             req.params,
     449           20 :             &tenant,
     450           20 :             &ctx,
     451           20 :             accept,
     452           20 :             &qry.trepr,
     453           20 :             qry.tq.as_ref(),
     454           20 :             qry.attrs_filter.as_ref(),
     455           20 :             outcome,
     456              :         );
     457         1000 :     }
     458         1000 :     let (all, pre_paged, pre_total) = (outcome.rows, outcome.paged, outcome.total);
     459         1000 :     let mut warnings: Vec<String> = Vec::new();
     460         1000 :     let all = federate_temporal(&req, &qry.timeprop, all, &mut warnings).await?;
     461         1000 :     let mut matches = Vec::new();
     462         2300 :     for doc in all {
     463         2300 :         let Some(doc) = qualifies(doc, &qry, &geos, &shown_geos, &ctx) else {
     464         1144 :             continue;
     465              :         };
     466         1156 :         if plan.narrowed_now {
     467           12 :             let id = doc
     468           12 :                 .get("id")
     469           12 :                 .and_then(Value::as_str)
     470           12 :                 .unwrap_or_default()
     471           12 :                 .to_owned();
     472           12 :             if !member_now(st, &tenant, &ctx, filter, &id, None).await? {
     473            2 :                 continue;
     474           10 :             }
     475         1144 :         }
     476         1154 :         matches.push(doc);
     477              :     }
     478         1000 :     if let Some(spec) = req.params.get("orderBy") {
     479           14 :         crate::paging::order_entities(&mut matches, spec, req.params, &ctx)?;
     480          986 :     }
     481         1000 :     let (page, count_hdr, links) = if pre_paged {
     482          214 :         let total = pre_total.map(|t| t as usize).unwrap_or(matches.len());
     483          214 :         crate::paging::paginate_pre(
     484          214 :             st,
     485          214 :             req.params,
     486          214 :             matches,
     487          214 :             "/ngsi-ld/v1/temporal/entities",
     488          214 :             total,
     489            0 :         )?
     490              :     } else {
     491          786 :         crate::paging::paginate(st, req.params, matches, "/ngsi-ld/v1/temporal/entities")?
     492              :     };
     493         1000 :     if let Some(sink) = merged {
     494           22 :         sink.extend(page.iter().cloned());
     495          978 :     }
     496         1000 :     let (payload, g) = assemble_page(&req, &qry, &page)?;
     497         1000 :     let mut resp = respond_temporal_page(&req, accept, &qry, payload, &g, &warnings);
     498         1000 :     attach_paging(&mut resp, count_hdr, &links);
     499         1000 :     attach_paging(&mut resp, None, &g.links(&req, accept, &qry));
     500              :     // 6.18.3.2: entityMap=true — the temporal EntityMap for this query is
     501              :     // (re)created; the response carries NGSILD-EntityMap and 201 Created.
     502         1000 :     if req.params.get("entityMap").map(String::as_str) == Some("true") {
     503           24 :         let map = build_temporal_map(st, &tenant, headers, &ctx, req.params, filter).await?;
     504           22 :         *resp.status_mut() = StatusCode::CREATED;
     505           22 :         if let Some(id) = map.get("id").and_then(Value::as_str) {
     506           22 :             if let Ok(v) = format!("/ngsi-ld/v1/entityMaps/{id}").parse() {
     507           22 :                 resp.headers_mut().insert("NGSILD-EntityMap", v);
     508           22 :             }
     509            0 :         }
     510          976 :     }
     511          998 :     filter.mark(&st.host_alias, resp.headers_mut());
     512          998 :     Ok(resp)
     513         1248 : }
     514              : 
     515              : /// The parameters 5.7.4 accepts (6.18.3.2 Table 6.18.3.2-1).
     516              : const QUERY_PARAMS: &[&str] = &[
     517              :     "id",
     518              :     "idPattern",
     519              :     "type",
     520              :     "attrs",
     521              :     "q",
     522              :     "georel",
     523              :     "geometry",
     524              :     "coordinates",
     525              :     "geoproperty",
     526              :     "scopeQ",
     527              :     "csf",
     528              :     "timerel",
     529              :     "timeAt",
     530              :     "endTimeAt",
     531              :     "timeproperty",
     532              :     "aggrMethods",
     533              :     "aggrPeriodDuration",
     534              :     "lastN",
     535              :     "firstN",
     536              :     "offsetN",
     537              :     "limit",
     538              :     "offset",
     539              :     "count",
     540              :     "options",
     541              :     "format",
     542              :     "lang",
     543              :     "local",
     544              :     "entityMap",
     545              :     "pick",
     546              :     "omit",
     547              :     "datasetId",
     548              :     "orderBy",
     549              :     "orderFrom",
     550              :     "orderGeometry",
     551              :     "collation",
     552              :     "entityMapLifetime",
     553              :     "splitEntities",
     554              :     "expandValues",
     555              :     "jsonKeys",
     556              : ];
     557              : 
     558              : /// 5.7.4.3 expandValues: the same 4.9 EXAMPLE 12 coercion as the entity
     559              : /// query — term values expanded against the @context before executing,
     560              : /// less the Attributes jsonKeys declares uninterpretable.
     561          510 : fn expand_q_values(
     562          510 :     ast: antares_ql::QNode,
     563          510 :     params: &HashMap<String, String>,
     564          510 :     ctx: &Context,
     565          510 : ) -> antares_ql::QNode {
     566          510 :     antares_ql::eval::apply_expand_values(
     567          510 :         ast,
     568          510 :         antares_ql::eval::expansion_list(
     569          510 :             params.get("expandValues").map(String::as_str),
     570          510 :             params.get("jsonKeys").map(String::as_str),
     571          510 :         )
     572          510 :         .as_deref(),
     573          510 :         ctx,
     574              :     )
     575          510 : }
     576              : 
     577              : /// One 5.7.4 request as every stage after the narrowing reads it; `params`
     578              : /// is the narrowed query.
     579              : struct QueryReq<'a> {
     580              :     st: &'a AppState,
     581              :     tenant: &'a TenantId,
     582              :     headers: &'a HeaderMap,
     583              :     ctx: &'a Context,
     584              :     params: &'a HashMap<String, String>,
     585              :     filter: &'a crate::policy::Filter,
     586              : }
     587              : 
     588              : /// The narrowed 5.7.4 query, parsed and validated up front.
     589              : struct Parsed<'p> {
     590              :     /// the type rules each Entity is shown by
     591              :     types_rule: Option<std::sync::Arc<crate::policy::Filter>>,
     592              :     /// the client's own q under rules, judged on the shown history
     593              :     client_q: Option<antares_ql::QNode>,
     594              :     /// the q the store and the stored history read
     595              :     q_ast: Option<antares_ql::QNode>,
     596              :     scope_q: Option<&'p str>,
     597              :     tq: Option<TemporalQ>,
     598              :     trepr: TRepr,
     599              :     ids: Option<Vec<&'p str>>,
     600              :     id_pattern: Option<IdMatcher>,
     601              :     /// the 4.17 type selection: alternatives of types an Entity must all
     602              :     /// have; `None` for no selector or `*`
     603              :     type_sel: Option<Vec<Vec<String>>>,
     604              :     /// every type the selection names, the store's (OR) prefilter
     605              :     types: Option<Vec<String>>,
     606              :     /// what the window keeps per Entity
     607              :     attrs_filter: Option<Vec<String>>,
     608              :     /// only the attrs= param excludes entities; pick is projection-only
     609              :     entity_attr_filter: Option<Vec<String>>,
     610              :     /// under rules: the attrs= the shown history must hold
     611              :     shown_attrs: Option<Vec<String>>,
     612              :     own_geo: Option<antares_ql::geo::GeoQuery>,
     613              :     timeprop: String,
     614              : }
     615              : 
     616              : impl<'p> Parsed<'p> {
     617         1160 :     fn new(
     618         1160 :         req: &QueryReq<'p>,
     619         1160 :         types_rule: Option<std::sync::Arc<crate::policy::Filter>>,
     620         1160 :         client_q: Option<antares_ql::QNode>,
     621         1160 :     ) -> ApiResult<Self> {
     622         1160 :         let (params, ctx) = (req.params, req.ctx);
     623         1160 :         let ruled = types_rule.is_some();
     624         1160 :         let q_ast = params
     625         1160 :             .get("q")
     626         1160 :             .map(|q| antares_ql::parse_narrowed_q(q))
     627         1160 :             .transpose()?
     628         1160 :             .map(|ast| expand_q_values(ast, params, ctx));
     629              :         // under rules only the engine's own q reaches the store and the stored
     630              :         // history; the client's waits for the shown one
     631         1160 :         let q_ast = if ruled {
     632           54 :             req.filter.q.as_deref().cloned()
     633              :         } else {
     634         1106 :             q_ast
     635              :         };
     636         1160 :         let tq = TemporalQ::from_params(params, true)?;
     637         1120 :         let mut trepr = parse_trepr(params, ctx)?;
     638         1044 :         crate::repr::narrow_projection(&mut trepr.pick, &mut trepr.omit, req.filter, ctx)?;
     639              :         // 5.7.4.4: {…} projection is Linked Entity retrieval — unconditional 400
     640         1044 :         reject_linked_projection(&trepr, "5.7.4.4")?;
     641         1032 :         let ids: Option<Vec<&str>> = params.get("id").map(|s| s.split(',').collect());
     642              :         // 5.7.4.4: an invalid URI in the id list is BadRequestData
     643         1032 :         for id in ids.iter().flatten() {
     644          100 :             antares_model::EntityId::new(id)?;
     645              :         }
     646         1024 :         let id_pattern = id_matcher(params.get("idPattern"))?;
     647         1024 :         let type_sel = crate::entities::type_selection(params, ctx);
     648         1024 :         let types: Option<Vec<String>> = type_sel.as_ref().map(|sel| {
     649          958 :             let mut all: Vec<String> = sel.iter().flatten().cloned().collect();
     650          958 :             all.sort();
     651          958 :             all.dedup();
     652          958 :             all
     653          958 :         });
     654         1024 :         let attrs_filter = selection(&trepr);
     655         1024 :         let (entity_attr_filter, shown_attrs) = if ruled {
     656           54 :             (None, trepr.attrs.clone())
     657              :         } else {
     658          970 :             (trepr.attrs.clone(), None)
     659              :         };
     660         1024 :         let own_geo = antares_ql::geo::GeoQuery::from_params(params)?;
     661         1024 :         let timeprop = tq
     662         1024 :             .as_ref()
     663         1024 :             .map_or("observedAt", |t| t.timeproperty.as_str())
     664         1024 :             .to_owned();
     665         1024 :         Ok(Self {
     666         1024 :             types_rule,
     667         1024 :             client_q,
     668         1024 :             q_ast,
     669         1024 :             scope_q: params.get("scopeQ").map(String::as_str),
     670         1024 :             tq,
     671         1024 :             trepr,
     672         1024 :             ids,
     673         1024 :             id_pattern,
     674         1024 :             type_sel,
     675         1024 :             types,
     676         1024 :             attrs_filter,
     677         1024 :             entity_attr_filter,
     678         1024 :             shown_attrs,
     679         1024 :             own_geo,
     680         1024 :             timeprop,
     681         1024 :         })
     682         1160 :     }
     683              : 
     684              :     /// The request's own geoquery and the narrowing's area, all of which
     685              :     /// must hold; under rules the request's reads the
     686              :     /// shown history.
     687         1024 :     fn geos<'g>(&'g self, filter: &'g crate::policy::Filter) -> (Geos<'g>, Geos<'g>) {
     688         1024 :         if self.types_rule.is_some() {
     689           54 :             (filter.areas().collect(), self.own_geo.iter().collect())
     690              :         } else {
     691          970 :             (
     692          970 :                 self.own_geo.iter().chain(filter.areas()).collect(),
     693          970 :                 Vec::new(),
     694          970 :             )
     695              :         }
     696         1024 :     }
     697              : 
     698         1270 :     fn instance_range(&self) -> Option<antares_store::filter::InstanceRange<'_>> {
     699         1270 :         self.tq
     700         1270 :             .as_ref()
     701         1270 :             .map(|t| antares_store::filter::InstanceRange {
     702         1270 :                 timerel: &t.timerel,
     703         1270 :                 time_at: &t.time_at,
     704         1270 :                 end_time_at: t.end_time_at.as_deref(),
     705         1270 :                 timeproperty: &t.timeproperty,
     706         1270 :             })
     707         1270 :     }
     708              : }
     709              : 
     710              : /// The geoqueries an Evolution's window is judged by.
     711              : type Geos<'g> = Vec<&'g antares_ql::geo::GeoQuery>;
     712              : 
     713              : /// The compiled idPattern, as the one test the query asks of an id.
     714              : type IdMatcher = Box<dyn Fn(&str) -> bool + Send + Sync>;
     715              : 
     716              : /// 5.7.4.4: an idPattern that does not compile is BadRequestData.
     717         1024 : fn id_matcher(pattern: Option<&String>) -> Result<Option<IdMatcher>, NgsiError> {
     718         1024 :     let Some(p) = pattern else {
     719         1020 :         return Ok(None);
     720              :     };
     721            4 :     let re = antares_ql::regex::compile(p)
     722            4 :         .map_err(|_| NgsiError::BadRequestData(format!("invalid idPattern {p:?}")))?;
     723            8 :     Ok(Some(Box::new(move |id: &str| re.is_match(id))))
     724         1024 : }
     725              : 
     726              : /// What the store decides itself for one query.
     727              : struct Pushdown {
     728              :     /// q, geo and scopeQ absent: the store's instance cap is exact
     729              :     exact: bool,
     730              :     /// the store pages the Entities
     731              :     page: bool,
     732              :     /// the store answers the 4.5.19 aggregation
     733              :     agg: bool,
     734              :     /// a narrowing reads the state now: each Entity is checked after the store
     735              :     narrowed_now: bool,
     736              :     offset: usize,
     737              :     limit: usize,
     738              :     /// with `page` under rules: the Attributes an Entity qualifies through
     739              :     page_attrs: Option<Vec<String>>,
     740              : }
     741              : 
     742              : impl Pushdown {
     743              :     // Push entity narrowing (ids/types/attrs) and instance-window
     744              :     // pruning (range + RANK()-capped lastN) into the store. The loop
     745              :     // and window() stay the arbiters — pruning is byte-exact against
     746              :     // instance_matches (compile::temporal), so it cannot change an answer.
     747              :     // 5.7.4.4 S2/S3: q and geo are judged on the instances WITHIN the
     748              :     // temporal interval (the eval_doc retain in `qualifies`), so RANGE pruning is
     749              :     // verdict-safe to push even with q=/geo present. Only the lastN cap
     750              :     // (its ordering vs the values filter is unspecified) and entity paging
     751              :     // (q/geo still drop entities after SQL) wait for exactness.
     752              :     // scopeQ joins q/geo here: the 4.18 validity filter drops entities and
     753              :     // instances AFTER SQL, so a pushed page/lastN cap would under-return.
     754         1024 :     async fn plan(req: &QueryReq<'_>, qry: &Parsed<'_>, no_geo: bool) -> ApiResult<Self> {
     755         1024 :         let (st, params, filter) = (req.st, req.params, req.filter);
     756         1024 :         let exact = qry.q_ast.is_none() && no_geo && qry.scope_q.is_none();
     757              :         // Entity-page pushdown: a temporal query used to materialize the
     758              :         // tenant's ENTIRE history. Pushed only when every filter the store
     759              :         // cannot see is absent — same gate family as the entity-query pushdown.
     760              :         // A values filter no longer blocks paging when its prefilter compiles
     761              :         // EXACTLY (every leaf a Cmp with the byte-exact text window): the SQL
     762              :         // entity verdict then equals the evaluator's. datasetId/pick still
     763              :         // block: their entity drops happen at presentation, after the page.
     764         1024 :         let q_page_exact = qry.q_ast.as_ref().is_none_or(|ast| {
     765          246 :             let r = qry.instance_range();
     766          246 :             st.temporal
     767          246 :                 .q_pushdown_exact(ast, r.as_ref(), &|t| req.ctx.expand_key(t))
     768          246 :         });
     769         1024 :         let (offset, limit, _) = crate::paging::page_params(st, params)?;
     770              :         // rule 8 drops Entities after the store answered, so the store may not
     771              :         // page under a narrowing that reads the state now (one read
     772              :         // per Entity; push it into the store query when it shows up)
     773         1024 :         let narrowed_now =
     774         1024 :             filter.q.is_some() || filter.scope_q.is_some() || filter.areas().next().is_some();
     775              :         // 5.7.4.4 + 5.5.9: pagination applies to the MERGED federated union, so
     776              :         // the store may only pre-page when nothing will federate — otherwise
     777              :         // page 1 is local-page + every remote row (matrix-9 IOP_EXT_TMP_03_04).
     778              :         // under rules only where their verdict on an Entity is the store's,
     779              :         // and the request's own q and geoquery, read on the shown history,
     780              :         // are absent
     781         1024 :         let ruled = match qry.types_rule.as_deref() {
     782          970 :             None => Some(None),
     783           54 :             Some(_) if qry.client_q.is_some() || qry.own_geo.is_some() => None,
     784           48 :             Some(rules) => page_attrs(
     785           48 :                 rules,
     786           48 :                 qry.types.as_deref(),
     787           48 :                 qry.tq.as_ref(),
     788           48 :                 qry.shown_attrs.as_deref(),
     789              :             ),
     790              :         };
     791         1024 :         let page = (exact || (no_geo && qry.scope_q.is_none() && q_page_exact))
     792          712 :             && ruled.is_some()
     793          680 :             && qry.id_pattern.is_none()
     794              :             // a 4.17 AND group is judged after the store's OR prefilter
     795          676 :             && qry
     796          676 :                 .type_sel
     797          676 :                 .iter()
     798          676 :                 .flatten()
     799          676 :                 .all(|and_group| and_group.len() == 1)
     800          670 :             && params.get("orderBy").is_none()
     801          656 :             && params.get("datasetId").is_none()
     802          632 :             && params.get("pick").is_none()
     803          606 :             && limit > 0
     804          606 :             && !narrowed_now
     805          606 :             && !crate::federation::would_federate(st, req.tenant, req.ctx, params, req.headers)
     806          606 :                 .await?;
     807              :         // the store aggregates every Attribute it reads: never one the rules hide
     808         1024 :         let agg = exact && page && qry.types_rule.is_none() && store_aggregates(req, &qry.trepr);
     809         1024 :         Ok(Self {
     810         1024 :             exact,
     811         1024 :             page,
     812         1024 :             agg,
     813         1024 :             narrowed_now,
     814         1024 :             offset,
     815         1024 :             limit,
     816         1024 :             page_attrs: ruled.flatten(),
     817         1024 :         })
     818         1024 :     }
     819              : }
     820              : 
     821              : /// 4.5.19 computed by the store: the numeric bucket matrix per attribute
     822              : /// comes back aggregated when nothing after the store call could change
     823              : /// the answer — every filter exact in SQL, the page pushed, no
     824              : /// projection/lastN/month periods — otherwise the instances are
     825              : /// aggregated here as before. A store that cannot (memory) or a
     826              : /// non-numeric value class leaves `outcome.aggregated` false.
     827          512 : fn store_aggregates(req: &QueryReq<'_>, trepr: &TRepr) -> bool {
     828          512 :     trepr.aggregated
     829           54 :         && !crate::history::records_on_change(req.st, req.tenant)
     830           54 :         && trepr.omit.is_none()
     831           54 :         && trepr.page == InstancePage::default()
     832           54 :         && !matches!(trepr.aggr_period, AggrPeriod::Months(..))
     833           54 :         && req.params.get("entityMap").map(String::as_str) != Some("true")
     834           54 :         && trepr
     835           54 :             .aggr_methods
     836           54 :             .iter()
     837           88 :             .all(|m| antares_store::filter::AGGREGATE_METHODS.contains(&m.as_str()))
     838          512 : }
     839              : 
     840              : /// The store call of one query, with what `plan` pushed down.
     841         1024 : async fn fetch_temporal(
     842         1024 :     req: &QueryReq<'_>,
     843         1024 :     qry: &Parsed<'_>,
     844         1024 :     geos: &[&antares_ql::geo::GeoQuery],
     845         1024 :     plan: &Pushdown,
     846         1024 : ) -> ApiResult<antares_store::filter::TemporalOutcome> {
     847         1024 :     let (ctx, trepr, page_n) = (req.ctx, &qry.trepr, qry.trepr.page);
     848              :     // scoped: the &dyn expander must not live across an await (handler
     849              :     // futures are Send; the store call itself is synchronous)
     850         1024 :     let expand = |t: &str| ctx.expand_key(t);
     851         1024 :     let geo_pre: Vec<_> = geos.iter().map(|g| g.to_instance_spec(ctx)).collect();
     852         1024 :     let tf = antares_store::filter::TemporalFilter {
     853         1024 :         ids: qry.ids.as_deref(),
     854         1024 :         types: qry.types.as_deref(),
     855         1024 :         attrs: qry.entity_attr_filter.as_deref(),
     856              :         // a condition rebuilds each instance's state from the whole
     857              :         // history; rules without one read only the window
     858         1024 :         range: qry
     859         1024 :             .instance_range()
     860         1024 :             .filter(|_| !replays_state(qry.types_rule.as_deref())),
     861         1024 :         last_n: page_n
     862         1024 :             .store_cap(default_page(req.st, req.tenant, trepr))
     863         1024 :             .filter(|_| {
     864          970 :                 plan.exact
     865          602 :                     && cap_keeps_shown(
     866          602 :                         qry.types_rule.as_deref(),
     867          602 :                         &page_n,
     868          602 :                         &qry.timeprop,
     869          602 :                         range_start(qry.tq.as_ref()),
     870              :                     )
     871          970 :             }),
     872         1024 :         oldest_first: !page_n.desc,
     873         1024 :         timeproperty: &qry.timeprop,
     874         1024 :         page: plan.page.then_some(antares_store::filter::Page {
     875         1024 :             offset: plan.offset as i64,
     876         1024 :             limit: plan.limit as i64,
     877         1024 :             count: true,
     878         1024 :         }),
     879         1024 :         page_attrs: plan.page_attrs.as_deref().filter(|_| plan.page),
     880         1024 :         q: qry.q_ast.as_ref(),
     881         1024 :         expand: &expand,
     882         1024 :         geo: &geo_pre,
     883         1024 :         aggregate: plan.agg.then_some(antares_store::filter::Aggregate {
     884         1024 :             methods: &trepr.aggr_methods,
     885         1024 :             period_secs: match trepr.aggr_period {
     886           20 :                 AggrPeriod::Seconds(sc) => Some(sc),
     887         1004 :                 _ => None,
     888              :             },
     889         1024 :             anchor: qry.tq.as_ref().map(|t| t.time_at.as_str()),
     890              :         }),
     891              :     };
     892         1024 :     Ok(req.st.temporal.query_temporal(req.tenant, &tf).await?)
     893         1024 : }
     894              : 
     895              : /// 5.7.4.4: fan the query out to matching queryTemporal registrations
     896              : /// and merge the remote Temporal Evolutions with the local set (4.5.5;
     897              : /// auxiliary data never introduces new entities).
     898         1000 : async fn federate_temporal(
     899         1000 :     req: &QueryReq<'_>,
     900         1000 :     timeprop: &str,
     901         1000 :     all: Vec<Value>,
     902         1000 :     warnings: &mut Vec<String>,
     903         1000 : ) -> ApiResult<Vec<Value>> {
     904         1000 :     let looped = crate::federation::via_loop(
     905         1000 :         req.headers,
     906         1000 :         &crate::federation::alias_for(&req.st.host_alias, req.tenant),
     907              :     );
     908         1000 :     if !crate::federation::active(req.params) || looped {
     909           44 :         return Ok(all);
     910          956 :     }
     911          956 :     let mut proxies = Vec::new();
     912          956 :     let fed = crate::federation::fed_query_temporal(
     913          956 :         req.st,
     914          956 :         req.tenant,
     915          956 :         req.headers,
     916          956 :         req.ctx,
     917          956 :         req.params,
     918          956 :         warnings,
     919          956 :         &mut proxies,
     920          956 :     )
     921          956 :     .await?;
     922          956 :     let mut all = all;
     923         2272 :     for doc in &mut all {
     924         2272 :         crate::federation::drop_proxied_local(doc, &proxies, req.ctx);
     925         2272 :     }
     926          956 :     Ok(merge_federated(all, &fed, timeprop))
     927         1000 : }
     928              : 
     929              : /// The local Evolutions in their order, each merged with its remote
     930              : /// copies, then the Entities only an exclusive registration holds.
     931          956 : fn merge_federated(all: Vec<Value>, fed: &[(bool, Value)], timeprop: &str) -> Vec<Value> {
     932          956 :     let mut order: Vec<String> = Vec::new();
     933          956 :     let mut by_id: std::collections::HashMap<String, Value> = Default::default();
     934         2272 :     for doc in all {
     935         2272 :         if let Some(id) = doc.get("id").and_then(Value::as_str) {
     936         2272 :             order.push(id.to_owned());
     937         2272 :             by_id.insert(id.to_owned(), doc);
     938         2272 :         }
     939              :     }
     940         1912 :     for aux_pass in [false, true] {
     941         1912 :         for (aux, d) in fed {
     942           32 :             if *aux != aux_pass {
     943           16 :                 continue;
     944           16 :             }
     945           16 :             let Some(id) = d.get("id").and_then(Value::as_str) else {
     946            0 :                 continue;
     947              :             };
     948           16 :             match by_id.get_mut(id) {
     949            2 :                 Some(base) => merge_temporal_docs(base, d, *aux, timeprop),
     950           14 :                 None if !aux => {
     951           14 :                     order.push(id.to_owned());
     952           14 :                     by_id.insert(id.to_owned(), d.clone());
     953           14 :                 }
     954            0 :                 None => {}
     955              :             }
     956              :         }
     957              :     }
     958          956 :     order
     959          956 :         .into_iter()
     960         2286 :         .filter_map(|id| by_id.remove(&id))
     961          956 :         .collect()
     962          956 : }
     963              : 
     964              : /// One candidate Evolution judged by the query: `None` drops it, `Some`
     965              : /// is the Evolution as the query keeps it (q instances retained, scopeQ
     966              : /// windows cut, the rules' shown history).
     967         2300 : fn qualifies(
     968         2300 :     mut doc: Value,
     969         2300 :     qry: &Parsed<'_>,
     970         2300 :     geos: &[&antares_ql::geo::GeoQuery],
     971         2300 :     shown_geos: &[&antares_ql::geo::GeoQuery],
     972         2300 :     ctx: &Context,
     973         2300 : ) -> Option<Value> {
     974         2300 :     let tq = qry.tq.as_ref();
     975         2300 :     let id = doc.get("id").and_then(Value::as_str).unwrap_or("");
     976         2300 :     match (&qry.ids, &qry.id_pattern) {
     977          128 :         (Some(ids), _) if !ids.contains(&id) => return None,
     978              :         // 5.2.33: "id takes precedence over idPattern"
     979            8 :         (None, Some(matches)) if !matches(id) => return None,
     980         2264 :         _ => {}
     981              :     }
     982         2264 :     if !type_and_attrs_match(&doc, qry.type_sel.as_ref(), qry.entity_attr_filter.as_ref()) {
     983           74 :         return None;
     984         2190 :     }
     985         2190 :     if !conditions_hold(&mut doc, tq, qry.q_ast.as_ref(), geos, ctx) {
     986          734 :         return None;
     987         1456 :     }
     988         1456 :     if let Some(sq) = qry.scope_q {
     989          368 :         if !scope_window_retain(&mut doc, sq, tq, &qry.timeprop) {
     990          272 :             return None;
     991           96 :         }
     992         1088 :     }
     993         1184 :     if let Some(rules) = qry.types_rule.as_deref() {
     994           60 :         doc = rules_per_instance(&doc, rules)?;
     995           58 :         if !type_and_attrs_match(&doc, None, qry.shown_attrs.as_ref()) {
     996            2 :             return None;
     997           56 :         }
     998           56 :         if !conditions_hold(&mut doc, tq, qry.client_q.as_ref(), shown_geos, ctx) {
     999            4 :             return None;
    1000           52 :         }
    1001         1124 :     }
    1002              :     // entity qualifies only if some instance falls in the window
    1003         1176 :     any_in_window(&doc, tq).then_some(doc)
    1004         2300 : }
    1005              : 
    1006              : /// 5.7.4.4 S2/S3: q and the geoqueries hold within the window, and the
    1007              : /// instances q does not hold for leave the Evolution.
    1008         2246 : fn conditions_hold(
    1009         2246 :     doc: &mut Value,
    1010         2246 :     tq: Option<&TemporalQ>,
    1011         2246 :     q: Option<&antares_ql::QNode>,
    1012         2246 :     geos: &[&antares_ql::geo::GeoQuery],
    1013         2246 :     ctx: &Context,
    1014         2246 : ) -> bool {
    1015         2246 :     if (q.is_some() || !geos.is_empty()) && !window_conditions_match(doc, tq, q, geos, ctx) {
    1016          738 :         return false;
    1017         1508 :     }
    1018         1508 :     if let Some(ast) = q {
    1019          238 :         retain_q_instances(doc, tq, ast, ctx);
    1020         1270 :     }
    1021         1508 :     true
    1022         2246 : }
    1023              : 
    1024         1176 : fn any_in_window(doc: &Value, tq: Option<&TemporalQ>) -> bool {
    1025         1176 :     doc.as_object().is_some_and(|o| {
    1026         2420 :         o.iter().any(|(k, v)| {
    1027         2420 :             !is_meta(k)
    1028         1198 :                 && v.as_array().is_some_and(|arr| {
    1029         1198 :                     arr.iter()
    1030         1544 :                         .any(|inst| tq.is_none_or(|tq| tq.instance_matches(inst)))
    1031         1198 :                 })
    1032         2420 :         })
    1033         1176 :     })
    1034         1176 : }
    1035              : 
    1036              : /// What the page's windows add up to: the 6.3.10 Content-Range and the
    1037              : /// 6.4.7.3 next-page links read it.
    1038              : #[derive(Default)]
    1039              : struct PageCut {
    1040              :     truncated: bool,
    1041              :     ts_min: Option<String>,
    1042              :     ts_max: Option<String>,
    1043              :     more: bool,
    1044              :     /// the next page's move: the fewest instances any Entity's cut Attribute
    1045              :     /// served, and the boundary every Entity's cut reached (`interval_links`)
    1046              :     cut: Option<usize>,
    1047              :     bound: Option<String>,
    1048              : }
    1049              : 
    1050              : impl PageCut {
    1051         1128 :     fn add(&mut self, w: &Windowed, desc: bool) {
    1052         1128 :         self.truncated |= w.truncated;
    1053         1128 :         self.more |= w.more;
    1054         1128 :         if let Some(c) = w.cut_served {
    1055          112 :             self.cut = Some(self.cut.map_or(c, |g| g.min(c)));
    1056         1016 :         }
    1057         1128 :         if let Some(b) = w.truncated.then(|| cut_boundary(w, desc)).flatten() {
    1058              :             // the earliest boundary forwards, the latest backwards: no
    1059              :             // Entity's instance past its own boundary is left behind
    1060          112 :             if self
    1061          112 :                 .bound
    1062          112 :                 .as_deref()
    1063          112 :                 .is_none_or(|g| (dt_key(b) < dt_key(g)) != desc)
    1064          104 :             {
    1065          104 :                 self.bound = Some(b.to_owned());
    1066          104 :             }
    1067         1016 :         }
    1068         1128 :         if let Some(m) = &w.ts_min {
    1069         1112 :             if self.ts_min.as_deref().is_none_or(|c| dt_key(m) < dt_key(c)) {
    1070          807 :                 self.ts_min = Some(m.clone());
    1071          807 :             }
    1072           16 :         }
    1073         1128 :         if let Some(m) = &w.ts_max {
    1074         1112 :             if self.ts_max.as_deref().is_none_or(|c| dt_key(m) > dt_key(c)) {
    1075          930 :                 self.ts_max = Some(m.clone());
    1076          930 :             }
    1077           16 :         }
    1078         1128 :     }
    1079              : 
    1080              :     /// aggregated responses are complete by construction — never 206 (6.3.10)
    1081         1000 :     fn content_range(&self, qry: &Parsed<'_>) -> Option<String> {
    1082         1000 :         if qry.trepr.aggregated {
    1083           34 :             return None;
    1084          966 :         }
    1085          966 :         content_range(
    1086          966 :             self.truncated,
    1087          966 :             self.ts_min.as_deref(),
    1088          966 :             self.ts_max.as_deref(),
    1089          966 :             qry.tq.as_ref(),
    1090          966 :             qry.trepr.page,
    1091              :         )
    1092         1000 :     }
    1093              : 
    1094         1000 :     fn links(&self, req: &QueryReq<'_>, accept: Accept, qry: &Parsed<'_>) -> Vec<String> {
    1095         1000 :         let page_n = qry.trepr.page;
    1096         1000 :         interval_links(
    1097         1000 :             req.params,
    1098         1000 :             "/ngsi-ld/v1/temporal/entities",
    1099         1000 :             accept,
    1100         1000 :             page_n,
    1101         1000 :             self.more,
    1102         1000 :             page_n.step(
    1103         1000 :                 qry.trepr.aggregated,
    1104         1000 :                 req.st.temporal_default_instances,
    1105         1000 :                 req.st.temporal_instance_limit,
    1106              :             ),
    1107         1000 :             self.cut.zip(self.bound.as_deref()),
    1108              :         )
    1109         1000 :     }
    1110              : }
    1111              : 
    1112              : /// Each Entity of the page windowed, cut and presented. Under the narrowing rules:
    1113              : /// each Entity's history was narrowed by its rules in `qualifies`, before
    1114              : /// the window, so lastN counts only what may be seen (rule 8).
    1115         1000 : fn assemble_page(
    1116         1000 :     req: &QueryReq<'_>,
    1117         1000 :     qry: &Parsed<'_>,
    1118         1000 :     page: &[Value],
    1119         1000 : ) -> ApiResult<(Vec<Value>, PageCut)> {
    1120         1000 :     let (st, trepr, page_n) = (req.st, &qry.trepr, qry.trepr.page);
    1121         1000 :     let core_only_pick = qry.attrs_filter.as_ref().is_some_and(Vec::is_empty);
    1122         1000 :     let mut payload: Vec<Value> = Vec::new();
    1123         1000 :     let mut g = PageCut::default();
    1124         1128 :     for d in page {
    1125         1128 :         let mut w = window(
    1126         1128 :             d,
    1127         1128 :             qry.tq.as_ref(),
    1128         1128 :             page_n,
    1129         1128 :             qry.attrs_filter.as_ref(),
    1130         1128 :             trepr.omit.as_ref(),
    1131         1128 :             trepr.dataset_id.as_ref(),
    1132         1128 :             &qry.timeprop,
    1133         1128 :             crate::history::held_attrs(st, req.tenant, d),
    1134              :         );
    1135         1128 :         if !trepr.aggregated {
    1136         1114 :             truncate(
    1137         1114 :                 &mut w,
    1138         1114 :                 &qry.timeprop,
    1139         1114 :                 page_n.desc,
    1140         1114 :                 page_n.size(st.temporal_default_instances, st.temporal_instance_limit),
    1141         1114 :             );
    1142         1114 :         }
    1143         1128 :         g.add(&w, page_n.desc);
    1144              :         // no instance survived the window/dataset filters ⇒ the entity is
    1145              :         // not part of the temporal result (unless the projection is
    1146              :         // deliberately core-only, e.g. pick=id)
    1147         1128 :         if w.attrs.is_empty() && !core_only_pick {
    1148            4 :             continue;
    1149         1124 :         }
    1150         1124 :         let presented = present_temporal(d, &w, req.ctx, trepr, qry.tq.as_ref(), &qry.timeprop)?;
    1151         1124 :         if trepr.pick.is_some() && presented.as_object().is_some_and(|o| o.is_empty()) {
    1152            0 :             continue;
    1153         1124 :         }
    1154         1124 :         payload.push(presented);
    1155              :     }
    1156         1000 :     Ok((payload, g))
    1157         1000 : }
    1158              : 
    1159              : /// The page's body, 200 or 206 with its Content-Range, and the
    1160              : /// federation's warnings.
    1161         1000 : fn respond_temporal_page(
    1162         1000 :     req: &QueryReq<'_>,
    1163         1000 :     accept: Accept,
    1164         1000 :     qry: &Parsed<'_>,
    1165         1000 :     payload: Vec<Value>,
    1166         1000 :     g: &PageCut,
    1167         1000 :     warnings: &[String],
    1168         1000 : ) -> Response {
    1169         1000 :     let cr = g.content_range(qry);
    1170         1000 :     let status = if cr.is_some() {
    1171           82 :         StatusCode::PARTIAL_CONTENT
    1172              :     } else {
    1173          918 :         StatusCode::OK
    1174              :     };
    1175         1000 :     let mut resp = crate::negotiate::respond_list(status, payload, req.ctx, accept, req.tenant);
    1176         1000 :     crate::paging::attach_warnings(&mut resp, warnings);
    1177         1000 :     if let Some(cr) = cr {
    1178           82 :         if let Ok(v) = cr.parse() {
    1179           82 :             resp.headers_mut().insert("Content-Range", v);
    1180           82 :         }
    1181          918 :     }
    1182         1000 :     resp
    1183         1000 : }
    1184              : 
    1185              : // ---------- POST /temporal/entityOperations/query (6.24) ----------
    1186              : 
    1187          204 : pub async fn batch_temporal_query(
    1188          204 :     State(st): State<AppState>,
    1189          204 :     CleanParams(params): CleanParams,
    1190          204 :     headers: HeaderMap,
    1191          204 :     body: Bytes,
    1192          204 : ) -> Response {
    1193          204 :     let go = async {
    1194              :         // 6.3.14 and 6.3.4: a Tenant outside the grammar and an Accept the
    1195              :         // operation cannot serve are both refused here. Neither VALUE is
    1196              :         // needed — the inner query reads the headers again — but the request
    1197              :         // must not reach it having skipped either check.
    1198          204 :         let tenant = tenant_from(&headers)?;
    1199          204 :         check_params(
    1200          204 :             &params,
    1201          204 :             &["limit", "offset", "count", "options", "format", "local"],
    1202            0 :         )?;
    1203          204 :         parse_accept(&headers)?;
    1204          204 :         let parsed = parse_body(&st, &headers, &body, BodyKind::Standard).await?;
    1205          204 :         let q = parsed.object(NgsiError::BadRequestData(
    1206          204 :             "query body must be an object".into(),
    1207          204 :         ))?;
    1208          204 :         if q.get("type").and_then(Value::as_str) != Some("Query") {
    1209            0 :             return Err(NgsiError::BadRequestData("body type must be Query".into()).into());
    1210          204 :         }
    1211              :         // 5.2.23 Query (temporal reading): members flattened with their
    1212              :         // Table 5.2.23-1 value spaces enforced, incl. temporalQ (5.2.21)
    1213              :         // and aggrParams (5.2.44).
    1214          204 :         let mut vp: HashMap<String, String> = params.clone();
    1215          204 :         crate::paging::query_doc_params(q, true, &mut vp)?;
    1216              :         // ADR-0020: the engine is shown every member of the Query body
    1217          134 :         let named = crate::entities::ReadMembers::with_ctx(&st, &vp, &parsed.ctx);
    1218          134 :         let filter = gate!(
    1219              :             st, &tenant, &headers, "5.7.4",
    1220              :             q: named.q.as_ref(),
    1221              :             scope_q: vp.get("scopeQ").map(String::as_str),
    1222              :             types: &named.types,
    1223              :             attrs: &named.attrs,
    1224              :             geo: named.geo.as_ref(),
    1225              :         )
    1226          134 :         .await?;
    1227              :         // 5.5.7: the terms of an application/ld+json body resolve against
    1228              :         // the body's own @context, not the Link header's
    1229          132 :         query_temporal_collected(&st, &vp, &headers, &filter, None, Some(parsed.ctx.clone())).await
    1230          204 :     };
    1231          204 :     go.await.unwrap_or_else(|e| e.into_response())
    1232          204 : }
    1233              : 
    1234              : /// 5.14.5.4: temporal query required; the S1–S4 candidate selection is the
    1235              : /// temporal query pipeline itself (5.7.4.4) run unpaged — the ids of its
    1236              : /// result set form the EntityMap; the createEntityMapQueryTemporal
    1237              : /// registrations are then merged like 5.14.4.
    1238              : /// Known ceiling: candidate ids are read from the internal 5.7.4 response capped
    1239              : /// at max_limit; raise the cap if temporal sets outgrow it.
    1240           64 : pub(crate) async fn build_temporal_map(
    1241           64 :     st: &AppState,
    1242           64 :     tenant: &TenantId,
    1243           64 :     headers: &HeaderMap,
    1244           64 :     ctx: &antares_jsonld::Context,
    1245           64 :     params: &HashMap<String, String>,
    1246           64 :     filter: &crate::policy::Filter,
    1247           64 : ) -> ApiResult<Value> {
    1248           64 :     if !params.contains_key("timerel") {
    1249           12 :         return Err(NgsiError::BadRequestData(
    1250           12 :             "a temporal query is required to create a temporal EntityMap (5.14.5.4)".into(),
    1251           12 :         )
    1252           12 :         .into());
    1253           52 :     }
    1254           52 :     let local_scope = params.get("local").map(String::as_str) == Some("true");
    1255           52 :     let split = params.get("splitEntities").map(String::as_str) == Some("true");
    1256           52 :     let mut eff: HashMap<String, String> = if split && !local_scope {
    1257            0 :         params
    1258            0 :             .iter()
    1259            0 :             .filter(|(k, _)| {
    1260            0 :                 [
    1261            0 :                     "id",
    1262            0 :                     "idPattern",
    1263            0 :                     "type",
    1264            0 :                     "local",
    1265            0 :                     "timerel",
    1266            0 :                     "timeAt",
    1267            0 :                     "endTimeAt",
    1268            0 :                     "timeproperty",
    1269            0 :                 ]
    1270            0 :                 .contains(&k.as_str())
    1271            0 :             })
    1272            0 :             .map(|(k, v)| (k.clone(), v.clone()))
    1273            0 :             .collect()
    1274              :     } else {
    1275           52 :         params.clone()
    1276              :     };
    1277          260 :     for k in [
    1278           52 :         "entityMap",
    1279           52 :         "entityMapLifetime",
    1280           52 :         "splitEntities",
    1281           52 :         "offset",
    1282           52 :         "count",
    1283          260 :     ] {
    1284          260 :         eff.remove(k);
    1285          260 :     }
    1286           52 :     eff.insert("limit".into(), st.max_limit.to_string());
    1287           52 :     show_ids(&mut eff);
    1288              :     // Box::pin: build_temporal_map is reachable from query_temporal_inner
    1289              :     // (entityMap=true), so this recursive edge needs indirection.
    1290           52 :     let resp = Box::pin(query_temporal_inner(st, &eff, &ids_shown(headers), filter)).await?;
    1291           52 :     let bytes = axum::body::to_bytes(resp.into_body(), st.max_answer_bytes)
    1292           52 :         .await
    1293           52 :         .map_err(|_| NgsiError::TooManyResults(crate::bounds::ANSWER_TOO_LARGE.into()))?;
    1294              :     // 5.5.14: the map FIXES the Entities considered by every later request, so
    1295              :     // an unreadable candidate set must fail rather than become "no candidates".
    1296           50 :     let candidates: Value = serde_json::from_slice(&bytes)
    1297           50 :         .map_err(|_| NgsiError::InternalError("temporal candidate parse".into()))?;
    1298           50 :     let mut emap = Map::new();
    1299           50 :     if let Some(arr) = candidates.as_array() {
    1300           50 :         for d in arr {
    1301           36 :             if let Some(id) = d.get("id").and_then(Value::as_str) {
    1302           36 :                 emap.insert(id.to_owned(), json!(["@none"]));
    1303           36 :             }
    1304              :         }
    1305            0 :     }
    1306           50 :     crate::entity_map::merge_and_store_map(st, tenant, headers, ctx, params, true, emap).await
    1307           64 : }
        

Generated by: LCOV version 2.0-1