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