Skip to main content

backbone_catalog/infrastructure/persistence/
uom_repository.rs

1//! Repository for Uom entities
2//!
3//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
4//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
5//! below hold the catalog write service's UOM SQL (4-layer rule: services orchestrate, repos hold
6//! SQL).
7//!
8//! Tenant-agnostic (ADR-0029): no statement here names a tenancy column. Statements ride
9//! whatever executor the caller passes — the write verbs pass their org-scoped
10//! transaction, so the composing host's fence applies; on a plain pool (standalone
11//! deployment, tests) they see the whole table.
12//!
13//! Thin newtype over `backbone_orm::GenericCrudRepository<Uom, backbone_orm::SoftDelete>`.
14//! All standard CRUD methods are available via `Deref`.
15
16use rust_decimal::Decimal;
17use sqlx::{PgConnection, PgPool};
18use uuid::Uuid;
19
20use crate::domain::entity::Uom;
21use crate::domain::services::uom_tree::UomChainNode;
22
23/// Table name for Uom entities
24pub const TABLE_NAME: &str = "catalog.uoms";
25
26/// Repository for Uom entities.
27///
28/// All standard CRUD, soft-delete, pagination, and bulk methods are
29/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
30pub struct UomRepository(
31    backbone_orm::GenericCrudRepository<Uom, backbone_orm::SoftDelete>,
32);
33
34impl std::ops::Deref for UomRepository {
35    type Target = backbone_orm::GenericCrudRepository<Uom, backbone_orm::SoftDelete>;
36    fn deref(&self) -> &Self::Target { &self.0 }
37}
38
39impl UomRepository {
40    /// Create a new repository instance.
41    pub fn new(pool: PgPool) -> Self {
42        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
43    }
44}
45
46/// The exact row a validated UOM insert writes.
47pub struct NewUomRow<'a> {
48    pub id: Uuid,
49    pub code: &'a str,
50    pub name: &'a str,
51    pub uom_type: &'a str,
52    pub decimal_places: i32,
53    /// Parent (reference) unit; `None` creates a tree root.
54    pub relative_uom_id: Option<Uuid>,
55    /// Ratio to the parent; must be `Some(> 0)` exactly when `relative_uom_id` is set.
56    pub relative_factor: Option<Decimal>,
57    /// Stored effective factor to the tree root (`1` for a root;
58    /// `parent.factor * relative_factor` for a child — the service computes it).
59    pub factor: Decimal,
60}
61
62/// Catalog UOM SQL. Lives here (not in the service) per the module's 4-layer rule.
63impl UomRepository {
64    /// `EXISTS` probe for a live unit (replaces the prior string-built `exists_in` helper
65    /// in the write service). Used for default_uom_id FK validation on create-item and
66    /// parent-unit validation on tree writes.
67    pub async fn exists_id(
68        &self,
69        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
70        id: Uuid,
71    ) -> Result<bool, sqlx::Error> {
72        let found: Option<Uuid> = sqlx::query_scalar(
73            "SELECT id FROM catalog.uoms \
74             WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
75        )
76        .bind(id)
77        .fetch_optional(executor)
78        .await?;
79        Ok(found.is_some())
80    }
81
82    /// Stored effective factor of one live unit (`None` if the unit does not exist).
83    /// Used to compute a child unit's stored factor at insert time.
84    pub async fn find_factor(
85        &self,
86        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
87        id: Uuid,
88    ) -> Result<Option<Decimal>, sqlx::Error> {
89        let factor: Option<Decimal> = sqlx::query_scalar(
90            "SELECT factor FROM catalog.uoms \
91             WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
92        )
93        .bind(id)
94        .fetch_optional(executor)
95        .await?;
96        Ok(factor)
97    }
98
99    /// Insert a validated UOM row. Unique-constraint errors propagate as `sqlx::Error` so
100    /// the service can disambiguate code duplicates.
101    pub async fn insert_uom(
102        &self,
103        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
104        r: &NewUomRow<'_>,
105    ) -> Result<(), sqlx::Error> {
106        sqlx::query(
107            r#"INSERT INTO catalog.uoms
108                   (id, code, name, uom_type, decimal_places,
109                    relative_uom_id, relative_factor, factor, status)
110               VALUES ($1,$2,$3,$4::uom_type,$5,$6,$7,$8,'active'::catalog_status)"#,
111        )
112        .bind(r.id)
113        .bind(r.code)
114        .bind(r.name)
115        .bind(r.uom_type)
116        .bind(r.decimal_places)
117        .bind(r.relative_uom_id)
118        .bind(r.relative_factor)
119        .bind(r.factor)
120        .execute(executor)
121        .await?;
122        Ok(())
123    }
124
125    /// Load `uom` together with its full ancestor chain up to (and including) its tree root.
126    ///
127    /// The leaf must be a live row (`None` otherwise); ancestors are loaded
128    /// regardless of their soft-delete state, so archiving a unit never makes its subtree
129    /// unconvertible. Rows come back in arbitrary SQL order — the caller assembles them into
130    /// an ordered [`UomChain`] with [`UomChain::from_rows`], which fails loudly on cycles
131    /// and dangling links. `UNION` (not `UNION ALL`) bounds the recursion if stored links
132    /// are ever corrupt.
133    pub async fn load_tree_chain(
134        &self,
135        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
136        uom: Uuid,
137    ) -> Result<Option<Vec<UomChainNode>>, sqlx::Error> {
138        let rows: Vec<UomChainNode> = sqlx::query_as(
139            r#"WITH RECURSIVE chain AS (
140                   SELECT id, code, relative_uom_id, relative_factor, factor
141                   FROM catalog.uoms
142                   WHERE id = $1 AND (metadata->>'deleted_at') IS NULL
143                   UNION
144                   SELECT p.id, p.code, p.relative_uom_id, p.relative_factor, p.factor
145                   FROM catalog.uoms p
146                   JOIN chain ON p.id = chain.relative_uom_id
147               )
148               SELECT id, code, relative_uom_id, relative_factor, factor FROM chain"#,
149        )
150        .bind(uom)
151        .fetch_all(executor)
152        .await?;
153        Ok(if rows.is_empty() { None } else { Some(rows) })
154    }
155
156    /// Is `candidate` the unit itself or one of its descendants in the tree?
157    /// The cycle guard for re-parenting: a unit must never point at its own subtree.
158    /// Runs on the caller's transaction connection.
159    pub async fn is_self_or_descendant(
160        &self,
161        conn: &mut PgConnection,
162        uom: Uuid,
163        candidate: Uuid,
164    ) -> Result<bool, sqlx::Error> {
165        let hit: bool = sqlx::query_scalar(
166            r#"WITH RECURSIVE subtree AS (
167                   SELECT id FROM catalog.uoms WHERE id = $1
168                   UNION
169                   SELECT c.id FROM catalog.uoms c JOIN subtree s ON c.relative_uom_id = s.id
170               )
171               SELECT EXISTS (SELECT 1 FROM subtree WHERE id = $2)"#,
172        )
173        .bind(uom)
174        .bind(candidate)
175        .fetch_one(conn)
176        .await?;
177        Ok(hit)
178    }
179
180    /// Point a live unit at a new parent (or detach it to become a root). The caller has
181    /// validated shape/positivity and run the descendant cycle guard; the stored
182    /// factor re-derivation happens in [`UomRepository::recompute_factors`].
183    pub async fn set_relative(
184        &self,
185        conn: &mut PgConnection,
186        uom: Uuid,
187        relative_uom_id: Option<Uuid>,
188        relative_factor: Option<Decimal>,
189    ) -> Result<(), sqlx::Error> {
190        sqlx::query(
191            "UPDATE catalog.uoms SET relative_uom_id = $2, relative_factor = $3 \
192             WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
193        )
194        .bind(uom)
195        .bind(relative_uom_id)
196        .bind(relative_factor)
197        .execute(conn)
198        .await?;
199        Ok(())
200    }
201
202    /// Re-derive every stored tree factor on the caller's connection by calling the SQL
203    /// function installed by the parent-store-tree migration. Idempotent (rows whose
204    /// derived factor equals the stored one are not rewritten) and loud: a cycle or
205    /// dangling link raises a database error instead of pinning a stale factor.
206    pub async fn recompute_factors(
207        &self,
208        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
209    ) -> Result<(), sqlx::Error> {
210        sqlx::query("SELECT catalog.uom_recompute_factors()")
211            .execute(executor)
212            .await?;
213        Ok(())
214    }
215
216    // ── Protected-unit retire path (UM-4) ─────────────────────────────────────────
217
218    /// Protection flag of one live unit (`None` if the unit does not exist). Protected
219    /// rows are module-seeded reference data and refuse deletion.
220    pub async fn find_protection(
221        &self,
222        conn: &mut PgConnection,
223        id: Uuid,
224    ) -> Result<Option<bool>, sqlx::Error> {
225        let protected: Option<bool> = sqlx::query_scalar(
226            "SELECT is_protected FROM catalog.uoms \
227             WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
228        )
229        .bind(id)
230        .fetch_optional(conn)
231        .await?;
232        Ok(protected)
233    }
234
235    /// Count the unit's live children in the tree — units whose `relative_uom_id`
236    /// points here. A unit that is still the live parent of live units must be
237    /// re-linked before it can be retired.
238    pub async fn count_live_children(
239        &self,
240        conn: &mut PgConnection,
241        uom: Uuid,
242    ) -> Result<i64, sqlx::Error> {
243        let n: i64 = sqlx::query_scalar(
244            "SELECT count(*) FROM catalog.uoms \
245             WHERE relative_uom_id = $1 AND (metadata->>'deleted_at') IS NULL",
246        )
247        .bind(uom)
248        .fetch_one(conn)
249        .await?;
250        Ok(n)
251    }
252
253    /// Does any live item still use this unit as its default? Retiring a referenced
254    /// unit would orphan the item's unit resolution (the reason generic delete is not
255    /// mounted for Uom — ADR-005).
256    pub async fn exists_live_item_using(
257        &self,
258        conn: &mut PgConnection,
259        uom: Uuid,
260    ) -> Result<bool, sqlx::Error> {
261        let hit: bool = sqlx::query_scalar(
262            "SELECT EXISTS ( \
263                 SELECT 1 FROM catalog.items \
264                 WHERE default_uom_id = $1 \
265                   AND (metadata->>'deleted_at') IS NULL )",
266        )
267        .bind(uom)
268        .fetch_one(conn)
269        .await?;
270        Ok(hit)
271    }
272
273    /// Archive (soft-delete) a unit the validated retire path has cleared. The
274    /// storage-level protected-unit guard is the backstop if a protected row ever
275    /// reaches this UPDATE.
276    pub async fn soft_delete_uom(
277        &self,
278        conn: &mut PgConnection,
279        uom: Uuid,
280    ) -> Result<(), sqlx::Error> {
281        sqlx::query(
282            "UPDATE catalog.uoms \
283             SET metadata = jsonb_set(metadata, '{deleted_at}', to_jsonb(now())) \
284             WHERE id = $1",
285        )
286        .bind(uom)
287        .execute(conn)
288        .await?;
289        Ok(())
290    }
291}
292
293backbone_core::impl_crud_repository!(UomRepository, Uom, soft_delete);