From 85dabf644ccb4fdfc6a66977cc8994ac5a7d16d9 Mon Sep 17 00:00:00 2001 From: Aravinth Manivannan Date: Mon, 15 Jul 2024 23:52:59 +0530 Subject: [PATCH] feat: add customization field to Product aggregate, and use separate query to list them --- ...6cfac98a2dee007a1388ffe95f7feecbc7442.json | 18 ++ ...22fad9db5e9d094497b9cc401e9301b5631f.json} | 10 +- ...e7ed9eadef4497648acff9d472a2d7fabf78.json} | 5 +- ...0e6d64dcded15e56e1a4064c66d9df57fcdda.json | 94 ++++++++++ ...df79e5f2ba063409fd81cb283bb2b75f10c32.json | 40 ++++ ...fc337a3ece328f5eba95f11f987af88f8b23.json} | 5 +- ...bb1d21f97a2f1391860eb8714a52ef4439d81.json | 18 ++ ...715113708_cqrs_inventory_product_query.sql | 15 ++ .../output/db/postgres/product_id_exists.rs | 21 ++- .../product_name_exists_for_category.rs | 2 +- .../output/db/postgres/product_view.rs | 176 ++++++++++++++++-- .../services/add_product_service.rs | 39 +++- .../services/remove_category_service.rs | 3 + .../services/remove_product_service.rs | 3 + .../services/update_category_service.rs | 3 + .../services/update_product_service.rs | 3 + src/inventory/domain/add_product_command.rs | 127 +++++++++++++ src/inventory/domain/product_added_event.rs | 20 +- src/inventory/domain/product_aggregate.rs | 13 ++ src/tests/bdd.rs | 2 + 20 files changed, 591 insertions(+), 26 deletions(-) create mode 100644 .sqlx/query-0994f26a6ec76a33d2839cc290e6cfac98a2dee007a1388ffe95f7feecbc7442.json rename .sqlx/{query-3f83167782a2de1be7d87d35f63a3e530373595ce83cd36612ee48d3c36c81f3.json => query-10c89e8b7be6fa4a1bf39ae2fbb922fad9db5e9d094497b9cc401e9301b5631f.json} (81%) rename .sqlx/{query-9390910c71001ef77313e594dea0e4f0682f740778e483a2db91d6c73ac6bc6d.json => query-1c048c8e1fc4739d20df983b5042e7ed9eadef4497648acff9d472a2d7fabf78.json} (73%) create mode 100644 .sqlx/query-27212976fb97f84722c5bf522580e6d64dcded15e56e1a4064c66d9df57fcdda.json create mode 100644 .sqlx/query-674258549f4d79aeaf3ecd7d242df79e5f2ba063409fd81cb283bb2b75f10c32.json rename .sqlx/{query-f55fe530202bb369fdea3baaf91f86bbc866218e4146b6d608255c1b929073b4.json => query-c0850c387878c368a89aa0f1f505fc337a3ece328f5eba95f11f987af88f8b23.json} (86%) create mode 100644 .sqlx/query-e88a5dae732c3f8180664f306b4bb1d21f97a2f1391860eb8714a52ef4439d81.json create mode 100644 src/inventory/application/services/remove_category_service.rs create mode 100644 src/inventory/application/services/remove_product_service.rs create mode 100644 src/inventory/application/services/update_category_service.rs create mode 100644 src/inventory/application/services/update_product_service.rs diff --git a/.sqlx/query-0994f26a6ec76a33d2839cc290e6cfac98a2dee007a1388ffe95f7feecbc7442.json b/.sqlx/query-0994f26a6ec76a33d2839cc290e6cfac98a2dee007a1388ffe95f7feecbc7442.json new file mode 100644 index 0000000..6ddb481 --- /dev/null +++ b/.sqlx/query-0994f26a6ec76a33d2839cc290e6cfac98a2dee007a1388ffe95f7feecbc7442.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO cqrs_inventory_product_customizations_query (\n version,\n name,\n customization_id,\n product_id,\n deleted\n ) VALUES (\n $1, $2, $3, $4, $5\n );", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Uuid", + "Uuid", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "0994f26a6ec76a33d2839cc290e6cfac98a2dee007a1388ffe95f7feecbc7442" +} diff --git a/.sqlx/query-3f83167782a2de1be7d87d35f63a3e530373595ce83cd36612ee48d3c36c81f3.json b/.sqlx/query-10c89e8b7be6fa4a1bf39ae2fbb922fad9db5e9d094497b9cc401e9301b5631f.json similarity index 81% rename from .sqlx/query-3f83167782a2de1be7d87d35f63a3e530373595ce83cd36612ee48d3c36c81f3.json rename to .sqlx/query-10c89e8b7be6fa4a1bf39ae2fbb922fad9db5e9d094497b9cc401e9301b5631f.json index bfc6121..07a4bdf 100644 --- a/.sqlx/query-3f83167782a2de1be7d87d35f63a3e530373595ce83cd36612ee48d3c36c81f3.json +++ b/.sqlx/query-10c89e8b7be6fa4a1bf39ae2fbb922fad9db5e9d094497b9cc401e9301b5631f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT \n name,\n description,\n image,\n product_id,\n category_id,\n price_major,\n price_minor,\n price_currency,\n sku_able,\n quantity_unit,\n quantity_number,\n deleted\n FROM\n cqrs_inventory_product_query\n WHERE\n product_id = $1;", + "query": "SELECT \n name,\n description,\n image,\n product_id,\n category_id,\n price_major,\n price_minor,\n price_currency,\n sku_able,\n quantity_unit,\n quantity_number,\n customizations_available,\n deleted\n FROM\n cqrs_inventory_product_query\n WHERE\n product_id = $1;", "describe": { "columns": [ { @@ -60,6 +60,11 @@ }, { "ordinal": 11, + "name": "customizations_available", + "type_info": "Bool" + }, + { + "ordinal": 12, "name": "deleted", "type_info": "Bool" } @@ -81,8 +86,9 @@ false, false, false, + false, false ] }, - "hash": "3f83167782a2de1be7d87d35f63a3e530373595ce83cd36612ee48d3c36c81f3" + "hash": "10c89e8b7be6fa4a1bf39ae2fbb922fad9db5e9d094497b9cc401e9301b5631f" } diff --git a/.sqlx/query-9390910c71001ef77313e594dea0e4f0682f740778e483a2db91d6c73ac6bc6d.json b/.sqlx/query-1c048c8e1fc4739d20df983b5042e7ed9eadef4497648acff9d472a2d7fabf78.json similarity index 73% rename from .sqlx/query-9390910c71001ef77313e594dea0e4f0682f740778e483a2db91d6c73ac6bc6d.json rename to .sqlx/query-1c048c8e1fc4739d20df983b5042e7ed9eadef4497648acff9d472a2d7fabf78.json index d70d210..00da8fb 100644 --- a/.sqlx/query-9390910c71001ef77313e594dea0e4f0682f740778e483a2db91d6c73ac6bc6d.json +++ b/.sqlx/query-1c048c8e1fc4739d20df983b5042e7ed9eadef4497648acff9d472a2d7fabf78.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO cqrs_inventory_product_query (\n version,\n name,\n description,\n image,\n product_id,\n category_id,\n price_major,\n price_minor,\n price_currency,\n sku_able,\n quantity_unit,\n quantity_number,\n deleted\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13\n );", + "query": "INSERT INTO cqrs_inventory_product_query (\n version,\n name,\n description,\n image,\n product_id,\n category_id,\n price_major,\n price_minor,\n price_currency,\n sku_able,\n quantity_unit,\n quantity_number,\n deleted,\n customizations_available\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14\n );", "describe": { "columns": [], "parameters": { @@ -17,10 +17,11 @@ "Bool", "Text", "Int4", + "Bool", "Bool" ] }, "nullable": [] }, - "hash": "9390910c71001ef77313e594dea0e4f0682f740778e483a2db91d6c73ac6bc6d" + "hash": "1c048c8e1fc4739d20df983b5042e7ed9eadef4497648acff9d472a2d7fabf78" } diff --git a/.sqlx/query-27212976fb97f84722c5bf522580e6d64dcded15e56e1a4064c66d9df57fcdda.json b/.sqlx/query-27212976fb97f84722c5bf522580e6d64dcded15e56e1a4064c66d9df57fcdda.json new file mode 100644 index 0000000..a89ff98 --- /dev/null +++ b/.sqlx/query-27212976fb97f84722c5bf522580e6d64dcded15e56e1a4064c66d9df57fcdda.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n name,\n description,\n image,\n product_id,\n category_id,\n price_major,\n price_minor,\n price_currency,\n sku_able,\n quantity_unit,\n quantity_number,\n deleted,\n customizations_available\n FROM\n cqrs_inventory_product_query\n WHERE\n product_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "image", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "product_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "category_id", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "price_major", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "price_minor", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "price_currency", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "sku_able", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "quantity_unit", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "quantity_number", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "customizations_available", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "27212976fb97f84722c5bf522580e6d64dcded15e56e1a4064c66d9df57fcdda" +} diff --git a/.sqlx/query-674258549f4d79aeaf3ecd7d242df79e5f2ba063409fd81cb283bb2b75f10c32.json b/.sqlx/query-674258549f4d79aeaf3ecd7d242df79e5f2ba063409fd81cb283bb2b75f10c32.json new file mode 100644 index 0000000..e858e54 --- /dev/null +++ b/.sqlx/query-674258549f4d79aeaf3ecd7d242df79e5f2ba063409fd81cb283bb2b75f10c32.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n name,\n customization_id,\n deleted,\n product_id\n FROM\n cqrs_inventory_product_customizations_query\n WHERE\n product_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "customization_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "product_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "674258549f4d79aeaf3ecd7d242df79e5f2ba063409fd81cb283bb2b75f10c32" +} diff --git a/.sqlx/query-f55fe530202bb369fdea3baaf91f86bbc866218e4146b6d608255c1b929073b4.json b/.sqlx/query-c0850c387878c368a89aa0f1f505fc337a3ece328f5eba95f11f987af88f8b23.json similarity index 86% rename from .sqlx/query-f55fe530202bb369fdea3baaf91f86bbc866218e4146b6d608255c1b929073b4.json rename to .sqlx/query-c0850c387878c368a89aa0f1f505fc337a3ece328f5eba95f11f987af88f8b23.json index b95a37b..7e5db91 100644 --- a/.sqlx/query-f55fe530202bb369fdea3baaf91f86bbc866218e4146b6d608255c1b929073b4.json +++ b/.sqlx/query-c0850c387878c368a89aa0f1f505fc337a3ece328f5eba95f11f987af88f8b23.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE\n cqrs_inventory_product_query\n SET\n version = $1,\n name = $2,\n description = $3,\n image = $4,\n product_id = $5,\n category_id = $6,\n price_major = $7,\n price_minor = $8,\n price_currency = $9,\n sku_able = $10,\n quantity_unit = $11,\n quantity_number = $12,\n deleted = $13;", + "query": "UPDATE\n cqrs_inventory_product_query\n SET\n version = $1,\n name = $2,\n description = $3,\n image = $4,\n product_id = $5,\n category_id = $6,\n price_major = $7,\n price_minor = $8,\n price_currency = $9,\n sku_able = $10,\n quantity_unit = $11,\n quantity_number = $12,\n deleted = $13,\n customizations_available = $14;", "describe": { "columns": [], "parameters": { @@ -17,10 +17,11 @@ "Bool", "Text", "Int4", + "Bool", "Bool" ] }, "nullable": [] }, - "hash": "f55fe530202bb369fdea3baaf91f86bbc866218e4146b6d608255c1b929073b4" + "hash": "c0850c387878c368a89aa0f1f505fc337a3ece328f5eba95f11f987af88f8b23" } diff --git a/.sqlx/query-e88a5dae732c3f8180664f306b4bb1d21f97a2f1391860eb8714a52ef4439d81.json b/.sqlx/query-e88a5dae732c3f8180664f306b4bb1d21f97a2f1391860eb8714a52ef4439d81.json new file mode 100644 index 0000000..64c9c18 --- /dev/null +++ b/.sqlx/query-e88a5dae732c3f8180664f306b4bb1d21f97a2f1391860eb8714a52ef4439d81.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE\n cqrs_inventory_product_customizations_query\n SET\n version = $1,\n name = $2,\n customization_id = $3,\n product_id = $4,\n deleted = $5;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Uuid", + "Uuid", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "e88a5dae732c3f8180664f306b4bb1d21f97a2f1391860eb8714a52ef4439d81" +} diff --git a/migrations/20240715113708_cqrs_inventory_product_query.sql b/migrations/20240715113708_cqrs_inventory_product_query.sql index b307cf5..18cb0d4 100644 --- a/migrations/20240715113708_cqrs_inventory_product_query.sql +++ b/migrations/20240715113708_cqrs_inventory_product_query.sql @@ -23,7 +23,22 @@ CREATE TABLE IF NOT EXISTS cqrs_inventory_product_query category_id UUID NOT NULL, deleted BOOLEAN NOT NULL DEFAULT FALSE, + customizations_available BOOLEAN NOT NULL DEFAULT FALSE, UNIQUE(category_id, name), PRIMARY KEY (product_id) ); + +CREATE TABLE IF NOT EXISTS cqrs_inventory_product_customizations_query +( + version bigint CHECK (version >= 0) NOT NULL, + + name TEXT NOT NULL, + customization_id UUID NOT NULL UNIQUE, + product_id UUID NOT NULL, + + deleted BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE(product_id, name), + + PRIMARY KEY (customization_id) +); diff --git a/src/inventory/adapters/output/db/postgres/product_id_exists.rs b/src/inventory/adapters/output/db/postgres/product_id_exists.rs index 34d714f..8f52440 100644 --- a/src/inventory/adapters/output/db/postgres/product_id_exists.rs +++ b/src/inventory/adapters/output/db/postgres/product_id_exists.rs @@ -32,6 +32,7 @@ impl ProductIDExistsDBPort for InventoryDBPostgresAdapter { pub mod tests { use super::*; + use crate::inventory::domain::add_product_command::tests::get_customizations; use crate::inventory::domain::{add_product_command::tests::get_command, product_aggregate::*}; use crate::utils::uuid::tests::UUID; @@ -47,6 +48,17 @@ pub mod tests { let cmd = get_command(); + let mut customizations = Vec::default(); + for c in get_customizations().iter() { + customizations.push( + CustomizationBuilder::default() + .name(c.name().into()) + .deleted(false) + .customization_id(Uuid::new_v4()) + .build() + .unwrap(), + ) + } let product = ProductBuilder::default() .name(cmd.name().into()) .description(cmd.description().as_ref().map(|s| s.to_string())) @@ -55,6 +67,7 @@ pub mod tests { .category_id(cmd.category_id().clone()) .quantity(cmd.quantity().clone()) .product_id(UUID.clone()) + .customizations(customizations) .price(cmd.price().clone()) .build() .unwrap(); @@ -85,9 +98,10 @@ pub mod tests { sku_able, quantity_unit, quantity_number, - deleted + deleted, + customizations_available ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 );", 1, p.name(), @@ -101,7 +115,8 @@ pub mod tests { p.sku_able().clone(), p.quantity().unit().to_string(), p.quantity().number().clone() as i32, - p.deleted().clone() + p.deleted().clone(), + false, ) .execute(&db.pool) .await diff --git a/src/inventory/adapters/output/db/postgres/product_name_exists_for_category.rs b/src/inventory/adapters/output/db/postgres/product_name_exists_for_category.rs index 5fb5e84..23f0e7d 100644 --- a/src/inventory/adapters/output/db/postgres/product_name_exists_for_category.rs +++ b/src/inventory/adapters/output/db/postgres/product_name_exists_for_category.rs @@ -62,6 +62,7 @@ mod tests { .image(cmd.image().as_ref().map(|s| s.to_string())) .sku_able(cmd.sku_able().clone()) .category_id(cmd.category_id().clone()) + .customizations(Vec::default()) .product_id(UUID.clone()) .price(cmd.price().clone()) .quantity(cmd.quantity().clone()) @@ -86,7 +87,6 @@ mod tests { .unwrap(); assert!(!db.product_name_exists_for_category(&product).await.unwrap()); - settings.drop_db().await; } } diff --git a/src/inventory/adapters/output/db/postgres/product_view.rs b/src/inventory/adapters/output/db/postgres/product_view.rs index 68562ab..916164b 100644 --- a/src/inventory/adapters/output/db/postgres/product_view.rs +++ b/src/inventory/adapters/output/db/postgres/product_view.rs @@ -10,11 +10,12 @@ use cqrs_es::{EventEnvelope, Query, View}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use super::errors::*; use super::InventoryDBPostgresAdapter; +use super::{errors::*, product_id_exists}; use crate::inventory::domain::events::InventoryEvent; use crate::inventory::domain::product_aggregate::{ - Currency, PriceBuilder, Product, ProductBuilder, QuantityBuilder, QuantityUnit, + Currency, Customization, CustomizationBuilder, PriceBuilder, Product, ProductBuilder, + QuantityBuilder, QuantityUnit, }; use crate::utils::parse_aggregate_id::parse_aggregate_id; @@ -39,9 +40,53 @@ pub struct ProductView { category_id: Uuid, + customizations: Vec, + deleted: bool, } +#[derive(Debug, Default, Serialize, Deserialize)] +struct InnerProductView { + name: String, + description: Option, + image: Option, // string = filename + product_id: Uuid, + sku_able: bool, + + price_minor: i32, + price_major: i32, + price_currency: String, + + quantity_unit: String, + quantity_number: i32, + + category_id: Uuid, + + customizations_available: bool, + + deleted: bool, +} + +impl InnerProductView { + fn to_product_view(self, customizations: Vec) -> ProductView { + ProductView { + name: self.name, + description: self.description, + image: self.image, + product_id: self.product_id, + category_id: self.category_id, + price_minor: self.price_minor, + price_major: self.price_major, + price_currency: self.price_currency, + sku_able: self.sku_able, + quantity_unit: self.quantity_unit, + quantity_number: self.quantity_number, + deleted: self.deleted, + customizations, + } + } +} + impl From for Product { fn from(v: ProductView) -> Self { let price = PriceBuilder::default() @@ -95,12 +140,66 @@ impl View for ProductView { self.quantity_unit = val.quantity().unit().to_string(); self.deleted = false; + self.customizations = val.customizations().clone(); } _ => (), } } } +pub struct InnerCustomization { + name: String, + customization_id: Uuid, + product_id: Uuid, + deleted: bool, +} + +impl From for Customization { + fn from(value: InnerCustomization) -> Self { + CustomizationBuilder::default() + .name(value.name) + .customization_id(value.customization_id) + .deleted(value.deleted) + .build() + .unwrap() + } +} + +impl InnerProductView { + async fn get_customizations( + &self, + db: &InventoryDBPostgresAdapter, + ) -> Result, PersistenceError> { + let customizations = if self.customizations_available { + let mut inner_customizations = sqlx::query_as!( + InnerCustomization, + "SELECT + name, + customization_id, + deleted, + product_id + FROM + cqrs_inventory_product_customizations_query + WHERE + product_id = $1;", + self.product_id + ) + .fetch_all(&db.pool) + .await + .map_err(PostgresAggregateError::from)?; + + let mut customizations = Vec::with_capacity(inner_customizations.len()); + for c in inner_customizations.drain(0..) { + customizations.push(c.into()); + } + customizations + } else { + Vec::default() + }; + Ok(customizations) + } +} + #[async_trait] impl ViewRepository for InventoryDBPostgresAdapter { async fn load(&self, product_id: &str) -> Result, PersistenceError> { @@ -110,7 +209,7 @@ impl ViewRepository for InventoryDBPostgresAdapter { }; let res = sqlx::query_as!( - ProductView, + InnerProductView, "SELECT name, description, @@ -123,7 +222,8 @@ impl ViewRepository for InventoryDBPostgresAdapter { sku_able, quantity_unit, quantity_number, - deleted + deleted, + customizations_available FROM cqrs_inventory_product_query WHERE @@ -133,7 +233,9 @@ impl ViewRepository for InventoryDBPostgresAdapter { .fetch_one(&self.pool) .await .map_err(PostgresAggregateError::from)?; - Ok(Some(res)) + + let customizations = res.get_customizations(&self).await?; + Ok(Some(res.to_product_view(customizations))) } async fn load_with_context( @@ -146,7 +248,7 @@ impl ViewRepository for InventoryDBPostgresAdapter { }; let res = sqlx::query_as!( - ProductView, + InnerProductView, "SELECT name, description, @@ -159,6 +261,7 @@ impl ViewRepository for InventoryDBPostgresAdapter { sku_able, quantity_unit, quantity_number, + customizations_available, deleted FROM cqrs_inventory_product_query @@ -170,6 +273,8 @@ impl ViewRepository for InventoryDBPostgresAdapter { .await .map_err(PostgresAggregateError::from)?; + let customizations = res.get_customizations(&self).await?; + struct Context { version: i64, product_id: Uuid, @@ -190,7 +295,7 @@ impl ViewRepository for InventoryDBPostgresAdapter { .map_err(PostgresAggregateError::from)?; let view_context = ViewContext::new(ctx.product_id.to_string(), ctx.version); - Ok(Some((res, view_context))) + Ok(Some((res.to_product_view(customizations), view_context))) } async fn update_view( @@ -215,9 +320,10 @@ impl ViewRepository for InventoryDBPostgresAdapter { sku_able, quantity_unit, quantity_number, - deleted + deleted, + customizations_available ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 );", version, view.name, @@ -231,11 +337,34 @@ impl ViewRepository for InventoryDBPostgresAdapter { view.sku_able, view.quantity_unit, view.quantity_number, - view.deleted + view.deleted, + !view.customizations.is_empty() ) .execute(&self.pool) .await .map_err(PostgresAggregateError::from)?; + + for c in view.customizations.iter() { + sqlx::query!( + "INSERT INTO cqrs_inventory_product_customizations_query ( + version, + name, + customization_id, + product_id, + deleted + ) VALUES ( + $1, $2, $3, $4, $5 + );", + version, + c.name(), + c.customization_id(), + view.product_id, + c.deleted(), + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } } _ => { let version = context.version + 1; @@ -255,7 +384,8 @@ impl ViewRepository for InventoryDBPostgresAdapter { sku_able = $10, quantity_unit = $11, quantity_number = $12, - deleted = $13;", + deleted = $13, + customizations_available = $14;", version, view.name, view.description, @@ -268,11 +398,33 @@ impl ViewRepository for InventoryDBPostgresAdapter { view.sku_able, view.quantity_unit, view.quantity_number, - view.deleted + view.deleted, + !view.customizations.is_empty() ) .execute(&self.pool) .await .map_err(PostgresAggregateError::from)?; + + for c in view.customizations.iter() { + sqlx::query!( + "UPDATE + cqrs_inventory_product_customizations_query + SET + version = $1, + name = $2, + customization_id = $3, + product_id = $4, + deleted = $5;", + version, + c.name(), + c.customization_id(), + view.product_id, + c.deleted(), + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } } } diff --git a/src/inventory/application/services/add_product_service.rs b/src/inventory/application/services/add_product_service.rs index 65cf1bb..e624725 100644 --- a/src/inventory/application/services/add_product_service.rs +++ b/src/inventory/application/services/add_product_service.rs @@ -52,6 +52,24 @@ impl AddProductUseCase for AddProductService { } } + let mut customizations = Vec::with_capacity(cmd.customizations().len()); + for c in cmd.customizations().iter() { + let mut customization_id = self.get_uuid.get_uuid(); + + // TODO: + // 1. check if customization.name exists for product + // 2. check customization.customization_id duplicate in query table + + customizations.push( + CustomizationBuilder::default() + .name(c.name().into()) + .deleted(false) + .customization_id(customization_id) + .build() + .unwrap(), + ); + } + let product = ProductBuilder::default() .name(cmd.name().into()) .description(cmd.description().as_ref().map(|s| s.to_string())) @@ -60,6 +78,7 @@ impl AddProductUseCase for AddProductService { .price(cmd.price().clone()) .category_id(cmd.category_id().clone()) .quantity(cmd.quantity().clone()) + .customizations(customizations) .product_id(product_id) .build() .unwrap(); @@ -81,6 +100,7 @@ impl AddProductUseCase for AddProductService { .price(product.price().clone()) .category_id(product.category_id().clone()) .product_id(product.product_id().clone()) + .customizations(product.customizations().clone()) .quantity(product.quantity().clone()) .build() .unwrap()) @@ -91,6 +111,8 @@ impl AddProductUseCase for AddProductService { pub mod tests { use super::*; + use uuid::Uuid; + use crate::inventory::domain::add_product_command::tests::get_command; use crate::utils::uuid::tests::UUID; use crate::{tests::bdd::*, utils::uuid::tests::mock_get_uuid}; @@ -101,6 +123,18 @@ pub mod tests { ) -> AddProductServiceObj { let mut m = MockAddProductUseCase::new(); + let mut customizations = Vec::with_capacity(cmd.customizations().len()); + for c in cmd.customizations().iter() { + customizations.push( + CustomizationBuilder::default() + .name(c.name().into()) + .deleted(false) + .customization_id(UUID) + .build() + .unwrap(), + ); + } + let res = ProductAddedEventBuilder::default() .name(cmd.name().into()) .description(cmd.description().as_ref().map(|s| s.to_string())) @@ -110,6 +144,7 @@ pub mod tests { .product_id(UUID.clone()) .price(cmd.price().clone()) .quantity(cmd.quantity().clone()) + .customizations(customizations) .added_by_user(cmd.adding_by().clone()) .build() .unwrap(); @@ -134,7 +169,7 @@ pub mod tests { mock_product_name_exists_for_category_db_port_false(IS_CALLED_ONLY_ONCE), ) .db_product_id_exists(mock_product_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) - .get_uuid(mock_get_uuid(IS_CALLED_ONLY_ONCE)) + .get_uuid(mock_get_uuid(IS_CALLED_ONLY_FOUR_TIMES)) .build() .unwrap(); @@ -158,7 +193,7 @@ pub mod tests { .db_product_name_exists_for_category( mock_product_name_exists_for_category_db_port_true(IS_CALLED_ONLY_ONCE), ) - .get_uuid(mock_get_uuid(IS_CALLED_ONLY_ONCE)) + .get_uuid(mock_get_uuid(IS_CALLED_ONLY_FOUR_TIMES)) .db_product_id_exists(mock_product_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) .build() .unwrap(); diff --git a/src/inventory/application/services/remove_category_service.rs b/src/inventory/application/services/remove_category_service.rs new file mode 100644 index 0000000..56f60de --- /dev/null +++ b/src/inventory/application/services/remove_category_service.rs @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/src/inventory/application/services/remove_product_service.rs b/src/inventory/application/services/remove_product_service.rs new file mode 100644 index 0000000..56f60de --- /dev/null +++ b/src/inventory/application/services/remove_product_service.rs @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/src/inventory/application/services/update_category_service.rs b/src/inventory/application/services/update_category_service.rs new file mode 100644 index 0000000..56f60de --- /dev/null +++ b/src/inventory/application/services/update_category_service.rs @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/src/inventory/application/services/update_product_service.rs b/src/inventory/application/services/update_product_service.rs new file mode 100644 index 0000000..56f60de --- /dev/null +++ b/src/inventory/application/services/update_product_service.rs @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/src/inventory/domain/add_product_command.rs b/src/inventory/domain/add_product_command.rs index 4a08f23..a32f5e5 100644 --- a/src/inventory/domain/add_product_command.rs +++ b/src/inventory/domain/add_product_command.rs @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later +use std::collections::HashSet; + use derive_builder::Builder; use derive_getters::Getters; use derive_more::{Display, Error}; @@ -13,6 +15,30 @@ use super::product_aggregate::{Price, Quantity}; #[derive(Debug, Error, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub enum AddProductCommandError { NameIsEmpty, + CustomizationNameIsEmpty, + DuplicateCustomizationName, +} +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct UnvalidatedAddCustomization { + name: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)] +pub struct AddCustomization { + name: String, +} + +impl UnvalidatedAddCustomization { + pub fn validate(self) -> Result { + let name = self.name.trim().to_owned(); + if name.is_empty() { + return Err(AddProductCommandError::CustomizationNameIsEmpty); + } + + Ok(AddCustomization { name }) + } } #[derive( @@ -27,6 +53,7 @@ pub struct UnvalidatedAddProductCommand { quantity: Quantity, price: Price, adding_by: Uuid, + customizations: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)] @@ -39,6 +66,7 @@ pub struct AddProductCommand { price: Price, quantity: Quantity, adding_by: Uuid, + customizations: Vec, } impl UnvalidatedAddProductCommand { @@ -70,6 +98,15 @@ impl UnvalidatedAddProductCommand { return Err(AddProductCommandError::NameIsEmpty); } + let mut unique_customization_names = HashSet::new(); + if !self + .customizations + .iter() + .all(|c| unique_customization_names.insert(c.name.clone())) + { + return Err(AddProductCommandError::DuplicateCustomizationName); + } + Ok(AddProductCommand { name, description, @@ -79,6 +116,7 @@ impl UnvalidatedAddProductCommand { price: self.price, quantity: self.quantity, adding_by: self.adding_by, + customizations: self.customizations, }) } } @@ -94,6 +132,28 @@ pub mod tests { utils::uuid::tests::UUID, }; + pub fn get_customizations() -> Vec { + vec![ + UnvalidatedAddCustomizationBuilder::default() + .name("foo".into()) + .build() + .unwrap() + .validate() + .unwrap(), + UnvalidatedAddCustomizationBuilder::default() + .name("bar".into()) + .build() + .unwrap() + .validate() + .unwrap(), + UnvalidatedAddCustomizationBuilder::default() + .name("baz".into()) + .build() + .unwrap() + .validate() + .unwrap(), + ] + } pub fn get_command() -> AddProductCommand { let name = "foo"; let adding_by = UUID; @@ -102,6 +162,8 @@ pub mod tests { let image = Some("image".to_string()); let description = Some("description".to_string()); + let customizations = get_customizations(); + let price = PriceBuilder::default() .minor(0) .major(100) @@ -124,6 +186,7 @@ pub mod tests { .quantity(quantity) .sku_able(sku_able) .price(price.clone()) + .customizations(customizations) .build() .unwrap(); @@ -137,6 +200,7 @@ pub mod tests { let category_id = Uuid::new_v4(); let sku_able = false; + let customizations = get_customizations(); let price = PriceBuilder::default() .minor(0) .major(100) @@ -159,6 +223,7 @@ pub mod tests { .quantity(quantity.clone()) .sku_able(sku_able) .price(price.clone()) + .customizations(customizations.clone()) .build() .unwrap(); @@ -172,6 +237,7 @@ pub mod tests { assert_eq!(cmd.sku_able(), &sku_able); assert_eq!(cmd.price(), &price); assert_eq!(cmd.quantity(), &quantity); + assert_eq!(cmd.customizations(), &customizations); } #[test] fn test_description_some() { @@ -182,6 +248,7 @@ pub mod tests { let image = Some("image".to_string()); let description = Some("description".to_string()); + let customizations = get_customizations(); let price = PriceBuilder::default() .minor(0) .major(100) @@ -203,6 +270,7 @@ pub mod tests { .adding_by(adding_by.clone()) .sku_able(sku_able) .price(price.clone()) + .customizations(customizations.clone()) .build() .unwrap(); @@ -216,6 +284,7 @@ pub mod tests { assert_eq!(cmd.sku_able(), &sku_able); assert_eq!(cmd.price(), &price); assert_eq!(cmd.quantity(), &quantity); + assert_eq!(cmd.customizations(), &customizations); } #[test] @@ -226,6 +295,7 @@ pub mod tests { let image = Some("image".to_string()); let description = Some("description".to_string()); + let customizations = get_customizations(); let price = PriceBuilder::default() .minor(0) .major(100) @@ -246,6 +316,7 @@ pub mod tests { .adding_by(adding_by.clone()) .quantity(quantity) .sku_able(sku_able) + .customizations(customizations) .price(price.clone()) .build() .unwrap(); @@ -253,4 +324,60 @@ pub mod tests { // AddProductCommandError::NameIsEmpty assert_eq!(cmd.validate(), Err(AddProductCommandError::NameIsEmpty)) } + + #[test] + fn test_customization_name_is_empty() { + assert_eq!( + UnvalidatedAddCustomizationBuilder::default() + .name("".into()) + .build() + .unwrap() + .validate(), + Err(AddProductCommandError::CustomizationNameIsEmpty) + ) + } + + #[test] + fn test_duplicate_customization() { + let name = "foo"; + let adding_by = UUID; + let category_id = Uuid::new_v4(); + let sku_able = false; + let image = Some("image".to_string()); + let description = Some("description".to_string()); + + let mut customizations = get_customizations(); + customizations.push(customizations.first().unwrap().to_owned()); + + let price = PriceBuilder::default() + .minor(0) + .major(100) + .currency(Currency::INR) + .build() + .unwrap(); + let quantity = QuantityBuilder::default() + .number(1) + .unit(QuantityUnit::DiscreteNumber) + .build() + .unwrap(); + + let cmd = UnvalidatedAddProductCommandBuilder::default() + .name(name.into()) + .description(description.clone()) + .image(image.clone()) + .category_id(category_id.clone()) + .adding_by(adding_by.clone()) + .quantity(quantity) + .sku_able(sku_able) + .customizations(customizations) + .price(price.clone()) + .build() + .unwrap(); + + // AddProductCommandError::DuplicateCustomizationName + assert_eq!( + cmd.validate(), + Err(AddProductCommandError::DuplicateCustomizationName) + ) + } } diff --git a/src/inventory/domain/product_added_event.rs b/src/inventory/domain/product_added_event.rs index 5c3ce09..305aa32 100644 --- a/src/inventory/domain/product_added_event.rs +++ b/src/inventory/domain/product_added_event.rs @@ -7,7 +7,7 @@ use derive_getters::Getters; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use super::product_aggregate::{Price, Quantity}; +use super::product_aggregate::{Customization, Price, Quantity}; #[derive( Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, @@ -23,17 +23,32 @@ pub struct ProductAddedEvent { category_id: Uuid, sku_able: bool, product_id: Uuid, + customizations: Vec, } #[cfg(test)] pub mod tests { - use crate::inventory::domain::add_product_command::AddProductCommand; + use crate::inventory::domain::{ + add_product_command::AddProductCommand, product_aggregate::CustomizationBuilder, + }; use super::*; use crate::utils::uuid::tests::UUID; pub fn get_event_from_command(cmd: &AddProductCommand) -> ProductAddedEvent { + let mut customizations = Vec::with_capacity(cmd.customizations().len()); + for c in cmd.customizations().iter() { + customizations.push( + CustomizationBuilder::default() + .name(c.name().into()) + .deleted(false) + .customization_id(UUID) + .build() + .unwrap(), + ); + } + ProductAddedEventBuilder::default() .name(cmd.name().into()) .description(cmd.description().as_ref().map(|s| s.to_string())) @@ -42,6 +57,7 @@ pub mod tests { .category_id(cmd.category_id().clone()) .product_id(UUID.clone()) .price(cmd.price().clone()) + .customizations(customizations) .quantity(cmd.quantity().clone()) .added_by_user(cmd.adding_by().clone()) .build() diff --git a/src/inventory/domain/product_aggregate.rs b/src/inventory/domain/product_aggregate.rs index c518a7f..109729a 100644 --- a/src/inventory/domain/product_aggregate.rs +++ b/src/inventory/domain/product_aggregate.rs @@ -5,6 +5,7 @@ use std::str::FromStr; use async_trait::async_trait; +use config::builder; use cqrs_es::Aggregate; use derive_builder::Builder; use derive_getters::Getters; @@ -113,6 +114,16 @@ pub struct Price { currency: Currency, } +#[derive( + Clone, Default, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct Customization { + name: String, + customization_id: Uuid, + #[builder(default = "false")] + deleted: bool, +} + #[derive( Clone, Debug, Default, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, )] @@ -127,6 +138,7 @@ pub struct Product { category_id: Uuid, sku_able: bool, product_id: Uuid, + customizations: Vec, #[builder(default = "false")] deleted: bool, } @@ -171,6 +183,7 @@ impl Aggregate for Product { .sku_able(e.sku_able().clone()) .product_id(e.product_id().clone()) .quantity(e.quantity().clone()) + .customizations(e.customizations().clone()) .deleted(false) .build() .unwrap(); diff --git a/src/tests/bdd.rs b/src/tests/bdd.rs index bf32c87..f5aefd8 100644 --- a/src/tests/bdd.rs +++ b/src/tests/bdd.rs @@ -5,6 +5,8 @@ pub const IS_CALLED_ONLY_ONCE: Option = Some(1); pub const IS_NEVER_CALLED: Option = Some(0); pub const IS_CALLED_ONLY_TWICE: Option = Some(2); +pub const IS_CALLED_ONLY_THRICE: Option = Some(3); +pub const IS_CALLED_ONLY_FOUR_TIMES: Option = Some(4); pub const RETURNS_RANDOM_STRING: &str = "test_random_string"; pub const IGNORE_CALL_COUNT: Option = None; -- 2.39.5