feat: with backend
All checks were successful
continuous-integration/drone/push Build is passing

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2024-05-14 23:30:20 +02:00
parent 816869e6f9
commit 837caee5db
14 changed files with 1050 additions and 158 deletions

View File

@@ -5,6 +5,7 @@ use crate::{
create_item::{self, CreateItem, CreateItemExt},
create_root::{self, CreateRoot, CreateRootExt},
create_section::{self, CreateSection, CreateSectionExt},
update_item::{UpdateItem, UpdateItemExt},
},
state::SharedState,
};
@@ -48,6 +49,7 @@ pub struct Commander {
create_root: CreateRoot,
create_section: CreateSection,
create_item: CreateItem,
update_item: UpdateItem,
}
impl Commander {
@@ -55,11 +57,13 @@ impl Commander {
create_root: CreateRoot,
create_section: CreateSection,
create_item: CreateItem,
update_item: UpdateItem,
) -> Self {
Self {
create_root,
create_section,
create_item,
update_item,
}
}
@@ -104,7 +108,19 @@ impl Commander {
title,
description,
state,
} => todo!(),
} => {
self.update_item
.execute(crate::services::update_item::Request {
root,
path,
title,
description,
state,
})
.await?;
Ok(())
}
Command::ToggleItem { root, path } => todo!(),
Command::Move { root, src, dest } => todo!(),
}
@@ -121,6 +137,7 @@ impl CommanderExt for SharedState {
self.create_root_service(),
self.create_section_service(),
self.create_item_service(),
self.update_item_service(),
)
}
}

View File

@@ -196,26 +196,23 @@ impl Graph for Server {
tracing::trace!("get: req({:?})", msg);
Ok(Response::new(GetReply {
item: Some(GraphItem {
path: "kjuulh".into(),
contents: Some(graph_item::Contents::User(UserGraphItem {
items: HashMap::from([(
"some".to_string(),
GraphItem {
path: "some".into(),
contents: Some(graph_item::Contents::Item(ItemGraphItem {
title: "some-title".into(),
description: "some-description".into(),
item_state: Some(item_graph_item::ItemState::NotDone(
ItemStateNotDone {},
)),
})),
},
)]),
})),
}),
}))
let res = self
.querier
.get(&msg.root, msg.paths.clone())
.await
.map_err(to_tonic_err)?;
match res {
Some(item) => Ok(Response::new(GetReply {
item: Some(to_native(&item).map_err(to_tonic_err)?),
})),
None => {
return Err(tonic::Status::new(
tonic::Code::NotFound,
"failed to find any valid roots",
))
}
}
}
async fn get_available_roots(
@@ -242,6 +239,130 @@ impl Graph for Server {
Ok(Response::new(GetAvailableRootsResponse { roots }))
}
async fn update_item(
&self,
request: tonic::Request<UpdateItemRequest>,
) -> std::result::Result<tonic::Response<UpdateItemResponse>, tonic::Status> {
let req = request.into_inner();
tracing::trace!("update item: req({:?})", req);
if req.root.is_empty() {
return Err(tonic::Status::new(
tonic::Code::InvalidArgument,
"root cannot be empty".to_string(),
));
}
if req.path.is_empty() {
return Err(tonic::Status::new(
tonic::Code::InvalidArgument,
"path cannot be empty".to_string(),
));
}
if req
.path
.iter()
.filter(|item| item.is_empty())
.collect::<Vec<_>>()
.first()
.is_some()
{
return Err(tonic::Status::new(
tonic::Code::InvalidArgument,
"path cannot contain empty paths".to_string(),
));
}
if req
.path
.iter()
.filter(|item| item.contains("."))
.collect::<Vec<_>>()
.first()
.is_some()
{
return Err(tonic::Status::new(
tonic::Code::InvalidArgument,
"path cannot contain `.`".to_string(),
));
}
let item = match req.item {
Some(i) => i,
None => {
return Err(tonic::Status::new(
tonic::Code::InvalidArgument,
"item cannot contain empty or null".to_string(),
));
}
};
self.commander
.execute(Command::UpdateItem {
root: req.root,
path: req.path,
title: item.title,
description: item.description,
state: match item.item_state {
Some(item_graph_item::ItemState::Done(_)) => {
hyperlog_core::log::ItemState::Done
}
Some(item_graph_item::ItemState::NotDone(_)) => {
hyperlog_core::log::ItemState::NotDone
}
None => hyperlog_core::log::ItemState::default(),
},
})
.await
.map_err(to_tonic_err)?;
Ok(Response::new(UpdateItemResponse {}))
}
}
fn to_native(from: &hyperlog_core::log::GraphItem) -> anyhow::Result<GraphItem> {
match from {
hyperlog_core::log::GraphItem::User(section)
| hyperlog_core::log::GraphItem::Section(section) => {
let mut root = HashMap::new();
for (key, value) in section.iter() {
root.insert(key.to_string(), to_native(value)?);
}
match from {
hyperlog_core::log::GraphItem::User(_) => Ok(GraphItem {
contents: Some(graph_item::Contents::User(UserGraphItem { items: root })),
}),
hyperlog_core::log::GraphItem::Section(_) => Ok(GraphItem {
contents: Some(graph_item::Contents::Section(SectionGraphItem {
items: root,
})),
}),
_ => {
todo!()
}
}
}
hyperlog_core::log::GraphItem::Item {
title,
description,
state,
} => Ok(GraphItem {
contents: Some(graph_item::Contents::Item(ItemGraphItem {
title: title.to_owned(),
description: description.to_owned(),
item_state: Some(match state {
hyperlog_core::log::ItemState::NotDone => {
item_graph_item::ItemState::NotDone(ItemStateNotDone {})
}
hyperlog_core::log::ItemState::Done => {
item_graph_item::ItemState::Done(ItemStateDone {})
}
}),
})),
}),
}
}
// TODO: create more defined protobuf categories for errors

