aboutsummaryrefslogtreecommitdiffstats
path: root/crates/rocie-server/src/storage
diff options
context:
space:
mode:
Diffstat (limited to 'crates/rocie-server/src/storage')
-rw-r--r--crates/rocie-server/src/storage/migrate/sql/0->1.sql28
-rw-r--r--crates/rocie-server/src/storage/sql/get/mod.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/get/product/mod.rs2
-rw-r--r--crates/rocie-server/src/storage/sql/get/product_parent/mod.rs4
-rw-r--r--crates/rocie-server/src/storage/sql/get/recipe/mod.rs90
-rw-r--r--crates/rocie-server/src/storage/sql/get/recipe_parent/mod.rs85
-rw-r--r--crates/rocie-server/src/storage/sql/get/user/mod.rs39
-rw-r--r--crates/rocie-server/src/storage/sql/insert/mod.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs4
-rw-r--r--crates/rocie-server/src/storage/sql/insert/recipe/mod.rs87
-rw-r--r--crates/rocie-server/src/storage/sql/insert/recipe_parent/mod.rs113
-rw-r--r--crates/rocie-server/src/storage/sql/mod.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/product.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/product_amount.rs2
-rw-r--r--crates/rocie-server/src/storage/sql/recipe.rs383
-rw-r--r--crates/rocie-server/src/storage/sql/recipe_parent.rs31
-rw-r--r--crates/rocie-server/src/storage/sql/unit.rs2
17 files changed, 818 insertions, 56 deletions
diff --git a/crates/rocie-server/src/storage/migrate/sql/0->1.sql b/crates/rocie-server/src/storage/migrate/sql/0->1.sql
index e3dd879..ba44c68 100644
--- a/crates/rocie-server/src/storage/migrate/sql/0->1.sql
+++ b/crates/rocie-server/src/storage/migrate/sql/0->1.sql
@@ -14,17 +14,29 @@ CREATE TABLE version (
valid_to INTEGER UNIQUE CHECK (valid_to > valid_from)
) STRICT;
--- Encodes the tree structure of the products.
--- A parent cannot be a product, but can have parents on it's own.
+-- Encodes the tree structure of the product parents.
+-- A product parent cannot be a product, but can have parents on it's own.
-- TODO: Fix the possibility for cyclic parent-ship entries <2025-09-05>
-CREATE TABLE parents (
+CREATE TABLE product_parents (
id TEXT UNIQUE NOT NULL PRIMARY KEY,
parent TEXT DEFAULT NULL CHECK (
id IS NOT parent
),
name TEXT UNIQUE NOT NULL,
description TEXT,
- FOREIGN KEY(parent) REFERENCES parents(id)
+ FOREIGN KEY(parent) REFERENCES product_parents(id)
+) STRICT;
+
+-- Encodes the tree structure of the recipe parents.
+-- TODO: Fix the possibility for cyclic parent-ship entries <2025-09-05>
+CREATE TABLE recipe_parents (
+ id TEXT UNIQUE NOT NULL PRIMARY KEY,
+ parent TEXT DEFAULT NULL CHECK (
+ id IS NOT parent
+ ),
+ name TEXT UNIQUE NOT NULL,
+ description TEXT,
+ FOREIGN KEY(parent) REFERENCES recipe_parents(id)
) STRICT;
-- Stores the registered users.
@@ -71,7 +83,7 @@ CREATE TABLE products (
description TEXT,
parent TEXT DEFAULT NULL,
unit_property TEXT NOT NULL,
- FOREIGN KEY(parent) REFERENCES parents(id),
+ FOREIGN KEY(parent) REFERENCES product_parents(id),
FOREIGN KEY(unit_property) REFERENCES unit_properties(id)
) STRICT;
@@ -123,8 +135,10 @@ CREATE TABLE unit_properties (
CREATE TABLE recipies (
id TEXT UNIQUE NOT NULL PRIMARY KEY,
- path TEXT UNIQUE NOT NULL,
- content TEXT NOT NULL
+ name TEXT UNIQUE NOT NULL,
+ parent TEXT,
+ content TEXT NOT NULL,
+ FOREIGN KEY(parent) REFERENCES recipe_parents(id)
) STRICT;
-- Encodes unit conversions:
diff --git a/crates/rocie-server/src/storage/sql/get/mod.rs b/crates/rocie-server/src/storage/sql/get/mod.rs
index 92b34aa..a6ee0e1 100644
--- a/crates/rocie-server/src/storage/sql/get/mod.rs
+++ b/crates/rocie-server/src/storage/sql/get/mod.rs
@@ -2,6 +2,7 @@ pub(crate) mod barcode;
pub(crate) mod product;
pub(crate) mod product_amount;
pub(crate) mod product_parent;
+pub(crate) mod recipe_parent;
pub(crate) mod recipe;
pub(crate) mod unit;
pub(crate) mod unit_property;
diff --git a/crates/rocie-server/src/storage/sql/get/product/mod.rs b/crates/rocie-server/src/storage/sql/get/product/mod.rs
index 915da81..3d8b6e6 100644
--- a/crates/rocie-server/src/storage/sql/get/product/mod.rs
+++ b/crates/rocie-server/src/storage/sql/get/product/mod.rs
@@ -66,7 +66,7 @@ impl Product {
}
}
- pub(crate) async fn from_name(app: &App, name: String) -> Result<Option<Self>, from_id::Error> {
+ pub(crate) async fn from_name(app: &App, name: &str) -> Result<Option<Self>, from_id::Error> {
let record = query!(
"
SELECT name, id, unit_property, description, parent
diff --git a/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs b/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs
index 5b85b62..243ae1e 100644
--- a/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs
+++ b/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs
@@ -10,7 +10,7 @@ impl ProductParent {
let records = query!(
"
SELECT id, parent, name, description
- FROM parents
+ FROM product_parents
"
)
.fetch_all(&app.db)
@@ -40,7 +40,7 @@ impl ProductParent {
let record = query!(
"
SELECT parent, name, description
- FROM parents
+ FROM product_parents
WHERE id = ?
",
id
diff --git a/crates/rocie-server/src/storage/sql/get/recipe/mod.rs b/crates/rocie-server/src/storage/sql/get/recipe/mod.rs
index 9d6dc79..f433541 100644
--- a/crates/rocie-server/src/storage/sql/get/recipe/mod.rs
+++ b/crates/rocie-server/src/storage/sql/get/recipe/mod.rs
@@ -1,15 +1,38 @@
use crate::{
app::App,
- storage::sql::recipe::{Recipe, RecipeId},
+ storage::sql::{
+ recipe::{CooklangRecipe, Recipe, RecipeId},
+ recipe_parent::RecipeParentId,
+ },
};
use sqlx::query;
+pub(crate) mod parse {
+ use crate::storage::sql::recipe::conversion;
+
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to convert from cooklang recipe to our own struct")]
+ Conversion(#[from] conversion::Error),
+ }
+}
+
+async fn recipe_from_content(app: &App, content: &str) -> Result<CooklangRecipe, parse::Error> {
+ // NOTE: We can ignore warnings here, as we should already have handled them at the recipe
+ // insert point. <2026-01-31>
+ let (output, _warnings) = cooklang::parse(content)
+ .into_result()
+ .expect("The values in the db should always be valid, as we checked before inserting them");
+
+ Ok(CooklangRecipe::from(app, output).await?)
+}
+
impl Recipe {
pub(crate) async fn from_id(app: &App, id: RecipeId) -> Result<Option<Self>, from_id::Error> {
let record = query!(
"
- SELECT content, path
+ SELECT name, parent, content
FROM recipies
WHERE id = ?
",
@@ -21,11 +44,33 @@ impl Recipe {
if let Some(record) = record {
Ok(Some(Self {
id,
- path: record
- .path
- .parse()
- .expect("Was a path before, should still be one"),
- content: record.content,
+ content: recipe_from_content(app, &record.content).await?,
+ name: record.name,
+ parent: record.parent.map(|id| RecipeParentId::from_db(&id)),
+ }))
+ } else {
+ Ok(None)
+ }
+ }
+
+ pub(crate) async fn from_name(app: &App, name: String) -> Result<Option<Self>, from_id::Error> {
+ let record = query!(
+ "
+ SELECT id, parent, content
+ FROM recipies
+ WHERE name = ?
+",
+ name
+ )
+ .fetch_optional(&app.db)
+ .await?;
+
+ if let Some(record) = record {
+ Ok(Some(Self {
+ id: RecipeId::from_db(&record.id),
+ content: recipe_from_content(app, &record.content).await?,
+ name,
+ parent: record.parent.map(|id| RecipeParentId::from_db(&id)),
}))
} else {
Ok(None)
@@ -35,31 +80,39 @@ impl Recipe {
pub(crate) async fn get_all(app: &App) -> Result<Vec<Self>, get_all::Error> {
let records = query!(
"
- SELECT id, content, path
+ SELECT id, name, parent, content
FROM recipies
",
)
.fetch_all(&app.db)
.await?;
- Ok(records
- .into_iter()
- .map(|record| Self {
+ let mut output = vec![];
+ for record in records {
+ output.push(Self {
id: RecipeId::from_db(&record.id),
- path: record.path.parse().expect("Is still valid"),
- content: record.content,
- })
- .collect())
+ content: recipe_from_content(app, &record.content).await?,
+ name: record.name,
+ parent: record.parent.map(|id| RecipeParentId::from_db(&id)),
+ });
+ }
+
+ Ok(output)
}
}
pub(crate) mod from_id {
use actix_web::ResponseError;
+ use crate::storage::sql::get::recipe::parse;
+
#[derive(thiserror::Error, Debug)]
pub(crate) enum Error {
- #[error("Failed to execute the sql query")]
+ #[error("Failed to execute the sql query: `{0}`")]
SqlError(#[from] sqlx::Error),
+
+ #[error("Failed to parse the recipe content as cooklang recipe: `{0}`")]
+ RecipeParse(#[from] parse::Error),
}
impl ResponseError for Error {}
@@ -68,10 +121,15 @@ pub(crate) mod from_id {
pub(crate) mod get_all {
use actix_web::ResponseError;
+ use crate::storage::sql::get::recipe::parse;
+
#[derive(thiserror::Error, Debug)]
pub(crate) enum Error {
#[error("Failed to execute the sql query")]
SqlError(#[from] sqlx::Error),
+
+ #[error("Failed to parse the recipe content as cooklang recipe")]
+ RecipeParse(#[from] parse::Error),
}
impl ResponseError for Error {}
diff --git a/crates/rocie-server/src/storage/sql/get/recipe_parent/mod.rs b/crates/rocie-server/src/storage/sql/get/recipe_parent/mod.rs
new file mode 100644
index 0000000..d53e853
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/get/recipe_parent/mod.rs
@@ -0,0 +1,85 @@
+use crate::{
+ app::App,
+ storage::sql::{
+ recipe_parent::{RecipeParent, RecipeParentId},
+ },
+};
+
+use sqlx::query;
+
+impl RecipeParent {
+ pub(crate) async fn get_all(app: &App) -> Result<Vec<Self>, get_all::Error> {
+ let records = query!(
+ "
+ SELECT id, parent, name, description
+ FROM recipe_parents
+"
+ )
+ .fetch_all(&app.db)
+ .await?;
+
+ let mut all = Vec::with_capacity(records.len());
+ for record in records {
+ let parent = Self {
+ id: RecipeParentId::from_db(&record.id),
+ parent: record.parent.map(|parent| RecipeParentId::from_db(&parent)),
+ name: record.name,
+ description: record.description,
+ };
+
+ all.push(parent);
+ }
+
+ Ok(all)
+ }
+
+ pub(crate) async fn from_id(
+ app: &App,
+ id: RecipeParentId,
+ ) -> Result<Option<Self>, from_id::Error> {
+ let record = query!(
+ "
+ SELECT parent, name, description
+ FROM recipe_parents
+ WHERE id = ?
+",
+ id
+ )
+ .fetch_optional(&app.db)
+ .await?;
+
+ match record {
+ Some(record) => Ok(Some(Self {
+ id,
+ parent: record.parent.map(|parent| RecipeParentId::from_db(&parent)),
+ name: record.name,
+ description: record.description,
+ })),
+ None => Ok(None),
+ }
+ }
+}
+
+pub(crate) mod from_id {
+ use actix_web::ResponseError;
+
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to execute the sql query")]
+ SqlError(#[from] sqlx::Error),
+ }
+
+ impl ResponseError for Error {}
+}
+
+pub(crate) mod get_all {
+ use actix_web::ResponseError;
+
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to execute the sql query")]
+ SqlError(#[from] sqlx::Error),
+ }
+
+ impl ResponseError for Error {}
+}
diff --git a/crates/rocie-server/src/storage/sql/get/user/mod.rs b/crates/rocie-server/src/storage/sql/get/user/mod.rs
index e36c6cf..e09ef67 100644
--- a/crates/rocie-server/src/storage/sql/get/user/mod.rs
+++ b/crates/rocie-server/src/storage/sql/get/user/mod.rs
@@ -50,6 +50,33 @@ impl User {
Ok(None)
}
}
+
+ pub(crate) async fn from_name(
+ app: &App,
+ name: String,
+ ) -> Result<Option<Self>, from_name::Error> {
+ let record = query!(
+ "
+ SELECT id, name, password_hash, description
+ FROM users
+ WHERE name = ?
+",
+ name
+ )
+ .fetch_optional(&app.db)
+ .await?;
+
+ if let Some(record) = record {
+ Ok(Some(Self {
+ name: record.name,
+ description: record.description,
+ id: UserId::from_db(&record.id),
+ password_hash: PasswordHash::from_db(record.password_hash),
+ }))
+ } else {
+ Ok(None)
+ }
+ }
}
pub(crate) mod get_all {
@@ -75,3 +102,15 @@ pub(crate) mod from_id {
impl ResponseError for Error {}
}
+
+pub(crate) mod from_name {
+ use actix_web::ResponseError;
+
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to execute the sql query")]
+ SqlError(#[from] sqlx::Error),
+ }
+
+ impl ResponseError for Error {}
+}
diff --git a/crates/rocie-server/src/storage/sql/insert/mod.rs b/crates/rocie-server/src/storage/sql/insert/mod.rs
index 673cbdd..b92a88c 100644
--- a/crates/rocie-server/src/storage/sql/insert/mod.rs
+++ b/crates/rocie-server/src/storage/sql/insert/mod.rs
@@ -11,6 +11,7 @@ pub(crate) mod barcode;
pub(crate) mod product;
pub(crate) mod product_parent;
pub(crate) mod recipe;
+pub(crate) mod recipe_parent;
pub(crate) mod unit;
pub(crate) mod unit_property;
pub(crate) mod user;
diff --git a/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs b/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs
index 644778f..72fb564 100644
--- a/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs
+++ b/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs
@@ -31,7 +31,7 @@ impl Transactionable for Operation {
} => {
query!(
"
- INSERT INTO parents (id, name, description, parent)
+ INSERT INTO product_parents (id, name, description, parent)
VALUES (?,?,?,?)
",
id,
@@ -56,7 +56,7 @@ impl Transactionable for Operation {
} => {
query!(
"
- DELETE FROM products
+ DELETE FROM product_parents
WHERE id = ? AND name = ? AND description = ? AND parent = ?;
",
id,
diff --git a/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs b/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs
index b223bfe..b60874f 100644
--- a/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs
+++ b/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs
@@ -1,19 +1,23 @@
-use std::path::PathBuf;
-
+use cooklang::{Converter, CooklangParser, Extensions};
use serde::{Deserialize, Serialize};
use sqlx::query;
use uuid::Uuid;
-use crate::storage::sql::{
- insert::{Operations, Transactionable},
- recipe::{Recipe, RecipeId},
+use crate::{
+ app::App,
+ storage::sql::{
+ insert::{Operations, Transactionable},
+ recipe::{CooklangRecipe, Recipe, RecipeId},
+ recipe_parent::RecipeParentId,
+ },
};
#[derive(Debug, Deserialize, Serialize)]
pub(crate) enum Operation {
New {
id: RecipeId,
- path: PathBuf,
+ name: String,
+ parent: Option<RecipeParentId>,
content: String,
},
}
@@ -24,16 +28,20 @@ impl Transactionable for Operation {
async fn apply(self, txn: &mut sqlx::SqliteConnection) -> Result<(), apply::Error> {
match self {
- Operation::New { id, path, content } => {
- let path = path.display().to_string();
-
+ Operation::New {
+ id,
+ name,
+ parent,
+ content,
+ } => {
query!(
"
- INSERT INTO recipies (id, path, content)
- VALUES (?, ?, ?)
+ INSERT INTO recipies (id, name, parent, content)
+ VALUES (?, ?, ?, ?)
",
id,
- path,
+ name,
+ parent,
content,
)
.execute(txn)
@@ -45,16 +53,20 @@ impl Transactionable for Operation {
async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> {
match self {
- Operation::New { id, path, content } => {
- let path = path.display().to_string();
-
+ Operation::New {
+ id,
+ name,
+ parent,
+ content,
+ } => {
query!(
"
DELETE FROM recipies
- WHERE id = ? AND path = ? AND content = ?
+ WHERE id = ? AND name = ? AND parent = ? AND content = ?
",
id,
- path,
+ name,
+ parent,
content
)
.execute(txn)
@@ -79,17 +91,50 @@ pub(crate) mod apply {
SqlError(#[from] sqlx::Error),
}
}
+pub(crate) mod new {
+ use actix_web::ResponseError;
+
+ use crate::storage::sql::recipe::conversion;
+
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to parse the recipe contents as cooklang: `{0}`")]
+ RecipeParse(#[from] cooklang::error::SourceReport),
+
+ #[error("Failed to convert the cooklang recipe to our struct: `{0}`")]
+ RecipeConvert(#[from] conversion::Error),
+ }
+
+ impl ResponseError for Error {}
+}
impl Recipe {
- pub(crate) fn new(path: PathBuf, content: String, ops: &mut Operations<Operation>) -> Self {
+ pub(crate) async fn new(
+ app: &App,
+ name: String,
+ parent: Option<RecipeParentId>,
+ content: String,
+ ops: &mut Operations<Operation>,
+ ) -> Result<Self, new::Error> {
let id = RecipeId::from(Uuid::new_v4());
+ let parser = CooklangParser::new(Extensions::empty(), Converter::bundled());
+
+ // TODO: Somehow return the warnings <2026-01-31>
+ let (recipe, _warnings) = parser.parse(&content).into_result()?;
+
ops.push(Operation::New {
id,
- path: path.clone(),
- content: content.clone(),
+ content,
+ name: name.clone(),
+ parent,
});
- Self { id, path, content }
+ Ok(Self {
+ id,
+ name,
+ parent,
+ content: CooklangRecipe::from(app, recipe).await?,
+ })
}
}
diff --git a/crates/rocie-server/src/storage/sql/insert/recipe_parent/mod.rs b/crates/rocie-server/src/storage/sql/insert/recipe_parent/mod.rs
new file mode 100644
index 0000000..95bc6f1
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/insert/recipe_parent/mod.rs
@@ -0,0 +1,113 @@
+use serde::{Deserialize, Serialize};
+use sqlx::query;
+use uuid::Uuid;
+
+use crate::storage::sql::{
+ insert::{Operations, Transactionable},
+ recipe_parent::{RecipeParent, RecipeParentId},
+};
+
+#[derive(Debug, Deserialize, Serialize)]
+pub(crate) enum Operation {
+ RegisterRecipeParent {
+ id: RecipeParentId,
+ name: String,
+ description: Option<String>,
+ parent: Option<RecipeParentId>,
+ },
+}
+
+impl Transactionable for Operation {
+ type ApplyError = apply::Error;
+ type UndoError = undo::Error;
+
+ async fn apply(self, txn: &mut sqlx::SqliteConnection) -> Result<(), apply::Error> {
+ match self {
+ Operation::RegisterRecipeParent {
+ id,
+ name,
+ description,
+ parent,
+ } => {
+ query!(
+ "
+ INSERT INTO recipe_parents (id, name, description, parent)
+ VALUES (?,?,?,?)
+",
+ id,
+ name,
+ description,
+ parent
+ )
+ .execute(txn)
+ .await?;
+ }
+ }
+ Ok(())
+ }
+
+ async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> {
+ match self {
+ Operation::RegisterRecipeParent {
+ id,
+ name,
+ description,
+ parent,
+ } => {
+ query!(
+ "
+ DELETE FROM recipe_parents
+ WHERE id = ? AND name = ? AND description = ? AND parent = ?;
+",
+ id,
+ name,
+ description,
+ parent,
+ )
+ .execute(txn)
+ .await?;
+ }
+ }
+ Ok(())
+ }
+}
+
+pub(crate) mod undo {
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to execute undo sql statments: {0}")]
+ SqlError(#[from] sqlx::Error),
+ }
+}
+pub(crate) mod apply {
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to execute apply sql statments: {0}")]
+ SqlError(#[from] sqlx::Error),
+ }
+}
+
+impl RecipeParent {
+ pub(crate) fn register(
+ name: String,
+ description: Option<String>,
+ parent: Option<RecipeParentId>,
+ ops: &mut Operations<Operation>,
+ ) -> Self {
+ let id = RecipeParentId::from(Uuid::new_v4());
+
+ ops.push(Operation::RegisterRecipeParent {
+ id,
+ name: name.clone(),
+ description: description.clone(),
+ parent,
+ });
+
+ Self {
+ id,
+ name,
+ description,
+ parent,
+ }
+ }
+}
diff --git a/crates/rocie-server/src/storage/sql/mod.rs b/crates/rocie-server/src/storage/sql/mod.rs
index 315c251..c37e68f 100644
--- a/crates/rocie-server/src/storage/sql/mod.rs
+++ b/crates/rocie-server/src/storage/sql/mod.rs
@@ -7,6 +7,7 @@ pub(crate) mod product;
pub(crate) mod product_amount;
pub(crate) mod product_parent;
pub(crate) mod recipe;
+pub(crate) mod recipe_parent;
pub(crate) mod unit;
pub(crate) mod unit_property;
pub(crate) mod user;
diff --git a/crates/rocie-server/src/storage/sql/product.rs b/crates/rocie-server/src/storage/sql/product.rs
index 00c79d3..c2c32ec 100644
--- a/crates/rocie-server/src/storage/sql/product.rs
+++ b/crates/rocie-server/src/storage/sql/product.rs
@@ -17,6 +17,7 @@ pub(crate) struct Product {
/// The parent this product has.
///
/// This is effectively it's anchor in the product DAG.
+ /// None means, that it has no parents and as such is in the toplevel.
#[schema(nullable = false)]
pub(crate) parent: Option<ProductParentId>,
diff --git a/crates/rocie-server/src/storage/sql/product_amount.rs b/crates/rocie-server/src/storage/sql/product_amount.rs
index 0f19afc..dafe43a 100644
--- a/crates/rocie-server/src/storage/sql/product_amount.rs
+++ b/crates/rocie-server/src/storage/sql/product_amount.rs
@@ -3,7 +3,7 @@ use utoipa::ToSchema;
use crate::storage::sql::{product::ProductId, unit::UnitAmount};
-#[derive(Clone, ToSchema, Serialize, Deserialize)]
+#[derive(Debug, Clone, ToSchema, Serialize, Deserialize, PartialEq)]
pub(crate) struct ProductAmount {
pub(crate) product_id: ProductId,
pub(crate) amount: UnitAmount,
diff --git a/crates/rocie-server/src/storage/sql/recipe.rs b/crates/rocie-server/src/storage/sql/recipe.rs
index 835d98b..1fc3b56 100644
--- a/crates/rocie-server/src/storage/sql/recipe.rs
+++ b/crates/rocie-server/src/storage/sql/recipe.rs
@@ -1,18 +1,391 @@
-use std::path::PathBuf;
+#![expect(clippy::unused_async)]
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
-use crate::storage::sql::mk_id;
+use crate::{
+ app::App,
+ storage::sql::{
+ mk_id,
+ product::{Product, ProductId},
+ product_amount::ProductAmount,
+ recipe_parent::RecipeParentId,
+ unit::UnitAmount,
+ },
+};
+macro_rules! for_in {
+ ($value:expr, |$name:ident| $closoure:expr) => {{
+ let fun = async |$name| $closoure;
+
+ let mut output = Vec::with_capacity($value.len());
+ for $name in $value {
+ output.push(fun($name).await?);
+ }
+ output
+ }};
+}
+
+/// An recipe.
+///
+/// These are transparently expressed in cooklang.
#[derive(ToSchema, Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Recipe {
+ /// The unique id of this recipe.
pub(crate) id: RecipeId,
- #[schema(value_type = String)]
- pub(crate) path: PathBuf,
+ /// The name of the recipe.
+ ///
+ /// This should be globally unique, to make searching easier for the user.
+ pub(crate) name: String,
+
+ /// The parent this recipe has.
+ ///
+ /// This is effectively it's anchor in the recipe DAG.
+ /// None means, that it has no parents and as such is in the toplevel.
+ #[schema(nullable = false)]
+ pub(crate) parent: Option<RecipeParentId>,
- pub(crate) content: String,
+ /// The actual content of this recipe.
+ pub(crate) content: CooklangRecipe,
}
mk_id!(RecipeId and RecipeIdStub);
+
+/// A complete recipe
+///
+/// The recipes do not have a name. You give it externally or maybe use
+/// some metadata key.
+///
+/// The recipe returned from parsing is a [`ScalableRecipe`].
+///
+/// The difference between [`ScalableRecipe`] and [`ScaledRecipe`] is in the
+/// values of the quantities of ingredients, cookware and timers. The parser
+/// returns [`ScalableValue`]s and after scaling, these are converted to regular
+/// [`Value`]s.
+#[derive(ToSchema, Debug, Serialize, Deserialize, PartialEq, Clone)]
+pub(crate) struct CooklangRecipe {
+ /// Metadata as read from preamble
+ pub(crate) metadata: Metadata,
+
+ /// Each of the sections
+ ///
+ /// If no sections declared, a section without name
+ /// is the default.
+ pub(crate) sections: Vec<Section>,
+
+ /// All the ingredients
+ pub(crate) ingredients: Vec<Ingredient>,
+
+ /// All the cookware
+ pub(crate) cookware: Vec<Cookware>,
+
+ /// All the timers
+ pub(crate) timers: Vec<Timer>,
+}
+
+/// A section holding steps
+#[derive(Debug, ToSchema, Default, Serialize, Deserialize, PartialEq, Clone)]
+pub(crate) struct Section {
+ /// Name of the section
+ #[schema(nullable = false)]
+ pub(crate) name: Option<String>,
+
+ /// Content inside
+ pub(crate) content: Vec<Content>,
+}
+
+/// Each type of content inside a section
+#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
+#[serde(tag = "type", content = "value", rename_all = "camelCase")]
+pub(crate) enum Content {
+ /// A step
+ Step(Step),
+
+ /// A paragraph of just text, no instructions
+ Text(String),
+}
+
+/// A step holding step [`Item`]s
+#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
+pub(crate) struct Step {
+ /// [`Item`]s inside
+ pub(crate) items: Vec<Item>,
+
+ /// Step number
+ ///
+ /// The step numbers start at 1 in each section and increase with non
+ /// text step.
+ pub(crate) number: u32,
+}
+
+/// A step item
+///
+/// Except for [`Item::Text`], the value is the index where the item is located
+/// in it's corresponding [`Vec`] in the [`Recipe`].
+#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub(crate) enum Item {
+ /// Just plain text
+ Text {
+ value: String,
+ },
+ Ingredient {
+ index: usize,
+ },
+ Cookware {
+ index: usize,
+ },
+ Timer {
+ index: usize,
+ },
+ InlineQuantity {
+ index: usize,
+ },
+}
+
+/// A recipe ingredient
+#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
+pub(crate) enum Ingredient {
+ /// This ingredient is a registered product.
+ RegisteredProduct {
+ id: ProductId,
+
+ /// Alias
+ #[schema(nullable = false)]
+ alias: Option<String>,
+
+ /// Quantity
+ #[schema(nullable = false)]
+ quantity: Option<ProductAmount>,
+ },
+
+ /// This ingredient is a not yet registered product.
+ NotRegisteredProduct {
+ name: String,
+
+ /// Quantity
+ #[schema(nullable = false)]
+ quantity: Option<UnitAmount>,
+ },
+
+ /// This ingredient is a reference to another recipe.
+ RecipeReference { id: RecipeId },
+}
+
+/// A recipe cookware item
+#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
+pub(crate) struct Cookware {
+ /// Name
+ pub(crate) name: String,
+
+ /// Alias
+ #[schema(nullable = false)]
+ pub(crate) alias: Option<String>,
+
+ /// Amount needed
+ ///
+ /// Note that this is a value, not a quantity, so it doesn't have units.
+ #[schema(nullable = false)]
+ pub(crate) quantity: Option<usize>,
+
+ /// Note
+ #[schema(nullable = false)]
+ pub(crate) note: Option<String>,
+}
+
+/// A recipe timer
+///
+/// If created from parsing, at least one of the fields is guaranteed to be
+/// [`Some`].
+#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
+pub(crate) struct Timer {
+ /// Name
+ #[schema(nullable = false)]
+ pub(crate) name: Option<String>,
+
+ /// Time quantity
+ pub(crate) quantity: UnitAmount,
+}
+
+#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
+pub(crate) struct Metadata {
+ #[schema(nullable = false)]
+ title: Option<String>,
+
+ #[schema(nullable = false)]
+ description: Option<String>,
+
+ #[schema(nullable = false)]
+ tags: Option<Vec<String>>,
+
+ #[schema(nullable = false)]
+ author: Option<NameAndUrl>,
+
+ #[schema(nullable = false)]
+ source: Option<NameAndUrl>,
+ // time: Option<serde_yaml::Value>,
+ // prep_time: Option<serde_yaml::Value>,
+ // cook_time: Option<serde_yaml::Value>,
+ // servings: Option<serde_yaml::Value>,
+ // difficulty: Option<serde_yaml::Value>,
+ // cuisine: Option<serde_yaml::Value>,
+ // diet: Option<serde_yaml::Value>,
+ // images: Option<serde_yaml::Value>,
+ // locale: Option<serde_yaml::Value>,
+
+ // other: serde_yaml::Mapping,
+}
+
+pub(crate) mod conversion {
+ use crate::storage::sql::get::product::from_id;
+
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to get a product by id: `{0}`")]
+ ProductAccess(#[from] from_id::Error),
+ }
+}
+
+#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
+pub(crate) struct NameAndUrl {
+ #[schema(nullable = false)]
+ name: Option<String>,
+
+ #[schema(nullable = false)]
+ url: Option<String>,
+}
+impl NameAndUrl {
+ async fn from(value: cooklang::metadata::NameAndUrl) -> Result<Self, conversion::Error> {
+ Ok(Self {
+ name: value.name().map(|v| v.to_owned()),
+ url: value.url().map(|v| v.to_owned()),
+ })
+ }
+}
+
+impl CooklangRecipe {
+ pub(crate) async fn from(
+ app: &App,
+ value: cooklang::Recipe,
+ ) -> Result<Self, conversion::Error> {
+ Ok(Self {
+ metadata: Metadata::from(value.metadata).await?,
+ sections: for_in!(value.sections, |s| Section::from(s).await),
+ ingredients: for_in!(value.ingredients, |i| Ingredient::from(app, i).await),
+ cookware: for_in!(value.cookware, |c| Cookware::from(c).await),
+ timers: for_in!(value.timers, |t| Timer::from(t).await),
+ })
+ }
+}
+
+impl Metadata {
+ async fn from(value: cooklang::Metadata) -> Result<Self, conversion::Error> {
+ let author = if let Some(author) = value.author() {
+ Some(NameAndUrl::from(author).await?)
+ } else {
+ None
+ };
+ let source = if let Some(source) = value.source() {
+ Some(NameAndUrl::from(source).await?)
+ } else {
+ None
+ };
+
+ Ok(Self {
+ title: value.title().map(str::to_owned),
+ description: value.description().map(str::to_owned),
+ tags: value
+ .tags()
+ .map(|vec| vec.into_iter().map(|c| c.to_string()).collect()),
+ author,
+ source,
+ // time: value.time(&Converter::bundled()).map(|t| t.total()),
+ // prep_time: todo!(),
+ // cook_time: todo!(),
+ // servings: todo!(),
+ // difficulty: todo!(),
+ // cuisine: todo!(),
+ // diet: todo!(),
+ // images: todo!(),
+ // locale: todo!(),
+ // other: value.map_filtered().,
+ })
+ }
+}
+
+impl Section {
+ async fn from(value: cooklang::Section) -> Result<Self, conversion::Error> {
+ Ok(Self {
+ name: value.name,
+ content: for_in!(value.content, |c| Content::from(c).await),
+ })
+ }
+}
+impl Content {
+ async fn from(value: cooklang::Content) -> Result<Self, conversion::Error> {
+ match value {
+ cooklang::Content::Step(step) => Ok(Self::Step(Step::from(step).await?)),
+ cooklang::Content::Text(text) => Ok(Self::Text(text)),
+ }
+ }
+}
+impl Step {
+ async fn from(value: cooklang::Step) -> Result<Self, conversion::Error> {
+ Ok(Self {
+ items: for_in!(value.items, |item| Item::from(item).await),
+ number: value.number,
+ })
+ }
+}
+impl Item {
+ async fn from(value: cooklang::Item) -> Result<Self, conversion::Error> {
+ match value {
+ cooklang::Item::Text { value } => Ok(Self::Text { value }),
+ cooklang::Item::Ingredient { index } => Ok(Self::Ingredient { index }),
+ cooklang::Item::Cookware { index } => Ok(Self::Cookware { index }),
+ cooklang::Item::Timer { index } => Ok(Self::Timer { index }),
+ cooklang::Item::InlineQuantity { index } => Ok(Self::InlineQuantity { index }),
+ }
+ }
+}
+impl Ingredient {
+ async fn from(app: &App, value: cooklang::Ingredient) -> Result<Self, conversion::Error> {
+ if value.name.starts_with('/') {
+ Ok(Self::RecipeReference { id: todo!() })
+ } else if let Some(product) = Product::from_name(&app, value.name.as_str()).await? {
+ Ok(Self::RegisteredProduct {
+ id: product.id,
+ alias: value.alias,
+ quantity: None,
+ })
+ } else {
+ Ok(Self::NotRegisteredProduct {
+ name: value.name,
+ quantity: None,
+ })
+ }
+ }
+}
+impl Cookware {
+ async fn from(value: cooklang::Cookware) -> Result<Self, conversion::Error> {
+ Ok(Self {
+ name: value.name,
+ alias: value.alias,
+ quantity: value.quantity.map(|q| match q.value() {
+ cooklang::Value::Number(number) => number.value() as usize,
+ cooklang::Value::Range { start, end } => todo!(),
+ cooklang::Value::Text(_) => todo!(),
+ }),
+ note: value.note,
+ })
+ }
+}
+impl Timer {
+ async fn from(value: cooklang::Timer) -> Result<Self, conversion::Error> {
+ Ok(Self {
+ name: value.name,
+ quantity: todo!(),
+ })
+ }
+}
diff --git a/crates/rocie-server/src/storage/sql/recipe_parent.rs b/crates/rocie-server/src/storage/sql/recipe_parent.rs
new file mode 100644
index 0000000..6225a4b
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/recipe_parent.rs
@@ -0,0 +1,31 @@
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+
+use crate::storage::sql::mk_id;
+
+/// The grouping system for recipes.
+///
+/// Every recipe can have a related parent, and every parent can have a parent themselves.
+/// As such, the recipe list constructs a DAG.
+#[derive(Clone, ToSchema, Serialize, Deserialize)]
+pub(crate) struct RecipeParent {
+ /// The id of the recipe parent.
+ pub(crate) id: RecipeParentId,
+
+ /// The optional id of the parent of this recipe parent.
+ ///
+ /// This must not form a cycle.
+ #[schema(nullable = false)]
+ pub(crate) parent: Option<RecipeParentId>,
+
+ /// The name of the recipe parent.
+ ///
+ /// This should be globally unique, to make searching easier for the user.
+ pub(crate) name: String,
+
+ /// An optional description of this recipe parent.
+ #[schema(nullable = false)]
+ pub(super) description: Option<String>,
+}
+
+mk_id!(RecipeParentId and RecipeParentIdStub);
diff --git a/crates/rocie-server/src/storage/sql/unit.rs b/crates/rocie-server/src/storage/sql/unit.rs
index 8bbfe60..dc16e4c 100644
--- a/crates/rocie-server/src/storage/sql/unit.rs
+++ b/crates/rocie-server/src/storage/sql/unit.rs
@@ -41,7 +41,7 @@ pub(crate) struct Unit {
pub(crate) unit_property: UnitPropertyId,
}
-#[derive(ToSchema, Debug, Clone, Copy, Serialize, Deserialize)]
+#[derive(ToSchema, Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub(crate) struct UnitAmount {
#[schema(minimum = 0)]
pub(crate) value: u32,