use strings as values, with option to allow removing

This commit is contained in:
Dustin J. Mitchell 2019-12-29 11:50:05 -05:00
parent e5bd258e84
commit e83bdc28cd
6 changed files with 89 additions and 39 deletions

View file

@ -15,8 +15,7 @@ The data model is only seen from the clients' perspective.
## Task Database
The task database is composed of an un-ordered collection of tasks, each keyed by a UUID.
Each task has an arbitrary-sized set of key/value properties, with JSON values.
A property with a `null` value is considered equivalent to that property not being set on the task.
Each task has an arbitrary-sized set of key/value properties, with string values.
Tasks are only created, never deleted.
See below for details on how tasks can "expire" from the task database.
@ -31,7 +30,8 @@ Each operation has one of the forms
The former form creates a new task.
It is invalid to create a task that already exists.
The latter form updates the given property of the given task.
The latter form updates the given property of the given task, where property and value are both strings.
Value can also be `None` to indicate deletion of a property.
It is invalid to update a task that does not exist.
The timestamp on updates serves as additional metadata and is used to resolve conflicts.

View file

@ -1,13 +1,9 @@
* assign types to properties
- modifications to types don't commute the same way
- optimize this to simplify the transform function
- types:
- dependencies: set of uuids
- annotations: set of annotations (incl timestamps for uniqueness)
- tags: set of tags
- idea: Update takes a dotted path for property; store everything as a map
e.g., {uuid: true}, {timestamp: annotation}, {tag: true}; keep the
set-to-null-to-delete to remove
- db / operation model is just k/v, but formatted names can be used for
structure:
- dependencies: `dependency.<uuid> = ""`
- annotations: `annotation.<epoch time> = "annotation"`
- tags: `tags.<tag> = ""`
* add HTTP API
* implement snapshot requests
* implement backups
@ -16,3 +12,4 @@
- need to be sure that create / delete operations don't get reversed
* cli tools
* prop testing for DB modifications
- 'strict' mode to fail on application of any nonsense operations

View file

