Skip to main content

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