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(¶ms, &["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(¶ms, &["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(¶ms, &["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(¶ms, &["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 : }
|