View File

@@ -1,3 +1,5 @@
#![feature(map_try_insert)]
use std::{net::SocketAddr, sync::Arc};
use crate::state::{SharedState, State};

View File

@@ -1,18 +1,23 @@
use hyperlog_core::log::GraphItem;
use crate::{
services::get_available_roots::{self, GetAvailableRoots, GetAvailableRootsExt},
services::{
get_available_roots::{self, GetAvailableRoots, GetAvailableRootsExt},
get_graph::{GetGraph, GetGraphExt},
},
state::SharedState,
};
pub struct Querier {
get_available_roots: GetAvailableRoots,
get_graph: GetGraph,
}
impl Querier {
pub fn new(get_available_roots: GetAvailableRoots) -> Self {
pub fn new(get_available_roots: GetAvailableRoots, get_graph: GetGraph) -> Self {
Self {
get_available_roots,
get_graph,
}
}
@@ -29,12 +34,20 @@ impl Querier {
Ok(Some(res.roots))
}
pub fn get(
pub async fn get(
&self,
root: &str,
path: impl IntoIterator<Item = impl Into<String>>,
) -> Option<GraphItem> {
todo!()
) -> anyhow::Result<Option<GraphItem>> {
let graph = self
.get_graph
.execute(crate::services::get_graph::Request {
root: root.into(),
path: path.into_iter().map(|s| s.into()).collect(),
})
.await?;
Ok(Some(graph.item))
}
}
@@ -44,6 +57,6 @@ pub trait QuerierExt {
impl QuerierExt for SharedState {
fn querier(&self) -> Querier {
Querier::new(self.get_available_roots_service())
Querier::new(self.get_available_roots_service(), self.get_graph_service())
}
}

View File

@@ -1,5 +1,7 @@
pub mod create_item;
pub mod create_root;
pub mod create_section;
pub mod update_item;
pub mod get_available_roots;
pub mod get_graph;

View File

@@ -0,0 +1,360 @@
use std::collections::BTreeMap;
use hyperlog_core::log::{GraphItem, ItemState};
use serde::Deserialize;
use sqlx::types::Json;
use crate::state::SharedState;
use self::engine::Engine;
#[derive(Clone)]
pub struct GetGraph {
db: sqlx::PgPool,
}
pub struct Request {
pub root: String,
pub path: Vec<String>,
}
pub struct Response {
pub item: GraphItem,
}
#[derive(sqlx::FromRow)]
struct Root {
id: uuid::Uuid,
}
#[derive(Deserialize)]
struct Item {
title: String,
description: String,
state: ItemState,
}
#[derive(sqlx::FromRow, Debug)]
struct Node {
id: uuid::Uuid,
path: String,
item_type: String,
item_content: Option<Json<serde_json::Value>>,
}
impl GetGraph {
pub fn new(db: sqlx::PgPool) -> Self {
Self { db }
}
pub async fn execute(&self, req: Request) -> anyhow::Result<Response> {
let Root { id: root_id, .. } =
sqlx::query_as(r#"SELECT * FROM roots WHERE root_name = $1"#)
.bind(&req.root)
.fetch_one(&self.db)
.await?;
let nodes: Vec<Node> = sqlx::query_as(
r#"
SELECT
*
FROM
nodes
WHERE
root_id = $1
LIMIT
1000
"#,
)
.bind(root_id)
.fetch_all(&self.db)
.await?;
let item = self.build_graph(req.root, req.path, nodes)?;
Ok(Response { item })
}
fn build_graph(
&self,
root: String,
path: Vec<String>,
mut nodes: Vec<Node>,
) -> anyhow::Result<GraphItem> {
nodes.sort_by(|a, b| a.path.cmp(&b.path));
let mut engine = Engine::default();
engine.create_root(&root)?;
self.get_graph_items(&root, &mut engine, &nodes)?;
engine
.get(&root, &path.iter().map(|s| s.as_str()).collect::<Vec<_>>())
.ok_or(anyhow::anyhow!("failed to find a valid graph"))
.cloned()
}
fn get_graph_items(
&self,
root: &str,
engine: &mut Engine,
nodes: &Vec<Node>,
) -> anyhow::Result<()> {
for node in nodes {
if let Some(item) = self.get_graph_item(node) {
let path = node.path.split('.').collect::<Vec<_>>();
engine.create(root, &path, item)?;
}
}
Ok(())
}
fn get_graph_item(&self, node: &Node) -> Option<GraphItem> {
match node.item_type.as_str() {
"SECTION" => Some(GraphItem::Section(BTreeMap::default())),
"ITEM" => {
if let Some(content) = &node.item_content {
let item: Item = serde_json::from_value(content.0.clone()).ok()?;
Some(GraphItem::Item {
title: item.title,
description: item.description,
state: item.state,
})
} else {
None
}
}
_ => None,
}
}
}
pub trait GetGraphExt {
fn get_graph_service(&self) -> GetGraph;
}
impl GetGraphExt for SharedState {
fn get_graph_service(&self) -> GetGraph {
GetGraph::new(self.db.clone())
}
}
mod engine {
use std::{collections::BTreeMap, fmt::Display};
use anyhow::{anyhow, Context};
use hyperlog_core::log::{Graph, GraphItem, ItemState};
#[derive(Default)]
pub struct Engine {
graph: Graph,
}
impl Engine {
pub fn engine_from_str(input: &str) -> anyhow::Result<Self> {
let graph: Graph = serde_json::from_str(input)?;
Ok(Self { graph })
}
pub fn to_str(&self) -> anyhow::Result<String> {
serde_json::to_string_pretty(&self.graph).context("failed to serialize graph")
}
pub fn create_root(&mut self, root: &str) -> anyhow::Result<()> {
self.graph
.try_insert(root.to_string(), GraphItem::User(BTreeMap::default()))
.map_err(|_| anyhow!("entry was already found, aborting"))?;
Ok(())
}
pub fn create(&mut self, root: &str, path: &[&str], item: GraphItem) -> anyhow::Result<()> {
let graph = &mut self.graph;
let (last, items) = path.split_last().ok_or(anyhow!(
"path cannot be empty, must contain at least one item"
))?;
let root = graph
.get_mut(root)
.ok_or(anyhow!("root was missing a user, aborting"))?;
let mut current_item = root;
for section in items {
match current_item {
GraphItem::User(u) => match u.get_mut(section.to_owned()) {
Some(graph_item) => {
current_item = graph_item;
}
None => anyhow::bail!("path: {} section was not found", section),
},
GraphItem::Item { .. } => anyhow::bail!("path: {} was already found", section),
GraphItem::Section(s) => match s.get_mut(section.to_owned()) {
Some(graph_item) => {
current_item = graph_item;
}
None => anyhow::bail!("path: {} section was not found", section),
},
}
}
match current_item {
GraphItem::User(u) => {
u.insert(last.to_string(), item);
}
GraphItem::Section(s) => {
s.insert(last.to_string(), item);
}
GraphItem::Item { .. } => anyhow::bail!("cannot insert an item into an item"),
}
Ok(())
}
pub fn get(&self, root: &str, path: &[&str]) -> Option<&GraphItem> {
let root = self.graph.get(root)?;
root.get(path)
}
pub fn get_mut(&mut self, root: &str, path: &[&str]) -> Option<&mut GraphItem> {
let root = self.graph.get_mut(root)?;
root.get_mut(path)
}
pub fn take(&mut self, root: &str, path: &[&str]) -> Option<GraphItem> {
let root = self.graph.get_mut(root)?;
root.take(path)
}
pub fn section_move(
&mut self,
root: &str,
src_path: &[&str],
dest_path: &[&str],
) -> anyhow::Result<()> {
let src = self
.take(root, src_path)
.ok_or(anyhow!("failed to find source path"))?;
let dest = self
.get_mut(root, dest_path)
.ok_or(anyhow!("failed to find destination"))?;
let src_item = src_path
.last()
.ok_or(anyhow!("src path must have at least one item"))?;
match dest {
GraphItem::User(u) => {
u.try_insert(src_item.to_string(), src)
.map_err(|_e| anyhow!("key was already found, aborting: {}", src_item))?;
}
GraphItem::Section(s) => {
s.try_insert(src_item.to_string(), src)
.map_err(|_e| anyhow!("key was already found, aborting: {}", src_item))?;
}
GraphItem::Item { .. } => {
anyhow::bail!(
"failed to insert src at item, item doesn't support arbitrary items"
)
}
}
Ok(())
}
pub fn delete(&mut self, root: &str, path: &[&str]) -> anyhow::Result<()> {
self.take(root, path)
.map(|_| ())
.ok_or(anyhow!("item was not found"))
}
pub fn toggle_item(&mut self, root: &str, path: &[&str]) -> anyhow::Result<()> {
if let Some(item) = self.get_mut(root, path) {
match item {
GraphItem::Item { state, .. } => match state {
ItemState::NotDone => *state = ItemState::Done,
ItemState::Done => *state = ItemState::NotDone,
},
_ => {
anyhow::bail!("{}.{:?} is not an item", root, path)
}
}
}
Ok(())
}
pub fn update_item(
&mut self,
root: &str,
path: &[&str],
item: &GraphItem,
) -> anyhow::Result<()> {
if let Some((name, dest_last)) = path.split_last() {
if let Some(parent) = self.get_mut(root, dest_last) {
match parent {
GraphItem::User(s) | GraphItem::Section(s) => {
if let Some(mut existing) = s.remove(*name) {
match (&mut existing, item) {
(
GraphItem::Item {
title: ex_title,
description: ex_desc,
state: ex_state,
},
GraphItem::Item {
title,
description,
state,
},
) => {
ex_title.clone_from(title);
ex_desc.clone_from(description);
ex_state.clone_from(state);
let title = title.replace(".", "-");
s.insert(title, existing.clone());
}
_ => {
anyhow::bail!(
"path: {}.{} found is not an item",
root,
path.join(".")
)
}
}
}
}
GraphItem::Item { .. } => {
anyhow::bail!("cannot rename when item is placed in an item")
}
}
}
}
Ok(())
}
pub fn get_roots(&self) -> Option<Vec<String>> {
let items = self.graph.keys().cloned().collect::<Vec<_>>();
if items.is_empty() {
None
} else {
Some(items)
}
}
}
impl Display for Engine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let output = serde_json::to_string_pretty(&self.graph).unwrap();
f.write_str(&output)
}
}
}

View File

@@ -0,0 +1,107 @@
use hyperlog_core::log::ItemState;
use sqlx::types::Json;
use crate::state::SharedState;
#[derive(Clone)]
pub struct UpdateItem {
db: sqlx::PgPool,
}
pub struct Request {
pub root: String,
pub path: Vec<String>,
pub title: String,
pub description: String,
pub state: ItemState,
}
pub struct Response {}
#[derive(serde::Serialize)]
struct ItemContent {
pub title: String,
pub description: String,
pub state: ItemState,
}
#[derive(sqlx::FromRow)]
struct Root {
id: uuid::Uuid,
}
impl UpdateItem {
pub fn new(db: sqlx::PgPool) -> Self {
Self { db }
}
pub async fn execute(&self, req: Request) -> anyhow::Result<Response> {
let Root { id: root_id, .. } =
sqlx::query_as(r#"SELECT * FROM roots WHERE root_name = $1"#)
.bind(req.root)
.fetch_one(&self.db)
.await?;
let Root { id: node_id } = sqlx::query_as(
r#"
SELECT
id
FROM
nodes
WHERE
root_id = $1
AND path = $2
AND item_type = $3
"#,
)
.bind(root_id)
.bind(req.path.join("."))
.bind("ITEM")
.fetch_one(&self.db)
.await?;
let (_, rest) = req
.path
.split_last()
.ok_or(anyhow::anyhow!("expected path to have at least one item"))?;
let mut rest = rest.to_vec();
rest.push(req.title.replace(".", "-"));
let res = sqlx::query(
r#"
UPDATE
nodes
SET
item_content = $1,
path = $2
WHERE
id = $3
"#,
)
.bind(Json(ItemContent {
title: req.title,
description: req.description,
state: req.state,
}))
.bind(rest.join("."))
.bind(node_id)
.execute(&self.db)
.await?;
if res.rows_affected() != 1 {
anyhow::bail!("failed to update item");
}
Ok(Response {})
}
}
pub trait UpdateItemExt {
fn update_item_service(&self) -> UpdateItem;
}
impl UpdateItemExt for SharedState {
fn update_item_service(&self) -> UpdateItem {
UpdateItem::new(self.db.clone())
}
}