@ -1,17 +1,23 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
/// An Operation defines a single change to the task database
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
pub enum Operation {
Create {
uuid: Uuid,
},
/// Create a new task; if the task already exists in the DB.
///
/// On application, if the task already exists, the operation does nothing.
Create { uuid: Uuid },
/// Update an existing task, setting the given property to the given value. If the value is
/// None, then the corresponding property is deleted.
///
/// If the given task does not exist, the operation does nothing.
Update {
uuid: Uuid,
property: String,
value: Value,
value: Option<String>,
timestamp: DateTime<Utc>,
},
}
@ -142,25 +148,25 @@ mod test {
Update {
uuid,
property: "abc".into(),
value: true.into(),
value: Some("true".into()),
timestamp,
},
Update {
uuid,
property: "def".into(),
value: false.into(),
value: Some("false".into()),
timestamp,
},
Some(Update {
uuid,
property: "abc".into(),
value: true.into(),
value: Some("true".into()),
timestamp,
}),
Some(Update {
uuid,
property: "def".into(),
value: false.into(),
value: Some("false".into()),
timestamp,
}),
);
@ -176,20 +182,20 @@ mod test {
Update {
uuid,
property: "abc".into(),
value: true.into(),
value: Some("true".into()),
timestamp: timestamp1,
},
Update {
uuid,
property: "abc".into(),
value: false.into(),
value: Some("false".into()),
timestamp: timestamp2,
},
None,
Some(Update {
uuid,
property: "abc".into(),
value: false.into(),
value: Some("false".into()),
timestamp: timestamp2,
}),
);
@ -204,19 +210,19 @@ mod test {
Update {
uuid,
property: "abc".into(),
value: true.into(),
value: Some("true".into()),
timestamp,
},
Update {
uuid,
property: "abc".into(),
value: false.into(),
value: Some("false".into()),
timestamp,
},
Some(Update {
uuid,
property: "abc".into(),
value: true.into(),
value: Some("true".into()),
timestamp,
}),
None,

View file

@ -1,16 +1,17 @@
use crate::operation::Operation;
use crate::server::{Server, VersionAdd};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::str;
use uuid::Uuid;
type TaskMap = HashMap<String, String>;
#[derive(PartialEq, Debug, Clone)]
pub struct DB {
// The current state, with all pending operations applied
tasks: HashMap<Uuid, HashMap<String, Value>>,
tasks: HashMap<Uuid, TaskMap>,
// The version at which `operations` begins
base_version: u64,
@ -56,17 +57,24 @@ impl DB {
} => {
// update if this task exists, otherwise ignore
if let Some(task) = self.tasks.get_mut(uuid) {
task.insert(property.clone(), value.clone());
DB::apply_update(task, property, value);
}
}
};
self.operations.push(op);
}
fn apply_update(task: &mut TaskMap, property: &str, value: &Option<String>) {
match value {
Some(ref val) => task.insert(property.to_string(), val.clone()),
None => task.remove(property),
};
}
/// Get a read-only reference to the underlying set of tasks.
///
/// This API is temporary, but provides query access to the DB.
pub fn tasks(&self) -> &HashMap<Uuid, HashMap<String, Value>> {
pub fn tasks(&self) -> &HashMap<Uuid, TaskMap> {
&self.tasks
}
@ -194,19 +202,58 @@ mod tests {
let op2 = Operation::Update {
uuid,
property: String::from("title"),
value: Value::from("\"my task\""),
value: Some("my task".into()),
timestamp: Utc::now(),
};
db.apply(op2.clone());
let mut exp = HashMap::new();
let mut task = HashMap::new();
task.insert(String::from("title"), Value::from("\"my task\""));
task.insert(String::from("title"), String::from("my task"));
exp.insert(uuid, task);
assert_eq!(db.tasks(), &exp);
assert_eq!(db.operations, vec![op1, op2]);
}
#[test]
fn test_apply_create_update_delete_prop() {
let mut db = DB::new();
let uuid = Uuid::new_v4();
let op1 = Operation::Create { uuid };
db.apply(op1.clone());
let op2 = Operation::Update {
uuid,
property: String::from("title"),
value: Some("my task".into()),
timestamp: Utc::now(),
};
db.apply(op2.clone());
let op3 = Operation::Update {
uuid,
property: String::from("priority"),
value: Some("H".into()),
timestamp: Utc::now(),
};
db.apply(op3.clone());
let op4 = Operation::Update {
uuid,
property: String::from("title"),
value: None,
timestamp: Utc::now(),
};
db.apply(op4.clone());
let mut exp = HashMap::new();
let mut task = HashMap::new();
task.insert(String::from("priority"), String::from("H"));
exp.insert(uuid, task);
assert_eq!(db.tasks(), &exp);
assert_eq!(db.operations, vec![op1, op2, op3, op4]);
}
#[test]
fn test_apply_update_does_not_exist() {
let mut db = DB::new();
@ -214,7 +261,7 @@ mod tests {
let op = Operation::Update {
uuid,
property: String::from("title"),
value: Value::from("\"my task\""),
value: Some("my task".into()),
timestamp: Utc::now(),
};
db.apply(op.clone());

View file

@ -20,7 +20,7 @@ fn operation_strategy() -> impl Strategy<Value = Operation> {
Operation::Update {
uuid,
property,
value: true.into(),
value: Some("true".into()),
timestamp: Utc::now(),
}
}),

View file

@ -18,7 +18,7 @@ fn test_sync() {
db1.apply(Operation::Update {
uuid: uuid1,
property: "title".into(),
value: "my first task".into(),
value: Some("my first task".into()),
timestamp: Utc::now(),
});
@ -27,7 +27,7 @@ fn test_sync() {
db2.apply(Operation::Update {
uuid: uuid2,
property: "title".into(),
value: "my second task".into(),
value: Some("my second task".into()),
timestamp: Utc::now(),
});
@ -41,13 +41,13 @@ fn test_sync() {
db1.apply(Operation::Update {
uuid: uuid2,
property: "priority".into(),
value: "H".into(),
value: Some("H".into()),
timestamp: Utc::now(),
});
db2.apply(Operation::Update {
uuid: uuid2,
property: "project".into(),
value: "personal".into(),
value: Some("personal".into()),
timestamp: Utc::now(),
});