Skip to main content

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}