Skip to main content

corium_forms/
txforms.rs

1//! Boundary conversion from wire EDN transaction forms to engine `TxItem`s.
2//!
3//! Accepts the Datomic-dialect forms used by the conformance corpus: map
4//! forms with `:db/id`, and list forms `[:db/add e a v]`, `[:db/retract e a
5//! v]`, `[:db/cas e a old new]`, `[:db/retractEntity e]`. Entity positions
6//! accept tempid strings, raw entity-id longs, `#eid` tags, idents, and
7//! lookup refs; the transaction's own entity is named by the reserved tempid
8//! `"datomic.tx"` or by `:db/current-tx`, which is how transaction metadata is
9//! asserted. Value positions for `ref` attributes accept the same except
10//! tempid strings (same-transaction value tempids are not supported by the
11//! transaction layer; clients resolve them against prior tempid maps).
12
13use corium_core::{EntityId, Keyword, KeywordInterner, TotalF64, Value, ValueType};
14use corium_db::Db;
15use corium_query::edn::Edn;
16use corium_tx::{EntityMap, EntityRef, TX_TEMPID, TxItem, TxOp};
17use thiserror::Error;
18
19/// Whether a keyword in entity position names the transaction being built.
20fn is_current_tx(keyword: &Keyword) -> bool {
21    keyword.namespace.as_deref() == Some("db") && keyword.name == "current-tx"
22}
23
24/// Transaction form conversion failure.
25#[derive(Debug, Error, Eq, PartialEq)]
26pub enum TxFormError {
27    /// Form is not a map or list form.
28    #[error("bad transaction form: {0}")]
29    BadForm(String),
30    /// Unknown transaction operation keyword.
31    #[error("unknown transaction op {0}")]
32    UnknownOp(String),
33    /// Attribute keyword has no ident.
34    #[error("unknown attribute {0}")]
35    UnknownAttribute(String),
36    /// Entity position not understood.
37    #[error("bad entity position: {0}")]
38    BadEntity(String),
39    /// Value form not convertible for the attribute.
40    #[error("bad value {0}")]
41    BadValue(String),
42}
43
44fn kw(text: &str) -> Edn {
45    Edn::keyword(text)
46}
47
48fn attr_of(db: &Db, form: &Edn) -> Result<EntityId, TxFormError> {
49    let keyword = form
50        .as_keyword()
51        .ok_or_else(|| TxFormError::BadForm(format!("attribute position {form}")))?;
52    db.idents()
53        .entid(keyword)
54        .ok_or_else(|| TxFormError::UnknownAttribute(keyword.to_string()))
55}
56
57fn entity_ref(db: &Db, form: &Edn) -> Result<EntityRef, TxFormError> {
58    match form {
59        Edn::Str(name) => Ok(EntityRef::Temp(name.clone())),
60        Edn::Long(n) => u64::try_from(*n)
61            .map(|raw| EntityRef::Id(EntityId::from_raw(raw)))
62            .map_err(|_| TxFormError::BadEntity(form.to_string())),
63        // `:db/current-tx` names the transaction being built, whose entity id
64        // only the transactor knows; the reserved tempid carries that
65        // intention through to `prepare`.
66        Edn::Keyword(keyword) if is_current_tx(keyword) => {
67            Ok(EntityRef::Temp(TX_TEMPID.to_owned()))
68        }
69        Edn::Keyword(keyword) => db
70            .idents()
71            .entid(keyword)
72            .map(EntityRef::Id)
73            .ok_or_else(|| TxFormError::UnknownAttribute(keyword.to_string())),
74        Edn::Tagged(tag, value) if tag == "eid" => match value.as_ref() {
75            Edn::Long(n) => u64::try_from(*n)
76                .map(|raw| EntityRef::Id(EntityId::from_raw(raw)))
77                .map_err(|_| TxFormError::BadEntity(form.to_string())),
78            _ => Err(TxFormError::BadEntity(form.to_string())),
79        },
80        Edn::Vector(items) => {
81            let [attr_form, value_form] = items.as_slice() else {
82                return Err(TxFormError::BadEntity(form.to_string()));
83            };
84            let attr = attr_of(db, attr_form)?;
85            let value_type = db
86                .schema()
87                .get(attr)
88                .map(|meta| meta.value_type)
89                .ok_or_else(|| TxFormError::UnknownAttribute(format!("{attr:?}")))?;
90            // Lookup values never intern new keywords: an uninterned keyword
91            // cannot equal any stored value, so resolution would fail anyway.
92            let value = match value_form {
93                Edn::Keyword(keyword) => Value::Keyword(
94                    db.interner()
95                        .get(keyword)
96                        .unwrap_or(corium_query::exec::UNKNOWN_KEYWORD),
97                ),
98                other => {
99                    let mut scratch = KeywordInterner::default();
100                    scalar_value(other, &mut scratch)
101                        .ok_or_else(|| TxFormError::BadValue(form.to_string()))?
102                }
103            };
104            Ok(EntityRef::Lookup(attr, coerce(value, value_type)))
105        }
106        other => Err(TxFormError::BadEntity(other.to_string())),
107    }
108}
109
110/// Converts a scalar EDN form to a value, interning keywords into `interner`.
111fn scalar_value(form: &Edn, interner: &mut KeywordInterner) -> Option<Value> {
112    match form {
113        Edn::Bool(v) => Some(Value::Bool(*v)),
114        Edn::Long(v) => Some(Value::Long(*v)),
115        Edn::Double(v) => Some(Value::Double(*v)),
116        Edn::Str(v) => Some(Value::Str(v.as_str().into())),
117        Edn::Keyword(k) => Some(Value::Keyword(interner.intern(k.clone()))),
118        Edn::Tagged(tag, value) => match (tag.as_str(), value.as_ref()) {
119            ("eid", Edn::Long(n)) => u64::try_from(*n)
120                .ok()
121                .map(|raw| Value::Ref(EntityId::from_raw(raw))),
122            ("inst", Edn::Long(ms)) => Some(Value::Instant(*ms)),
123            ("uuid", Edn::Str(hex)) => u128::from_str_radix(hex, 16).ok().map(Value::Uuid),
124            ("bytes", Edn::Str(hex)) => decode_hex(hex).map(|b| Value::Bytes(b.into())),
125            _ => None,
126        },
127        _ => None,
128    }
129}
130
131fn decode_hex(hex: &str) -> Option<Vec<u8>> {
132    if !hex.len().is_multiple_of(2) {
133        return None;
134    }
135    (0..hex.len())
136        .step_by(2)
137        .map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok())
138        .collect()
139}
140
141#[allow(clippy::cast_precision_loss)]
142fn coerce(value: Value, value_type: ValueType) -> Value {
143    match (&value, value_type) {
144        (Value::Long(n), ValueType::Ref) => u64::try_from(*n)
145            .map(|raw| Value::Ref(EntityId::from_raw(raw)))
146            .unwrap_or(value),
147        (Value::Long(n), ValueType::Instant) => Value::Instant(*n),
148        (Value::Long(n), ValueType::Double) => Value::Double(TotalF64(*n as f64)),
149        _ => value,
150    }
151}
152
153/// Converts a value-position form for `attr`, resolving reference values.
154fn tx_value(
155    db: &Db,
156    interner: &mut KeywordInterner,
157    attr: EntityId,
158    form: &Edn,
159) -> Result<Value, TxFormError> {
160    let value_type = db
161        .schema()
162        .get(attr)
163        .map(|meta| meta.value_type)
164        .ok_or_else(|| TxFormError::UnknownAttribute(format!("{attr:?}")))?;
165    if value_type == ValueType::Ref {
166        return match entity_ref(db, form)? {
167            EntityRef::Id(e) => Ok(Value::Ref(e)),
168            EntityRef::Temp(name) => Err(TxFormError::BadValue(format!(
169                "value-position tempid \"{name}\" must be resolved by the client"
170            ))),
171            EntityRef::Lookup(a, v) => db
172                .lookup(a, &v)
173                .map(Value::Ref)
174                .ok_or_else(|| TxFormError::BadValue(format!("lookup ref {form} did not resolve"))),
175        };
176    }
177    scalar_value(form, interner)
178        .map(|value| coerce(value, value_type))
179        .ok_or_else(|| TxFormError::BadValue(form.to_string()))
180}
181
182/// Converts wire EDN transaction forms into engine transaction items.
183///
184/// New keyword values are interned into `interner` (the caller persists the
185/// naming change before committing the transaction).
186///
187/// # Errors
188/// Returns [`TxFormError`] when a form is malformed or references unknown
189/// attributes/idents.
190pub fn tx_items_from_edn(
191    db: &Db,
192    interner: &mut KeywordInterner,
193    forms: &[Edn],
194) -> Result<Vec<TxItem>, TxFormError> {
195    forms
196        .iter()
197        .map(|form| match form {
198            Edn::Vector(items) => list_form(db, interner, items, form),
199            Edn::Map(pairs) => map_form(db, interner, pairs),
200            other => Err(TxFormError::BadForm(other.to_string())),
201        })
202        .collect()
203}
204
205fn list_form(
206    db: &Db,
207    interner: &mut KeywordInterner,
208    items: &[Edn],
209    form: &Edn,
210) -> Result<TxItem, TxFormError> {
211    let op = items
212        .first()
213        .and_then(Edn::as_keyword)
214        .ok_or_else(|| TxFormError::BadForm(form.to_string()))?;
215    let name = format!(
216        "{}/{}",
217        op.namespace.as_deref().unwrap_or_default(),
218        op.name
219    );
220    let arg = |index: usize| {
221        items
222            .get(index)
223            .ok_or_else(|| TxFormError::BadForm(form.to_string()))
224    };
225    Ok(TxItem::Op(match name.as_str() {
226        "db/add" => {
227            let attr = attr_of(db, arg(2)?)?;
228            TxOp::Add(
229                entity_ref(db, arg(1)?)?,
230                attr,
231                tx_value(db, interner, attr, arg(3)?)?,
232            )
233        }
234        "db/retract" => {
235            let attr = attr_of(db, arg(2)?)?;
236            TxOp::Retract(
237                entity_ref(db, arg(1)?)?,
238                attr,
239                tx_value(db, interner, attr, arg(3)?)?,
240            )
241        }
242        "db/cas" => {
243            let attr = attr_of(db, arg(2)?)?;
244            let old = match arg(3)? {
245                Edn::Nil => None,
246                other => Some(tx_value(db, interner, attr, other)?),
247            };
248            TxOp::Cas(
249                entity_ref(db, arg(1)?)?,
250                attr,
251                old,
252                tx_value(db, interner, attr, arg(4)?)?,
253            )
254        }
255        "db/retractEntity" => TxOp::RetractEntity(entity_ref(db, arg(1)?)?),
256        _ => return Err(TxFormError::UnknownOp(op.to_string())),
257    }))
258}
259
260fn map_form(
261    db: &Db,
262    interner: &mut KeywordInterner,
263    pairs: &[(Edn, Edn)],
264) -> Result<TxItem, TxFormError> {
265    let id_key = kw("db/id");
266    let entity = pairs
267        .iter()
268        .find(|(key, _)| *key == id_key)
269        .map(|(_, value)| entity_ref(db, value))
270        .ok_or_else(|| TxFormError::BadForm("map form requires :db/id".into()))??;
271    let mut attributes = Vec::new();
272    for (key, value) in pairs.iter().filter(|(key, _)| *key != id_key) {
273        let attr = attr_of(db, key)?;
274        // A vector value is a cardinality-many set of values unless it reads
275        // as a lookup ref (`[:attr value]`), matching the corpus convention.
276        let many = matches!(value, Edn::Vector(items)
277            if !(items.len() == 2 && items[0].as_keyword().is_some()));
278        let values = if many {
279            value
280                .as_seq()
281                .unwrap_or_default()
282                .iter()
283                .map(|item| tx_value(db, interner, attr, item))
284                .collect::<Result<Vec<_>, _>>()?
285        } else {
286            vec![tx_value(db, interner, attr, value)?]
287        };
288        attributes.push((attr, values));
289    }
290    Ok(TxItem::Map(EntityMap { entity, attributes }))
291}