energyml_storage/resource.rs
1//! What a store knows about an object before it reads it.
2//!
3//! Three levels of materialisation, and a listing needs all three (see
4//! ADR-0002):
5//!
6//! 1. [`ObjectSummary`] - identity only, from the package index. No object XML
7//! is opened at all.
8//! 2. [`ObjectHeader`] - plus the citation. Read by stopping the XML parser as
9//! soon as the citation has gone by; the rest of the part is never parsed.
10//! 3. The object itself, through the registry.
11//!
12//! An interface listing a two-gigabyte EPC lives at level 2. Getting there
13//! cheaply is the whole point.
14
15/// One data store: an EPC file, or a remote dataspace.
16///
17/// An EPC **is** a dataspace, holding exactly one. That is not a convenience:
18/// it means code walking a remote dataspace walks a file without knowing it.
19///
20/// `#[non_exhaustive]`, like every record of this module: built with
21/// [`Dataspace::new`] and its fields set afterwards, so that a field ETP needs
22/// tomorrow is not a breaking change for every caller.
23#[derive(Debug, Clone, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct Dataspace {
26 /// URI of the dataspace: `eml:///dataspace('name')`, or `eml:///` for the
27 /// default one.
28 pub uri: String,
29 /// Human-readable name. For an EPC, the file name.
30 pub name: String,
31 /// When it was created, in its lexical form, when the store knows.
32 pub created: Option<String>,
33 /// When it last changed, in its lexical form, when the store knows.
34 pub last_changed: Option<String>,
35 /// ETP `Dataspace.storeCreated`: when **this store** created the dataspace,
36 /// as the ETP timestamp (microseconds since the Unix epoch, UTC). `None` when
37 /// the store does not keep it - an EPC does not, and says so rather than
38 /// inventing a date.
39 pub store_created: Option<i64>,
40 /// ETP `Dataspace.storeLastWrite`: when this store last wrote into it, same
41 /// unit. `None` when the store does not keep it.
42 pub store_last_write: Option<i64>,
43 /// ETP `Dataspace.customData`: what a server attaches to a dataspace beyond
44 /// ETP's own fields - OSDU's access lists and legal tags (`viewers`,
45 /// `owners`, `legaltags`) travel here. Empty when there is none.
46 pub custom_data: std::collections::BTreeMap<String, CustomValue>,
47}
48
49/// One value of ETP's `customData` (its `DataValue`), the forms a store has
50/// been seen to attach to a dataspace. A floating-point value is not among them
51/// on purpose: it would make every record that carries custom data lose `Eq`,
52/// and none is known to be needed; a store meeting one refuses it by name.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum CustomValue {
55 /// A text.
56 Text(String),
57 /// A list of texts (an access list, a list of legal tags).
58 Texts(Vec<String>),
59 /// A boolean.
60 Bool(bool),
61 /// An integer.
62 Integer(i64),
63}
64
65impl Dataspace {
66 /// A dataspace by its URI and name; everything the store may not know is
67 /// left unknown.
68 pub fn new(uri: impl Into<String>, name: impl Into<String>) -> Self {
69 Self {
70 uri: uri.into(),
71 name: name.into(),
72 created: None,
73 last_changed: None,
74 store_created: None,
75 store_last_write: None,
76 custom_data: std::collections::BTreeMap::new(),
77 }
78 }
79}
80
81/// ETP `ActiveStatusKind`: whether an object is still growing.
82///
83/// Only a growing object (a WITSML log, a trajectory being drilled) is ever
84/// active; ETP 1.2 carries the status on every resource. `TODO(verify)`: that
85/// every object which cannot grow is `Inactive` (ETP 1.2 §3.12 and the growing
86/// object protocol); until then a store that does not know leaves it unset.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum ActiveStatus {
89 /// Still being written.
90 Active,
91 /// Not, or no longer, being written.
92 Inactive,
93}
94
95/// Level 1: an object's identity, without opening it.
96///
97/// Built with [`ObjectSummary::new`]; `#[non_exhaustive]` (see [`Dataspace`]).
98#[derive(Debug, Clone, PartialEq, Eq)]
99#[non_exhaustive]
100pub struct ObjectSummary {
101 /// ETP URI, the way every layer above addresses this object.
102 pub uri: String,
103 /// UUID, as written.
104 pub uuid: String,
105 /// `resqml20.obj_Activity` - the identifier used across versions.
106 pub qualified_type: String,
107 /// The full content type, as the package declares it.
108 pub content_type: String,
109 /// Where the object lives inside the store. For an EPC, the part name.
110 ///
111 /// Deliberately not a path a caller should build on: two EPC files may
112 /// disagree on layout, and the specification allows both.
113 pub locator: String,
114 /// ETP `Resource.alternateUris`: other URIs the store answers to for this
115 /// object. Empty when it names none.
116 pub alternate_uris: Vec<String>,
117 /// ETP `Resource.storeCreated`: when this store first held the object, as
118 /// the ETP timestamp (microseconds since the Unix epoch, UTC). `None` when
119 /// the store does not keep it.
120 pub store_created: Option<i64>,
121 /// ETP `Resource.storeLastWrite`: when this store last wrote it, same unit.
122 /// `None` when the store does not keep it.
123 pub store_last_write: Option<i64>,
124 /// ETP `Resource.activeStatus`. `None` when the store does not say.
125 pub active_status: Option<ActiveStatus>,
126 /// ETP `Resource.sourceCount`: how many objects point at this one, counted
127 /// over the relationships the store states ([`crate::EnergymlStorage::edges`]).
128 /// `None` unless a context asked for counts (`Context::count_objects`).
129 pub source_count: Option<u32>,
130 /// ETP `Resource.targetCount`: how many objects this one points at, same
131 /// counting. `None` unless asked.
132 pub target_count: Option<u32>,
133 /// The URIs of the containers that hold this object **by value**, direct
134 /// containers only (a `Channel` in a `ChannelSet` in a `Log` names the
135 /// `ChannelSet`), sorted and without duplicates. ETP 1.2 section 9.1.3
136 /// (p. 178): an object "MAY be included in one or more container data
137 /// objects". Empty for a part of the archive.
138 pub contained_in: Vec<String>,
139}
140
141impl ObjectSummary {
142 /// An object's identity; the content type and the locator are empty until
143 /// set, and everything the store may not know is left unknown.
144 pub fn new(uri: impl Into<String>, uuid: impl Into<String>, qualified_type: impl Into<String>) -> Self {
145 Self {
146 uri: uri.into(),
147 uuid: uuid.into(),
148 qualified_type: qualified_type.into(),
149 content_type: String::new(),
150 locator: String::new(),
151 alternate_uris: Vec::new(),
152 store_created: None,
153 store_last_write: None,
154 active_status: None,
155 source_count: None,
156 target_count: None,
157 contained_in: Vec::new(),
158 }
159 }
160
161 /// With the content type the package declares.
162 pub fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
163 self.content_type = content_type.into();
164 self
165 }
166
167 /// With where the object lives inside the store.
168 pub fn with_locator(mut self, locator: impl Into<String>) -> Self {
169 self.locator = locator.into();
170 self
171 }
172
173 /// With the URIs of the containers that hold the object by value. Sorted
174 /// and deduplicated here, so that two summaries of one object compare equal
175 /// whatever order the containers were met in.
176 pub fn with_contained_in(mut self, mut containers: Vec<String>) -> Self {
177 containers.sort();
178 containers.dedup();
179 self.contained_in = containers;
180 self
181 }
182}
183
184/// Level 2: the identity, plus what a citation carries.
185///
186/// Everything here comes from the first few hundred bytes of a part. Nothing
187/// below the citation is parsed.
188///
189/// Built with [`ObjectHeader::new`]; `#[non_exhaustive]` (see [`Dataspace`]).
190#[derive(Debug, Clone, PartialEq, Eq)]
191#[non_exhaustive]
192pub struct ObjectHeader {
193 /// Level 1.
194 pub summary: ObjectSummary,
195 /// `Citation.Title` - the name a user recognises.
196 pub title: Option<String>,
197 /// Who or what produced the object.
198 pub originator: Option<String>,
199 /// Creation timestamp, in its lexical form.
200 pub creation: Option<String>,
201 /// Last update timestamp, in its lexical form.
202 pub last_update: Option<String>,
203 /// The producing software, as it named itself.
204 pub format: Option<String>,
205 /// `objectVersion`, when the object carries one.
206 pub object_version: Option<String>,
207 /// Schema version as written in the document, e.g. `2.0`.
208 pub schema_version: Option<String>,
209}
210
211impl ObjectHeader {
212 /// The header of an object whose citation is not known yet: every field of
213 /// it unset.
214 pub fn new(summary: ObjectSummary) -> Self {
215 Self {
216 summary,
217 title: None,
218 originator: None,
219 creation: None,
220 last_update: None,
221 format: None,
222 object_version: None,
223 schema_version: None,
224 }
225 }
226
227 /// With a title.
228 pub fn with_title(mut self, title: impl Into<String>) -> Self {
229 self.title = Some(title.into());
230 self
231 }
232
233 /// Identity of the object.
234 pub fn uri(&self) -> &str {
235 &self.summary.uri
236 }
237
238 /// Title, or the uuid when the citation has none - a UI needs *something*
239 /// to show, and an empty row is worse than a technical one.
240 pub fn display_name(&self) -> &str {
241 match self.title.as_deref() {
242 Some(title) if !title.trim().is_empty() => title,
243 _ => &self.summary.uuid,
244 }
245 }
246
247 /// The title (the uuid when there is none) followed by ` (version N)` when
248 /// the object declares a non-blank `objectVersion` - what a column that
249 /// already has its own type column shows. The same rule as [`Self::label`].
250 pub fn titled(&self) -> String {
251 match self
252 .object_version
253 .as_deref()
254 .map(str::trim)
255 .filter(|version| !version.is_empty())
256 {
257 Some(version) => format!("{} (version {version})", self.display_name()),
258 None => self.display_name().to_string(),
259 }
260 }
261
262 /// What a view shows of the object: `type - title (version N)`, the
263 /// version only when the object declares a non-blank one -
264 /// [`energyml_core::identity::ObjectId::label`], the one spelling every
265 /// face uses. The qualified type alone when it cannot be parsed.
266 pub fn label(&self) -> String {
267 match energyml_core::identity::ObjectId::from_qualified_type(
268 &self.summary.qualified_type,
269 &self.summary.uuid,
270 ) {
271 Ok(mut id) => {
272 id.object_version = self.object_version.clone();
273 id.label(self.title.as_deref())
274 }
275 Err(_) => format!("{} - {}", self.summary.qualified_type, self.display_name()),
276 }
277 }
278}
279
280/// One relationship between two parts, as the package records it.
281///
282/// Kept as read: the relationship system is an index, and an index can be
283/// wrong. Compare it with what the objects say (`identity::collect_refs`)
284/// rather than trusting it.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct Relationship {
287 /// Relationship id, unique within its `.rels` part.
288 pub id: String,
289 /// Full type URI, e.g.
290 /// `http://schemas.energistics.org/package/2012/relationships/destinationObject`.
291 pub kind: String,
292 /// What it points at: a part name, or a file name when external.
293 pub target: String,
294 /// Whether the target lies outside the package.
295 pub external: bool,
296}
297
298impl Relationship {
299 /// The Energistics relationship types, as short names.
300 ///
301 /// The full form is `http://schemas.energistics.org/package/2012/relationships/<name>`
302 /// (EPC specification §3.4.2).
303 pub fn short_kind(&self) -> &str {
304 self.kind.rsplit('/').next().unwrap_or(&self.kind)
305 }
306
307 /// Whether this relationship points at a file outside the package -
308 /// an HDF5 file, typically.
309 pub fn is_external_resource(&self) -> bool {
310 self.external || self.short_kind() == "externalResource"
311 }
312}
313
314/// Whether a qualified type contains this filter, ignoring case.
315///
316/// **One spelling, because four had grown**: `list`, `show` and `geometry` in
317/// the command line each lowercased their own way, and the Python bridge was
318/// about to make a fourth. The filter is a substring and not a type name on
319/// purpose - `Representation` is the useful question, and nobody wants to type
320/// `resqml22.TriangulatedSetRepresentation` to ask it.
321pub fn type_matches(qualified_type: &str, filter: &str) -> bool {
322 qualified_type.to_lowercase().contains(&filter.to_lowercase())
323}
324
325/// Whether a citation title contains this filter, ignoring case.
326///
327/// Beside [`type_matches`] and for the same reason. A title is not in an
328/// [`ObjectSummary`] - reading one costs a header pass - so this takes the
329/// title the caller already has.
330pub fn title_matches(title: &str, filter: &str) -> bool {
331 title.to_lowercase().contains(&filter.to_lowercase())
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 fn header(title: Option<&str>, version: Option<&str>) -> ObjectHeader {
339 let mut header = ObjectHeader::new(ObjectSummary::new(
340 "eml:///resqml22.BoundaryFeature(7f000000-0000-4000-8000-000000000001)",
341 "7f000000-0000-4000-8000-000000000001",
342 "resqml22.BoundaryFeature",
343 ));
344 header.title = title.map(str::to_string);
345 header.object_version = version.map(str::to_string);
346 header
347 }
348
349 #[test]
350 fn a_view_shows_the_version_when_the_object_declares_one() {
351 let versioned = header(Some("Top Brent"), Some("2"));
352 assert_eq!(
353 versioned.label(),
354 "resqml22.BoundaryFeature - Top Brent (version 2)"
355 );
356 assert_eq!(versioned.titled(), "Top Brent (version 2)");
357 }
358
359 #[test]
360 fn no_version_a_blank_one_or_no_title_changes_nothing_but_what_is_missing() {
361 for none in [None, Some(""), Some(" ")] {
362 let plain = header(Some("Top Brent"), none);
363 assert_eq!(plain.label(), "resqml22.BoundaryFeature - Top Brent", "{none:?}");
364 assert_eq!(plain.titled(), "Top Brent", "{none:?}");
365 }
366 // No title: the uuid stands in, and the version still shows.
367 let untitled = header(None, Some("1"));
368 assert_eq!(
369 untitled.titled(),
370 "7f000000-0000-4000-8000-000000000001 (version 1)"
371 );
372 }
373}