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        let id = EntityId(self.next_id);
159        self.next_id += 1;
160        self.edits.push(Edit::Create { id, entity });
161        id
162    }
163
164    /// Stage an already-constructed edit.
165    ///
166    /// [`Transaction::create`] allocates ids and is what an author should
167    /// use. This exists for replaying a batch that was built elsewhere --
168    /// deserialized, or produced by another process -- where the edit list
169    /// is data rather than a sequence of calls. Preflight checks it exactly
170    /// the same way, so a replayed batch cannot bypass validation.
171    pub fn stage(&mut self, edit: Edit) -> &mut Self {
172        if let Edit::Create { id, .. } = &edit {
173            // Keep the allocator ahead of any id staged this way, so a later
174            // `create` cannot hand out an id this batch already occupies.
175            self.next_id = self.next_id.max(id.0 + 1);
176        }
177        self.edits.push(edit);
178        self
179    }
180
181    /// Stage an attribute write.
182    pub fn set_attribute(&mut self, id: EntityId, slot: usize, value: Value) -> &mut Self {
183        self.edits.push(Edit::SetAttribute { id, slot, value });
184        self
185    }
186
187    /// Stage a type change.
188    pub fn retype(&mut self, id: EntityId, type_name: impl Into<std::sync::Arc<str>>) -> &mut Self {
189        self.edits.push(Edit::Retype {
190            id,
191            type_name: type_name.into(),
192        });
193        self
194    }
195
196    /// Stage a removal.
197    pub fn remove(&mut self, id: EntityId) -> &mut Self {
198        self.edits.push(Edit::Remove { id });
199        self
200    }
201
202    /// Check every edit without touching the model.
203    ///
204    /// Returns every conflict found rather than the first: an author fixing a
205    /// batch wants the whole list, and stopping at the first turns one review
206    /// into N round trips.
207    ///
208    /// Conflicts are ordered by the edit that produced them, so the report
209    /// reads in the order the caller wrote the batch.
210    #[must_use]
211    pub fn preflight(&self, model: &Model) -> Vec<Conflict> {
212        let mut conflicts = Vec::new();
213
214        if model.revision() != self.revision {
215            conflicts.push(Conflict::StaleRevision {
216                expected: self.revision,
217                found: model.revision(),
218            });
219            // Every other check would be computed against a model the caller
220            // never saw, so the results would be noise. Report the one fact
221            // that matters and stop.
222            return conflicts;
223        }
224
225        // --- project the end state -------------------------------------
226        let mut created: AHashMap<EntityId, &Entity> = AHashMap::new();
227        let mut removed: AHashSet<EntityId> = AHashSet::new();
228        // Slot writes, keyed by entity then slot: a later edit to the same
229        // slot wins, matching apply order.
230        let mut writes: AHashMap<EntityId, AHashMap<usize, &Value>> = AHashMap::new();
231
232        for edit in &self.edits {
233            match edit {
234                Edit::Create { id, entity } => {
235                    created.insert(*id, entity);
236                    removed.remove(id);
237                }
238                Edit::Remove { id } => {
239                    removed.insert(*id);
240                }
241                Edit::SetAttribute { id, slot, value } => {
242                    writes.entry(*id).or_default().insert(*slot, value);
243                }
244                Edit::Retype { .. } => {}
245            }
246        }
247
248        let exists = |id: EntityId| -> bool {
249            !removed.contains(&id) && (created.contains_key(&id) || model.get(id).is_some())
250        };
251
252        // --- per-edit checks -------------------------------------------
253        for (index, edit) in self.edits.iter().enumerate() {
254            match edit {
255                Edit::Create { id, entity } => {
256                    if model.get(*id).is_some() {
257                        conflicts.push(Conflict::IdAlreadyExists {
258                            edit: index,
259                            id: *id,
260                        });
261                    }
262                    for (slot, attribute) in entity.attributes.iter().enumerate() {
263                        // A slot overwritten later in the same batch is not
264                        // what will be stored, so checking it would reject a
265                        // batch that is actually coherent.
266                        if writes.get(id).is_some_and(|w| w.contains_key(&slot)) {
267                            continue;
268                        }
269                        check_refs(attribute, index, *id, slot, &exists, &mut conflicts);
270                    }
271                }
272                Edit::SetAttribute { id, slot, value } => {
273                    if !exists(*id) {
274                        conflicts.push(Conflict::MissingTarget {
275                            edit: index,
276                            id: *id,
277                        });
278                        continue;
279                    }
280                    // Only the winning write for a slot is checked; an earlier
281                    // superseded one never reaches the model.
282                    if writes
283                        .get(id)
284                        .and_then(|w| w.get(slot))
285                        .is_some_and(|winner| !std::ptr::eq(*winner, value))
286                    {
287                        continue;
288                    }
289                    check_refs(value, index, *id, *slot, &exists, &mut conflicts);
290                }
291                Edit::Retype { id, .. } => {
292                    if !exists(*id) {
293                        conflicts.push(Conflict::MissingTarget {
294                            edit: index,
295                            id: *id,
296                        });
297                    }
298                }
299                Edit::Remove { id } => {
300                    if model.get(*id).is_none() && !created.contains_key(id) {
301                        conflicts.push(Conflict::MissingTarget {
302                            edit: index,
303                            id: *id,
304                        });
305                    }
306                }
307            }
308        }
309
310        // --- removals must not orphan surviving references --------------
311        //
312        // Built once for the whole batch rather than per removal: the index is
313        // a single scan, and a batch deleting a hundred entities would
314        // otherwise rescan the model a hundred times.
315        if self.edits.iter().any(|e| matches!(e, Edit::Remove { .. })) {
316            let index = ReverseIndex::build(model);
317            // Staging the same removal twice is one problem, not two: report
318            // it against the first occurrence only.
319            let mut reported: AHashSet<EntityId> = AHashSet::new();
320            for (position, edit) in self.edits.iter().enumerate() {
321                let Edit::Remove { id } = edit else { continue };
322                if !reported.insert(*id) {
323                    continue;
324                }
325                for referrer in index.referrers(*id) {
326                    // A referrer that is itself going away cannot dangle.
327                    if removed.contains(&referrer.from) {
328                        continue;
329                    }
330                    // A slot being rewritten in this batch is governed by the
331                    // new value, which the per-edit check already validated.
332                    if writes
333                        .get(&referrer.from)
334                        .is_some_and(|w| w.contains_key(&referrer.slot))
335                    {
336                        continue;
337                    }
338                    conflicts.push(Conflict::RemovalWouldDangle {
339                        edit: position,
340                        removed: *id,
341                        referrer: referrer.from,
342                        slot: referrer.slot,
343                    });
344                }
345            }
346        }
347
348        conflicts
349    }
350
351    /// Validate and apply, or report every conflict and change nothing.
352    ///
353    /// On `Err` the model is untouched: preflight runs to completion before
354    /// the first write, so a rejected transaction cannot leave partial state.
355    pub fn commit(self, model: &mut Model) -> Result<Applied, Vec<Conflict>> {
356        let conflicts = self.preflight(model);
357        if !conflicts.is_empty() {
358            return Err(conflicts);
359        }
360
361        let mut applied = Applied {
362            created: Vec::new(),
363            removed: Vec::new(),
364            revision: 0,
365        };
366
367        for edit in self.edits {
368            match edit {
369                Edit::Create { id, entity } => {
370                    model.insert(id, entity);
371                    applied.created.push(id);
372                }
373                Edit::SetAttribute { id, slot, value } => {
374                    // Preflight proved the entity exists.
375                    model.set_attribute(id, slot, value);
376                }
377                Edit::Retype { id, type_name } => {
378                    model.retype(id, type_name);
379                }
380                Edit::Remove { id } => {
381                    if let Some(entity) = model.remove(id) {
382                        applied.removed.push((id, entity));
383                    }
384                }
385            }
386        }
387
388        applied.revision = model.revision();
389        Ok(applied)
390    }
391}
392
393/// What a committed transaction did.
394#[derive(Debug, Clone, PartialEq)]
395pub struct Applied {
396    /// Ids created, in commit order.
397    pub created: Vec<EntityId>,
398    /// Entities removed, with their contents.
399    ///
400    /// Returned rather than dropped so a caller can undo, log, or re-file
401    /// them. A delete that silently discards the payload makes an editor's
402    /// undo stack impossible to build.
403    pub removed: Vec<(EntityId, Entity)>,
404    /// The model revision after the commit.
405    pub revision: u64,
406}
407
408/// Record a conflict for every reference in `value` that will not resolve.
409fn check_refs(
410    value: &Value,
411    edit: usize,
412    from: EntityId,
413    slot: usize,
414    exists: &impl Fn(EntityId) -> bool,
415    conflicts: &mut Vec<Conflict>,
416) {
417    value.for_each_ref(&mut |target| {
418        if !exists(target) {
419            conflicts.push(Conflict::DanglingReference {
420                edit,
421                from,
422                slot,
423                target,
424            });
425        }
426    });
427}