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    ///
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}