about summary refs log tree commit diff stats
path: root/crates/rocie-server/src/storage/sql
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-02-15 22:24:32 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-02-15 22:25:06 +0100
commite5f90f4474cb96a78080395980283e4b2ce40214 (patch)
treecaac3300795eae8e4cb1ee3c1c4bf85cd5950402 /crates/rocie-server/src/storage/sql
parentchore(treewide): Update (diff)
downloadserver-e5f90f4474cb96a78080395980283e4b2ce40214.zip
feat(treewide): Add recipes and user handling
Diffstat (limited to 'crates/rocie-server/src/storage/sql')
-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
16 files changed, 797 insertions, 49 deletions
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,