diff options
Diffstat (limited to 'src/document/typed.rs')
| -rw-r--r-- | src/document/typed.rs | 227 |
1 files changed, 226 insertions, 1 deletions
diff --git a/src/document/typed.rs b/src/document/typed.rs index bca79b3..a542578 100644 --- a/src/document/typed.rs +++ b/src/document/typed.rs @@ -35,6 +35,8 @@ pub(crate) const FLOAT: &str = "application/vnd.continuum.float"; pub(crate) const BOOLEAN: &str = "application/vnd.continuum.boolean"; pub(crate) const COUNTER: &str = "application/vnd.continuum.counter"; pub(crate) const TIMESTAMP: &str = "application/vnd.continuum.timestamp"; +/// JSON whose scalars keep their types (`Val::from_typed_json`, `scalar_to_json`). +pub(crate) const TYPED_JSON: &str = "application/vnd.continuum.typed+json"; impl Kind { pub(crate) fn of(scalar: &ScalarValue) -> Self { @@ -194,11 +196,66 @@ impl TimestampFormat { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct JsonForm { pub(crate) timestamps: TimestampFormat, + /// Typed JSON (`TYPED_JSON`): what plain JSON doesn't tell apart is tagged, so it + /// reads back as it is stored. + pub(crate) typed: bool, +} + +// Typed JSON is plain JSON but for what plain JSON shows as another type: a single-key +// object whose key is a tag names it. `{"$uint": 5}`, `{"$counter": 5}`, +// `{"$timestamp": "2026-11-01T21:50:20.345Z"}` (in the timestamp format asked), +// `{"$bytes": "<base64>"}`, `{"$float": 2}` (a float without a fraction, which JSON +// numbers don't keep apart from integers in every language), `{"$text": "…"}` (an +// Automerge text). A map whose only key starts with `$` is `{"$map": {…}}`, so that +// nothing else reads as a tag. + +/// A map's single key that is a tag, or would be taken for one. +pub(crate) fn tag_like(map: &serde_json::Map<String, JsonValue>) -> bool { + map.len() == 1 && map.keys().all(|key| key.starts_with('$')) +} + +fn tagged(tag: &str, value: JsonValue) -> JsonValue { + let mut map = serde_json::Map::new(); + map.insert(tag.to_owned(), value); + JsonValue::Object(map) +} + +/// A map in `form` (`$map` around one that would read as a tag in typed JSON). +pub(crate) fn map_to_json(map: serde_json::Map<String, JsonValue>, form: JsonForm) -> JsonValue { + if form.typed && tag_like(&map) { + tagged("$map", JsonValue::Object(map)) + } else { + JsonValue::Object(map) + } +} + +/// A text in `form`. +pub(crate) fn text_to_json(text: String, form: JsonForm) -> JsonValue { + if form.typed { + tagged("$text", JsonValue::String(text)) + } else { + JsonValue::String(text) + } } /// `scalar` in JSON: integers, floats and counters as numbers, bytes as base64, -/// timestamps as `form` says. +/// timestamps as `form` says; in typed JSON, tagged where plain JSON would show it as +/// another type. pub(crate) fn scalar_to_json(scalar: &ScalarValue, form: JsonForm) -> Option<JsonValue> { + if form.typed { + match scalar { + ScalarValue::Uint(u) => return Some(tagged("$uint", (*u).into())), + ScalarValue::Counter(c) => return Some(tagged("$counter", i64::from(c).into())), + ScalarValue::Timestamp(t) => { + return Some(tagged("$timestamp", form.timestamps.json(*t))); + } + ScalarValue::Bytes(b) => return Some(tagged("$bytes", BASE64.encode(b).into())), + ScalarValue::F64(f) if f.fract() == 0.0 => { + return Some(tagged("$float", serde_json::Number::from_f64(*f)?.into())); + } + _ => {} + } + } Some(match scalar { ScalarValue::Null => JsonValue::Null, ScalarValue::Boolean(b) => JsonValue::Bool(*b), @@ -316,3 +373,171 @@ pub(crate) fn param(params: &str, name: &str) -> Option<String> { .then(|| value.trim().trim_matches('"').to_owned()) }) } + +/// A value to write (`store::write_val_path`), as Automerge stores it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Val { + /// Its keys in order. + Map(Vec<(String, Val)>), + List(Vec<Val>), + /// An Automerge text. + Text(String), + Scalar(ScalarValue), +} + +/// What a plain JSON `value` is stored as, if it is a scalar: an integer where it fits +/// (signed, else unsigned), a float otherwise. +pub(crate) fn json_scalar(value: &JsonValue) -> Result<Option<ScalarValue>, String> { + Ok(Some(match value { + JsonValue::String(value) => ScalarValue::Str(value.as_str().into()), + JsonValue::Bool(value) => ScalarValue::Boolean(*value), + JsonValue::Number(value) => { + if let Some(value) = value.as_i64() { + ScalarValue::Int(value) + } else if let Some(value) = value.as_u64() { + ScalarValue::Uint(value) + } else if let Some(value) = value.as_f64() { + ScalarValue::F64(value) + } else { + return Err(format!("unsupported JSON number {value}")); + } + } + JsonValue::Null => ScalarValue::Null, + JsonValue::Object(_) | JsonValue::Array(_) => return Ok(None), + })) +} + +impl Val { + /// Plain JSON, as it is stored: maps, lists and scalars (`json_scalar`). + pub(crate) fn from_json(value: &JsonValue) -> Result<Val, String> { + Ok(match value { + JsonValue::Object(map) => Val::Map( + map.iter() + .map(|(key, value)| Ok((key.clone(), Val::from_json(value)?))) + .collect::<Result<_, String>>()?, + ), + JsonValue::Array(items) => { + Val::List(items.iter().map(Val::from_json).collect::<Result<_, _>>()?) + } + scalar => Val::Scalar(json_scalar(scalar)?.expect("a scalar")), + }) + } + + /// Typed JSON (`TYPED_JSON`): plain JSON, but tagged values as their tags say, + /// timestamps in `timestamps`. An object of one key that starts with `$` and is no + /// tag is an error. + pub(crate) fn from_typed_json( + value: &JsonValue, + timestamps: TimestampFormat, + ) -> Result<Val, String> { + let JsonValue::Object(map) = value else { + return match value { + JsonValue::Array(items) => Ok(Val::List( + items + .iter() + .map(|item| Val::from_typed_json(item, timestamps)) + .collect::<Result<_, _>>()?, + )), + scalar => Val::from_json(scalar), + }; + }; + if !tag_like(map) { + return Ok(Val::Map( + map.iter() + .map(|(key, value)| Ok((key.clone(), Val::from_typed_json(value, timestamps)?))) + .collect::<Result<_, String>>()?, + )); + } + let (tag, value) = map.iter().next().expect("one key"); + let integer = |value: &JsonValue| { + value + .as_i64() + .ok_or_else(|| format!("{tag}: not an integer: {value}")) + }; + Ok(match tag.as_str() { + "$map" => match value { + JsonValue::Object(map) => Val::Map( + map.iter() + .map(|(key, value)| { + Ok((key.clone(), Val::from_typed_json(value, timestamps)?)) + }) + .collect::<Result<_, String>>()?, + ), + other => return Err(format!("$map: not an object: {other}")), + }, + "$text" => Val::Text( + value + .as_str() + .ok_or_else(|| format!("$text: not a string: {value}"))? + .to_owned(), + ), + "$bytes" => Val::Scalar(ScalarValue::Bytes( + value + .as_str() + .and_then(|text| BASE64.decode(text).ok()) + .ok_or_else(|| format!("$bytes: not base64: {value}"))?, + )), + "$uint" => { + Val::Scalar(ScalarValue::Uint(value.as_u64().ok_or_else(|| { + format!("$uint: not an unsigned integer: {value}") + })?)) + } + "$counter" => Val::Scalar(ScalarValue::counter(integer(value)?)), + "$float" => Val::Scalar(ScalarValue::F64( + value + .as_f64() + .ok_or_else(|| format!("$float: not a number: {value}"))?, + )), + "$timestamp" => { + let text = match value { + JsonValue::String(text) => text.clone(), + JsonValue::Number(number) => number.to_string(), + other => { + return Err(format!( + "$timestamp: neither a string nor a number: {other}" + )); + } + }; + Val::Scalar(ScalarValue::Timestamp(timestamps.read(&text).ok_or_else( + || format!("$timestamp: not a {} timestamp: {text}", timestamps.name()), + )?)) + } + other => { + return Err(format!( + "no tag {other} (a map whose only key starts with $ is written as {{\"$map\": …}})" + )); + } + }) + } + + /// What it is stored as. + pub(crate) fn kind(&self) -> Kind { + match self { + Val::Map(_) => Kind::Map, + Val::List(_) => Kind::List, + Val::Text(_) => Kind::Text, + Val::Scalar(scalar) => Kind::of(scalar), + } + } + + /// In JSON of `form`. + pub(crate) fn to_json(&self, form: JsonForm) -> Option<JsonValue> { + Some(match self { + Val::Map(entries) => map_to_json( + entries + .iter() + .map(|(key, value)| Some((key.clone(), value.to_json(form)?))) + .collect::<Option<_>>()?, + form, + ), + Val::List(items) => JsonValue::Array( + items + .iter() + .map(|item| item.to_json(form)) + .collect::<Option<_>>()?, + ), + Val::Text(text) => text_to_json(text.clone(), form), + Val::Scalar(scalar) => scalar_to_json(scalar, form)?, + }) + } +} |
