1use 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
9pub type TempId = String;
11
12#[derive(Clone, Debug, Eq, PartialEq)]
14pub enum EntityRef {
15 Id(EntityId),
17 Temp(TempId),
19 Lookup(EntityId, Value),
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
25pub enum TxOp {
26 Add(EntityRef, EntityId, Value),
28 Retract(EntityRef, EntityId, Value),
30 Cas(EntityRef, EntityId, Option<Value>, Value),
32 RetractEntity(EntityRef),
34}
35
36#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct EntityMap {
39 pub entity: EntityRef,
41 pub attributes: Vec<(EntityId, Vec<Value>)>,
43}
44
45#[derive(Clone, Debug, Eq, PartialEq)]
47pub enum TxItem {
48 Op(TxOp),
50 Map(EntityMap),
52}
53
54#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct PreparedTx {
57 pub datoms: Vec<Datom>,
59 pub tempids: BTreeMap<TempId, EntityId>,
61}
62
63#[derive(Debug, Error, Eq, PartialEq)]
65pub enum TxError {
66 #[error("unknown attribute {0:?}")]
68 UnknownAttribute(EntityId),
69 #[error("value has wrong type for attribute {0:?}")]
71 TypeMismatch(EntityId),
72 #[error("lookup ref did not resolve")]
74 LookupNotFound,
75 #[error("lookup attribute is not unique")]
77 LookupNotUnique,
78 #[error("unique value conflict")]
80 UniqueConflict,
81 #[error("compare-and-swap failed")]
83 CasFailed,
84 #[error("compare-and-swap requires cardinality one")]
86 CasCardinality,
87}
88
89#[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 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 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 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
270fn 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}