aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/openapi.yaml34
-rw-r--r--src/api/store.rs103
-rw-r--r--src/document/store.rs333
3 files changed, 394 insertions, 76 deletions
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 09d0bc6..66ff2d3 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -96,8 +96,9 @@ paths:
tags: [Store]
summary: Write a value
description: |
- Writes the value, creating intermediate maps. One request is one Automerge
- change. Answers with the value written (JSON, or text with
+ Writes the value, creating intermediate maps (not lists). In a list, an index
+ replaces the item there, the list's length appends, and past it is `404`. One
+ request is one Automerge change. Answers with the value written (JSON, or text with
`Accept: text/plain`).
JSON maps and arrays become Automerge maps and lists; numbers become integers
@@ -133,17 +134,10 @@ paths:
tags: [Store]
summary: Insert into a list
description: |
- Inserts the value (as `PUT` takes it) into the list at the path: at `index`,
- the items from there on moving up one, or at the end without one (or with
- `-1`). One request is one Automerge change. To add a key to a map, `PUT` it.
- parameters:
- - name: index
- in: query
- required: false
- description: Where, from 0 to the list's length (that's the end); `-1` is the end too.
- schema:
- type: integer
- minimum: -1
+ Inserts the value (as `PUT` takes it) into a list: the path ends in the index
+ it goes to (`queue/2`), the items from there on moving up one; the list's
+ length, or `-1` (a `307` to it), appends. One request is one Automerge change.
+ To add a key to a map, `PUT` it; to replace an item, `PUT` its index.
requestBody:
required: true
content:
@@ -171,6 +165,8 @@ paths:
description: As for `PUT`.
'404':
description: Nothing at this path.
+ '307':
+ description: A negative index, made positive (`Location`).
'409':
description: Not a list there, or the index is past its end (the message says which, and the length).
'415':
@@ -180,10 +176,13 @@ paths:
delete:
tags: [Store]
summary: Delete a value
+ description: |
+ Deletes the value; in a list, the item at the index (the items after it move
+ down one).
parameters:
- name: prune
in: query
- description: Also remove ancestor maps left empty.
+ description: Also remove ancestor maps left empty (not through a list).
schema:
type: boolean
default: false
@@ -1036,7 +1035,12 @@ components:
in: path
required: true
description: |
- Slash-separated store path, e.g. `settings/theme`. A key with `/` in it, or an
+ Slash-separated store path, e.g. `settings/theme`. Within a list a segment is
+ an index (`servers/0/name`): decimal, from 0, without a sign or leading zeros;
+ anything else names nothing there (`404`). A negative index counts from the end
+ (`-1` the last item; for `POST`, the end): answered with a `307` to the path
+ with it made positive. In a map every segment is a key, `0` and `-1` too. A key
+ with `/` in it, or an
empty key, can't be named here (neither `%2F` nor `//` helps: the path is
split after decoding, and empty segments are `400`); such keys exist only
inside a value written at a parent path, and are read through that parent.
diff --git a/src/api/store.rs b/src/api/store.rs
index 89123e2..f60d30d 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -6,10 +6,10 @@ use automerge::ChangeHash;
use axum::{
Extension,
body::{Body, Bytes},
- extract::{Path, Query, State},
+ extract::{Path, Query, RawQuery, State},
http::{
HeaderMap, HeaderValue, StatusCode,
- header::{ACCEPT, CONTENT_TYPE},
+ header::{ACCEPT, CONTENT_TYPE, LOCATION},
},
response::{IntoResponse, Response},
};
@@ -39,13 +39,6 @@ pub(crate) struct ReadQuery {
}
#[derive(Debug, Deserialize)]
-pub(crate) struct InsertQuery {
- /// Where in the list: the items from there on move up one. At the end without one,
- /// or with `-1`.
- index: Option<i64>,
-}
-
-#[derive(Debug, Deserialize)]
pub(crate) struct DeleteQuery {
#[serde(default)]
prune: bool,
@@ -103,8 +96,12 @@ pub(crate) async fn get_store_entry(
Path(key): Path<String>,
headers: HeaderMap,
Query(query): Query<ReadQuery>,
+ RawQuery(raw_query): RawQuery,
) -> Result<Response, StatusCode> {
let path = parse_key_path(&key)?;
+ if let Some(redirect) = to_positive(&state, &path, false, raw_query.as_deref()).await? {
+ return Ok(redirect);
+ }
if query.shallow {
return shallow(&state, path, false, &query).await;
}
@@ -201,6 +198,7 @@ pub(crate) async fn put_store_entry(
State(state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
Path(key): Path<String>,
+ RawQuery(raw_query): RawQuery,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, StatusCode> {
@@ -209,6 +207,9 @@ pub(crate) async fn put_store_entry(
}
let path = parse_write_path(&state, &user, &key, "PUT")?;
+ if let Some(redirect) = to_positive(&state, &path, false, raw_query.as_deref()).await? {
+ return Ok(redirect);
+ }
let value = body_value(&headers, &body)?;
let written = value.clone();
@@ -241,14 +242,15 @@ fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<JsonValue, StatusCode
}
}
-/// `POST /store/{key}`: inserts the body into the list at `key` (`store::insert_path`),
-/// at `?index=` or at the end; `201` with where it went (`{"index": n}`). No list there,
-/// or an index past its end: `409`, saying which.
+/// `POST /store/{key}`: inserts the body into a list, `key` ending in the index it
+/// goes to (`store::insert_path`: the items from there on move up; the length, or `-1`,
+/// appends); `201` with where it went (`{"index": n}`). Not a list there, or an index
+/// past its end: `409`, saying which.
pub(crate) async fn post_store_entry(
State(state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
Path(key): Path<String>,
- Query(query): Query<InsertQuery>,
+ RawQuery(raw_query): RawQuery,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, StatusCode> {
@@ -256,16 +258,16 @@ pub(crate) async fn post_store_entry(
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
let path = parse_write_path(&state, &user, &key, "POST")?;
- let index = match query.index {
- None | Some(-1) => None,
- Some(index) => Some(usize::try_from(index).map_err(|_| StatusCode::BAD_REQUEST)?),
- };
+ if let Some(redirect) = to_positive(&state, &path, true, raw_query.as_deref()).await? {
+ return Ok(redirect);
+ }
+ let index = path.last().and_then(|leaf| store::list_index(leaf));
let value = body_value(&headers, &body)?;
let inserted = state
- .mutate_doc(move |doc| store::insert_path(doc, &path, index, &value))
+ .mutate_doc(move |doc| store::insert_path(doc, &path, &value))
.await;
match inserted {
- Ok(index) => Ok((
+ Ok(()) => Ok((
StatusCode::CREATED,
axum::Json(serde_json::json!({ "index": index })),
)
@@ -284,7 +286,8 @@ pub(crate) async fn delete_store_entry(
Extension(user): Extension<AuthenticatedUser>,
Path(key): Path<String>,
Query(query): Query<DeleteQuery>,
-) -> Result<StatusCode, StatusCode> {
+ RawQuery(raw_query): RawQuery,
+) -> Result<Response, StatusCode> {
let path = parse_write_path(&state, &user, &key, "DELETE")?;
tracing::Span::current().record("path", path.join("/").as_str());
@@ -297,13 +300,69 @@ pub(crate) async fn delete_store_entry(
return Err(StatusCode::BAD_REQUEST);
}
+ if let Some(redirect) = to_positive(&state, &path, false, raw_query.as_deref()).await? {
+ return Ok(redirect);
+ }
+
let prune = query.prune;
state
- .mutate_doc(move |doc| store::delete_path(doc, &path, prune))
+ .mutate_doc(move |doc| store::delete_at(doc, &path, prune))
.await
.map_err(|e| store_status(e, "Failed to delete store value"))?;
- Ok(StatusCode::NO_CONTENT)
+ Ok(StatusCode::NO_CONTENT.into_response())
+}
+
+/// A path with negative list indexes (`-1` the last item; for an insert, the end): a
+/// `307` to it with them made positive (`store::positive_path`), the query kept, so a
+/// client reads, writes or deletes what the index names now. None without any.
+async fn to_positive(
+ state: &AppState,
+ path: &[String],
+ insertion: bool,
+ query: Option<&str>,
+) -> Result<Option<Response>, StatusCode> {
+ if !path.iter().any(|segment| segment.starts_with('-')) {
+ return Ok(None);
+ }
+ let positive = {
+ let replica = state.doc.read().await;
+ store::positive_path(&replica.doc, path, insertion)
+ }
+ .map_err(|e| store_status(e, "Failed to resolve a negative index"))?;
+ let Some(positive) = positive else {
+ return Ok(None);
+ };
+ let mut location = format!(
+ "/store/{}",
+ positive
+ .iter()
+ .map(|segment| encode_segment(segment))
+ .collect::<Vec<_>>()
+ .join("/")
+ );
+ if let Some(query) = query.filter(|query| !query.is_empty()) {
+ location.push('?');
+ location.push_str(query);
+ }
+ let location = HeaderValue::from_str(&location).map_err(|_| StatusCode::BAD_REQUEST)?;
+ Ok(Some(
+ (StatusCode::TEMPORARY_REDIRECT, [(LOCATION, location)]).into_response(),
+ ))
+}
+
+/// `segment` as a URL path segment: unreserved characters as they are, the rest
+/// percent-encoded (UTF-8).
+fn encode_segment(segment: &str) -> String {
+ let mut encoded = String::with_capacity(segment.len());
+ for byte in segment.bytes() {
+ if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
+ encoded.push(char::from(byte));
+ } else {
+ encoded.push_str(&format!("%{byte:02X}"));
+ }
+ }
+ encoded
}
// --- streams -----------------------------------------------------------------
diff --git a/src/document/store.rs b/src/document/store.rs
index 13a3fc3..a55f95d 100644
--- a/src/document/store.rs
+++ b/src/document/store.rs
@@ -2,7 +2,8 @@
use anyhow::anyhow;
use automerge::{
- ActorId, AutoCommit, ObjId, ObjType, ReadDoc, ScalarValue, Value, transaction::Transactable,
+ ActorId, AutoCommit, ObjId, ObjType, Prop, ReadDoc, ScalarValue, Value,
+ transaction::Transactable,
};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use serde::Serialize;
@@ -18,7 +19,7 @@ pub(crate) enum StoreError {
/// An insert where there is no list.
#[error("not a list")]
NotAList,
- /// An insert past a list's end.
+ /// An index past a list's end (`length` items).
#[error("index {index} is past the list's end ({length} items)")]
PastTheEnd { index: usize, length: usize },
#[error(transparent)]
@@ -47,11 +48,11 @@ enum Resolved {
fn candidates(
doc: &AutoCommit,
parents: &[ObjId],
- key: &str,
+ prop: impl Into<Prop> + Clone,
) -> Result<Vec<(Value<'static>, ObjId)>, StoreError> {
let mut all = Vec::new();
for parent in parents {
- for (value, id) in doc.get_all(parent, key)? {
+ for (value, id) in doc.get_all(parent, prop.clone())? {
all.push((value.into_owned(), id));
}
}
@@ -65,7 +66,104 @@ fn resolve(
key: &str,
log: Option<&mut ConflictLog>,
) -> Result<Option<Resolved>, StoreError> {
- let mut all = candidates(doc, parents, key)?;
+ resolve_prop(doc, parents, key, key, log)
+}
+
+/// The item at `index` of `list` (concurrent writes there resolved as for a key).
+fn resolve_item(
+ doc: &AutoCommit,
+ list: &ObjId,
+ index: usize,
+ log: Option<&mut ConflictLog>,
+) -> Result<Option<Resolved>, StoreError> {
+ if index >= doc.length(list) {
+ return Ok(None);
+ }
+ resolve_prop(
+ doc,
+ std::slice::from_ref(list),
+ index,
+ &index.to_string(),
+ log,
+ )
+}
+
+/// What `segment` names within `current`: a key of merged maps, or the item of a list
+/// at the index it spells (`list_index`); None for anything else, and in a scalar.
+fn step(
+ doc: &AutoCommit,
+ current: &Resolved,
+ segment: &str,
+ log: Option<&mut ConflictLog>,
+) -> Result<Option<Resolved>, StoreError> {
+ match current {
+ Resolved::Maps(maps) => resolve(doc, maps, segment, log),
+ Resolved::Other(Value::Object(ObjType::List), list) => match list_index(segment) {
+ Some(index) => resolve_item(doc, list, index, log),
+ None => Ok(None),
+ },
+ Resolved::Other(..) => Ok(None),
+ }
+}
+
+/// A list index as a path names it: decimal digits, no sign, no leading zeros.
+pub(crate) fn list_index(segment: &str) -> Option<usize> {
+ let digits = !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_digit());
+ if !digits || (segment.len() > 1 && segment.starts_with('0')) {
+ return None;
+ }
+ segment.parse().ok()
+}
+
+/// A negative list index (`-1` the last item): how far from the end.
+fn from_the_end(segment: &str) -> Option<usize> {
+ list_index(segment.strip_prefix('-')?).filter(|back| *back > 0)
+}
+
+/// `path` with its negative list indexes made positive (`-1` the last item; or, as the
+/// last segment with `insertion`, the end: where an insert appends), if it has any;
+/// None if it has none. An index before the start names nothing (`NotFound`). Only
+/// segments within a list count: in a map, `-1` is a key like any other.
+pub(crate) fn positive_path(
+ doc: &AutoCommit,
+ path: &[String],
+ insertion: bool,
+) -> Result<Option<Vec<String>>, StoreError> {
+ let mut current = Resolved::Maps(vec![automerge::ROOT]);
+ let mut positive = path.to_vec();
+ let mut changed = false;
+ for (depth, segment) in path.iter().enumerate() {
+ let mut segment = segment.clone();
+ if let Resolved::Other(Value::Object(ObjType::List), list) = &current
+ && let Some(back) = from_the_end(&segment)
+ {
+ let last = depth + 1 == path.len();
+ let length = doc.length(list) + usize::from(insertion && last);
+ let index = length.checked_sub(back).ok_or(StoreError::NotFound)?;
+ segment = index.to_string();
+ positive[depth] = segment.clone();
+ changed = true;
+ }
+ match step(doc, &current, &segment, None)? {
+ Some(next) => current = next,
+ // Nothing further to make positive.
+ None => break,
+ }
+ }
+ Ok(changed.then_some(positive))
+}
+
+/// Resolves `key` (a map key or a list index) in `parents`; `name` is how conflict
+/// reports name it.
+fn resolve_prop(
+ doc: &AutoCommit,
+ parents: &[ObjId],
+ prop: impl Into<Prop> + Clone,
+ name: &str,
+ log: Option<&mut ConflictLog>,
+) -> Result<Option<Resolved>, StoreError> {
+ let key = name;
+ let mut all = candidates(doc, parents, prop)?;
if all.is_empty() {
return Ok(None);
}
@@ -187,14 +285,11 @@ fn read_path_inner(
let mut current = Resolved::Maps(vec![automerge::ROOT]);
for (index, segment) in path.iter().enumerate() {
- let Resolved::Maps(maps) = &current else {
- return Ok(None);
- };
if let Some(log) = log.as_deref_mut() {
// The last segment is the requested value itself, not an ancestor.
log.in_ancestors = index + 1 < path.len();
}
- match resolve(doc, maps, segment, log.as_deref_mut())? {
+ match step(doc, &current, segment, log.as_deref_mut())? {
Some(next) => current = next,
None => return Ok(None),
}
@@ -253,13 +348,27 @@ pub(crate) fn read_shallow(
doc: &AutoCommit,
path: &[String],
) -> Result<Option<JsonValue>, StoreError> {
- let Some(resolved) = resolve_at(doc, path)? else {
+ let Some(resolved) = resolve_through(doc, path)? else {
return Ok(None);
};
summary(doc, &resolved, true).map(Some)
}
-/// A summary of `resolved` (`read_shallow`); with `entries`, a map's keys' too.
+/// The resolved value at `path` (the empty path is the root map), through maps and
+/// lists (`step`). `resolve_at` goes through maps only.
+fn resolve_through(doc: &AutoCommit, path: &[String]) -> Result<Option<Resolved>, StoreError> {
+ let mut current = Resolved::Maps(vec![automerge::ROOT]);
+ for segment in path {
+ match step(doc, &current, segment, None)? {
+ Some(next) => current = next,
+ None => return Ok(None),
+ }
+ }
+ Ok(Some(current))
+}
+
+/// A summary of `resolved` (`read_shallow`); with `entries`, a map's keys' too, or a
+/// list's items' (by index).
fn summary(doc: &AutoCommit, resolved: &Resolved, entries: bool) -> Result<JsonValue, StoreError> {
let mut out = serde_json::Map::new();
match resolved {
@@ -281,8 +390,18 @@ fn summary(doc: &AutoCommit, resolved: &Resolved, entries: bool) -> Result<JsonV
return summary(doc, &Resolved::Maps(vec![id.clone()]), entries);
}
Resolved::Other(Value::Object(ObjType::List), id) => {
+ let length = doc.length(id);
out.insert("type".into(), "list".into());
- out.insert("size".into(), doc.length(id).into());
+ out.insert("size".into(), length.into());
+ if entries {
+ let mut listed = serde_json::Map::new();
+ for index in 0..length {
+ if let Some(item) = resolve_item(doc, id, index, None)? {
+ listed.insert(index.to_string(), summary(doc, &item, false)?);
+ }
+ }
+ out.insert("entries".into(), JsonValue::Object(listed));
+ }
}
Resolved::Other(Value::Object(ObjType::Text), id) => {
out.insert("type".into(), "text".into());
@@ -338,7 +457,7 @@ pub(crate) fn read_candidates(
_ => return Ok(Vec::new()),
};
}
- candidates(doc, &maps, leaf)?
+ candidates(doc, &maps, leaf.as_str())?
.into_iter()
.map(|(value, id)| match value {
Value::Scalar(scalar) => scalar_to_json(scalar.into_owned()),
@@ -347,43 +466,96 @@ pub(crate) fn read_candidates(
.collect()
}
-/// Writes (replaces) the value at `path`, creating intermediate maps.
+/// Writes (replaces) the value at `path`, creating intermediate maps. Within a list,
+/// the segment is an index (`list_index`): an item there is replaced, one at the end
+/// appended; past the end, or anything but an index, names nothing (`NotFound`).
pub(crate) fn write_path(
doc: &mut AutoCommit,
path: &[String],
value: &JsonValue,
) -> Result<(), StoreError> {
let (leaf, parents) = path.split_last().ok_or(StoreError::NotFound)?;
- let maps = ensure_maps(doc, parents)?;
-
- // Replace semantics: clear the key in every merged parent, or an old map
- // would merge into the new value.
- delete_in_all(doc, &maps, leaf)?;
- json_to_automerge(doc, &maps[0], leaf, value)?;
+ match parent_for_write(doc, parents)? {
+ Parent::Maps(maps) => {
+ // Replace semantics: clear the key in every merged parent, or an old map
+ // would merge into the new value.
+ delete_in_all(doc, &maps, leaf)?;
+ json_to_automerge(doc, &maps[0], leaf, value)?;
+ }
+ Parent::List(list) => {
+ let index = list_index(leaf).ok_or(StoreError::NotFound)?;
+ let length = doc.length(&list);
+ if index > length {
+ return Err(StoreError::NotFound);
+ }
+ if index < length {
+ doc.delete(&list, index)?;
+ }
+ write_value(doc, &list, Slot::Index(index), value)?;
+ }
+ }
Ok(())
}
-/// Inserts `value` into the list at `path`: at `index` (the items from there on move
-/// up one), or at the end without one. Returns where it went. An index past the end is
-/// an error (`PastTheEnd`), as is a path without a list (`NotAList`, `NotFound`).
+/// What a write's last segment goes into.
+enum Parent {
+ /// Merged maps (`[0]` takes new keys).
+ Maps(Vec<ObjId>),
+ List(ObjId),
+}
+
+/// The parent of a write at `parents` + one more segment: missing maps are created on
+/// the way; through a list only by an index of an item it has. A scalar or text on the
+/// way is a `Conflict`.
+fn parent_for_write(doc: &mut AutoCommit, parents: &[String]) -> Result<Parent, StoreError> {
+ let mut current = Parent::Maps(vec![automerge::ROOT]);
+ for segment in parents {
+ let next = match &current {
+ Parent::Maps(maps) => match resolve(doc, maps, segment, None)? {
+ // A concurrent creation elsewhere is merged on read.
+ None => Resolved::Maps(vec![doc.put_object(
+ &maps[0],
+ segment.as_str(),
+ ObjType::Map,
+ )?]),
+ Some(next) => next,
+ },
+ Parent::List(list) => {
+ let index = list_index(segment).ok_or(StoreError::NotFound)?;
+ resolve_item(doc, list, index, None)?.ok_or(StoreError::NotFound)?
+ }
+ };
+ current = match next {
+ Resolved::Maps(maps) => Parent::Maps(maps),
+ Resolved::Other(Value::Object(ObjType::List), list) => Parent::List(list),
+ Resolved::Other(..) => return Err(StoreError::Conflict),
+ };
+ }
+ Ok(current)
+}
+
+/// 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
+/// no parent, `NotFound`.
pub(crate) fn insert_path(
doc: &mut AutoCommit,
path: &[String],
- index: Option<usize>,
value: &JsonValue,
-) -> Result<usize, StoreError> {
- let list = match resolve_at(doc, path)? {
+) -> Result<(), StoreError> {
+ let (leaf, parents) = path.split_last().ok_or(StoreError::NotFound)?;
+ let list = match resolve_through(doc, parents)? {
None => return Err(StoreError::NotFound),
Some(Resolved::Other(Value::Object(ObjType::List), id)) => id,
Some(_) => return Err(StoreError::NotAList),
};
+ let index = list_index(leaf).ok_or(StoreError::NotFound)?;
let length = doc.length(&list);
- let index = index.unwrap_or(length);
if index > length {
return Err(StoreError::PastTheEnd { index, length });
}
write_value(doc, &list, Slot::Index(index), value)?;
- Ok(index)
+ Ok(())
}
/// The merged maps at `path`, creating missing maps on the way.
@@ -456,6 +628,43 @@ pub(crate) fn copy_path_to(
)
}
+/// Deletes the value at `path` as the API names it: through lists too, where a segment
+/// is an index (`list_index`; the items after it move down one); `prune` doesn't reach
+/// through a list. Within maps only, as `delete_path`.
+pub(crate) fn delete_at(
+ doc: &mut AutoCommit,
+ path: &[String],
+ prune: bool,
+) -> Result<(), StoreError> {
+ let (leaf, parents) = path.split_last().ok_or(StoreError::NotFound)?;
+ let mut current = Resolved::Maps(vec![automerge::ROOT]);
+ let mut through_list = false;
+ for segment in parents {
+ if let Resolved::Other(Value::Object(ObjType::List), _) = current {
+ through_list = true;
+ }
+ current = step(doc, &current, segment, None)?.ok_or(StoreError::NotFound)?;
+ }
+ match (&current, through_list) {
+ (Resolved::Other(Value::Object(ObjType::List), list), _) => {
+ let index = list_index(leaf).ok_or(StoreError::NotFound)?;
+ if index >= doc.length(list) {
+ return Err(StoreError::NotFound);
+ }
+ doc.delete(list, index)?;
+ Ok(())
+ }
+ (Resolved::Maps(maps), true) => {
+ if resolve(doc, maps, leaf, None)?.is_none() {
+ return Err(StoreError::NotFound);
+ }
+ delete_in_all(doc, maps, leaf)
+ }
+ (Resolved::Maps(_), false) => delete_path(doc, path, prune),
+ (Resolved::Other(..), _) => Err(StoreError::NotFound),
+ }
+}
+
/// Deletes the value at `path`, optionally pruning ancestors that become empty.
pub(crate) fn delete_path(
doc: &mut AutoCommit,
@@ -846,27 +1055,73 @@ mod tests {
}
#[test]
- fn inserts_into_lists() {
+ fn paths_go_through_lists() {
let mut doc = AutoCommit::new();
- write_path(&mut doc, &path("list"), &json!(["b", "d"])).unwrap();
+ write_path(&mut doc, &path("list"), &json!(["b", {"k": 1}, "d"])).unwrap();
+ assert_eq!(read_path(&doc, &path("list/0")).unwrap(), Some(json!("b")));
+ assert_eq!(read_path(&doc, &path("list/1/k")).unwrap(), Some(json!(1)));
+ // Not an index, or past the end: nothing.
+ for missing in ["list/3", "list/x", "list/01", "list/-1", "list/1/k/0"] {
+ assert_eq!(read_path(&doc, &path(missing)).unwrap(), None, "{missing}");
+ }
+ // Replaced, and appended at the end; past it, nothing.
+ write_path(&mut doc, &path("list/0"), &json!("a")).unwrap();
+ write_path(&mut doc, &path("list/3"), &json!("e")).unwrap();
+ write_path(&mut doc, &path("list/1/k"), &json!(2)).unwrap();
+ assert!(matches!(
+ write_path(&mut doc, &path("list/9"), &json!(0)),
+ Err(StoreError::NotFound)
+ ));
assert_eq!(
- insert_path(&mut doc, &path("list"), Some(0), &json!("a")).unwrap(),
- 0
+ read_path(&doc, &path("list")).unwrap(),
+ Some(json!(["a", {"k": 2}, "d", "e"]))
);
+ delete_at(&mut doc, &path("list/2"), false).unwrap();
assert_eq!(
- insert_path(&mut doc, &path("list"), Some(2), &json!("c")).unwrap(),
- 2
+ read_path(&doc, &path("list")).unwrap(),
+ Some(json!(["a", {"k": 2}, "e"]))
);
assert_eq!(
- insert_path(&mut doc, &path("list"), None, &json!({"e": 5})).unwrap(),
- 4
+ read_shallow(&doc, &path("list")).unwrap().unwrap()["entries"]["1"]["type"],
+ json!("map")
);
+ }
+
+ #[test]
+ fn negative_indexes_count_from_the_end() {
+ let mut doc = AutoCommit::new();
+ write_path(&mut doc, &path("list"), &json!(["a", "b"])).unwrap();
+ write_path(&mut doc, &path("map"), &json!({"-1": "a key"})).unwrap();
+ assert_eq!(
+ positive_path(&doc, &path("list/-1"), false).unwrap(),
+ Some(path("list/1"))
+ );
+ // Inserting: -1 is the end.
+ assert_eq!(
+ positive_path(&doc, &path("list/-1"), true).unwrap(),
+ Some(path("list/2"))
+ );
+ assert!(matches!(
+ positive_path(&doc, &path("list/-3"), false),
+ Err(StoreError::NotFound)
+ ));
+ assert_eq!(positive_path(&doc, &path("map/-1"), false).unwrap(), None);
+ assert_eq!(positive_path(&doc, &path("list/0"), false).unwrap(), None);
+ }
+
+ #[test]
+ fn inserts_into_lists() {
+ let mut doc = AutoCommit::new();
+ write_path(&mut doc, &path("list"), &json!(["b", "d"])).unwrap();
+ insert_path(&mut doc, &path("list/0"), &json!("a")).unwrap();
+ insert_path(&mut doc, &path("list/2"), &json!("c")).unwrap();
+ insert_path(&mut doc, &path("list/4"), &json!({"e": 5})).unwrap();
assert_eq!(
read_path(&doc, &path("list")).unwrap(),
Some(json!(["a", "b", "c", "d", {"e": 5}]))
);
assert!(matches!(
- insert_path(&mut doc, &path("list"), Some(6), &json!(1)),
+ insert_path(&mut doc, &path("list/6"), &json!(1)),
Err(StoreError::PastTheEnd {
index: 6,
length: 5
@@ -874,11 +1129,11 @@ mod tests {
));
write_path(&mut doc, &path("map"), &json!({"k": 1})).unwrap();
assert!(matches!(
- insert_path(&mut doc, &path("map"), None, &json!(1)),
+ insert_path(&mut doc, &path("map/0"), &json!(1)),
Err(StoreError::NotAList)
));
assert!(matches!(
- insert_path(&mut doc, &path("nothing"), None, &json!(1)),
+ insert_path(&mut doc, &path("nothing/0"), &json!(1)),
Err(StoreError::NotFound)
));
}