ifc_classification/release.rs
1//! The IFC release a model's classification records are read and written
2//! against.
3//!
4//! Every slot position, select, domain and enumeration comes from the
5//! bundled table of one release, looked up by attribute name, so a record is
6//! never decoded or authored with another release's positions.
7//!
8//! Binding, from `FILE_SCHEMA`, as `ifc-material` binds (#77):
9//! - one recognised declaration (`IFC2X3`, `IFC4`, `IFC4X3`/`IFC4X3_ADD2`)
10//! binds that release's own table;
11//! - one unrecognised declaration fails closed with
12//! [`ClassificationError::UnsupportedSchema`];
13//! - several declarations fail closed with
14//! [`ClassificationError::MultipleSchemas`];
15//! - no declaration at all (an in-memory [`Model::new`]) binds IFC4, the
16//! 0.2.0 behaviour.
17
18use ifc_model::{Entity, EntityId, Model, Value};
19use ifc_schema::{for_version, Attribute, Schema, SchemaVersion, TypeKind};
20
21use crate::{ClassificationError, ClassificationResult};
22
23/// The release a view or authoring call binds to, or why none could be.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub(crate) enum Release<'m> {
26 /// Reads and writes resolve against this release's bundled table.
27 Bound(SchemaVersion),
28 /// The header declares this many schemas.
29 Multiple(usize),
30 /// The header declares one schema that has no bundled table.
31 Unsupported(&'m str),
32}
33
34/// Releases this crate's layouts are proven against.
35///
36/// `ifc-schema` also bundles IFC4X1 and IFC4X2, but nothing here is verified
37/// against their tables, so a header declaring either is refused with the
38/// unsupported-schema error rather than read through a neighbour's layout.
39const fn proven(version: SchemaVersion) -> bool {
40 matches!(
41 version,
42 SchemaVersion::Ifc2x3 | SchemaVersion::Ifc4 | SchemaVersion::Ifc4x3
43 )
44}
45
46impl<'m> Release<'m> {
47 /// The binding of projections built without a model (`try_new`), of
48 /// authoring calls that take no model, and of a model whose header
49 /// declares no schema.
50 pub(crate) const LEGACY: Release<'static> = Release::Bound(SchemaVersion::Ifc4);
51
52 /// The release `model`'s header binds.
53 pub(crate) fn of(model: &'m Model) -> Self {
54 match model.header().schema.as_slice() {
55 [] => Release::LEGACY,
56 [token] => SchemaVersion::from_header_token(token)
57 .filter(|version| proven(*version))
58 .map_or(Self::Unsupported(token.as_str()), Self::Bound),
59 tokens => Self::Multiple(tokens.len()),
60 }
61 }
62
63 /// The bound version and its bundled table.
64 ///
65 /// A release whose table this build leaves out (its release feature is
66 /// off, #306) is refused as unsupported.
67 pub(crate) fn bound(self) -> ClassificationResult<(SchemaVersion, &'static Schema)> {
68 match self {
69 Self::Bound(version) => match for_version(version) {
70 Ok(schema) => Ok((version, schema)),
71 Err(_) => Err(ClassificationError::UnsupportedSchema {
72 schema: version.header_tokens()[0].to_owned(),
73 }),
74 },
75 Self::Multiple(schemas) => Err(ClassificationError::MultipleSchemas { schemas }),
76 Self::Unsupported(schema) => Err(ClassificationError::UnsupportedSchema {
77 schema: schema.to_owned(),
78 }),
79 }
80 }
81
82 /// Position of `attribute` (IFC4 name) on `entity` in the bound release.
83 ///
84 /// An attribute or record the release does not declare is `NotInSchema`,
85 /// never a silent `None` read past the record or from a slot the release
86 /// gives another meaning.
87 pub(crate) fn slot(
88 self,
89 entity: &'static str,
90 id: EntityId,
91 attribute: &'static str,
92 ) -> ClassificationResult<usize> {
93 let (version, schema) = self.bound()?;
94 position(schema, version, entity, attribute)
95 .map(|(slot, _)| slot)
96 .ok_or(ClassificationError::NotInSchema {
97 entity,
98 id,
99 attribute,
100 schema: version,
101 })
102 }
103
104 /// [`Self::slot`] for a text accessor.
105 ///
106 /// IFC2X3 types several dates and formats as entity records (for example
107 /// `IfcClassification.EditionDate` is an `IfcCalendarDate`). Such a value
108 /// is valid, not malformed, so it is reported as `StructuredValue` with
109 /// the record id instead of being rejected as an invalid string.
110 pub(crate) fn text_slot(
111 self,
112 entity: &'static str,
113 id: EntityId,
114 record: &Entity,
115 attribute: &'static str,
116 ) -> ClassificationResult<usize> {
117 let slot = self.slot(entity, id, attribute)?;
118 if let Some(Value::Ref(target)) = record.attribute(slot) {
119 let declared = self.declared_type(entity, id, attribute)?;
120 let (_, schema) = self.bound()?;
121 if schema.entity(declared).is_some() {
122 return Err(ClassificationError::StructuredValue {
123 entity,
124 id,
125 attribute,
126 target: *target,
127 });
128 }
129 }
130 Ok(slot)
131 }
132
133 /// The type the bound release declares for `attribute` on `entity`,
134 /// for example `IfcClassificationNotationSelect` for IFC2X3
135 /// `IfcRelAssociatesClassification.RelatingClassification`.
136 pub(crate) fn declared_type(
137 self,
138 entity: &'static str,
139 id: EntityId,
140 attribute: &'static str,
141 ) -> ClassificationResult<&'static str> {
142 let slot = self.slot(entity, id, attribute)?;
143 let (_, schema) = self.bound()?;
144 Ok(schema.attributes(entity)[slot].type_name.as_str())
145 }
146
147 /// Whether `candidate` is a legal value of `attribute` on `entity`.
148 pub(crate) fn accepts(
149 self,
150 entity: &'static str,
151 id: EntityId,
152 attribute: &'static str,
153 candidate: &str,
154 ) -> ClassificationResult<bool> {
155 let declared = self.declared_type(entity, id, attribute)?;
156 let (_, schema) = self.bound()?;
157 Ok(schema.accepts_type(declared, candidate))
158 }
159
160 /// The enumerators the bound release declares for the enumeration-typed
161 /// `attribute` on `entity`, in declaration order. Empty when the
162 /// attribute is not an enumeration, so every value is refused.
163 pub(crate) fn enumerators(
164 self,
165 entity: &'static str,
166 id: EntityId,
167 attribute: &'static str,
168 ) -> ClassificationResult<Vec<&'static str>> {
169 let declared = self.declared_type(entity, id, attribute)?;
170 let (_, schema) = self.bound()?;
171 Ok(enumeration(schema, declared))
172 }
173
174 /// Fail with [`ClassificationError::EntityNotInSchema`] unless the bound
175 /// release can instantiate `entity`.
176 pub(crate) fn require_entity(
177 self,
178 entity: &'static str,
179 ) -> ClassificationResult<(SchemaVersion, &'static Schema)> {
180 let (version, schema) = self.bound()?;
181 if schema.entity(entity).is_some_and(|e| !e.abstract_) {
182 Ok((version, schema))
183 } else {
184 Err(ClassificationError::EntityNotInSchema {
185 entity,
186 schema: version,
187 })
188 }
189 }
190
191 /// The bound release's declaration of `attribute` (IFC4 name) on
192 /// `entity`, for authoring: `EntityNotInSchema` or
193 /// `AuthoringNotInSchema` when the release lacks either.
194 pub(crate) fn declared(
195 self,
196 entity: &'static str,
197 attribute: &'static str,
198 ) -> ClassificationResult<&'static Attribute> {
199 let (version, schema) = self.require_entity(entity)?;
200 position(schema, version, entity, attribute)
201 .map(|(_, declared)| declared)
202 .ok_or(ClassificationError::AuthoringNotInSchema {
203 entity,
204 attribute,
205 schema: version,
206 })
207 }
208
209 /// Build `entity`'s record in the bound release's layout from values
210 /// named by their IFC4 attribute names.
211 ///
212 /// A non-null value for an attribute the release does not declare is
213 /// refused with `AuthoringNotInSchema` rather than dropped; text for an
214 /// attribute the release types as an entity record with
215 /// `AuthoringValueType`; and a null for an attribute the release
216 /// requires with `AuthoringRequired`. References are checked by the
217 /// caller against [`Self::declared`], which sees staged edits.
218 pub(crate) fn record(
219 self,
220 entity: &'static str,
221 values: Vec<(&'static str, Value)>,
222 ) -> ClassificationResult<Entity> {
223 let (version, schema) = self.require_entity(entity)?;
224 let declared = schema.attributes(entity);
225 let mut slots = vec![None; declared.len()];
226 for (attribute, value) in values {
227 match position(schema, version, entity, attribute) {
228 Some((slot, declaration)) => {
229 if holds_text(&value) && !is_text_type(schema, &declaration.type_name) {
230 return Err(ClassificationError::AuthoringValueType {
231 entity,
232 attribute,
233 declared: declaration.type_name.as_str(),
234 schema: version,
235 });
236 }
237 slots[slot] = Some(value);
238 }
239 None if value == Value::Null => {}
240 None => {
241 return Err(ClassificationError::AuthoringNotInSchema {
242 entity,
243 attribute,
244 schema: version,
245 })
246 }
247 }
248 }
249 let mut attributes = Vec::with_capacity(slots.len());
250 for (slot, value) in slots.into_iter().enumerate() {
251 match value.unwrap_or(Value::Null) {
252 Value::Null if !declared[slot].optional => {
253 return Err(ClassificationError::AuthoringRequired {
254 entity,
255 attribute: declared[slot].name.as_str(),
256 schema: version,
257 })
258 }
259 value => attributes.push(value),
260 }
261 }
262 Ok(Entity::new(entity, attributes))
263 }
264}
265
266/// Position and declaration of the attribute this crate knows by its IFC4
267/// name `attribute`, in `version`'s table.
268fn position(
269 schema: &'static Schema,
270 version: SchemaVersion,
271 entity: &str,
272 attribute: &'static str,
273) -> Option<(usize, &'static Attribute)> {
274 let name = release_name(version, entity, attribute);
275 schema
276 .attributes(entity)
277 .into_iter()
278 .enumerate()
279 .find(|(_, declared)| declared.name.eq_ignore_ascii_case(name))
280}
281
282fn holds_text(value: &Value) -> bool {
283 match value {
284 Value::Text(_) => true,
285 Value::List(items) => items.iter().any(holds_text),
286 _ => false,
287 }
288}
289
290/// Whether `declared` resolves to an EXPRESS `STRING` (a label, identifier,
291/// URI, IFC4 date string), rather than an entity record such as IFC2X3
292/// `IfcCalendarDate`.
293fn is_text_type(schema: &Schema, declared: &str) -> bool {
294 schema
295 .resolve_defined(declared)
296 .to_ascii_uppercase()
297 .starts_with("STRING")
298}
299
300/// The enumerators of the enumeration type `declared`, in declaration
301/// order; empty when `declared` is not an enumeration.
302pub(crate) fn enumeration(schema: &'static Schema, declared: &str) -> Vec<&'static str> {
303 match schema.type_def(declared).map(|definition| &definition.kind) {
304 Some(TypeKind::Enumeration(values)) => values.iter().map(String::as_str).collect(),
305 _ => Vec::new(),
306 }
307}
308
309/// The name `release` gives the attribute this crate knows by its IFC4 name.
310///
311/// Each alias keeps its position and meaning, so the IFC4-named accessor and
312/// draft field read and write it:
313/// - IFC2X3 `IfcExternalReference.ItemReference` became `Identification`;
314/// - IFC2X3 `IfcDocumentInformation.DocumentId` became `Identification`;
315/// - IFC4X3 ADD2 renamed IFC4 `IfcClassification.Location` to
316/// `Specification` (both `OPTIONAL IfcURIReference`, sixth attribute).
317///
318/// No other attribute this crate reads or writes is renamed.
319pub(crate) fn release_name(
320 release: SchemaVersion,
321 entity: &str,
322 attribute: &'static str,
323) -> &'static str {
324 let entity = entity.to_ascii_uppercase();
325 match (release, entity.as_str(), attribute) {
326 (SchemaVersion::Ifc2x3, "IFCDOCUMENTINFORMATION", "Identification") => "DocumentId",
327 (
328 SchemaVersion::Ifc2x3,
329 "IFCCLASSIFICATIONREFERENCE" | "IFCDOCUMENTREFERENCE" | "IFCLIBRARYREFERENCE",
330 "Identification",
331 ) => "ItemReference",
332 (SchemaVersion::Ifc4x3, "IFCCLASSIFICATION", "Location") => "Specification",
333 _ => attribute,
334 }
335}
336
337/// The IFC release `model`'s classification records are read and written
338/// against.
339///
340/// A header declaring one recognised schema binds that release: `IFC2X3`,
341/// `IFC4`, or `IFC4X3`/`IFC4X3_ADD2` (the IFC4X3 ADD2 table). A header with
342/// no declaration binds IFC4 (an in-memory model; the 0.2.0 behaviour). A
343/// consumer binds its vocabulary with this answer instead of re-parsing the
344/// header.
345///
346/// # Errors
347///
348/// [`ClassificationError::MultipleSchemas`] when the header declares several
349/// schemas, and [`ClassificationError::UnsupportedSchema`] when it declares
350/// one this crate is not verified for (including IFC4X1 and IFC4X2).
351/// Neither is read as IFC4.
352pub fn classification_schema(model: &Model) -> ClassificationResult<SchemaVersion> {
353 Release::of(model).bound().map(|(version, _)| version)
354}
355
356#[cfg(test)]
357mod binding_tests;
358#[cfg(test)]
359mod tests;
360
361#[cfg(test)]
362mod intermediate_release_tests {
363 use super::*;
364
365 /// IFC4X1 and IFC4X2 have bundled tables but no verified layout here:
366 /// refused with the unsupported-schema error, never read as IFC4/IFC4X3.
367 #[test]
368 fn ifc4x1_and_ifc4x2_are_refused_not_aliased() {
369 for token in ["IFC4X1", "IFC4X2"] {
370 let mut model = Model::new();
371 model.header_mut().schema = vec![token.to_owned()];
372 assert!(
373 matches!(Release::of(&model).bound(), Err(ClassificationError::UnsupportedSchema { schema }) if schema == token),
374 "{token} must be refused"
375 );
376 }
377 }
378}