Skip to main content

corium_tx/
lib.rs

1//! Pure transaction expansion, entity resolution, and validation.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use corium_core::{Cardinality, Datom, EntityId, Partition, Unique, Value};
6use corium_db::{Db, FIRST_USER_ID};
7use thiserror::Error;
8
9/// A temporary entity identifier scoped to one transaction.
10pub type TempId = String;
11
12/// An entity position accepted by transaction operations.
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub enum EntityRef {
15    /// A concrete entity id.
16    Id(EntityId),
17    /// A transaction-local identifier.
18    Temp(TempId),
19    /// A unique attribute/value lookup.
20    Lookup(EntityId, Value),
21}
22
23/// A transaction operation after boundary conversion.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub enum TxOp {
26    /// Assert a fact.
27    Add(EntityRef, EntityId, Value),
28    /// Retract a fact.
29    Retract(EntityRef, EntityId, Value),
30    /// Compare and swap a cardinality-one value.
31    Cas(EntityRef, EntityId, Option<Value>, Value),
32    /// Recursively retract an entity and its component children.
33    RetractEntity(EntityRef),
34}
35
36/// A map-form entity; each `(attribute, values)` entry expands to additions.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct EntityMap {
39    /// Entity position.
40    pub entity: EntityRef,
41    /// Attribute values.
42    pub attributes: Vec<(EntityId, Vec<Value>)>,
43}
44
45/// Transaction input supporting list and map forms.
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub enum TxItem {
48    /// List-form operation.
49    Op(TxOp),
50    /// Map-form entity.
51    Map(EntityMap),
52}
53
54/// Successfully prepared transaction.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct PreparedTx {
57    /// Resolved datoms.
58    pub datoms: Vec<Datom>,
59    /// Allocations/upserts for caller tempids.
60    pub tempids: BTreeMap<TempId, EntityId>,
61}
62
63/// Transaction validation error.
64#[derive(Debug, Error, Eq, PartialEq)]
65pub enum TxError {
66    /// Attribute is absent from schema.
67    #[error("unknown attribute {0:?}")]
68    UnknownAttribute(EntityId),
69    /// Value does not match attribute type.
70    #[error("value has wrong type for attribute {0:?}")]
71    TypeMismatch(EntityId),
72    /// A lookup ref did not resolve.
73    #[error("lookup ref did not resolve")]
74    LookupNotFound,
75    /// Lookup refs require unique attributes.
76    #[error("lookup attribute is not unique")]
77    LookupNotUnique,
78    /// A uniqueness constraint would be violated.
79    #[error("unique value conflict")]
80    UniqueConflict,
81    /// CAS old value did not match.
82    #[error("compare-and-swap failed")]
83    CasFailed,
84    /// CAS is only valid for cardinality one.
85    #[error("compare-and-swap requires cardinality one")]
86    CasCardinality,
87}
88
89/// Expands and validates transaction input against `db`.
90///
91/// `tx` is the already allocated transaction entity id. Allocation begins at
92/// `next_user_sequence`, making the function deterministic and easy to model-test.
93///
94/// # Errors
95///
96/// Returns [`TxError`] when entity resolution, schema validation, uniqueness,
97/// or a built-in operation fails.
98#[allow(clippy::too_many_lines)]
99pub fn prepare(
100    db: &Db,
101    items: impl IntoIterator<Item = TxItem>,
102    tx: EntityId,
103    next_user_sequence: u64,
104) -> Result<PreparedTx, TxError> {
105    let mut ops = Vec::new();
106    for item in items {
107        match item {
108            TxItem::Op(op) => ops.push(op),
109            TxItem::Map(map) => {
110                for (a, values) in map.attributes {
111                    for value in values {
112                        ops.push(TxOp::Add(map.entity.clone(), a, value));
113                    }
114                }
115            }
116        }
117    }
118    let mut tempids = BTreeMap::new();
119    // Identity assertions unify a tempid with an existing entity before allocation.
120    for op in &ops {
121        if let TxOp::Add(EntityRef::Temp(temp), a, value) = op
122            && db.schema().get(*a).and_then(|x| x.unique) == Some(Unique::Identity)
123            && let Some(e) = db.lookup(*a, value)
124        {
125            tempids.insert(temp.clone(), e);
126        }
127    }
128    let mut next = next_user_sequence.max(FIRST_USER_ID);
129    for op in &ops {
130        let entity = match op {
131            TxOp::Add(e, ..) | TxOp::Retract(e, ..) | TxOp::Cas(e, ..) | TxOp::RetractEntity(e) => {
132                e
133            }
134        };
135        if let EntityRef::Temp(temp) = entity {
136            tempids.entry(temp.clone()).or_insert_with(|| {
137                let e = EntityId::new(Partition::User as u32, next);
138                next += 1;
139                e
140            });
141        }
142    }
143    let resolve = |entity: &EntityRef| -> Result<EntityId, TxError> {
144        match entity {
145            EntityRef::Id(e) => Ok(*e),
146            EntityRef::Temp(t) => Ok(tempids[t]),
147            EntityRef::Lookup(a, v) => {
148                let attr = db.schema().get(*a).ok_or(TxError::UnknownAttribute(*a))?;
149                if attr.unique.is_none() {
150                    return Err(TxError::LookupNotUnique);
151                }
152                db.lookup(*a, v).ok_or(TxError::LookupNotFound)
153            }
154        }
155    };
156    let mut datoms = Vec::new();
157    let mut working = db.clone();
158    for op in ops {
159        let start = datoms.len();
160        match op {
161            TxOp::Add(entity, a, v) => {
162                let e = resolve(&entity)?;
163                validate(&working, a, &v)?;
164                if let Some(attr) = working.schema().get(a) {
165                    if attr.unique.is_some()
166                        && working.lookup(a, &v).is_some_and(|owner| owner != e)
167                    {
168                        return Err(TxError::UniqueConflict);
169                    }
170                    let current = working.values(e, a);
171                    // Re-asserting a present fact is a no-op: no datom recorded.
172                    if current.contains(&v) {
173                        continue;
174                    }
175                    if attr.cardinality == Cardinality::One {
176                        for old in current {
177                            datoms.push(Datom {
178                                e,
179                                a,
180                                v: old,
181                                tx,
182                                added: false,
183                            });
184                        }
185                    }
186                }
187                datoms.push(Datom {
188                    e,
189                    a,
190                    v,
191                    tx,
192                    added: true,
193                });
194            }
195            TxOp::Retract(entity, a, v) => {
196                validate(&working, a, &v)?;
197                let e = resolve(&entity)?;
198                // Retracting an absent fact is a no-op: no datom recorded.
199                if working.values(e, a).contains(&v) {
200                    datoms.push(Datom {
201                        e,
202                        a,
203                        v,
204                        tx,
205                        added: false,
206                    });
207                }
208            }
209            TxOp::Cas(entity, a, old, new) => {
210                validate(&working, a, &new)?;
211                let e = resolve(&entity)?;
212                if working
213                    .schema()
214                    .get(a)
215                    .is_none_or(|x| x.cardinality != Cardinality::One)
216                {
217                    return Err(TxError::CasCardinality);
218                }
219                let current = working.values(e, a).into_iter().next();
220                if current != old {
221                    return Err(TxError::CasFailed);
222                }
223                if let Some(value) = current {
224                    datoms.push(Datom {
225                        e,
226                        a,
227                        v: value,
228                        tx,
229                        added: false,
230                    });
231                }
232                datoms.push(Datom {
233                    e,
234                    a,
235                    v: new,
236                    tx,
237                    added: true,
238                });
239            }
240            TxOp::RetractEntity(entity) => {
241                let mut facts = BTreeSet::new();
242                collect_entity_retractions(
243                    &working,
244                    resolve(&entity)?,
245                    &mut facts,
246                    &mut BTreeSet::new(),
247                );
248                datoms.extend(facts.into_iter().map(|(e, a, v)| Datom {
249                    e,
250                    a,
251                    v,
252                    tx,
253                    added: false,
254                }));
255            }
256        }
257        working = working.with_transaction(working.basis_t() + 1, &datoms[start..]);
258    }
259    Ok(PreparedTx { datoms, tempids })
260}
261
262fn validate(db: &Db, a: EntityId, value: &Value) -> Result<(), TxError> {
263    let attr = db.schema().get(a).ok_or(TxError::UnknownAttribute(a))?;
264    if !value.has_type(attr.value_type) {
265        return Err(TxError::TypeMismatch(a));
266    }
267    Ok(())
268}
269
270/// Collects the current facts removed by `:db/retractEntity` for `e`:
271/// the entity's own datoms, incoming references to it, and (recursively)
272/// its component children. Deduplicated by `(e, a, v)` because a component
273/// child's outgoing-ref datom is also an incoming reference to the child.
274fn collect_entity_retractions(
275    db: &Db,
276    e: EntityId,
277    facts: &mut BTreeSet<(EntityId, EntityId, Value)>,
278    seen: &mut BTreeSet<EntityId>,
279) {
280    if !seen.insert(e) {
281        return;
282    }
283    for datom in db.datoms() {
284        if datom.e == e {
285            if db.schema().get(datom.a).is_some_and(|a| a.is_component)
286                && let Value::Ref(child) = &datom.v
287            {
288                collect_entity_retractions(db, *child, facts, seen);
289            }
290            facts.insert((datom.e, datom.a, datom.v));
291        } else if datom.v == Value::Ref(e) {
292            facts.insert((datom.e, datom.a, datom.v));
293        }
294    }
295}