LCOV - code coverage report
Current view: top level - antares-api/src - types_attrs.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.4 % 684 680
Test Date: 2026-09-21 10:31:06 Functions: 52.6 % 175 92

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Discovery: /types and /attributes (5.7.5–5.7.10; resources 6.25–6.28).
       3              : 
       4              : use crate::negotiate::*;
       5              : use crate::state::AppState;
       6              : use antares_model::NgsiError;
       7              : use antares_store::filter::{EntityFilter, Page};
       8              : use axum::extract::{Path, State};
       9              : use axum::http::{HeaderMap, StatusCode};
      10              : use axum::response::{IntoResponse, Response};
      11              : use serde_json::{json, Value};
      12              : use std::collections::{BTreeMap, BTreeSet};
      13              : 
      14              : use crate::negotiate::CleanParams;
      15              : 
      16              : /// Which of the tenant's entities a fold needs. 5.7.5/5.7.8 describe the
      17              : /// types and attributes "for which entity instances exist within the NGSI-LD
      18              : /// system", so those two fold the whole tenant; 5.7.7 asks about ONE entity
      19              : /// type and 5.7.10 about ONE attribute, and an entity carrying neither can
      20              : /// contribute nothing to either answer.
      21              : #[derive(Clone, Copy)]
      22              : enum Narrow<'a> {
      23              :     Tenant,
      24              :     Type(&'a str),
      25              :     Attr(&'a str),
      26              : }
      27              : 
      28              : impl Narrow<'_> {
      29              :     /// The same predicate the datastore compiles, re-applied when the backend
      30              :     /// reports it did not (`decided == false`): that arm returns the tenant's
      31              :     /// rows unfiltered, and cutting THOSE at the ceiling would answer 5.7.7
      32              :     /// with ResourceNotFound for a type whose entities merely sort past it.
      33          500 :     fn matches(self, doc: &Value) -> bool {
      34          500 :         match self {
      35          296 :             Narrow::Tenant => true,
      36              :             // `types @> ARRAY[t]`
      37          102 :             Narrow::Type(t) => doc["type"]
      38          102 :                 .as_array()
      39          102 :                 .is_some_and(|a| a.iter().any(|v| v.as_str() == Some(t))),
      40              :             // `entity ?| ARRAY[a]`
      41          102 :             Narrow::Attr(a) => doc.get(a).is_some(),
      42              :         }
      43          500 :     }
      44              : }
      45              : 
      46              : /// At most `max` of the entities the fold needs, plus whether more exist. The
      47              : /// narrowing and the page are pushed into the datastore, so on a backend that
      48              : /// takes them the ceiling bounds the rows MATERIALIZED and it bounds them over
      49              : /// the narrowed set — a 5.7.7 request about one type reads that type, not the
      50              : /// tenant. A backend that ignores them (`decided == false`) hands back its own
      51              : /// row set, and the predicate above plus the truncation here reproduce the
      52              : /// same answer. Asking for one row past `max` is what makes the overflow
      53              : /// visible; `total`, when the backend reports it, is the exact pre-page count.
      54          334 : async fn scan(
      55          334 :     st: &AppState,
      56          334 :     tenant: &antares_model::TenantId,
      57          334 :     max: usize,
      58          334 :     only: Narrow<'_>,
      59          334 : ) -> Result<(Vec<Value>, bool), NgsiError> {
      60          334 :     let groups: Vec<Vec<String>> = match only {
      61           66 :         Narrow::Type(t) => vec![vec![t.to_owned()]],
      62          268 :         _ => Vec::new(),
      63              :     };
      64          334 :     let attrs: Vec<String> = match only {
      65           68 :         Narrow::Attr(a) => vec![a.to_owned()],
      66          266 :         _ => Vec::new(),
      67              :     };
      68          334 :     let f = EntityFilter {
      69          334 :         types: (!groups.is_empty()).then_some(&groups[..]),
      70          334 :         attrs: (!attrs.is_empty()).then_some(&attrs[..]),
      71          334 :         page: Some(Page {
      72          334 :             offset: 0,
      73          334 :             limit: max.saturating_add(1).min(i64::MAX as usize) as i64,
      74          334 :             count: false,
      75          334 :         }),
      76          334 :         ..Default::default()
      77          334 :     };
      78          334 :     let out = st.store.query_entities(tenant, &f).await?;
      79          334 :     let mut rows = out.rows;
      80          334 :     if !out.decided {
      81          464 :         rows.retain(|d| only.matches(d));
      82            6 :     }
      83          334 :     let more = out.total.map_or(rows.len() > max, |t| {
      84            6 :         t > i64::try_from(max).unwrap_or(i64::MAX)
      85            6 :     });
      86          334 :     rows.truncate(max);
      87          334 :     Ok((rows, more))
      88          334 : }
      89              : 
      90              : /// A fold that hit the scan ceiling answered from a prefix of the tenant's
      91              : /// entities: the list is a subset and a by-name lookup can miss. IETF RFC
      92              : /// 7234 5.5.1 warn-code 199 (Miscellaneous Warning) carries that fact to the
      93              : /// client in the `NGSILD-Warning` header of 6.3.17.
      94          182 : fn mark_partial(resp: &mut Response, partial: bool, alias: &str) {
      95          182 :     if partial {
      96            4 :         crate::paging::attach_warnings(
      97            4 :             resp,
      98            4 :             &[crate::federation::warning(
      99            4 :                 199,
     100            4 :                 alias,
     101            4 :                 "entity scan ceiling reached; the discovery result is incomplete",
     102            4 :             )],
     103            4 :         );
     104          178 :     }
     105          182 : }
     106              : 
     107              : use antares_model::is_meta;
     108              : 
     109              : /// type IRI → (entity count, attr IRI → attribute types seen)
     110              : type TypeStats = BTreeMap<String, (usize, BTreeMap<String, BTreeSet<String>>)>;
     111              : 
     112              : /// A datastore failure is an InternalError (Table 6.3.2-1), never an empty
     113              : /// fold — reporting "no such type" because the query failed would be a lie.
     114              : /// The second return member is true when the fold stopped at `max`, i.e. the
     115              : /// answer is a subset of the tenant's types.
     116          168 : async fn type_stats(
     117          168 :     st: &AppState,
     118          168 :     tenant: &antares_model::TenantId,
     119          168 :     max: usize,
     120          168 :     only: Narrow<'_>,
     121          168 : ) -> Result<(TypeStats, bool), NgsiError> {
     122          168 :     let mut map: TypeStats = BTreeMap::new();
     123          168 :     let (rows, partial) = scan(st, tenant, max, only).await?;
     124          168 :     for doc in rows {
     125          126 :         let mut attrs: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
     126          126 :         if let Some(o) = doc.as_object() {
     127          502 :             for (k, v) in o {
     128          502 :                 if is_meta(k) {
     129          374 :                     continue;
     130          128 :                 }
     131          128 :                 let types: BTreeSet<String> = v
     132          128 :                     .as_array()
     133          128 :                     .map(|arr| {
     134          128 :                         arr.iter()
     135          132 :                             .filter_map(|i| i.get("type").and_then(Value::as_str))
     136          128 :                             .map(str::to_owned)
     137          128 :                             .collect()
     138          128 :                     })
     139          128 :                     .unwrap_or_default();
     140          128 :                 attrs.entry(k.clone()).or_default().extend(types);
     141              :             }
     142            0 :         }
     143          134 :         for t in doc["type"].as_array().cloned().unwrap_or_default() {
     144          134 :             if let Some(t) = t.as_str() {
     145          134 :                 let e = map.entry(t.to_owned()).or_default();
     146          134 :                 e.0 += 1;
     147          140 :                 for (a, tys) in &attrs {
     148          140 :                     e.1.entry(a.clone())
     149          140 :                         .or_default()
     150          140 :                         .extend(tys.iter().cloned());
     151          140 :                 }
     152            0 :             }
     153              :         }
     154              :     }
     155          168 :     Ok((map, partial))
     156          168 : }
     157              : 
     158              : /// attr IRI → (count, attribute types, entity type IRIs)
     159              : type AttrStats = BTreeMap<String, (usize, BTreeSet<String>, BTreeSet<String>)>;
     160              : 
     161              : /// Same bound and same incompleteness report as `type_stats`.
     162          142 : async fn attr_stats(
     163          142 :     st: &AppState,
     164          142 :     tenant: &antares_model::TenantId,
     165          142 :     max: usize,
     166          142 :     only: Narrow<'_>,
     167          142 : ) -> Result<(AttrStats, bool), NgsiError> {
     168          142 :     let mut map: AttrStats = BTreeMap::new();
     169          142 :     let (rows, partial) = scan(st, tenant, max, only).await?;
     170          142 :     for doc in rows {
     171           76 :         let etypes: Vec<String> = doc["type"]
     172           76 :             .as_array()
     173           76 :             .cloned()
     174           76 :             .unwrap_or_default()
     175           76 :             .iter()
     176           76 :             .filter_map(Value::as_str)
     177           76 :             .map(str::to_owned)
     178           76 :             .collect();
     179           76 :         if let Some(o) = doc.as_object() {
     180          318 :             for (k, v) in o {
     181          318 :                 if is_meta(k) {
     182          242 :                     continue;
     183           76 :                 }
     184           76 :                 let e = map.entry(k.clone()).or_default();
     185              :                 // Table 5.2.28-1 attributeCount: "Number of attribute
     186              :                 // instances with this attribute name" — a multi-instance
     187              :                 // attribute (4.5.5 datasetId) counts once per instance.
     188           76 :                 e.0 += v.as_array().map_or(1, Vec::len);
     189           76 :                 if let Some(arr) = v.as_array() {
     190           86 :                     for inst in arr {
     191           86 :                         if let Some(t) = inst.get("type").and_then(Value::as_str) {
     192           86 :                             e.1.insert(t.to_owned());
     193           86 :                         }
     194              :                     }
     195            0 :                 }
     196           76 :                 e.2.extend(etypes.iter().cloned());
     197              :             }
     198            0 :         }
     199              :     }
     200          142 :     Ok((map, partial))
     201          142 : }
     202              : 
     203              : // ---------- GET /types (5.7.5/5.7.6) ----------
     204              : 
     205              : /// 4.5.10 Entity Type List Representation, members per Table 5.2.24-1
     206              : /// (5.2.24 EntityTypeList): id a valid URI, type equal to "EntityTypeList",
     207              : /// typeList the entity type names — with details=true the 4.5.11 detailed
     208              : /// list of Table 5.2.25-1 EntityType objects (id = type FQN, type
     209              : /// "EntityType", attributeNames, typeName) (5.7.5/5.7.6).
     210           54 : pub async fn entity_types(
     211           54 :     State(st): State<AppState>,
     212           54 :     CleanParams(params): CleanParams,
     213           54 :     headers: HeaderMap,
     214           54 : ) -> Response {
     215           54 :     let go = async {
     216           54 :         let tenant = tenant_from(&headers)?;
     217           54 :         check_params(&params, &["details", "local", "count"])?;
     218           48 :         let accept = parse_accept(&headers)?;
     219           46 :         let ctx = request_context(&st.loader, &headers).await?;
     220           46 :         gate!(st, &tenant, &headers, "5.7.5").await?;
     221           46 :         let (stats, partial) =
     222           46 :             type_stats(&st, &tenant, *crate::bounds::MAX_FOLD_DOCS, Narrow::Tenant).await?;
     223           46 :         let details = params.get("details").map(String::as_str) == Some("true");
     224           46 :         let payload = if details {
     225              :             Value::Array(
     226           12 :                 stats
     227           12 :                     .iter()
     228           12 :                     .map(|(t, (_, attrs))| {
     229           10 :                         json!({
     230           10 :                             "id": t,
     231           10 :                             "type": "EntityType",
     232           10 :                             "typeName": ctx.compact_iri(t),
     233           12 :                             "attributeNames": attrs.keys().map(|a| ctx.compact_iri(a)).collect::<Vec<_>>(),
     234              :                         })
     235           10 :                     })
     236           12 :                     .collect(),
     237              :             )
     238              :         } else {
     239           34 :             json!({
     240           34 :                 "id": format!("urn:ngsi-ld:EntityTypeList:{}", uuid::Uuid::new_v4()),
     241           34 :                 "type": "EntityTypeList",
     242           34 :                 "typeList": stats.keys().map(|t| ctx.compact_iri(t)).collect::<Vec<_>>(),
     243              :             })
     244              :         };
     245           46 :         let mut resp = respond(StatusCode::OK, payload, &ctx, accept, &tenant);
     246           46 :         mark_partial(&mut resp, partial, &st.host_alias);
     247           46 :         Ok::<_, ApiError>(resp)
     248           54 :     };
     249           54 :     go.await.unwrap_or_else(|e| e.into_response())
     250           54 : }
     251              : 
     252              : // ---------- GET /types/{type} (5.7.7) ----------
     253              : 
     254              : /// 4.5.12 Entity Type Information Representation, members per Table
     255              : /// 5.2.26-1 (5.2.26 EntityTypeInfo): id = the entity type FQN, fixed type
     256              : /// "EntityTypeInfo", typeName (short name under the @context), entityCount
     257              : /// an unsigned integer, attributeDetails Attribute[] restricted to the
     258              : /// elements id/type/attributeName/attributeTypes (5.7.7).
     259           54 : pub async fn entity_type_info(
     260           54 :     State(st): State<AppState>,
     261           54 :     Path(type_name): Path<String>,
     262           54 :     CleanParams(params): CleanParams,
     263           54 :     headers: HeaderMap,
     264           54 : ) -> Response {
     265           54 :     let go = async {
     266           54 :         let tenant = tenant_from(&headers)?;
     267           54 :         check_params(&params, &["local"])?;
     268           48 :         let accept = parse_accept(&headers)?;
     269           46 :         let ctx = request_context(&st.loader, &headers).await?;
     270           42 :         gate!(st, &tenant, &headers, "5.7.7").await?;
     271           42 :         let iri = ctx.expand_key(&type_name);
     272              :         // Only instances of this type carry its entityCount and its
     273              :         // attributeDetails (5.2.26), so the fold asks the datastore for those
     274              :         // and the ceiling applies to them — a tenant of 100M entities with 50
     275              :         // Buildings answers exactly, instead of from the tenant's first page.
     276           42 :         let (stats, partial) = type_stats(
     277           42 :             &st,
     278           42 :             &tenant,
     279           42 :             *crate::bounds::MAX_FOLD_DOCS,
     280           42 :             Narrow::Type(&iri),
     281           42 :         )
     282           42 :         .await?;
     283           42 :         let Some((count, attrs)) = stats.get(&iri) else {
     284           24 :             let mut resp = ApiError::from(NgsiError::ResourceNotFound(format!(
     285           24 :                 "no entities of type {type_name}"
     286           24 :             )))
     287           24 :             .into_response();
     288           24 :             mark_partial(&mut resp, partial, &st.host_alias);
     289           24 :             return Ok(resp);
     290              :         };
     291           18 :         let attr_details: Vec<Value> = attrs
     292           18 :             .iter()
     293           22 :             .map(|(a, atypes)| {
     294           22 :                 json!({
     295           22 :                     "id": a,
     296           22 :                     "type": "Attribute",
     297           22 :                     "attributeName": ctx.compact_iri(a),
     298           22 :                     "attributeTypes": atypes.iter().cloned().collect::<Vec<_>>(),
     299              :                 })
     300           22 :             })
     301           18 :             .collect();
     302           18 :         let payload = json!({
     303           18 :             "id": iri,
     304           18 :             "type": "EntityTypeInfo",
     305           18 :             "typeName": ctx.compact_iri(&iri),
     306           18 :             "entityCount": count,
     307           18 :             "attributeDetails": attr_details,
     308              :         });
     309           18 :         let mut resp = respond(StatusCode::OK, payload, &ctx, accept, &tenant);
     310           18 :         mark_partial(&mut resp, partial, &st.host_alias);
     311           18 :         Ok::<_, ApiError>(resp)
     312           54 :     };
     313           54 :     go.await.unwrap_or_else(|e| e.into_response())
     314           54 : }
     315              : 
     316              : // ---------- GET /attributes (5.7.8/5.7.9) ----------
     317              : 
     318              : /// 4.5.13 Attribute List Representation, members per Table 5.2.27-1
     319              : /// (5.2.27 AttributeList): id a valid URI, type "AttributeList",
     320              : /// attributeList of attribute names — with details=true the 4.5.14 detailed
     321              : /// list of Table 5.2.28-1 Attribute objects (id = attribute URI, type
     322              : /// "Attribute", attributeName, typeNames).
     323           42 : pub async fn attributes(
     324           42 :     State(st): State<AppState>,
     325           42 :     CleanParams(params): CleanParams,
     326           42 :     headers: HeaderMap,
     327           42 : ) -> Response {
     328           42 :     let go = async {
     329           42 :         let tenant = tenant_from(&headers)?;
     330           42 :         check_params(&params, &["details", "local", "count"])?;
     331           40 :         let accept = parse_accept(&headers)?;
     332           38 :         let ctx = request_context(&st.loader, &headers).await?;
     333           38 :         gate!(st, &tenant, &headers, "5.7.8").await?;
     334           38 :         let (stats, partial) =
     335           38 :             attr_stats(&st, &tenant, *crate::bounds::MAX_FOLD_DOCS, Narrow::Tenant).await?;
     336           38 :         let details = params.get("details").map(String::as_str) == Some("true");
     337           38 :         let payload = if details {
     338              :             Value::Array(
     339            8 :                 stats
     340            8 :                     .iter()
     341            8 :                     .map(|(a, (_, _, etypes))| {
     342            6 :                         json!({
     343            6 :                             "id": a,
     344            6 :                             "type": "Attribute",
     345            6 :                             "attributeName": ctx.compact_iri(a),
     346            8 :                             "typeNames": etypes.iter().map(|t| ctx.compact_iri(t)).collect::<Vec<_>>(),
     347              :                         })
     348            6 :                     })
     349            8 :                     .collect(),
     350              :             )
     351              :         } else {
     352           30 :             json!({
     353           30 :                 "id": format!("urn:ngsi-ld:AttributeList:{}", uuid::Uuid::new_v4()),
     354           30 :                 "type": "AttributeList",
     355           30 :                 "attributeList": stats.keys().map(|a| ctx.compact_iri(a)).collect::<Vec<_>>(),
     356              :             })
     357              :         };
     358           38 :         let mut resp = respond(StatusCode::OK, payload, &ctx, accept, &tenant);
     359           38 :         mark_partial(&mut resp, partial, &st.host_alias);
     360           38 :         Ok::<_, ApiError>(resp)
     361           42 :     };
     362           42 :     go.await.unwrap_or_else(|e| e.into_response())
     363           42 : }
     364              : 
     365              : // ---------- GET /attributes/{attrId} (5.7.10) ----------
     366              : 
     367              : /// 4.5.15 Attribute Information Representation, members per Table 5.2.28-1
     368              : /// (5.2.28 Attribute): id = the attribute URI, fixed type "Attribute",
     369              : /// attributeName (short name under @context), plus the optional
     370              : /// attributeCount (unsigned integer) / attributeTypes / typeNames members.
     371           52 : pub async fn attribute_info(
     372           52 :     State(st): State<AppState>,
     373           52 :     Path(attr): Path<String>,
     374           52 :     CleanParams(params): CleanParams,
     375           52 :     headers: HeaderMap,
     376           52 : ) -> Response {
     377           52 :     let go = async {
     378           52 :         let tenant = tenant_from(&headers)?;
     379           52 :         check_params(&params, &["local"])?;
     380           50 :         let accept = parse_accept(&headers)?;
     381           48 :         let ctx = request_context(&st.loader, &headers).await?;
     382           48 :         gate!(st, &tenant, &headers, "5.7.10").await?;
     383              :         // 5.7.10 / Table 6.28.2-1: the FQN or a shortname the request
     384              :         // @context defines. Discovery reads the tenant's attribute
     385              :         // inventory and touches no stored document, so a name that
     386              :         // expands to nothing an Entity carries is simply absent —
     387              :         // ResourceNotFound below, not a rejected name.
     388           48 :         let iri = ctx.expand_key(&attr);
     389              :         // attributeCount, attributeTypes and typeNames (5.2.28) are all
     390              :         // properties of the entities that CARRY this attribute; the rest of
     391              :         // the tenant is not read.
     392           48 :         let (stats, partial) = attr_stats(
     393           48 :             &st,
     394           48 :             &tenant,
     395           48 :             *crate::bounds::MAX_FOLD_DOCS,
     396           48 :             Narrow::Attr(&iri),
     397           48 :         )
     398           48 :         .await?;
     399           48 :         let Some((count, attr_types, etypes)) = stats.get(&iri) else {
     400           36 :             let mut resp = ApiError::from(NgsiError::ResourceNotFound(format!(
     401           36 :                 "attribute {attr} not found"
     402           36 :             )))
     403           36 :             .into_response();
     404           36 :             mark_partial(&mut resp, partial, &st.host_alias);
     405           36 :             return Ok(resp);
     406              :         };
     407           12 :         let payload = json!({
     408           12 :             "id": iri,
     409           12 :             "type": "Attribute",
     410           12 :             "attributeName": ctx.compact_iri(&iri),
     411           12 :             "attributeCount": count,
     412           12 :             "attributeTypes": attr_types.iter().cloned().collect::<Vec<_>>(),
     413           14 :             "typeNames": etypes.iter().map(|t| ctx.compact_iri(t)).collect::<Vec<_>>(),
     414              :         });
     415           12 :         let mut resp = respond(StatusCode::OK, payload, &ctx, accept, &tenant);
     416           12 :         mark_partial(&mut resp, partial, &st.host_alias);
     417           12 :         Ok::<_, ApiError>(resp)
     418           52 :     };
     419           52 :     go.await.unwrap_or_else(|e| e.into_response())
     420           52 : }
     421              : 
     422              : #[cfg(test)]
     423              : mod discovery_folds {
     424              :     use super::*;
     425              :     use crate::state::AppState;
     426              :     use antares_model::TenantId;
     427              :     use antares_store::Kind;
     428              :     use serde_json::json;
     429              : 
     430              :     const V: &str = "https://uri.etsi.org/ngsi-ld/default-context/v";
     431              :     const R: &str = "https://uri.etsi.org/ngsi-ld/default-context/r";
     432              :     const BUILDING: &str = "https://uri.etsi.org/ngsi-ld/default-context/Building";
     433              :     const SENSOR: &str = "https://uri.etsi.org/ngsi-ld/default-context/Sensor";
     434              : 
     435              :     /// A ceiling no test fixture reaches, so the fold sees every entity.
     436              :     const ALL: usize = 1_000;
     437              : 
     438          200 :     fn tid(t: &str) -> TenantId {
     439          200 :         TenantId::new(t).expect("tenant")
     440          200 :     }
     441              : 
     442           80 :     async fn seed(st: &AppState, tenant: &str, id: &str, doc: Value) {
     443           80 :         assert!(st
     444           80 :             .store
     445           80 :             .create(&tid(tenant), Kind::Entity, id, doc)
     446           80 :             .await
     447           80 :             .expect("create"));
     448           80 :     }
     449              : 
     450              :     /// 5.2.26: entityCount is the number of entity instances of the type;
     451              :     /// 5.2.25 attributeNames lists the attributes those instances can have —
     452              :     /// Entity members are not among them.
     453              :     #[tokio::test]
     454            4 :     async fn type_stats_folds_types_and_attribute_names() {
     455            4 :         let st = AppState::new("test".into());
     456            4 :         seed(
     457            4 :             &st,
     458            4 :             "ta",
     459            4 :             "urn:ngsi-ld:B:1",
     460            4 :             json!({
     461            4 :                 "id": "urn:ngsi-ld:B:1",
     462            4 :                 "type": [BUILDING, SENSOR],
     463            4 :                 "createdAt": "2026-01-01T00:00:00Z",
     464            4 :                 "modifiedAt": "2026-01-01T00:00:00Z",
     465            4 :                 "scope": ["/a/b"],
     466            4 :                 V: [{"type": "Property", "value": 1}],
     467            4 :                 R: [{"type": "Relationship", "object": "urn:ngsi-ld:B:2"}],
     468            4 :             }),
     469            4 :         )
     470            4 :         .await;
     471            4 :         seed(
     472            4 :             &st,
     473            4 :             "ta",
     474            4 :             "urn:ngsi-ld:B:2",
     475            4 :             json!({
     476            4 :                 "id": "urn:ngsi-ld:B:2",
     477            4 :                 "type": [BUILDING],
     478            4 :                 V: [{"type": "GeoProperty", "value": {"type": "Point", "coordinates": [0, 0]}}],
     479            4 :             }),
     480            4 :         )
     481            4 :         .await;
     482            4 :         let (stats, partial) = type_stats(&st, &tid("ta"), ALL, Narrow::Tenant)
     483            4 :             .await
     484            4 :             .expect("stats");
     485            4 :         assert!(!partial, "two entities are under any sane ceiling");
     486            4 :         let (count, attrs) = stats.get(BUILDING).expect("Building");
     487            4 :         assert_eq!(*count, 2, "two entities carry the Building type");
     488            4 :         assert_eq!(stats.get(SENSOR).expect("Sensor").0, 1);
     489            4 :         assert!(attrs.contains_key(V) && attrs.contains_key(R));
     490           20 :         for meta in ["id", "type", "scope", "createdAt", "modifiedAt"] {
     491           20 :             assert!(
     492           20 :                 !attrs.contains_key(meta),
     493            4 :                 "{meta} must not be reported as an attribute name"
     494            4 :             );
     495            4 :         }
     496            4 :         // 5.2.28 attributeTypes: every attribute type an instance carried
     497            4 :         assert!(attrs[V].contains("Property") && attrs[V].contains("GeoProperty"));
     498            4 :     }
     499              : 
     500              :     /// One shared datastore, one tenant per request: an entity of another
     501              :     /// tenant contributes to no fold (4.15 multi-tenancy).
     502              :     #[tokio::test]
     503            4 :     async fn stats_are_tenant_scoped() {
     504            4 :         let st = AppState::new("test".into());
     505            4 :         seed(
     506            4 :             &st,
     507            4 :             "ta",
     508            4 :             "urn:ngsi-ld:B:1",
     509            4 :             json!({
     510            4 :                 "id": "urn:ngsi-ld:B:1",
     511            4 :                 "type": [BUILDING],
     512            4 :                 V: [{"type": "Property", "value": 1}],
     513            4 :             }),
     514            4 :         )
     515            4 :         .await;
     516            8 :         for other in ["tb", TenantId::DEFAULT] {
     517            8 :             let t = tid(other);
     518            8 :             assert!(
     519            8 :                 type_stats(&st, &t, ALL, Narrow::Tenant)
     520            8 :                     .await
     521            8 :                     .expect("stats")
     522            4 :                     .0
     523            8 :                     .is_empty(),
     524            4 :                 "{other} must not see another tenant's types"
     525            4 :             );
     526            8 :             assert!(
     527            8 :                 attr_stats(&st, &t, ALL, Narrow::Tenant)
     528            8 :                     .await
     529            8 :                     .expect("stats")
     530            4 :                     .0
     531            8 :                     .is_empty(),
     532            4 :                 "{other} must not see another tenant's attributes"
     533            4 :             );
     534            4 :         }
     535            4 :         assert!(type_stats(&st, &tid("ta"), ALL, Narrow::Tenant)
     536            4 :             .await
     537            4 :             .expect("stats")
     538            4 :             .0
     539            4 :             .contains_key(BUILDING));
     540            4 :     }
     541              : 
     542              :     /// Table 5.2.28-1: attributeCount is the "number of attribute instances
     543              :     /// with this attribute name" — multi-instance attributes (4.5.5
     544              :     /// datasetId) count once per instance, not once per entity.
     545              :     #[tokio::test]
     546            4 :     async fn attr_stats_counts_attribute_instances() {
     547            4 :         let st = AppState::new("test".into());
     548            4 :         seed(
     549            4 :             &st,
     550            4 :             "ta",
     551            4 :             "urn:ngsi-ld:B:1",
     552            4 :             json!({
     553            4 :                 "id": "urn:ngsi-ld:B:1",
     554            4 :                 "type": [BUILDING],
     555            4 :                 V: [
     556            4 :                     {"type": "Property", "value": 1,
     557            4 :                      "datasetId": "urn:ngsi-ld:ds:1"},
     558            4 :                     {"type": "Property", "value": 2,
     559            4 :                      "datasetId": "urn:ngsi-ld:ds:2"},
     560            4 :                 ],
     561            4 :             }),
     562            4 :         )
     563            4 :         .await;
     564            4 :         seed(
     565            4 :             &st,
     566            4 :             "ta",
     567            4 :             "urn:ngsi-ld:B:2",
     568            4 :             json!({
     569            4 :                 "id": "urn:ngsi-ld:B:2",
     570            4 :                 "type": [SENSOR],
     571            4 :                 V: [{"type": "Property", "value": 3}],
     572            4 :             }),
     573            4 :         )
     574            4 :         .await;
     575            4 :         let (stats, _) = attr_stats(&st, &tid("ta"), ALL, Narrow::Tenant)
     576            4 :             .await
     577            4 :             .expect("stats");
     578            4 :         let (count, atypes, etypes) = stats.get(V).expect("v");
     579            4 :         assert_eq!(*count, 3, "two instances on B:1 plus one on B:2");
     580            4 :         assert!(atypes.contains("Property"));
     581            4 :         assert_eq!(etypes.len(), 2, "both entity types are reported");
     582            4 :         assert!(
     583            4 :             !stats.contains_key("createdAt") && !stats.contains_key("id"),
     584            4 :             "Entity members must not appear in the attribute fold"
     585            4 :         );
     586            4 :     }
     587              : 
     588              :     /// 4.8 expiresAt: an expired entity no longer exists, and an expired
     589              :     /// attribute instance is gone with it — neither is discoverable.
     590              :     #[tokio::test]
     591            4 :     async fn expired_entities_and_attributes_are_not_discoverable() {
     592            4 :         let st = AppState::new("test".into());
     593            4 :         seed(
     594            4 :             &st,
     595            4 :             "ta",
     596            4 :             "urn:ngsi-ld:B:1",
     597            4 :             json!({
     598            4 :                 "id": "urn:ngsi-ld:B:1",
     599            4 :                 "type": [BUILDING],
     600            4 :                 "expiresAt": "2000-01-01T00:00:00Z",
     601            4 :                 V: [{"type": "Property", "value": 1}],
     602            4 :             }),
     603            4 :         )
     604            4 :         .await;
     605            4 :         seed(
     606            4 :             &st,
     607            4 :             "ta",
     608            4 :             "urn:ngsi-ld:B:2",
     609            4 :             json!({
     610            4 :                 "id": "urn:ngsi-ld:B:2",
     611            4 :                 "type": [SENSOR],
     612            4 :                 V: [{"type": "Property", "value": 1,
     613            4 :                      "expiresAt": "2000-01-01T00:00:00Z"}],
     614            4 :                 R: [{"type": "Relationship", "object": "urn:ngsi-ld:B:1"}],
     615            4 :             }),
     616            4 :         )
     617            4 :         .await;
     618            4 :         let (stats, _) = type_stats(&st, &tid("ta"), ALL, Narrow::Tenant)
     619            4 :             .await
     620            4 :             .expect("stats");
     621            4 :         assert!(
     622            4 :             !stats.contains_key(BUILDING),
     623              :             "the expired entity must not appear in the type list"
     624              :         );
     625            4 :         let (count, attrs) = stats.get(SENSOR).expect("Sensor");
     626            4 :         assert_eq!(*count, 1);
     627            4 :         assert!(
     628            4 :             !attrs.contains_key(V),
     629              :             "the expired attribute instance must not appear"
     630              :         );
     631            4 :         assert!(attrs.contains_key(R));
     632            4 :         assert!(!attr_stats(&st, &tid("ta"), ALL, Narrow::Tenant)
     633            4 :             .await
     634            4 :             .expect("stats")
     635            4 :             .0
     636            4 :             .contains_key(V));
     637            4 :     }
     638              : 
     639              :     /// A tenant with no entities has no types and no attributes — an empty
     640              :     /// fold, not an error, and never "incomplete".
     641              :     #[tokio::test]
     642            4 :     async fn empty_tenant_folds_to_nothing() {
     643            4 :         let st = AppState::new("test".into());
     644            8 :         for t in ["ta", TenantId::DEFAULT] {
     645            8 :             let (types, partial) = type_stats(&st, &tid(t), ALL, Narrow::Tenant)
     646            8 :                 .await
     647            8 :                 .expect("stats");
     648            8 :             assert!(types.is_empty() && !partial);
     649            8 :             let (attrs, partial) = attr_stats(&st, &tid(t), ALL, Narrow::Tenant)
     650            8 :                 .await
     651            8 :                 .expect("stats");
     652            8 :             assert!(attrs.is_empty() && !partial);
     653            8 :             let (rows, partial) = scan(&st, &tid(t), ALL, Narrow::Tenant).await.expect("scan");
     654            8 :             assert!(rows.is_empty() && !partial);
     655            4 :         }
     656            4 :     }
     657              : 
     658              :     /// The fold reads at most `max` entities and says so when the tenant
     659              :     /// holds more, instead of silently answering from a prefix.
     660              :     #[tokio::test]
     661            4 :     async fn scan_stops_at_the_ceiling_and_reports_it() {
     662            4 :         let st = AppState::new("test".into());
     663           20 :         for i in 0..5 {
     664           20 :             let id = format!("urn:ngsi-ld:B:{i}");
     665           20 :             let mut doc = serde_json::Map::new();
     666           20 :             doc.insert("id".into(), Value::String(id.clone()));
     667           20 :             doc.insert("type".into(), json!([format!("{BUILDING}{i}")]));
     668           20 :             doc.insert(format!("{V}{i}"), json!([{"type": "Property", "value": i}]));
     669           20 :             seed(&st, "ta", &id, Value::Object(doc)).await;
     670              :         }
     671           16 :         for (max, want) in [(1, 1), (4, 4), (5, 5), (6, 5)] {
     672           16 :             let (rows, partial) = scan(&st, &tid("ta"), max, Narrow::Tenant)
     673           16 :                 .await
     674           16 :                 .expect("scan");
     675           16 :             assert_eq!(rows.len(), want, "max {max}");
     676           16 :             assert_eq!(partial, max < 5, "max {max} incompleteness");
     677              :         }
     678              :         // the folds inherit the bound: one entity read, one type, one attr
     679            4 :         let (types, partial) = type_stats(&st, &tid("ta"), 1, Narrow::Tenant)
     680            4 :             .await
     681            4 :             .expect("stats");
     682            4 :         assert_eq!(types.len(), 1, "the fold read past its ceiling");
     683            4 :         assert!(partial, "a truncated type fold must report itself");
     684            4 :         let (attrs, partial) = attr_stats(&st, &tid("ta"), 1, Narrow::Tenant)
     685            4 :             .await
     686            4 :             .expect("stats");
     687            4 :         assert_eq!(attrs.len(), 1, "the fold read past its ceiling");
     688            4 :         assert!(partial, "a truncated attribute fold must report itself");
     689              :         // at the exact size the answer is complete, so nothing is flagged
     690            4 :         let (types, partial) = type_stats(&st, &tid("ta"), 5, Narrow::Tenant)
     691            4 :             .await
     692            4 :             .expect("stats");
     693            4 :         assert_eq!(types.len(), 5);
     694            4 :         assert!(!partial);
     695            4 :     }
     696              : 
     697              :     /// A complete answer carries no warning; a truncated one carries exactly
     698              :     /// one 199 (RFC 7234 5.5.1) naming this broker.
     699              :     #[test]
     700            4 :     fn mark_partial_emits_one_199_only_when_truncated() {
     701            4 :         let mut resp = StatusCode::OK.into_response();
     702            4 :         mark_partial(&mut resp, false, "broker-a");
     703            4 :         assert!(resp.headers().get("NGSILD-Warning").is_none());
     704            4 :         mark_partial(&mut resp, true, "broker-a");
     705            4 :         let vals: Vec<_> = resp.headers().get_all("NGSILD-Warning").iter().collect();
     706            4 :         assert_eq!(vals.len(), 1);
     707            4 :         let v = vals[0].to_str().expect("ascii");
     708            4 :         assert!(v.starts_with("199 broker-a \"") && v.ends_with('"'), "{v}");
     709            4 :         assert!(v.contains("incomplete"), "{v}");
     710            4 :     }
     711              : 
     712              :     /// The narrowing predicate selects exactly what the datastore's
     713              :     /// `types @> ARRAY[t]` / `entity ?| ARRAY[a]` select, and nothing else.
     714              :     #[test]
     715            4 :     fn narrow_selects_only_its_own_entities() {
     716            4 :         let doc = json!({
     717            4 :             "id": "urn:ngsi-ld:B:1",
     718            4 :             "type": [BUILDING],
     719            4 :             V: [{"type": "Property", "value": 1}],
     720              :         });
     721            4 :         assert!(Narrow::Tenant.matches(&doc));
     722            4 :         assert!(Narrow::Tenant.matches(&json!({})));
     723            4 :         assert!(Narrow::Type(BUILDING).matches(&doc));
     724            4 :         assert!(Narrow::Attr(V).matches(&doc));
     725              :         // the negatives: a type this entity does not carry, an attribute it
     726              :         // does not carry, a prefix of either, and a typeless document
     727            4 :         assert!(!Narrow::Type(SENSOR).matches(&doc));
     728            4 :         assert!(!Narrow::Attr(R).matches(&doc));
     729            4 :         assert!(!Narrow::Type("https://uri.etsi.org/ngsi-ld/default-context/Build").matches(&doc));
     730            4 :         assert!(!Narrow::Type(BUILDING).matches(&json!({"id": "urn:ngsi-ld:B:2"})));
     731            4 :         assert!(!Narrow::Attr(V).matches(&json!({"type": [BUILDING]})));
     732            4 :     }
     733              : 
     734              :     /// 5.7.7/5.7.10 name ONE type / ONE attribute, so the ceiling applies to
     735              :     /// the entities that carry it: a type sitting past the ceiling in the
     736              :     /// tenant's row order is still found, with an exact entityCount and no
     737              :     /// incompleteness warning. Under the tenant-wide fold the same ceiling
     738              :     /// hides it — that is the difference the narrowing buys.
     739              :     #[tokio::test]
     740            4 :     async fn narrowed_folds_reach_past_the_tenant_wide_ceiling() {
     741            4 :         let st = AppState::new("test".into());
     742           20 :         for i in 0..5 {
     743           20 :             let id = format!("urn:ngsi-ld:B:{i}");
     744           20 :             let mut doc = serde_json::Map::new();
     745           20 :             doc.insert("id".into(), Value::String(id.clone()));
     746           20 :             doc.insert("type".into(), json!([format!("{BUILDING}{i}")]));
     747           20 :             doc.insert(format!("{V}{i}"), json!([{"type": "Property", "value": i}]));
     748           20 :             seed(&st, "ta", &id, Value::Object(doc)).await;
     749              :         }
     750            4 :         let last_type = format!("{BUILDING}4");
     751            4 :         let last_attr = format!("{V}4");
     752              : 
     753              :         // tenant-wide, ceiling 1: the last entity is not read at all
     754            4 :         let (wide, partial) = type_stats(&st, &tid("ta"), 1, Narrow::Tenant)
     755            4 :             .await
     756            4 :             .expect("stats");
     757            4 :         assert!(!wide.contains_key(&last_type), "the ceiling hid the type");
     758            4 :         assert!(partial);
     759              : 
     760              :         // narrowed to that type, same ceiling: found, exact, complete
     761            4 :         let (stats, partial) = type_stats(&st, &tid("ta"), 1, Narrow::Type(&last_type))
     762            4 :             .await
     763            4 :             .expect("stats");
     764            4 :         let (count, attrs) = stats.get(&last_type).expect("the narrowed type");
     765            4 :         assert_eq!(*count, 1, "one instance carries it");
     766            4 :         assert!(!partial, "the narrowed set fits under the ceiling");
     767            4 :         assert!(attrs.contains_key(&last_attr));
     768           16 :         for i in 0..4 {
     769           16 :             assert!(
     770           16 :                 !stats.contains_key(&format!("{BUILDING}{i}")),
     771              :                 "no other type may be folded into a 5.7.7 answer"
     772              :             );
     773              :         }
     774              : 
     775              :         // and the same for one attribute (5.7.10)
     776            4 :         let (wide, partial) = attr_stats(&st, &tid("ta"), 1, Narrow::Tenant)
     777            4 :             .await
     778            4 :             .expect("stats");
     779            4 :         assert!(!wide.contains_key(&last_attr), "the ceiling hid the attr");
     780            4 :         assert!(partial);
     781            4 :         let (stats, partial) = attr_stats(&st, &tid("ta"), 1, Narrow::Attr(&last_attr))
     782            4 :             .await
     783            4 :             .expect("stats");
     784            4 :         let (count, atypes, etypes) = stats.get(&last_attr).expect("the narrowed attr");
     785            4 :         assert_eq!(*count, 1);
     786            4 :         assert!(!partial);
     787            4 :         assert!(atypes.contains("Property"));
     788            4 :         assert_eq!(etypes.iter().cloned().collect::<Vec<_>>(), vec![last_type]);
     789           16 :         for i in 0..4 {
     790           16 :             assert!(
     791           16 :                 !stats.contains_key(&format!("{V}{i}")),
     792              :                 "no other attribute may be folded into a 5.7.10 answer"
     793              :             );
     794              :         }
     795              : 
     796              :         // a narrowing that matches nothing is an empty fold, not a prefix of
     797              :         // the tenant — this is what makes 5.7.7 answer ResourceNotFound
     798            4 :         let (stats, partial) = type_stats(&st, &tid("ta"), 1, Narrow::Type(SENSOR))
     799            4 :             .await
     800            4 :             .expect("stats");
     801            4 :         assert!(stats.is_empty() && !partial);
     802            4 :         let (stats, partial) = attr_stats(&st, &tid("ta"), 1, Narrow::Attr(R))
     803            4 :             .await
     804            4 :             .expect("stats");
     805            4 :         assert!(stats.is_empty() && !partial);
     806            4 :     }
     807              : 
     808              :     /// A narrowed fold is tenant-scoped like the tenant-wide one (4.15): the
     809              :     /// type exists, but only for the tenant that created its instances.
     810              :     #[tokio::test]
     811            4 :     async fn narrowed_folds_are_tenant_scoped() {
     812            4 :         let st = AppState::new("test".into());
     813            4 :         seed(
     814            4 :             &st,
     815            4 :             "ta",
     816            4 :             "urn:ngsi-ld:B:1",
     817            4 :             json!({
     818            4 :                 "id": "urn:ngsi-ld:B:1",
     819            4 :                 "type": [BUILDING],
     820            4 :                 V: [{"type": "Property", "value": 1}],
     821            4 :             }),
     822            4 :         )
     823            4 :         .await;
     824            8 :         for other in ["tb", TenantId::DEFAULT] {
     825            8 :             let t = tid(other);
     826            8 :             assert!(
     827            8 :                 type_stats(&st, &t, ALL, Narrow::Type(BUILDING))
     828            8 :                     .await
     829            8 :                     .expect("stats")
     830            4 :                     .0
     831            8 :                     .is_empty(),
     832            4 :                 "{other} must not see another tenant's type"
     833            4 :             );
     834            8 :             assert!(
     835            8 :                 attr_stats(&st, &t, ALL, Narrow::Attr(V))
     836            8 :                     .await
     837            8 :                     .expect("stats")
     838            4 :                     .0
     839            8 :                     .is_empty(),
     840            4 :                 "{other} must not see another tenant's attribute"
     841            4 :             );
     842            4 :         }
     843            4 :         assert_eq!(
     844            4 :             type_stats(&st, &tid("ta"), ALL, Narrow::Type(BUILDING))
     845            4 :                 .await
     846            4 :                 .expect("stats")
     847            4 :                 .0
     848            4 :                 .get(BUILDING)
     849            4 :                 .expect("Building")
     850            4 :                 .0,
     851            4 :             1
     852            4 :         );
     853            4 :     }
     854              : 
     855              :     /// 5.7.5.4: the answer lists the types "for which entity instances exist
     856              :     /// within the NGSI-LD system" — the entity written a moment ago included.
     857              :     /// No fold result may be carried over a write.
     858              :     #[tokio::test]
     859            4 :     async fn a_freshly_created_type_is_in_the_very_next_fold() {
     860            4 :         let st = AppState::new("test".into());
     861            4 :         let t = tid("ta");
     862            4 :         assert!(type_stats(&st, &t, ALL, Narrow::Tenant)
     863            4 :             .await
     864            4 :             .expect("stats")
     865              :             .0
     866            4 :             .is_empty());
     867            4 :         seed(
     868            4 :             &st,
     869            4 :             "ta",
     870            4 :             "urn:ngsi-ld:B:1",
     871            4 :             json!({"id": "urn:ngsi-ld:B:1", "type": [BUILDING],
     872            4 :                    V: [{"type": "Property", "value": 1}]}),
     873            4 :         )
     874            4 :         .await;
     875            4 :         let (stats, _) = type_stats(&st, &t, ALL, Narrow::Tenant)
     876            4 :             .await
     877            4 :             .expect("stats");
     878            4 :         assert!(stats.contains_key(BUILDING), "the new type must be visible");
     879            4 :         assert!(!stats.contains_key(SENSOR));
     880            4 :         assert!(attr_stats(&st, &t, ALL, Narrow::Tenant)
     881            4 :             .await
     882            4 :             .expect("stats")
     883              :             .0
     884            4 :             .contains_key(V));
     885              :         // a second type on a second write, and a narrowed fold sees it too
     886            4 :         seed(
     887            4 :             &st,
     888            4 :             "ta",
     889            4 :             "urn:ngsi-ld:B:2",
     890            4 :             json!({"id": "urn:ngsi-ld:B:2", "type": [SENSOR],
     891            4 :                    R: [{"type": "Relationship", "object": "urn:ngsi-ld:B:1"}]}),
     892            4 :         )
     893            4 :         .await;
     894            4 :         let (stats, _) = type_stats(&st, &t, ALL, Narrow::Tenant)
     895            4 :             .await
     896            4 :             .expect("stats");
     897            4 :         assert!(stats.contains_key(BUILDING) && stats.contains_key(SENSOR));
     898            4 :         assert!(type_stats(&st, &t, ALL, Narrow::Type(SENSOR))
     899            4 :             .await
     900            4 :             .expect("stats")
     901              :             .0
     902            4 :             .contains_key(SENSOR));
     903            4 :         assert!(attr_stats(&st, &t, ALL, Narrow::Attr(R))
     904            4 :             .await
     905            4 :             .expect("stats")
     906              :             .0
     907            4 :             .contains_key(R));
     908              :         // and a deleted type disappears from the next fold just as fast
     909            4 :         assert!(st
     910            4 :             .store
     911            4 :             .delete(&t, Kind::Entity, "urn:ngsi-ld:B:2")
     912            4 :             .await
     913            4 :             .expect("delete"));
     914            4 :         let (stats, _) = type_stats(&st, &t, ALL, Narrow::Tenant)
     915            4 :             .await
     916            4 :             .expect("stats");
     917            4 :         assert!(
     918            4 :             !stats.contains_key(SENSOR),
     919            4 :             "a type with no instances left must not be listed"
     920            4 :         );
     921            4 :     }
     922              : }
        

Generated by: LCOV version 2.0-1