diff --git a/.sqlx/query-1e9dcba9a2b5a7e0bbb2b8c0d87166c24567420c5f7579aaa12b9d3d60c4e24d.json b/.sqlx/query-1e9dcba9a2b5a7e0bbb2b8c0d87166c24567420c5f7579aaa12b9d3d60c4e24d.json new file mode 100644 index 0000000..f4413b8 --- /dev/null +++ b/.sqlx/query-1e9dcba9a2b5a7e0bbb2b8c0d87166c24567420c5f7579aaa12b9d3d60c4e24d.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n name, description, category_id, store_id\n FROM\n cqrs_inventory_category_query\n WHERE\n view_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "category_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "store_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + false, + false + ] + }, + "hash": "1e9dcba9a2b5a7e0bbb2b8c0d87166c24567420c5f7579aaa12b9d3d60c4e24d" +} diff --git a/.sqlx/query-5944442c15d28d47654afae92815ccefea89cc9aee705e0e1e54a3bf884bb194.json b/.sqlx/query-5944442c15d28d47654afae92815ccefea89cc9aee705e0e1e54a3bf884bb194.json new file mode 100644 index 0000000..8ff850c --- /dev/null +++ b/.sqlx/query-5944442c15d28d47654afae92815ccefea89cc9aee705e0e1e54a3bf884bb194.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_inventory_category_query\n WHERE\n name = $1\n AND\n store_id = $2\n );", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "5944442c15d28d47654afae92815ccefea89cc9aee705e0e1e54a3bf884bb194" +} diff --git a/.sqlx/query-74313c3fbf8a5985b6deae21b56469fb66ddb078d016fd6f05cb5e62ef0b23d5.json b/.sqlx/query-74313c3fbf8a5985b6deae21b56469fb66ddb078d016fd6f05cb5e62ef0b23d5.json new file mode 100644 index 0000000..7473eb6 --- /dev/null +++ b/.sqlx/query-74313c3fbf8a5985b6deae21b56469fb66ddb078d016fd6f05cb5e62ef0b23d5.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n view_id, version\n FROM\n cqrs_inventory_category_query\n WHERE\n view_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "view_id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "version", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "74313c3fbf8a5985b6deae21b56469fb66ddb078d016fd6f05cb5e62ef0b23d5" +} diff --git a/.sqlx/query-b70e7f74ae29d5e7cfdec133f1cedbd455d56875545f952efa261c99b0e7b4b4.json b/.sqlx/query-b70e7f74ae29d5e7cfdec133f1cedbd455d56875545f952efa261c99b0e7b4b4.json new file mode 100644 index 0000000..2b682e7 --- /dev/null +++ b/.sqlx/query-b70e7f74ae29d5e7cfdec133f1cedbd455d56875545f952efa261c99b0e7b4b4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_inventory_category_query\n WHERE\n category_id = $1\n );", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b70e7f74ae29d5e7cfdec133f1cedbd455d56875545f952efa261c99b0e7b4b4" +} diff --git a/.sqlx/query-c524c7fc3281e8ffe5d3d6f237d49cae596a9f621e6a64c31270d744406271ec.json b/.sqlx/query-c524c7fc3281e8ffe5d3d6f237d49cae596a9f621e6a64c31270d744406271ec.json new file mode 100644 index 0000000..f5b81b5 --- /dev/null +++ b/.sqlx/query-c524c7fc3281e8ffe5d3d6f237d49cae596a9f621e6a64c31270d744406271ec.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE\n cqrs_inventory_category_query\n SET\n view_id = $1,\n version = $2,\n name = $3,\n description = $4,\n category_id = $5,\n store_id = $6;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8", + "Text", + "Text", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "c524c7fc3281e8ffe5d3d6f237d49cae596a9f621e6a64c31270d744406271ec" +} diff --git a/.sqlx/query-e66276ae53a3155b2a682a451bf4800f2ec7f777cc822cbe0866105def3e11af.json b/.sqlx/query-e66276ae53a3155b2a682a451bf4800f2ec7f777cc822cbe0866105def3e11af.json new file mode 100644 index 0000000..32f191e --- /dev/null +++ b/.sqlx/query-e66276ae53a3155b2a682a451bf4800f2ec7f777cc822cbe0866105def3e11af.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO cqrs_inventory_category_query (\n view_id, version, name, description, category_id, store_id\n ) VALUES (\n $1, $2, $3, $4, $5, $6\n );", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8", + "Text", + "Text", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e66276ae53a3155b2a682a451bf4800f2ec7f777cc822cbe0866105def3e11af" +} diff --git a/migrations/20240713073740_cqrs_inventory_category_query.sql b/migrations/20240713073740_cqrs_inventory_category_query.sql new file mode 100644 index 0000000..95398df --- /dev/null +++ b/migrations/20240713073740_cqrs_inventory_category_query.sql @@ -0,0 +1,36 @@ +-- SPDX-FileCopyrightText: 2024 Aravinth Manivannan +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +CREATE TABLE IF NOT EXISTS categoty_events +( + aggregate_type text NOT NULL, + aggregate_id text NOT NULL, + sequence bigint CHECK (sequence >= 0) NOT NULL, + event_type text NOT NULL, + event_version text NOT NULL, + payload json NOT NULL, + metadata json NOT NULL, + timestamp timestamp with time zone DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (aggregate_type, aggregate_id, sequence) +); + +CREATE TABLE IF NOT EXISTS cqrs_inventory_category_query +( + view_id text NOT NULL, + version bigint CHECK (version >= 0) NOT NULL, + + name TEXT NOT NULL, + description TEXT, + store_id UUID NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + category_id UUID NOT NULL UNIQUE, + UNIQUE(store_id, name), + + PRIMARY KEY (view_id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS + cqrs_inventory_store_query_category_id_index +ON + cqrs_inventory_category_query (category_id); diff --git a/src/identity/application/aggregate.rs b/src/identity/application/aggregate.rs index 08b194a..3b0470f 100644 --- a/src/identity/application/aggregate.rs +++ b/src/identity/application/aggregate.rs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + use async_trait::async_trait; use cqrs_es::Aggregate; diff --git a/src/inventory/adapters/output/db/mod.rs b/src/inventory/adapters/output/db/mod.rs index 26e9103..6be5441 100644 --- a/src/inventory/adapters/output/db/mod.rs +++ b/src/inventory/adapters/output/db/mod.rs @@ -1 +1,5 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + pub mod postgres; diff --git a/src/inventory/adapters/output/db/postgres/category_exists.rs b/src/inventory/adapters/output/db/postgres/category_exists.rs new file mode 100644 index 0000000..3724faf --- /dev/null +++ b/src/inventory/adapters/output/db/postgres/category_exists.rs @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::InventoryDBPostgresAdapter; +use crate::inventory::application::port::output::db::{ + errors::*, store_id_exists::*, +}; +use crate::inventory::domain::store_aggregate::*; + +#[async_trait::async_trait] +impl StoreIDExistsDBPort for InventoryDBPostgresAdapter { + async fn store_id_exists(&self, s: &Store) -> InventoryDBResult { + let res = sqlx::query!( + "SELECT EXISTS ( + SELECT 1 + FROM cqrs_inventory_store_query + WHERE + store_id = $1 + );", + s.store_id(), + ) + .fetch_one(&self.pool) + .await?; + if let Some(x) = res.exists { + Ok(x) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + + #[actix_rt::test] + async fn test_postgres_store_exists() { + let store_id = Uuid::new_v4(); + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::InventoryDBPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let store = StoreBuilder::default().name("store_name".into()).owner("store_owner".into()) + .address(Some("store_address".into())) + .store_id(store_id) + .build().unwrap(); + + // state doesn't exist + assert!(!db.store_id_exists(&store).await.unwrap()); + + sqlx::query!( + "INSERT INTO cqrs_inventory_store_query + (view_id, version, name, address, store_id, owner) + VALUES ($1, $2, $3, $4, $5, $6);", + "1", + 1, + store.name(), + store.address().as_ref().unwrap(), + store.store_id(), + store.owner(), + ) + .execute(&db.pool) + .await + .unwrap(); + + // state exists + assert!(db.store_id_exists(&store).await.unwrap()); + + settings.drop_db().await; + } +} diff --git a/src/inventory/adapters/output/db/postgres/category_id_exists.rs b/src/inventory/adapters/output/db/postgres/category_id_exists.rs new file mode 100644 index 0000000..a385c9d --- /dev/null +++ b/src/inventory/adapters/output/db/postgres/category_id_exists.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::InventoryDBPostgresAdapter; +use crate::inventory::application::port::output::db::{category_id_exists::*, errors::*}; +use crate::inventory::domain::category_aggregate::*; + +#[async_trait::async_trait] +impl CategoryIDExistsDBPort for InventoryDBPostgresAdapter { + async fn category_id_exists(&self, s: &Category) -> InventoryDBResult { + let res = sqlx::query!( + "SELECT EXISTS ( + SELECT 1 + FROM cqrs_inventory_category_query + WHERE + category_id = $1 + );", + s.category_id(), + ) + .fetch_one(&self.pool) + .await?; + if let Some(x) = res.exists { + Ok(x) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + + #[actix_rt::test] + async fn test_postgres_category_exists() { + let category_id = Uuid::new_v4(); + let store_id = Uuid::new_v4(); + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::InventoryDBPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let category = CategoryBuilder::default() + .name("category_name".into()) + .description(Some("category_description".into())) + .category_id(category_id) + .store_id(store_id) + .build() + .unwrap(); + + // state doesn't exist + assert!(!db.category_id_exists(&category).await.unwrap()); + + sqlx::query!( + "INSERT INTO cqrs_inventory_category_query + (view_id, version, name, description, category_id, store_id) + VALUES ($1, $2, $3, $4, $5, $6);", + "1", + 1, + category.name(), + category.description().as_ref().unwrap(), + category.category_id(), + category.store_id(), + ) + .execute(&db.pool) + .await + .unwrap(); + + // state exists + assert!(db.category_id_exists(&category).await.unwrap()); + + settings.drop_db().await; + } +} diff --git a/src/inventory/adapters/output/db/postgres/category_name_exists_for_store.rs b/src/inventory/adapters/output/db/postgres/category_name_exists_for_store.rs new file mode 100644 index 0000000..05c87ca --- /dev/null +++ b/src/inventory/adapters/output/db/postgres/category_name_exists_for_store.rs @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::InventoryDBPostgresAdapter; +use crate::inventory::application::port::output::db::{ + category_name_exists_for_store::*, errors::*, +}; +use crate::inventory::domain::category_aggregate::*; + +#[async_trait::async_trait] +impl CategoryNameExistsForStoreDBPort for InventoryDBPostgresAdapter { + async fn category_name_exists_for_store(&self, s: &Category) -> InventoryDBResult { + let res = sqlx::query!( + "SELECT EXISTS ( + SELECT 1 + FROM cqrs_inventory_category_query + WHERE + name = $1 + AND + store_id = $2 + );", + s.name(), + s.store_id(), + ) + .fetch_one(&self.pool) + .await?; + if let Some(x) = res.exists { + Ok(x) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + + #[actix_rt::test] + async fn test_postgres_category_exists() { + let category_id = Uuid::new_v4(); + let store_id = Uuid::new_v4(); + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::InventoryDBPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let category = CategoryBuilder::default() + .name("category_name".into()) + .description(Some("category_description".into())) + .category_id(category_id) + .store_id(store_id) + .build() + .unwrap(); + + // state doesn't exist + assert!(!db.category_name_exists_for_store(&category).await.unwrap()); + + sqlx::query!( + "INSERT INTO cqrs_inventory_category_query + (view_id, version, name, description, category_id, store_id) + VALUES ($1, $2, $3, $4, $5, $6);", + "1", + 1, + category.name(), + category.description().as_ref().unwrap(), + category.category_id(), + category.store_id(), + ) + .execute(&db.pool) + .await + .unwrap(); + + // state exists + assert!(db.category_name_exists_for_store(&category).await.unwrap()); + + settings.drop_db().await; + } +} diff --git a/src/inventory/adapters/output/db/postgres/category_view.rs b/src/inventory/adapters/output/db/postgres/category_view.rs new file mode 100644 index 0000000..939a7a2 --- /dev/null +++ b/src/inventory/adapters/output/db/postgres/category_view.rs @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_trait::async_trait; +use cqrs_es::persist::GenericQuery; +use cqrs_es::persist::{PersistenceError, ViewContext, ViewRepository}; +use cqrs_es::{EventEnvelope, Query, View}; +use uuid::Uuid; + +use super::errors::*; +use super::InventoryDBPostgresAdapter; +use crate::inventory::domain::category_aggregate::Category; +use crate::inventory::domain::events::InventoryEvent; +use serde::{Deserialize, Serialize}; + +// The view for a Category query, for a standard http application this should +// be designed to reflect the response dto that will be returned to a user. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct CategoryView { + name: String, + description: Option, + category_id: Uuid, + store_id: Uuid, +} + +// This updates the view with events as they are committed. +// The logic should be minimal here, e.g., don't calculate the account balance, +// design the events to carry the balance information instead. +impl View for CategoryView { + fn update(&mut self, event: &EventEnvelope) { + match &event.payload { + InventoryEvent::CategoryAdded(val) => { + self.name = val.name().into(); + self.description = val.description().clone(); + self.category_id = val.category_id().clone(); + self.store_id = val.store_id().clone(); + } + _ => (), + } + } +} + +#[async_trait] +impl ViewRepository for InventoryDBPostgresAdapter { + async fn load(&self, view_id: &str) -> Result, PersistenceError> { + let res = sqlx::query_as!( + CategoryView, + "SELECT + name, description, category_id, store_id + FROM + cqrs_inventory_category_query + WHERE + view_id = $1;", + view_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + Ok(Some(res)) + } + + async fn load_with_context( + &self, + view_id: &str, + ) -> Result, PersistenceError> { + let res = sqlx::query_as!( + CategoryView, + "SELECT + name, description, category_id, store_id + FROM + cqrs_inventory_category_query + WHERE + view_id = $1;", + view_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + struct Context { + version: i64, + view_id: String, + } + + let ctx = sqlx::query_as!( + Context, + "SELECT + view_id, version + FROM + cqrs_inventory_category_query + WHERE + view_id = $1;", + view_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + let view_context = ViewContext::new(ctx.view_id, ctx.version); + Ok(Some((res, view_context))) + } + + async fn update_view( + &self, + view: CategoryView, + context: ViewContext, + ) -> Result<(), PersistenceError> { + match context.version { + 0 => { + let version = context.version + 1; + sqlx::query!( + "INSERT INTO cqrs_inventory_category_query ( + view_id, version, name, description, category_id, store_id + ) VALUES ( + $1, $2, $3, $4, $5, $6 + );", + context.view_instance_id, + version, + view.name, + view.description, + view.category_id, + view.store_id, + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } + _ => { + let version = context.version + 1; + sqlx::query!( + "UPDATE + cqrs_inventory_category_query + SET + view_id = $1, + version = $2, + name = $3, + description = $4, + category_id = $5, + store_id = $6;", + context.view_instance_id, + version, + view.name, + view.description, + view.category_id, + view.store_id + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } + } + + Ok(()) + } +} + +pub struct SimpleLoggingQuery {} + +// Our simplest query, this is great for debugging but absolutely useless in production. +// This query just pretty prints the events as they are processed. +#[async_trait] +impl Query for SimpleLoggingQuery { + async fn dispatch(&self, aggregate_id: &str, events: &[EventEnvelope]) { + for event in events { + let payload = serde_json::to_string_pretty(&event.payload).unwrap(); + println!("{}-{}\n{}", aggregate_id, event.sequence, payload); + } + } +} + +// Our second query, this one will be handled with Postgres `GenericQuery` +// which will serialize and persist our view after it is updated. It also +// provides a `load` method to deserialize the view on request. +pub type CategoryQuery = GenericQuery; diff --git a/src/inventory/adapters/output/db/postgres/mod.rs b/src/inventory/adapters/output/db/postgres/mod.rs index f743e32..d9385e4 100644 --- a/src/inventory/adapters/output/db/postgres/mod.rs +++ b/src/inventory/adapters/output/db/postgres/mod.rs @@ -1,12 +1,16 @@ // SPDX-FileCopyrightText: 2024 Aravinth Manivannan // // SPDX-License-Identifier: AGPL-3.0-or-later + use std::sync::Arc; use sqlx::postgres::PgPool; use crate::db::{migrate::RunMigrations, sqlx_postgres::Postgres}; +mod category_id_exists; +mod category_name_exists_for_store; +mod category_view; mod errors; mod store_id_exists; mod store_view; diff --git a/src/inventory/application/port/output/db/category_id_exists.rs b/src/inventory/application/port/output/db/category_id_exists.rs new file mode 100644 index 0000000..4b0e19b --- /dev/null +++ b/src/inventory/application/port/output/db/category_id_exists.rs @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use mockall::*; + +use crate::inventory::domain::category_aggregate::Category; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait CategoryIDExistsDBPort: Send + Sync { + async fn category_id_exists(&self, c: &Category) -> InventoryDBResult; +} + +pub type CategoryIDExistsDBPortObj = std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_category_id_exists_db_port_false( + times: Option, + ) -> CategoryIDExistsDBPortObj { + let mut m = MockCategoryIDExistsDBPort::new(); + if let Some(times) = times { + m.expect_category_id_exists() + .times(times) + .returning(|_| Ok(false)); + } else { + m.expect_category_id_exists().returning(|_| Ok(false)); + } + + Arc::new(m) + } + + pub fn mock_category_id_exists_db_port_true(times: Option) -> CategoryIDExistsDBPortObj { + let mut m = MockCategoryIDExistsDBPort::new(); + if let Some(times) = times { + m.expect_category_id_exists() + .times(times) + .returning(|_| Ok(true)); + } else { + m.expect_category_id_exists().returning(|_| Ok(true)); + } + + Arc::new(m) + } +} diff --git a/src/inventory/application/port/output/db/category_name_exists_for_store.rs b/src/inventory/application/port/output/db/category_name_exists_for_store.rs new file mode 100644 index 0000000..cefc8be --- /dev/null +++ b/src/inventory/application/port/output/db/category_name_exists_for_store.rs @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use mockall::*; + +use crate::inventory::domain::category_aggregate::Category; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait CategoryNameExistsForStoreDBPort: Send + Sync { + async fn category_name_exists_for_store(&self, c: &Category) -> InventoryDBResult; +} + +pub type CategoryNameExistsForStoreDBPortObj = std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_category_name_exists_for_store_db_port_false( + times: Option, + ) -> CategoryNameExistsForStoreDBPortObj { + let mut m = MockCategoryNameExistsForStoreDBPort::new(); + if let Some(times) = times { + m.expect_category_name_exists_for_store() + .times(times) + .returning(|_| Ok(false)); + } else { + m.expect_category_name_exists_for_store() + .returning(|_| Ok(false)); + } + + Arc::new(m) + } + + pub fn mock_category_name_exists_for_store_db_port_true( + times: Option, + ) -> CategoryNameExistsForStoreDBPortObj { + let mut m = MockCategoryNameExistsForStoreDBPort::new(); + if let Some(times) = times { + m.expect_category_name_exists_for_store() + .times(times) + .returning(|_| Ok(true)); + } else { + m.expect_category_name_exists_for_store() + .returning(|_| Ok(true)); + } + + Arc::new(m) + } +} diff --git a/src/inventory/application/port/output/db/mod.rs b/src/inventory/application/port/output/db/mod.rs index efa524e..782819f 100644 --- a/src/inventory/application/port/output/db/mod.rs +++ b/src/inventory/application/port/output/db/mod.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -//pub mod category_exists; +pub mod category_id_exists; +pub mod category_name_exists_for_store; pub mod errors; pub mod store_id_exists; diff --git a/src/inventory/application/services/add_category_service.rs b/src/inventory/application/services/add_category_service.rs index 6592989..11537ec 100644 --- a/src/inventory/application/services/add_category_service.rs +++ b/src/inventory/application/services/add_category_service.rs @@ -1,9 +1,11 @@ // SPDX-FileCopyrightText: 2024 Aravinth Manivannan // // SPDX-License-Identifier: AGPL-3.0-or-later +use std::sync::Arc; use derive_builder::Builder; -use uuid::Uuid; +use mockall::predicate::*; +use mockall::*; use super::errors::*; use crate::inventory::{ @@ -16,12 +18,13 @@ use crate::inventory::{ }; use crate::utils::uuid::*; +#[automock] #[async_trait::async_trait] pub trait AddCategoryUseCase: Send + Sync { async fn add_category(&self, cmd: AddCategoryCommand) -> InventoryResult; } -pub type AddCategoryServiceObj = std::sync::Arc; +pub type AddCategoryServiceObj = Arc; #[derive(Clone, Builder)] pub struct AddCategoryService { @@ -83,7 +86,7 @@ impl AddCategoryUseCase for AddCategoryService { } #[cfg(test)] -mod tests { +pub mod tests { use super::*; use uuid::Uuid; @@ -91,6 +94,32 @@ mod tests { use crate::utils::uuid::tests::UUID; use crate::{tests::bdd::*, utils::uuid::tests::mock_get_uuid}; + pub fn mock_add_category_service( + times: Option, + cmd: AddCategoryCommand, + ) -> AddCategoryServiceObj { + let mut m = MockAddCategoryUseCase::new(); + + let res = CategoryAddedEventBuilder::default() + .name(cmd.name().into()) + .description(cmd.description().as_ref().map(|s| s.to_string())) + .added_by_user(cmd.adding_by().into()) + .store_id(cmd.store_id().clone()) + .category_id(UUID.clone()) + .build() + .unwrap(); + + if let Some(times) = times { + m.expect_add_category() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_add_category().returning(move |_| Ok(res.clone())); + } + + Arc::new(m) + } + #[actix_rt::test] async fn test_service_category_doesnt_exist() { let name = "foo"; diff --git a/src/inventory/application/services/mod.rs b/src/inventory/application/services/mod.rs index 85d1758..4a95814 100644 --- a/src/inventory/application/services/mod.rs +++ b/src/inventory/application/services/mod.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2024 Aravinth Manivannan // // SPDX-License-Identifier: AGPL-3.0-or-later + use derive_builder::Builder; use mockall::predicate::*; use mockall::*; @@ -8,26 +9,26 @@ use mockall::*; pub mod errors; // services -//pub mod add_category_service; +pub mod add_category_service; pub mod add_store_service; #[automock] pub trait InventoryServicesInterface: Send + Sync { fn add_store(&self) -> add_store_service::AddStoreServiceObj; - // fn add_category(&self) -> add_category_service::AddCategoryServiceObj; + fn add_category(&self) -> add_category_service::AddCategoryServiceObj; } #[derive(Clone, Builder)] pub struct InventoryServices { add_store: add_store_service::AddStoreServiceObj, - // add_category: add_category_service::AddCategoryServiceObj, + add_category: add_category_service::AddCategoryServiceObj, } impl InventoryServicesInterface for InventoryServices { fn add_store(&self) -> add_store_service::AddStoreServiceObj { self.add_store.clone() } - // fn add_category(&self) -> add_category_service::AddCategoryServiceObj { - // self.add_category.clone() - // } + fn add_category(&self) -> add_category_service::AddCategoryServiceObj { + self.add_category.clone() + } } diff --git a/src/inventory/domain/add_category_command.rs b/src/inventory/domain/add_category_command.rs new file mode 100644 index 0000000..0beb41d --- /dev/null +++ b/src/inventory/domain/add_category_command.rs @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_getters::Getters; +use derive_more::{Display, Error}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Error, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum AddCategoryCommandError { + NameIsEmpty, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)] +pub struct AddCategoryCommand { + name: String, + description: Option, + store_id: Uuid, + adding_by: String, +} + +impl AddCategoryCommand { + pub fn new( + name: String, + description: Option, + store_id: Uuid, + adding_by: String, + ) -> Result { + let description: Option = if let Some(description) = description { + let description = description.trim(); + if description.is_empty() { + None + } else { + Some(description.to_owned()) + } + } else { + None + }; + + let name = name.trim().to_owned(); + if name.is_empty() { + return Err(AddCategoryCommandError::NameIsEmpty); + } + + Ok(Self { + name, + store_id, + description, + adding_by, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cmd() { + let name = "foo"; + let description = "bar"; + let username = "baz"; + let store_id = Uuid::new_v4(); + + // description = None + let cmd = + AddCategoryCommand::new(name.into(), None, store_id.clone(), username.into()).unwrap(); + assert_eq!(cmd.name(), name); + assert_eq!(cmd.description(), &None); + assert_eq!(cmd.adding_by(), username); + assert_eq!(cmd.store_id(), &store_id); + + // description = Some + let cmd = AddCategoryCommand::new( + name.into(), + Some(description.into()), + store_id.clone(), + username.into(), + ) + .unwrap(); + assert_eq!(cmd.name(), name); + assert_eq!(cmd.description(), &Some(description.to_owned())); + assert_eq!(cmd.adding_by(), username); + assert_eq!(cmd.store_id(), &store_id); + + // AddCategoryCommandError::NameIsEmpty + assert_eq!( + AddCategoryCommand::new( + "".into(), + Some(description.into()), + store_id.clone(), + username.into() + ), + Err(AddCategoryCommandError::NameIsEmpty) + ) + } +} diff --git a/src/inventory/domain/category_added_event.rs b/src/inventory/domain/category_added_event.rs new file mode 100644 index 0000000..e2ead93 --- /dev/null +++ b/src/inventory/domain/category_added_event.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive( + Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct CategoryAddedEvent { + name: String, + description: Option, + added_by_user: String, + category_id: Uuid, + store_id: Uuid, +} diff --git a/src/inventory/domain/category_aggregate.rs b/src/inventory/domain/category_aggregate.rs new file mode 100644 index 0000000..a0e32ff --- /dev/null +++ b/src/inventory/domain/category_aggregate.rs @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_trait::async_trait; +use cqrs_es::Aggregate; +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::inventory::application::services::errors::*; +use crate::inventory::application::services::InventoryServicesInterface; + +use super::{commands::InventoryCommand, events::InventoryEvent}; + +#[derive( + Clone, Debug, Serialize, Default, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct Category { + name: String, + description: Option, + store_id: Uuid, + category_id: Uuid, +} + +#[async_trait] +impl Aggregate for Category { + type Command = InventoryCommand; + type Event = InventoryEvent; + type Error = InventoryError; + type Services = std::sync::Arc; + + // This identifier should be unique to the system. + fn aggregate_type() -> String { + "inventory.category".to_string() + } + + // The aggregate logic goes here. Note that this will be the _bulk_ of a CQRS system + // so expect to use helper functions elsewhere to keep the code clean. + async fn handle( + &self, + command: Self::Command, + services: &Self::Services, + ) -> Result, Self::Error> { + match command { + InventoryCommand::AddCategory(cmd) => { + let res = services.add_category().add_category(cmd).await?; + Ok(vec![InventoryEvent::CategoryAdded(res)]) + } + _ => Ok(Vec::default()), + } + } + + fn apply(&mut self, event: Self::Event) { + match event { + InventoryEvent::CategoryAdded(e) => { + *self = CategoryBuilder::default() + .name(e.name().into()) + .category_id(e.category_id().clone()) + .description(e.description().clone()) + .store_id(e.store_id().clone()) + .build() + .unwrap(); + } + _ => (), + } + } +} + +#[cfg(test)] +mod aggregate_tests { + use std::sync::Arc; + + use cqrs_es::test::TestFramework; + use uuid::Uuid; + + use super::*; + use crate::inventory::{ + application::services::{add_category_service::tests::*, *}, + domain::{ + add_category_command::*, category_added_event::*, commands::InventoryCommand, + events::InventoryEvent, + }, + }; + use crate::tests::bdd::*; + use crate::utils::uuid::tests::*; + + type CategoryTestFramework = TestFramework; + + #[test] + fn test_create_store() { + let name = "category_name"; + let description = Some("category_description".to_string()); + let adding_by = "store_owner"; + let store_id = Uuid::new_v4(); + let category_id = UUID.clone(); + + let cmd = AddCategoryCommand::new( + name.into(), + description.clone(), + store_id.clone(), + adding_by.into(), + ) + .unwrap(); + + let expected = CategoryAddedEventBuilder::default() + .name(cmd.name().into()) + .description(cmd.description().as_ref().map(|s| s.to_string())) + .added_by_user(cmd.adding_by().into()) + .store_id(cmd.store_id().clone()) + .category_id(category_id.clone()) + .build() + .unwrap(); + let expected = InventoryEvent::CategoryAdded(expected); + + let mut services = MockInventoryServicesInterface::new(); + services + .expect_add_category() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(mock_add_category_service(IS_CALLED_ONLY_ONCE, cmd.clone())); + + CategoryTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(InventoryCommand::AddCategory(cmd)) + .then_expect_events(vec![expected]); + } +} diff --git a/src/inventory/domain/commands.rs b/src/inventory/domain/commands.rs index 8f6db77..f87fdf9 100644 --- a/src/inventory/domain/commands.rs +++ b/src/inventory/domain/commands.rs @@ -5,13 +5,10 @@ use mockall::predicate::*; use serde::{Deserialize, Serialize}; -use super::{ - // add_category_command::AddCategoryCommand, - add_store_command::AddStoreCommand, -}; +use super::{add_category_command::AddCategoryCommand, add_store_command::AddStoreCommand}; #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] pub enum InventoryCommand { - //AddCategory(AddCategoryCommand), + AddCategory(AddCategoryCommand), AddStore(AddStoreCommand), } diff --git a/src/inventory/domain/events.rs b/src/inventory/domain/events.rs index 4f468f0..f487a8a 100644 --- a/src/inventory/domain/events.rs +++ b/src/inventory/domain/events.rs @@ -5,14 +5,11 @@ use cqrs_es::DomainEvent; use serde::{Deserialize, Serialize}; -use super::{ - // category_added_event::*, - store_added_event::StoreAddedEvent, -}; +use super::{category_added_event::*, store_added_event::StoreAddedEvent}; #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] pub enum InventoryEvent { - // CategoryAdded(CategoryAddedEvent), + CategoryAdded(CategoryAddedEvent), StoreAdded(StoreAddedEvent), } @@ -25,7 +22,7 @@ impl DomainEvent for InventoryEvent { fn event_type(&self) -> String { let e: &str = match self { - // InventoryEvent::CategoryAdded { .. } => "InventoryCategoryAdded", + InventoryEvent::CategoryAdded { .. } => "InventoryCategoryAdded", InventoryEvent::StoreAdded { .. } => "InventoryStoredded", }; diff --git a/src/inventory/domain/mod.rs b/src/inventory/domain/mod.rs index 8d475b8..bce356e 100644 --- a/src/inventory/domain/mod.rs +++ b/src/inventory/domain/mod.rs @@ -5,15 +5,16 @@ // aggregates //pub mod money_aggregate; //pub mod product_aggregate; +pub mod category_aggregate; //pub mod stock_aggregate; pub mod store_aggregate; // commands -//pub mod add_category_command; +pub mod add_category_command; pub mod add_store_command; pub mod commands; // events -//pub mod category_added_event; +pub mod category_added_event; pub mod events; pub mod store_added_event; diff --git a/src/inventory/domain/store_aggregate.rs b/src/inventory/domain/store_aggregate.rs index 9a68280..90fe3a2 100644 --- a/src/inventory/domain/store_aggregate.rs +++ b/src/inventory/domain/store_aggregate.rs @@ -72,9 +72,7 @@ impl Aggregate for Store { } } } -// The aggregate tests are the most important part of a CQRS system. -// The simplicity and flexibility of these tests are a good part of what -// makes an event sourced system so friendly to changing business requirements. + #[cfg(test)] mod aggregate_tests { use std::sync::Arc;