From 2ad5d7c6ba0962e7ba2a8c919cddc1c2fa136f63 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 2 Nov 2026 11:04:15 +0100 Subject: api: add type casts --- src/document/typed.rs | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) (limited to 'src/document/typed.rs') diff --git a/src/document/typed.rs b/src/document/typed.rs index cf7f49b..7f6e0cc 100644 --- a/src/document/typed.rs +++ b/src/document/typed.rs @@ -73,6 +73,26 @@ impl Kind { } } + /// The kind `name` names (`name`). + pub(crate) fn named(name: &str) -> Option { + [ + Kind::Map, + Kind::List, + Kind::Text, + Kind::String, + Kind::Bytes, + Kind::Int, + Kind::Uint, + Kind::Float, + Kind::Boolean, + Kind::Null, + Kind::Counter, + Kind::Timestamp, + ] + .into_iter() + .find(|kind| kind.name() == name) + } + /// The media type a value of this kind is shown as natively, if any. pub(crate) fn media_type(self) -> Option<&'static str> { Some(match self { @@ -364,6 +384,93 @@ pub(crate) fn from_native( }) } +/// `value` (a scalar, or a text's characters as a string), held as `from`, cast to +/// `to` (`store::cast_path`): numbers (integers, floats, counters, timestamps as +/// milliseconds since 1970) to numbers where they are exactly that; anything but bytes +/// to a string or a text as its type's own media type shows it (`native`, timestamps +/// in `timestamps`), and back (`from_native`); bytes to a string or a text and back as +/// UTF-8. An error says why not (maps, lists and nulls are cast to nothing, and nothing +/// to them). +pub(crate) fn cast( + from: Kind, + value: &ScalarValue, + to: Kind, + timestamps: TimestampFormat, +) -> Result { + let string = |value: &ScalarValue| -> Result { + match value { + ScalarValue::Str(text) => Ok(text.to_string()), + ScalarValue::Bytes(bytes) => { + String::from_utf8(bytes.clone()).map_err(|_| "not UTF-8".to_owned()) + } + other => native(other, timestamps) + .and_then(|(_, body)| String::from_utf8(body).ok()) + .ok_or_else(|| "it has no text form".to_owned()), + } + }; + match to { + Kind::String => { + return string(value).map(|text| Val::Scalar(ScalarValue::Str(text.into()))); + } + Kind::Text => return string(value).map(Val::Text), + Kind::Map | Kind::List | Kind::Null | Kind::Unknown => { + return Err(format!("nothing is cast to {}", to.name())); + } + _ => {} + } + if let ScalarValue::Str(text) = value { + if to == Kind::Bytes { + return Ok(Val::Scalar(ScalarValue::Bytes(text.as_bytes().to_vec()))); + } + let media = to.media_type().ok_or("it has no text form")?; + let params = format!("; format={}", timestamps.name()); + return from_native(media, ¶ms, text.as_bytes()) + .unwrap_or_else(|| Err("it has no text form".to_owned())) + .map(Val::Scalar); + } + // Numbers: exactly, as an integer if they are one, else as a float. + let number = match value { + ScalarValue::Int(i) => Ok(i128::from(*i)), + ScalarValue::Uint(u) => Ok(i128::from(*u)), + ScalarValue::Counter(c) => Ok(i128::from(i64::from(c))), + ScalarValue::Timestamp(t) => Ok(i128::from(*t)), + ScalarValue::F64(f) => Err(*f), + _ => return Err(format!("{} isn't a number", from.name())), + }; + let integer = || -> Result { + match number { + Ok(integer) => Ok(integer), + // Integral floats up to 2^64 convert exactly (`as` saturates beyond). + Err(float) if float.fract() == 0.0 && float.abs() < 2f64.powi(64) => Ok(float as i128), + Err(float) => Err(format!("{float} isn't an integer that fits")), + } + }; + let signed = || -> Result { + let integer = integer()?; + i64::try_from(integer).map_err(|_| format!("{integer} doesn't fit a 64-bit integer")) + }; + let scalar = match to { + Kind::Int => ScalarValue::Int(signed()?), + Kind::Counter => ScalarValue::counter(signed()?), + Kind::Timestamp => ScalarValue::Timestamp(signed()?), + Kind::Uint => { + let integer = integer()?; + ScalarValue::Uint( + u64::try_from(integer) + .map_err(|_| format!("{integer} doesn't fit an unsigned 64-bit integer"))?, + ) + } + Kind::Float => match number { + Err(float) => ScalarValue::F64(float), + // Exactly: up to 2^53 every integer is a float. + Ok(integer) if integer.unsigned_abs() <= 1 << 53 => ScalarValue::F64(integer as f64), + Ok(integer) => return Err(format!("{integer} isn't exactly a float")), + }, + _ => return Err(format!("numbers aren't cast to {}", to.name())), + }; + Ok(Val::Scalar(scalar)) +} + /// The value of parameter `name` among a media type's `params` (`; a=b; c="d"`). pub(crate) fn param(params: &str, name: &str) -> Option { params.split(';').find_map(|param| { @@ -520,3 +627,61 @@ impl Val { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn cast_to(from: ScalarValue, to: Kind) -> Result { + cast(Kind::of(&from), &from, to, TimestampFormat::default()) + } + + #[test] + fn numbers_cast_where_exact() { + assert_eq!( + cast_to(ScalarValue::F64(2.0), Kind::Int), + Ok(Val::Scalar(ScalarValue::Int(2))) + ); + assert!(cast_to(ScalarValue::F64(2.5), Kind::Int).is_err()); + assert!(cast_to(ScalarValue::Int(-1), Kind::Uint).is_err()); + assert_eq!( + cast_to(ScalarValue::Int(5), Kind::Counter), + Ok(Val::Scalar(ScalarValue::counter(5))) + ); + assert!(cast_to(ScalarValue::Int(i64::MAX), Kind::Float).is_err()); + assert!(cast_to(ScalarValue::Boolean(true), Kind::Int).is_err()); + } + + #[test] + fn strings_cast_as_media_types_show_them() { + assert_eq!( + cast_to( + ScalarValue::Str("2026-11-01T21:50:20.345Z".into()), + Kind::Timestamp + ), + Ok(Val::Scalar(ScalarValue::Timestamp(1_793_569_820_345))) + ); + assert!(cast_to(ScalarValue::Str("soon".into()), Kind::Timestamp).is_err()); + assert_eq!( + cast_to(ScalarValue::Timestamp(1_793_569_820_345), Kind::String), + Ok(Val::Scalar(ScalarValue::Str( + "2026-11-01T21:50:20.345Z".into() + ))) + ); + assert_eq!( + cast_to(ScalarValue::Str("true".into()), Kind::Boolean), + Ok(Val::Scalar(ScalarValue::Boolean(true))) + ); + assert_eq!( + cast_to(ScalarValue::Str("hé".into()), Kind::Text), + Ok(Val::Text("hé".to_owned())) + ); + assert_eq!( + cast_to(ScalarValue::Bytes(b"hi".to_vec()), Kind::String), + Ok(Val::Scalar(ScalarValue::Str("hi".into()))) + ); + assert!(cast_to(ScalarValue::Bytes(vec![0xff]), Kind::String).is_err()); + assert!(cast_to(ScalarValue::Null, Kind::String).is_err()); + assert!(cast_to(ScalarValue::Int(1), Kind::Map).is_err()); + } +} -- cgit v1.3.1