Skip to main content

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}