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}