diff options
| author | tv <tv@krebsco.de> | 2026-11-02 11:04:15 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-02 11:04:15 +0100 |
| commit | 694661d7477e4bd1e8d7ec96dcc37742d1b5ca3f (patch) | |
| tree | b9b4766e689913b6322bb356782756d66383be5f /src | |
| parent | 177c0a5b3b80fe2d502f31b7badd220a1cf5ed7c (diff) | |
api: add type casts
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 3 | ||||
| -rw-r--r-- | src/api/store.rs | 50 | ||||
| -rw-r--r-- | src/document/store.rs | 31 | ||||
| -rw-r--r-- | src/document/typed.rs | 165 | ||||
| -rw-r--r-- | src/remake/bench.rs | 1 |
5 files changed, 244 insertions, 6 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index ede1cfa..9bb2bed 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -212,7 +212,8 @@ pub(crate) fn store_status(error: StoreError, context: &str) -> ApiError { | StoreError::NotAList | StoreError::PastTheEnd { .. } | StoreError::NotACounter - | StoreError::Retyping(_) => StatusCode::CONFLICT, + | StoreError::Retyping(_) + | StoreError::NotCastable { .. } => StatusCode::CONFLICT, StoreError::NotFound | StoreError::UnknownHeads => StatusCode::NOT_FOUND, StoreError::Rejoining => StatusCode::SERVICE_UNAVAILABLE, other => { diff --git a/src/api/store.rs b/src/api/store.rs index e6921cd..48bdafc 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -83,6 +83,12 @@ pub(crate) struct PostQuery { /// Adds this to the counter at the path instead of inserting the body /// (`store::increment_path`). increment: Option<i64>, + /// Casts what is at the path to this type instead of inserting the body + /// (`store::cast_path`; `typed::Kind::name` names it). + cast: Option<String>, + /// With `cast`, how timestamps are read from and written as strings (`rfc3339` by + /// default; `typed::TimestampFormat`). + format: Option<String>, } #[derive(Debug, Deserialize)] @@ -342,7 +348,11 @@ pub(crate) async fn put_store_entry( .await?; let read = read.ok_or(ApiError(StatusCode::INTERNAL_SERVER_ERROR, None))?; - let mut response = with_heads(read.respond()?, &heads); + Ok(with_retyped(with_heads(read.respond()?, &heads), &retyped)) +} + +/// `response` saying where a write changed types (`RETYPED_HEADER`), if it did. +fn with_retyped(mut response: Response, retyped: &[store::Retyped]) -> Response { if !retyped.is_empty() { let mut listed: Vec<String> = retyped .iter() @@ -356,7 +366,7 @@ pub(crate) async fn put_store_entry( response.headers_mut().insert(RETYPED_HEADER, value); } } - Ok(response) + response } fn etag_for(bytes: &[u8]) -> String { @@ -619,7 +629,9 @@ fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<WriteBody, ApiError> /// appends); `201` with where it went (`{"index": n}`). Not a list there, or an index /// past its end: `409`, saying which. With `?increment=n`, adds `n` to the counter at /// `key` instead (`store::increment_path`), answering with its value then; not a -/// counter there: `409`. +/// counter there: `409`. With `?cast=<type>`, casts what is at `key` to that type +/// instead (`store::cast_path`), answering with it then, as `PUT` does; no cast +/// possible: `409`. pub(crate) async fn post_store_entry( State(state): State<AppState>, Extension(user): Extension<AuthenticatedUser>, @@ -633,8 +645,15 @@ pub(crate) async fn post_store_entry( return Err(StatusCode::SERVICE_UNAVAILABLE.into()); } let path = parse_write_path(&state, &user, &key, "POST")?; - // An increment names an item (`-1`: the last); an insert where it goes (`-1`: the end). - let insertion = query.increment.is_none(); + if query.increment.is_some() && query.cast.is_some() { + return Err(ApiError( + StatusCode::BAD_REQUEST, + Some("increment or cast, not both".to_owned()), + )); + } + // An increment or a cast names an item (`-1`: the last); an insert where it goes + // (`-1`: the end). + let insertion = query.increment.is_none() && query.cast.is_none(); if let Some(redirect) = to_positive(&state, &path, insertion, None, raw_query.as_deref()).await? { @@ -663,6 +682,27 @@ pub(crate) async fn post_store_entry( &heads, )); } + if let Some(name) = &query.cast { + let bad = |message: String| ApiError(StatusCode::BAD_REQUEST, Some(message)); + let to = Kind::named(name).ok_or_else(|| bad(format!("no type {name:?}")))?; + let timestamps = match &query.format { + None => TimestampFormat::default(), + Some(format) => TimestampFormat::named(format) + .ok_or_else(|| bad(format!("no timestamp format {format:?}")))?, + }; + let accept = headers.get(ACCEPT).cloned(); + // A value that can't be shown as asked is refused, and the cast with it. + let ((retyped, read), heads) = state + .mutate_doc_for(Some(user.0.clone()), move |doc| { + let retyped = store::cast_path(doc, &path, to, timestamps) + .map_err(|e| store_status(e, "Failed to cast a store value"))?; + let read = read_shown(&*doc, &path, accept.as_ref(), false)?; + Ok::<_, ApiError>((retyped, read)) + }) + .await?; + let read = read.ok_or(ApiError(StatusCode::INTERNAL_SERVER_ERROR, None))?; + return Ok(with_retyped(with_heads(read.respond()?, &heads), &retyped)); + } let index = path.last().and_then(|leaf| store::list_index(leaf)); let value = body_value(&headers, &body)?; let inserted = state diff --git a/src/document/store.rs b/src/document/store.rs index 07f2e1a..9075032 100644 --- a/src/document/store.rs +++ b/src/document/store.rs @@ -107,6 +107,9 @@ pub(crate) enum StoreError { /// wanted (`Writing::retype`). #[error("would change {0}")] Retyping(Retyped), + /// A cast that isn't possible (`cast_path`, `typed::cast`), and why. + #[error("can't cast {} to {}: {why}", .from.name(), .to.name())] + NotCastable { from: Kind, to: Kind, why: String }, /// A read at heads the document doesn't have (`at::At`). #[error("this node doesn't have these heads (yet)")] UnknownHeads, @@ -1055,6 +1058,34 @@ fn counter_at(doc: &impl ReadDoc, parent: &ObjId, prop: Prop) -> Result<i64, Sto } } +/// Casts what is at `path` (the newest of concurrent values, as a read resolves them) to +/// `to` (`typed::cast`, timestamps to and from strings in `timestamps`), written as a +/// write of the cast value would be (`write_val_path`); returns where that changed the +/// type. Already `to`: nothing changes. Nothing there: `NotFound`; no cast possible: +/// `NotCastable`. +pub(crate) fn cast_path( + doc: &mut impl Transactable, + path: &[String], + to: Kind, + timestamps: TimestampFormat, +) -> Result<Vec<Retyped>, StoreError> { + let held = read_held(doc, path)?.ok_or(StoreError::NotFound)?; + if held.kind == to { + return Ok(Vec::new()); + } + let not_castable = |why: String| StoreError::NotCastable { + from: held.kind, + to, + why, + }; + let value = held + .value + .as_ref() + .ok_or_else(|| not_castable(format!("{} is cast to nothing", held.kind.name())))?; + let cast = typed::cast(held.kind, value, to, timestamps).map_err(not_castable)?; + write_val_path(doc, path, &cast, Writing::new(false, timestamps, true)) +} + /// Inserts `value` into a list: `path` ends in the index it goes to (the items from /// there on move up one; the list's length appends). An index past the end is /// `PastTheEnd`; a parent that isn't a list `NotAList`; anything but an index there, or 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<Self> { + [ + 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<Val, String> { + let string = |value: &ScalarValue| -> Result<String, String> { + 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<i128, String> { + 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<i64, String> { + 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<String> { 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<Val, String> { + 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()); + } +} diff --git a/src/remake/bench.rs b/src/remake/bench.rs index 213ed4b..3c3f513 100644 --- a/src/remake/bench.rs +++ b/src/remake/bench.rs @@ -83,6 +83,7 @@ fn replay(keys: usize, late: usize, newer: usize) { #[test] #[ignore = "a benchmark: cargo benchmarks"] fn bench_replay() { + println!(""); // put reports on new line replay(1_000, 100, 100); replay(100_000, 100, 100); replay(100_000, 1_000, 1_000); |
