ifc_author/builder/entity.rs
1//! Build one entity by naming its attributes.
2//!
3//! # The problem this solves
4//!
5//! `Model::push` takes a positional `Vec<Value>`. Authoring an `IfcAnnotation`
6//! therefore means knowing that it has seven slots, that slot 0 is the
7//! GlobalId, slot 2 the name, and that slots 1 and 3..6 are optional. Get the
8//! count wrong and the file still writes -- and is rejected downstream.
9//!
10//! Here the caller names attributes and the schema decides the positions.
11//!
12//! # Attribute names match case-insensitively
13//!
14//! Names are compared with `eq_ignore_ascii_case`, so an IFC4 and IFC4X3
15//! spelling that differs only in case resolves to the same slot and is not a
16//! bug. A true rename between releases is a different name and is refused as
17//! unknown in the release that lacks it.
18
19use ifc_model::{Entity, Value};
20use ifc_schema::Schema;
21
22use crate::check::{aggregate_element, describe_value, is_derived_slot, judge_value, Verdict};
23use crate::error::{AuthorError, AuthorResult};
24
25/// A partially-specified entity, checked against the schema on [`build`].
26///
27/// [`build`]: EntityBuilder::build
28#[derive(Debug, Clone)]
29pub struct EntityBuilder<'a> {
30 schema: &'a Schema,
31 entity: String,
32 /// Attribute name and value, in the order the caller set them. Kept as a
33 /// list rather than a map so a duplicate set is detectable and the error
34 /// can name the attribute.
35 set: Vec<(String, Value)>,
36}
37
38impl<'a> EntityBuilder<'a> {
39 /// Start building `entity`, whose type must be declared by `schema`.
40 ///
41 /// The type is not checked here: an unknown type is reported by [`build`],
42 /// so that a caller assembling several entities gets every failure at the
43 /// same point in its code rather than some at construction and some later.
44 ///
45 /// [`build`]: EntityBuilder::build
46 pub fn new(schema: &'a Schema, entity: impl Into<String>) -> Self {
47 Self {
48 schema,
49 entity: entity.into(),
50 set: Vec::new(),
51 }
52 }
53
54 /// Set one attribute by its declared name.
55 ///
56 /// Names are matched case-insensitively, because EXPRESS declares
57 /// `GlobalId` while STEP files and much prose write `GLOBALID`.
58 #[must_use]
59 pub fn set(mut self, attribute: impl Into<String>, value: Value) -> Self {
60 self.set.push((attribute.into(), value));
61 self
62 }
63
64 /// Set a text attribute.
65 pub fn text(self, attribute: impl Into<String>, text: impl Into<std::sync::Arc<str>>) -> Self {
66 self.set(attribute, Value::Text(text.into()))
67 }
68
69 /// Set a real-valued attribute.
70 pub fn real(self, attribute: impl Into<String>, value: f64) -> Self {
71 self.set(attribute, Value::Real(value))
72 }
73
74 /// Set an entity-reference attribute.
75 pub fn reference(self, attribute: impl Into<String>, id: ifc_model::EntityId) -> Self {
76 self.set(attribute, Value::Ref(id))
77 }
78
79 /// Set an enumeration attribute (written `.PLAN_VIEW.` in STEP).
80 pub fn enumeration(
81 self,
82 attribute: impl Into<String>,
83 constant: impl Into<std::sync::Arc<str>>,
84 ) -> Self {
85 self.set(attribute, Value::Enum(constant.into()))
86 }
87}
88
89impl EntityBuilder<'_> {
90 /// Resolve every named attribute to its STEP slot and produce the entity.
91 ///
92 /// # Errors
93 ///
94 /// Refuses an unknown entity type, an attribute the schema does not
95 /// declare, an attribute set twice, a required attribute left unset, a
96 /// scalar/aggregate confusion, a value whose shape contradicts the declared
97 /// type, and a malformed GlobalId.
98 pub fn build(self) -> AuthorResult<Entity> {
99 let declared = self.schema.attributes(&self.entity);
100 if declared.is_empty() && self.schema.entity(&self.entity).is_none() {
101 return Err(AuthorError::UnknownEntity {
102 entity: self.entity,
103 });
104 }
105
106 // Positional order comes from the schema: inherited attributes first,
107 // which is what makes a STEP record readable by anything else. A slot
108 // the schema derives for this entity is written `*` unless set.
109 let mut slots: Vec<Value> = declared
110 .iter()
111 .map(|attribute| {
112 if is_derived_slot(self.schema, &self.entity, &attribute.name) {
113 Value::Derived
114 } else {
115 Value::Null
116 }
117 })
118 .collect();
119 let mut filled = vec![false; declared.len()];
120
121 for (name, value) in &self.set {
122 let Some(index) = declared
123 .iter()
124 .position(|a| a.name.eq_ignore_ascii_case(name))
125 else {
126 return Err(AuthorError::UnknownAttribute {
127 entity: self.entity.clone(),
128 attribute: name.clone(),
129 known: declared.iter().map(|a| a.name.clone()).collect(),
130 });
131 };
132 if filled[index] {
133 return Err(AuthorError::DuplicateAttribute {
134 entity: self.entity.clone(),
135 attribute: declared[index].name.clone(),
136 });
137 }
138 check_value(self.schema, &self.entity, declared[index], value)?;
139 slots[index] = value.clone();
140 filled[index] = true;
141 }
142
143 for (index, attribute) in declared.iter().enumerate() {
144 if !filled[index] && !attribute.optional && !matches!(slots[index], Value::Derived) {
145 return Err(AuthorError::MissingRequired {
146 entity: self.entity.clone(),
147 attribute: attribute.name.clone(),
148 });
149 }
150 }
151
152 Ok(Entity::new(self.entity.to_ascii_uppercase(), slots))
153 }
154
155 /// Build the entity and append it to `model`, returning its new id.
156 ///
157 /// The model is left untouched when construction fails, so a rejected
158 /// entity cannot leave a half-written record behind.
159 ///
160 /// # Errors
161 ///
162 /// Any failure from [`build`](EntityBuilder::build).
163 pub fn insert(self, model: &mut ifc_model::Model) -> AuthorResult<ifc_model::EntityId> {
164 let entity = self.build()?;
165 Ok(model.push(entity))
166 }
167}
168
169/// Check one value against one declared attribute.
170///
171/// Split out as a free function because it borrows the schema and the entity
172/// name while the builder's `set` list is being consumed.
173pub(crate) fn check_value(
174 schema: &Schema,
175 entity: &str,
176 attribute: &ifc_schema::Attribute,
177 value: &Value,
178) -> AuthorResult<()> {
179 // A derived slot admits exactly `*`, and `*` fits nowhere else.
180 let derived = is_derived_slot(schema, entity, &attribute.name);
181 match (derived, value) {
182 (true, Value::Derived) => return Ok(()),
183 (true, other) => {
184 return Err(AuthorError::DerivedAttribute {
185 entity: entity.to_owned(),
186 attribute: attribute.name.clone(),
187 found: describe_value(other),
188 })
189 }
190 (false, Value::Derived) => {
191 return Err(AuthorError::NotDerived {
192 entity: entity.to_owned(),
193 attribute: attribute.name.clone(),
194 })
195 }
196 (false, _) => {}
197 }
198
199 // An aggregate declaration wants a list and a scalar declaration does not.
200 // The `LIST` may sit in the attribute declaration or in a defined type the
201 // attribute is declared as (`IfcCompoundPlaneAngleMeasure`). `$` is exempt:
202 // an unset optional aggregate is still `$`, not `()`.
203 //
204 // An empty `()` is invalid for a `LIST [1:?]` / `SET [1:?]` attribute: a
205 // caller writes `$` when the attribute is optional and must not build the
206 // entity when it is required. That cannot be refused here, because the
207 // schema tables keep no aggregate bounds. TODO(#111)
208 let aliased = if attribute.aggregate {
209 None
210 } else {
211 aggregate_element(schema, &attribute.type_name)
212 };
213 let expected_aggregate = attribute.aggregate || aliased.is_some();
214 if !matches!(value, Value::Null) {
215 let supplied_aggregate = matches!(value, Value::List(_));
216 if supplied_aggregate != expected_aggregate {
217 return Err(AuthorError::AggregateMismatch {
218 entity: entity.to_owned(),
219 attribute: attribute.name.clone(),
220 expected_aggregate,
221 });
222 }
223 }
224
225 // GlobalId is the one attribute whose *content* is worth checking here: it
226 // is the only stable cross-file identity an element has, and a malformed
227 // one silently breaks diffing and issue tracking rather than failing loudly.
228 if attribute.name.eq_ignore_ascii_case("GlobalId") {
229 if let Value::Text(text) = value {
230 if ifc_model::guid::Guid::parse(text).is_none() {
231 return Err(AuthorError::InvalidGlobalId {
232 entity: entity.to_owned(),
233 found: text.to_string(),
234 });
235 }
236 }
237 }
238
239 // Aggregate element types are checked per item; the declaration names the
240 // element type, not the container. For an aliased aggregate the element
241 // type is the alias's, e.g. `INTEGER` for `IfcCompoundPlaneAngleMeasure`.
242 //
243 // The first refused item is the one reported; a wrong type is reported
244 // before a wrong form, since rewrapping would not fix it.
245 let element_type = aliased.as_deref().unwrap_or(&attribute.type_name);
246 let verdicts: Vec<(Verdict, &Value)> = match value {
247 Value::List(items) => items
248 .iter()
249 .map(|item| (judge_value(schema, element_type, item), item))
250 .collect(),
251 scalar => vec![(judge_value(schema, &attribute.type_name, scalar), scalar)],
252 };
253 if verdicts
254 .iter()
255 .any(|(verdict, _)| *verdict == Verdict::WrongType)
256 {
257 return Err(AuthorError::TypeMismatch {
258 entity: entity.to_owned(),
259 attribute: attribute.name.clone(),
260 expected: attribute.type_name.clone(),
261 found: describe_value(value),
262 });
263 }
264 if let Some((Verdict::WrongForm { typed_required }, offending)) = verdicts
265 .into_iter()
266 .find(|(verdict, _)| matches!(verdict, Verdict::WrongForm { .. }))
267 {
268 return Err(AuthorError::ValueForm {
269 entity: entity.to_owned(),
270 attribute: attribute.name.clone(),
271 declared: element_type.to_owned(),
272 typed_required,
273 found: describe_value(offending),
274 });
275 }
276 Ok(())
277}