Skip to main content

energyml_storage/
context.rs

1//! Which objects a request reaches: ETP's `ContextInfo` and `ContextScopeKind`.
2//!
3//! `GetResources`, `FindResources`, `FindDataObjects`, `GetSupportedTypes` and a
4//! notification subscription all name the objects they are about the same way:
5//! a starting URI, a depth, a direction, the kinds of relationship to follow and
6//! the types to keep (ETP 1.2 §8.1.1 and §23.34.3). [`Context`] is that request,
7//! spelled without depending on the protocol crates, and [`discover`] answers it
8//! from what any store can list - its resources and its edges.
9//!
10//! That answer is the **default** of [`crate::EnergymlStorage::resources_in`]: right
11//! everywhere, and the wrong thing for a store that indexes its graph, which
12//! answers natively and overrides it - the same arrangement as
13//! [`crate::EnergymlStorage::edges`].
14//!
15//! # What is refused rather than guessed
16//!
17//! - **Following only primary or only secondary relationships**, and the
18//!   secondary expansions, need to know the kind of each edge. Which relationship
19//!   is primary is ML-specific (ETP 1.2 §8.1.1.1.1, note 1: *"see the relevant
20//!   ML's ETP implementation specification"*); a store that derives its edges
21//!   from the objects states it by the rules of [`crate::kinds`], and for an ML
22//!   with no rule the edge has no kind ([`crate::graph::Edge::kind`]). The
23//!   refusal is **met**, not global: a request by kind answers while its walk
24//!   meets only edges that have a kind, and refuses ([`crate::Error::UnstatedKind`],
25//!   naming the type and the cause) at the first edge that has none.
26//! - **Filtering on a fact the store does not state** - the active status, the
27//!   store's last write - is refused by name for the object that lacks it, never
28//!   answered as "nothing matches".
29//!
30//! A type ending in `.*` is a wildcard: every type of that schema version
31//! (`resqml20.*`), as `ContextInfo.dataObjectTypes` allows (ETP 1.2 §23.34.15,
32//! p. 475).
33
34use std::collections::{BTreeSet, HashMap, VecDeque};
35
36use energyml_core::identifiers::EtpUri;
37
38use crate::error::{Error, Result};
39use crate::graph::{Edge, EdgeKind};
40use crate::resource::{ActiveStatus, ObjectSummary};
41
42/// ETP `ContextScopeKind`: which way from the starting object a request goes,
43/// and whether the starting object is part of the answer.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
45pub enum Scope {
46    /// The starting object alone (depth is ignored).
47    #[default]
48    Itself,
49    /// The objects pointing at it, up to the depth.
50    Sources,
51    /// The objects it points at, up to the depth.
52    Targets,
53    /// [`Scope::Sources`] and the starting object.
54    SourcesOrSelf,
55    /// [`Scope::Targets`] and the starting object.
56    TargetsOrSelf,
57}
58
59/// ETP `RelationshipKind` as a filter: which relationships a request follows.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
61pub enum Navigable {
62    /// Primary relationships only.
63    Primary,
64    /// Secondary relationships only.
65    Secondary,
66    /// Both - the only filter that needs no knowledge of the edges' kinds.
67    #[default]
68    Both,
69}
70
71/// ETP `ContextInfo` plus the scope and filters of a discovery request.
72///
73/// Built with [`Context::of`]; `#[non_exhaustive]` so that what ETP adds is not a
74/// breaking change.
75#[derive(Debug, Clone, PartialEq, Eq)]
76#[non_exhaustive]
77pub struct Context {
78    /// The dataspace or the object to start from, by its canonical URI. For a
79    /// dataspace, scope and depth are ignored and the answer is its content
80    /// (ETP 1.2 §8.2.2, row 9).
81    pub uri: String,
82    /// How many relationships away from the starting object to go.
83    pub depth: u32,
84    /// The direction, and whether the starting object is included.
85    pub scope: Scope,
86    /// The qualified types to keep (`resqml22.HorizonInterpretation`), applied
87    /// **after** the traversal (§8.2.2, row 10). Empty keeps every type.
88    pub types: Vec<String>,
89    /// Which relationships to follow.
90    pub navigable: Navigable,
91    /// Add the secondary targets of the objects found (§8.1.1.1.4, step 2).
92    pub include_secondary_targets: bool,
93    /// Add the secondary sources of the objects found.
94    pub include_secondary_sources: bool,
95    /// Keep only objects the store last wrote after this ETP timestamp
96    /// (`storeLastWriteFilter`). `None`: no filter.
97    pub store_last_write_after: Option<i64>,
98    /// Keep only objects in this active status (`activeStatusFilter`). `None`:
99    /// no filter.
100    pub active_status: Option<ActiveStatus>,
101    /// Count, for each object found, the objects pointing at it and those it
102    /// points at (`countObjects`, ETP 1.2 §8.3.1), over the relationships the
103    /// store states.
104    pub count_objects: bool,
105}
106
107impl Context {
108    /// The objects reached from `uri` in this direction, every type, every
109    /// relationship, depth 1.
110    pub fn of(uri: impl Into<String>, scope: Scope) -> Self {
111        Self {
112            uri: uri.into(),
113            depth: 1,
114            scope,
115            types: Vec::new(),
116            navigable: Navigable::Both,
117            include_secondary_targets: false,
118            include_secondary_sources: false,
119            store_last_write_after: None,
120            active_status: None,
121            count_objects: false,
122        }
123    }
124
125    /// The content of a dataspace.
126    pub fn dataspace(uri: impl Into<String>) -> Self {
127        Self::of(uri, Scope::Itself)
128    }
129
130    /// With a depth.
131    pub fn with_depth(mut self, depth: u32) -> Self {
132        self.depth = depth;
133        self
134    }
135
136    /// Keeping only these qualified types.
137    pub fn with_types(mut self, types: impl IntoIterator<Item = impl Into<String>>) -> Self {
138        self.types = types.into_iter().map(Into::into).collect();
139        self
140    }
141}
142
143/// What a discovery found: the objects, and the edges between them (ETP's
144/// `GetResourcesResponse` and `GetResourcesEdgesResponse`).
145#[derive(Debug, Clone, Default, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct Found {
148    /// The objects, in the order the store listed them.
149    pub resources: Vec<ObjectSummary>,
150    /// The edges with at least one end among [`Found::resources`]: ETP 1.2
151    /// §8.3.1 (p. 163, `GetResourcesEdgesResponse` row, 3.c) recommends an
152    /// edge "if EITHER end (or both) are present", even when the other end was
153    /// filtered out by the types.
154    pub edges: Vec<Edge>,
155}
156
157/// An object a store deleted, as ETP's `DeletedResource` reports it.
158#[derive(Debug, Clone, PartialEq, Eq)]
159#[non_exhaustive]
160pub struct DeletedResource {
161    /// The URI it had.
162    pub uri: String,
163    /// When it was deleted (ETP timestamp, microseconds since the Unix epoch,
164    /// UTC).
165    pub deleted_time: i64,
166}
167
168impl DeletedResource {
169    /// A deletion.
170    pub fn new(uri: impl Into<String>, deleted_time: i64) -> Self {
171        Self {
172            uri: uri.into(),
173            deleted_time,
174        }
175    }
176}
177
178/// ETP `GetDeletedResources`: the deletions of one dataspace, at the dataspace
179/// level (tombstones have no context left, §8.3, `GetDeletedResources`).
180#[derive(Debug, Clone, PartialEq, Eq)]
181#[non_exhaustive]
182pub struct DeletedFilter {
183    /// The dataspace.
184    pub dataspace: String,
185    /// Only deletions after this ETP timestamp. `None`: all that are kept.
186    pub after: Option<i64>,
187    /// Only these qualified types. Empty: every type.
188    pub types: Vec<String>,
189}
190
191impl DeletedFilter {
192    /// Every deletion kept for a dataspace.
193    pub fn of(dataspace: impl Into<String>) -> Self {
194        Self {
195            dataspace: dataspace.into(),
196            after: None,
197            types: Vec::new(),
198        }
199    }
200}
201
202/// Whether a URI names a dataspace rather than an object - through the one
203/// parser of ETP URIs.
204pub fn is_dataspace_uri(uri: &str) -> Result<bool> {
205    let parsed = EtpUri::parse(uri).map_err(|error| Error::BadUri {
206        uri: uri.to_string(),
207        reason: error.to_string(),
208    })?;
209    Ok(parsed.uuid.is_none() && parsed.object_type.is_none())
210}
211
212/// The refusal of an edge whose kind nobody states: the type of its source and why.
213fn unstated(edge: &Edge) -> Error {
214    let source_type = EtpUri::parse(&edge.from)
215        .ok()
216        .and_then(|uri| Some(format!("{}{}.{}", uri.domain?, uri.version?, uri.object_type?)))
217        .unwrap_or_else(|| edge.from.clone());
218    let why = match edge.basis {
219        Some(crate::kinds::KindBasis::Unknown { why }) => why,
220        _ => "no rule or server states the kind of this edge",
221    };
222    Error::UnstatedKind { source_type, why }
223}
224
225/// An edge met by a request that needs its kind: refused when it has none.
226fn needs_kind(edge: &Edge) -> Result<()> {
227    if edge.kind.is_none() {
228        return Err(unstated(edge));
229    }
230    Ok(())
231}
232
233/// Answer a [`Context`] from what a store lists: its resources and its edges.
234///
235/// `dataspace` is the URI of the one dataspace these resources belong to: a
236/// context naming another dataspace is refused as not found, never answered
237/// with this one's content.
238pub fn discover(
239    dataspace: &str,
240    resources: Vec<ObjectSummary>,
241    edges: Vec<Edge>,
242    context: &Context,
243) -> Result<Found> {
244    if let Some(odd) = context
245        .types
246        .iter()
247        .find(|t| t.contains('*') && (!t.ends_with(".*") || t[..t.len() - 1].contains('*')))
248    {
249        return Err(Error::BadUri {
250            uri: odd.clone(),
251            reason: "a type wildcard is a schema version followed by `.*` (ETP 1.2 §23.34.15)".into(),
252        });
253    }
254    let typed = |summary: &ObjectSummary| {
255        context.types.is_empty()
256            || context
257                .types
258                .iter()
259                .any(|t| type_matches(t, &summary.qualified_type))
260    };
261    let in_status = |summary: &ObjectSummary| match context.active_status {
262        None => Ok(true),
263        Some(wanted) => summary
264            .active_status
265            .map(|status| status == wanted)
266            .ok_or(Error::Unsupported {
267                operation: "filtering on the active status",
268                store: "discover",
269            }),
270    };
271    let written_after = |summary: &ObjectSummary| match context.store_last_write_after {
272        None => Ok(true),
273        Some(after) => summary
274            .store_last_write
275            .map(|written| written > after)
276            .ok_or(Error::Unsupported {
277                operation: "filtering on the store's last write",
278                store: "discover",
279            }),
280    };
281
282    if is_dataspace_uri(&context.uri)? {
283        if !same_dataspace(&context.uri, dataspace)? {
284            return Err(Error::NotFound(context.uri.clone()));
285        }
286        let mut kept = Vec::new();
287        for summary in resources {
288            if typed(&summary) && written_after(&summary)? && in_status(&summary)? {
289                kept.push(summary);
290            }
291        }
292        return Ok(counted(with_edges(kept, &edges), &edges, context));
293    }
294
295    // A request by kind is refused when its walk meets an edge with no kind
296    // (`needs_kind`), not because some edge elsewhere in the dataspace has none.
297    let follows = |edge: &Edge| match context.navigable {
298        Navigable::Both => true,
299        Navigable::Primary => edge.kind == Some(EdgeKind::Primary),
300        Navigable::Secondary => edge.kind == Some(EdgeKind::Secondary),
301    };
302
303    let start = context.uri.as_str();
304    if !resources.iter().any(|summary| summary.uri == start) {
305        return Err(Error::NotFound(context.uri.clone()));
306    }
307    let mut out_of: HashMap<&str, Vec<&Edge>> = HashMap::new();
308    let mut into: HashMap<&str, Vec<&Edge>> = HashMap::new();
309    for edge in &edges {
310        out_of.entry(edge.from.as_str()).or_default().push(edge);
311        into.entry(edge.to.as_str()).or_default().push(edge);
312    }
313
314    // Step 1: the candidates the URI and the depth reach.
315    let (towards_targets, towards_sources, with_self) = match context.scope {
316        Scope::Itself => (false, false, true),
317        Scope::Sources => (false, true, false),
318        Scope::Targets => (true, false, false),
319        Scope::SourcesOrSelf => (false, true, true),
320        Scope::TargetsOrSelf => (true, false, true),
321    };
322    let mut reached: BTreeSet<String> = BTreeSet::new();
323    let mut seen: BTreeSet<&str> = BTreeSet::from([start]);
324    let mut queue: VecDeque<(&str, u32)> = VecDeque::from([(start, 0)]);
325    let depth = if context.scope == Scope::Itself {
326        0
327    } else {
328        context.depth
329    };
330    while let Some((uri, level)) = queue.pop_front() {
331        if level == depth {
332            continue;
333        }
334        let mut next: Vec<&str> = Vec::new();
335        if towards_targets {
336            for edge in out_of.get(uri).into_iter().flatten() {
337                if context.navigable != Navigable::Both {
338                    needs_kind(edge)?;
339                }
340                if follows(edge) {
341                    next.push(edge.to.as_str());
342                }
343            }
344        }
345        if towards_sources {
346            for edge in into.get(uri).into_iter().flatten() {
347                if context.navigable != Navigable::Both {
348                    needs_kind(edge)?;
349                }
350                if follows(edge) {
351                    next.push(edge.from.as_str());
352                }
353            }
354        }
355        for other in next {
356            if seen.insert(other) {
357                reached.insert(other.to_string());
358                queue.push_back((other, level + 1));
359            }
360        }
361    }
362    // The starting object is never reached by the walk (it is seen from the
363    // start), so a cycle back to it does not make it "self".
364    if with_self {
365        reached.insert(start.to_string());
366    }
367
368    // Step 2: the secondary expansions, applied once and together.
369    let candidates: Vec<String> = reached.iter().cloned().collect();
370    for uri in &candidates {
371        if context.include_secondary_targets {
372            for edge in out_of.get(uri.as_str()).into_iter().flatten() {
373                needs_kind(edge)?;
374                if edge.kind == Some(EdgeKind::Secondary) {
375                    reached.insert(edge.to.clone());
376                }
377            }
378        }
379        if context.include_secondary_sources {
380            for edge in into.get(uri.as_str()).into_iter().flatten() {
381                needs_kind(edge)?;
382                if edge.kind == Some(EdgeKind::Secondary) {
383                    reached.insert(edge.from.clone());
384                }
385            }
386        }
387    }
388
389    // Step 3: the types, after the traversal; the store's order kept.
390    let mut kept = Vec::new();
391    for summary in resources {
392        if reached.contains(&summary.uri)
393            && typed(&summary)
394            && written_after(&summary)?
395            && in_status(&summary)?
396        {
397            kept.push(summary);
398        }
399    }
400    // Step 4: the edges touching what is kept.
401    Ok(counted(with_edges(kept, &edges), &edges, context))
402}
403
404/// Whether a qualified type matches one entry of `dataObjectTypes`: equal, or
405/// of the schema version a `prefix.*` wildcard names (ETP 1.2 §23.34.15).
406/// Type names are case sensitive there; the prefix is compared the same way.
407pub fn type_matches(pattern: &str, qualified_type: &str) -> bool {
408    match pattern.strip_suffix(".*") {
409        Some(version) => qualified_type
410            .split_once('.')
411            .is_some_and(|(prefix, _)| prefix == version),
412        None => pattern == qualified_type,
413    }
414}
415
416/// The edges of an answer: those with **either** end among the objects found
417/// (ETP 1.2 §8.3.1, p. 163, 3.c - the reading of §8.1.1.1.4 step 4, "not
418/// connected to a node in the final set", that the specification spells out).
419fn with_edges(resources: Vec<ObjectSummary>, edges: &[Edge]) -> Found {
420    let found: BTreeSet<&str> = resources.iter().map(|summary| summary.uri.as_str()).collect();
421    let edges = edges
422        .iter()
423        .filter(|edge| found.contains(edge.from.as_str()) || found.contains(edge.to.as_str()))
424        .cloned()
425        .collect();
426    Found { resources, edges }
427}
428
429/// The source and target counts of each object found, when the context asks
430/// for them: distinct objects at the other end of the store's relationships.
431///
432/// `TODO(verify)`: whether `countObjects` counts over every relationship or
433/// only those `navigableEdges` names (ETP 1.2 §8.3.1 says "counts of sources
434/// and targets for each type of resource"); every stated relationship is
435/// counted here.
436fn counted(mut found: Found, edges: &[Edge], context: &Context) -> Found {
437    if !context.count_objects {
438        return found;
439    }
440    let mut sources: HashMap<&str, BTreeSet<&str>> = HashMap::new();
441    let mut targets: HashMap<&str, BTreeSet<&str>> = HashMap::new();
442    for edge in edges {
443        sources
444            .entry(edge.to.as_str())
445            .or_default()
446            .insert(edge.from.as_str());
447        targets
448            .entry(edge.from.as_str())
449            .or_default()
450            .insert(edge.to.as_str());
451    }
452    let count = |map: &HashMap<&str, BTreeSet<&str>>, uri: &str| {
453        u32::try_from(map.get(uri).map_or(0, BTreeSet::len)).unwrap_or(u32::MAX)
454    };
455    for summary in &mut found.resources {
456        summary.source_count = Some(count(&sources, &summary.uri));
457        summary.target_count = Some(count(&targets, &summary.uri));
458    }
459    found
460}
461
462/// The types a context reaches and how many objects of each - what ETP's
463/// `GetSupportedTypes` answers with `countObjects` (ETP 1.2 §25), derived from
464/// a discovery rather than asked again. Sorted by type.
465pub fn types_of(found: &Found) -> Vec<(String, usize)> {
466    let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
467    for summary in &found.resources {
468        *counts.entry(summary.qualified_type.as_str()).or_default() += 1;
469    }
470    counts.into_iter().map(|(t, n)| (t.to_string(), n)).collect()
471}
472
473fn same_dataspace(a: &str, b: &str) -> Result<bool> {
474    let parse = |uri: &str| {
475        EtpUri::parse(uri).map_err(|error| Error::BadUri {
476            uri: uri.to_string(),
477            reason: error.to_string(),
478        })
479    };
480    Ok(parse(a)?.dataspace == parse(b)?.dataspace)
481}