ifc_model/mutation/transaction.rs
1//! Validate a batch of edits, then apply it as a unit.
2//!
3//! # Why the direct edit methods are not enough
4//!
5//! [`Model::set_attribute`], [`Model::retype`] and [`Model::remove`] each do
6//! one thing correctly and immediately. That is right for a codec and wrong
7//! for an author: `remove` is documented to leave every reference to the
8//! removed entity dangling, so deleting a storey that walls still reference
9//! produces a file that parses and is wrong.
10//!
11//! A transaction closes that gap without changing the primitives. It collects
12//! edits, checks them against a PROJECTED view of the model -- what would
13//! exist if the whole batch applied -- and only then writes.
14//!
15//! # Atomicity without an undo log
16//!
17//! Every failure mode is decided during preflight. Once preflight returns
18//! clean, apply consists of map inserts, slot writes and map removals against
19//! entities already proven to exist. None of those can fail, so there is
20//! nothing to roll back and no half-applied state to observe.
21//!
22//! That is a stronger guarantee than "we rolled back on error", because a
23//! rollback path is itself code that can be wrong and is exercised only on
24//! failure. Here the failure path never touches the model at all.
25//!
26//! # Projection is what makes co-dependent edits expressible
27//!
28//! Checking each edit against the CURRENT model would reject a transaction
29//! that creates an entity and references it in the same batch, and would
30//! accept one that removes an entity another edit still points at. Both
31//! answers are wrong. Validating against the projected end state gets both
32//! right, and is the reason "delete a storey and re-parent its walls" is a
33//! single atomic edit rather than a sequence with a broken middle.
34//!
35//! ```
36//! use ifc_model::{Entity, Model, Transaction, Value};
37//!
38//! let mut model = Model::new();
39//! let storey = model.push(Entity::new("IFCBUILDINGSTOREY", vec![]));
40//!
41//! // A wall that references the storey.
42//! let mut tx = Transaction::new(&model);
43//! let wall = tx.create(Entity::new("IFCWALL", vec![Value::Ref(storey)]));
44//! tx.commit(&mut model).expect("the storey exists");
45//!
46//! // Removing the storey alone is refused: the wall still points at it.
47//! let mut tx = Transaction::new(&model);
48//! tx.remove(storey);
49//! assert!(tx.commit(&mut model).is_err());
50//!
51//! // Removing both together is fine -- nothing survives to dangle.
52//! let mut tx = Transaction::new(&model);
53//! tx.remove(storey);
54//! tx.remove(wall);
55//! assert!(tx.commit(&mut model).is_ok());
56//! ```
57
58use ahash::{AHashMap, AHashSet};
59
60use crate::entity::Entity;
61use crate::index::ReverseIndex;
62use crate::model::Model;
63use crate::mutation::conflict::Conflict;
64use crate::value::{EntityId, Value};
65
66/// One staged change.
67///
68/// Deliberately structural: there is no `set_name` or `set_material` here.
69/// Domain-shaped authoring belongs in the crate that owns the domain, built
70/// on top of these operations -- otherwise every schema concept leaks into
71/// the model layer, which is the boundary this crate exists to hold.
72#[derive(Debug, Clone, PartialEq)]
73pub enum Edit {
74 /// Create an entity under a reserved id.
75 Create {
76 /// The id reserved for it by [`Transaction::create`].
77 id: EntityId,
78 /// The entity to store.
79 entity: Entity,
80 },
81 /// Replace one positional attribute.
82 SetAttribute {
83 /// The entity to edit.
84 id: EntityId,
85 /// Attribute slot.
86 slot: usize,
87 /// New value.
88 value: Value,
89 },
90 /// Change an entity's type name, keeping its id and attributes.
91 Retype {
92 /// The entity to retype.
93 id: EntityId,
94 /// The new type name.
95 type_name: std::sync::Arc<str>,
96 },
97 /// Delete an entity.
98 Remove {
99 /// The entity to delete.
100 id: EntityId,
101 },
102}
103
104/// A batch of edits, validated together and applied as a unit.
105///
106/// Opened against a model snapshot and carrying that snapshot's revision, so
107/// a commit against a model that has since changed is refused rather than
108/// applied to state the caller never saw.
109#[derive(Debug, Clone)]
110pub struct Transaction {
111 revision: u64,
112 next_id: u64,
113 edits: Vec<Edit>,
114}
115
116impl Transaction {
117 /// Open a transaction against a model.
118 #[must_use]
119 pub fn new(model: &Model) -> Self {
120 Self {
121 revision: model.revision(),
122 next_id: model.next_id().0,
123 edits: Vec::new(),
124 }
125 }
126
127 /// The model revision this transaction was opened against.
128 #[must_use]
129 pub fn revision(&self) -> u64 {
130 self.revision
131 }
132
133 /// The staged edits, in the order they were added.
134 #[must_use]
135 pub fn edits(&self) -> &[Edit] {
136 &self.edits
137 }
138
139 /// Whether anything is staged.
140 #[must_use]
141 pub fn is_empty(&self) -> bool {
142 self.edits.is_empty()
143 }
144
145 /// Number of staged edits.
146 #[must_use]
147 pub fn len(&self) -> usize {
148 self.edits.len()
149 }
150
151 /// Stage a new entity, returning the id reserved for it.
152 ///
153 /// The id is allocated from the transaction, not the model, so several
154 /// creates in one batch cannot collide and the caller can reference a
155 /// newly created entity from another edit in the same transaction before
156 /// anything is written.
157 pub fn create(&mut self, entity: Entity) -> EntityId {
158 #[cfg(feature = "authored-dump")]
159 crate::authored_dump::record(&entity.type_name, "create");
160 let id = EntityId(self.next_id);
161 self.next_id += 1;
162 self.edits.push(Edit::Create { id, entity });
163 id
164 }
165
166 /// Stage an already-constructed edit.
167 ///
168 /// [`Transaction::create`] allocates ids and is what an author should
169 /// use. This exists for replaying a batch that was built elsewhere --
170 /// deserialized, or produced by another process -- where the edit list
171 /// is data rather than a sequence of calls. Preflight checks it exactly
172 /// the same way, so a replayed batch cannot bypass validation.
173 pub fn stage(&mut self, edit: Edit) -> &mut Self {
174 if let Edit::Create { id, .. } = &edit {
175 // Keep the allocator ahead of any id staged this way, so a later
176 // `create` cannot hand out an id this batch already occupies.
177 self.next_id = self.next_id.max(id.0 + 1);
178 }
179 self.edits.push(edit);
180 self
181 }
182
183 /// Stage an attribute write.
184 pub fn set_attribute(&mut self, id: EntityId, slot: usize, value: Value) -> &mut Self {
185 self.edits.push(Edit::SetAttribute { id, slot, value });
186 self
187 }
188
189 /// Stage a type change.
190 pub fn retype(&mut self, id: EntityId, type_name: impl Into<std::sync::Arc<str>>) -> &mut Self {
191 self.edits.push(Edit::Retype {
192 id,
193 type_name: type_name.into(),
194 });
195 self
196 }
197
198 /// Stage a removal.
199 pub fn remove(&mut self, id: EntityId) -> &mut Self {
200 self.edits.push(Edit::Remove { id });
201 self
202 }
203
204 /// Check every edit without touching the model.
205 ///
206 /// Returns every conflict found rather than the first: an author fixing a
207 /// batch wants the whole list, and stopping at the first turns one review
208 /// into N round trips.
209 ///
210 /// Conflicts are ordered by the edit that produced them, so the report
211 /// reads in the order the caller wrote the batch.
212 #[must_use]
213 pub fn preflight(&self, model: &Model) -> Vec<Conflict> {
214 let mut conflicts = Vec::new();
215
216 if model.revision() != self.revision {
217 conflicts.push(Conflict::StaleRevision {
218 expected: self.revision,
219 found: model.revision(),
220 });
221 // Every other check would be computed against a model the caller
222 // never saw, so the results would be noise. Report the one fact
223 // that matters and stop.
224 return conflicts;
225 }
226
227 // --- project the end state -------------------------------------
228 let mut created: AHashMap<EntityId, &Entity> = AHashMap::new();
229 let mut removed: AHashSet<EntityId> = AHashSet::new();
230 // Slot writes, keyed by entity then slot: a later edit to the same
231 // slot wins, matching apply order.
232 let mut writes: AHashMap<EntityId, AHashMap<usize, &Value>> = AHashMap::new();
233
234 for edit in &self.edits {
235 match edit {
236 Edit::Create { id, entity } => {
237 created.insert(*id, entity);
238 removed.remove(id);
239 }
240 Edit::Remove { id } => {
241 removed.insert(*id);
242 }
243 Edit::SetAttribute { id, slot, value } => {
244 writes.entry(*id).or_default().insert(*slot, value);
245 }
246 Edit::Retype { .. } => {}
247 }
248 }
249
250 let exists = |id: EntityId| -> bool {
251 !removed.contains(&id) && (created.contains_key(&id) || model.get(id).is_some())
252 };
253
254 // --- per-edit checks -------------------------------------------
255 for (index, edit) in self.edits.iter().enumerate() {
256 match edit {
257 Edit::Create { id, entity } => {
258 if model.get(*id).is_some() {
259 conflicts.push(Conflict::IdAlreadyExists {
260 edit: index,
261 id: *id,
262 });
263 }
264 for (slot, attribute) in entity.attributes.iter().enumerate() {
265 // A slot overwritten later in the same batch is not
266 // what will be stored, so checking it would reject a
267 // batch that is actually coherent.
268 if writes.get(id).is_some_and(|w| w.contains_key(&slot)) {
269 continue;
270 }
271 check_refs(attribute, index, *id, slot, &exists, &mut conflicts);
272 }
273 }
274 Edit::SetAttribute { id, slot, value } => {
275 if !exists(*id) {
276 conflicts.push(Conflict::MissingTarget {
277 edit: index,
278 id: *id,
279 });
280 continue;
281 }
282 // Only the winning write for a slot is checked; an earlier
283 // superseded one never reaches the model.
284 if writes
285 .get(id)
286 .and_then(|w| w.get(slot))
287 .is_some_and(|winner| !std::ptr::eq(*winner, value))
288 {
289 continue;
290 }
291 check_refs(value, index, *id, *slot, &exists, &mut conflicts);
292 }
293 Edit::Retype { id, .. } => {
294 if !exists(*id) {
295 conflicts.push(Conflict::MissingTarget {
296 edit: index,
297 id: *id,
298 });
299 }
300 }
301 Edit::Remove { id } => {
302 if model.get(*id).is_none() && !created.contains_key(id) {
303 conflicts.push(Conflict::MissingTarget {
304 edit: index,
305 id: *id,
306 });
307 }
308 }
309 }
310 }
311
312 // --- removals must not orphan surviving references --------------
313 //
314 // Built once for the whole batch rather than per removal: the index is
315 // a single scan, and a batch deleting a hundred entities would
316 // otherwise rescan the model a hundred times.
317 if self.edits.iter().any(|e| matches!(e, Edit::Remove { .. })) {
318 let index = ReverseIndex::build(model);
319 // Staging the same removal twice is one problem, not two: report
320 // it against the first occurrence only.
321 let mut reported: AHashSet<EntityId> = AHashSet::new();
322 for (position, edit) in self.edits.iter().enumerate() {
323 let Edit::Remove { id } = edit else { continue };
324 if !reported.insert(*id) {
325 continue;
326 }
327 for referrer in index.referrers(*id) {
328 // A referrer that is itself going away cannot dangle.
329 if removed.contains(&referrer.from) {
330 continue;
331 }
332 // A slot being rewritten in this batch is governed by the
333 // new value, which the per-edit check already validated.
334 if writes
335 .get(&referrer.from)
336 .is_some_and(|w| w.contains_key(&referrer.slot))
337 {
338 continue;
339 }
340 conflicts.push(Conflict::RemovalWouldDangle {
341 edit: position,
342 removed: *id,
343 referrer: referrer.from,
344 slot: referrer.slot,
345 });
346 }
347 }
348 }
349
350 conflicts
351 }
352
353 /// Validate and apply, or report every conflict and change nothing.
354 ///
355 /// On `Err` the model is untouched: preflight runs to completion before
356 /// the first write, so a rejected transaction cannot leave partial state.
357 pub fn commit(self, model: &mut Model) -> Result<Applied, Vec<Conflict>> {
358 let conflicts = self.preflight(model);
359 if !conflicts.is_empty() {
360 return Err(conflicts);
361 }
362
363 let mut applied = Applied {
364 created: Vec::new(),
365 removed: Vec::new(),
366 revision: 0,
367 };
368
369 for edit in self.edits {
370 match edit {
371 Edit::Create { id, entity } => {
372 model.insert(id, entity);
373 applied.created.push(id);
374 }
375 Edit::SetAttribute { id, slot, value } => {
376 // Preflight proved the entity exists.
377 model.set_attribute(id, slot, value);
378 }
379 Edit::Retype { id, type_name } => {
380 model.retype(id, type_name);
381 }
382 Edit::Remove { id } => {
383 if let Some(entity) = model.remove(id) {
384 applied.removed.push((id, entity));
385 }
386 }
387 }
388 }
389
390 applied.revision = model.revision();
391 Ok(applied)
392 }
393}
394
395/// What a committed transaction did.
396#[derive(Debug, Clone, PartialEq)]
397#[non_exhaustive]
398pub struct Applied {
399 /// Ids created, in commit order.
400 pub created: Vec<EntityId>,
401 /// Entities removed, with their contents.
402 ///
403 /// Returned rather than dropped so a caller can undo, log, or re-file
404 /// them. A delete that silently discards the payload makes an editor's
405 /// undo stack impossible to build.
406 pub removed: Vec<(EntityId, Entity)>,
407 /// The model revision after the commit.
408 pub revision: u64,
409}
410
411/// Record a conflict for every reference in `value` that will not resolve.
412fn check_refs(
413 value: &Value,
414 edit: usize,
415 from: EntityId,
416 slot: usize,
417 exists: &impl Fn(EntityId) -> bool,
418 conflicts: &mut Vec<Conflict>,
419) {
420 value.for_each_ref(&mut |target| {
421 if !exists(target) {
422 conflicts.push(Conflict::DanglingReference {
423 edit,
424 from,
425 slot,
426 target,
427 });
428 }
429 });
430}