1use super::{
4 map_db_error, parse_datetime_row, parse_decimal_row, parse_json_row, parse_uuid_opt_row,
5 parse_uuid_row, with_immediate_transaction,
6};
7use chrono::Utc;
8use r2d2::Pool;
9use r2d2_sqlite::SqliteConnectionManager;
10use stateset_core::{
11 CommerceError, IngestOrder, MapPurgatoryLine, PurgatoryFilter, PurgatoryLineItem,
12 PurgatoryLineItemId, PurgatoryOrder, PurgatoryOrderId, PurgatoryRepository, Result,
13};
14
15#[derive(Debug)]
16pub struct SqlitePurgatoryRepository {
17 pool: Pool<SqliteConnectionManager>,
18}
19
20impl SqlitePurgatoryRepository {
21 #[must_use]
22 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
23 Self { pool }
24 }
25
26 fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
27 self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
28 }
29
30 fn row_to_line(row: &rusqlite::Row<'_>) -> rusqlite::Result<PurgatoryLineItem> {
31 Ok(PurgatoryLineItem {
32 id: parse_uuid_row(&row.get::<_, String>("id")?, "purgatory_line", "id")?.into(),
33 purgatory_order_id: parse_uuid_row(
34 &row.get::<_, String>("purgatory_order_id")?,
35 "purgatory_line",
36 "purgatory_order_id",
37 )?
38 .into(),
39 external_sku: row.get("external_sku")?,
40 product_id: parse_uuid_opt_row(
41 row.get::<_, Option<String>>("product_id")?,
42 "purgatory_line",
43 "product_id",
44 )?
45 .map(Into::into),
46 quantity: parse_decimal_row(
47 &row.get::<_, String>("quantity")?,
48 "purgatory_line",
49 "quantity",
50 )?,
51 ignore_item: row.get::<_, i32>("ignore_item")? != 0,
52 non_physical: row.get::<_, i32>("non_physical")? != 0,
53 })
54 }
55
56 fn row_to_head(row: &rusqlite::Row<'_>) -> rusqlite::Result<PurgatoryOrder> {
57 let metadata_json: String = row.get("metadata")?;
58 Ok(PurgatoryOrder {
59 id: parse_uuid_row(&row.get::<_, String>("id")?, "purgatory_order", "id")?.into(),
60 channel_id: parse_uuid_opt_row(
61 row.get::<_, Option<String>>("channel_id")?,
62 "purgatory_order",
63 "channel_id",
64 )?
65 .map(Into::into),
66 external_order_id: row.get("external_order_id")?,
67 external_status: row.get("external_status")?,
68 is_posted: row.get::<_, i32>("is_posted")? != 0,
69 hold_reason: row.get("hold_reason")?,
70 metadata: parse_json_row(&metadata_json, "purgatory_order", "metadata")?,
71 items: Vec::new(),
72 created_at: parse_datetime_row(
73 &row.get::<_, String>("created_at")?,
74 "purgatory_order",
75 "created_at",
76 )?,
77 updated_at: parse_datetime_row(
78 &row.get::<_, String>("updated_at")?,
79 "purgatory_order",
80 "updated_at",
81 )?,
82 })
83 }
84
85 fn load_items(
86 conn: &rusqlite::Connection,
87 id: &str,
88 ) -> rusqlite::Result<Vec<PurgatoryLineItem>> {
89 let mut stmt = conn.prepare(
90 "SELECT * FROM purgatory_line_items WHERE purgatory_order_id = ? ORDER BY external_sku",
91 )?;
92 stmt.query_map([id], Self::row_to_line)?.collect()
93 }
94
95 fn load_items_batch(
96 conn: &rusqlite::Connection,
97 ids: &[String],
98 ) -> rusqlite::Result<std::collections::HashMap<String, Vec<PurgatoryLineItem>>> {
99 let mut map: std::collections::HashMap<String, Vec<PurgatoryLineItem>> =
100 std::collections::HashMap::with_capacity(ids.len());
101 for chunk in ids.chunks(500) {
102 let placeholders = super::build_in_clause(chunk.len());
103 let sql = format!(
104 "SELECT * FROM purgatory_line_items WHERE purgatory_order_id IN ({placeholders}) ORDER BY external_sku"
105 );
106 let mut stmt = conn.prepare(&sql)?;
107 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
108 chunk.iter().map(|s| s as &dyn rusqlite::types::ToSql).collect();
109 let rows = stmt.query_map(param_refs.as_slice(), |row| {
110 let parent: String = row.get("purgatory_order_id")?;
111 Ok((parent, Self::row_to_line(row)?))
112 })?;
113 for row in rows {
114 let (parent, item) = row?;
115 map.entry(parent).or_default().push(item);
116 }
117 }
118 Ok(map)
119 }
120
121 fn load_full(conn: &rusqlite::Connection, id: &str) -> rusqlite::Result<PurgatoryOrder> {
122 let mut head =
123 conn.query_row("SELECT * FROM purgatory_orders WHERE id = ?", [id], Self::row_to_head)?;
124 head.items = Self::load_items(conn, id)?;
125 Ok(head)
126 }
127}
128
129impl PurgatoryRepository for SqlitePurgatoryRepository {
130 fn ingest(&self, input: IngestOrder) -> Result<PurgatoryOrder> {
131 if input.items.is_empty() {
132 return Err(CommerceError::ValidationError(
133 "an ingested order requires at least one line".into(),
134 ));
135 }
136 let id = PurgatoryOrderId::new();
137 let id_str = id.to_string();
138 let now = Utc::now().to_rfc3339();
139 let metadata_json = serde_json::to_string(&input.metadata)
140 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
141 with_immediate_transaction(&self.pool, |tx| {
142 tx.execute(
143 "INSERT INTO purgatory_orders (id, channel_id, external_order_id, external_status, is_posted, metadata, created_at, updated_at)
144 VALUES (?, ?, ?, ?, 0, ?, ?, ?)",
145 rusqlite::params![
146 &id_str,
147 input.channel_id.map(|c| c.to_string()),
148 &input.external_order_id,
149 &input.external_status,
150 &metadata_json,
151 &now,
152 &now,
153 ],
154 )?;
155 for item in &input.items {
156 tx.execute(
157 "INSERT INTO purgatory_line_items (id, purgatory_order_id, external_sku, product_id, quantity, ignore_item, non_physical)
158 VALUES (?, ?, ?, ?, ?, 0, 0)",
159 rusqlite::params![
160 PurgatoryLineItemId::new().to_string(),
161 &id_str,
162 &item.external_sku,
163 item.product_id.map(|p| p.to_string()),
164 item.quantity.to_string(),
165 ],
166 )?;
167 }
168 Self::load_full(tx, &id_str)
169 })
170 }
171
172 fn get(&self, id: PurgatoryOrderId) -> Result<Option<PurgatoryOrder>> {
173 let conn = self.conn()?;
174 match Self::load_full(&conn, &id.to_string()) {
175 Ok(o) => Ok(Some(o)),
176 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
177 Err(e) => Err(map_db_error(e)),
178 }
179 }
180
181 fn list(&self, filter: PurgatoryFilter) -> Result<Vec<PurgatoryOrder>> {
182 let conn = self.conn()?;
183 let mut sql = "SELECT * FROM purgatory_orders WHERE 1=1".to_string();
184 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
185 if let Some(channel) = filter.channel_id {
186 sql.push_str(" AND channel_id = ?");
187 params.push(Box::new(channel.to_string()));
188 }
189 let is_posted = filter.is_posted.unwrap_or(false);
191 sql.push_str(" AND is_posted = ?");
192 params.push(Box::new(is_posted as i32));
193 sql.push_str(" ORDER BY created_at DESC");
194 crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
195 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
196 params.iter().map(|p| p.as_ref()).collect();
197 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
198 let heads = stmt
199 .query_map(param_refs.as_slice(), Self::row_to_head)
200 .map_err(map_db_error)?
201 .collect::<std::result::Result<Vec<_>, _>>()
202 .map_err(map_db_error)?;
203 let ids: Vec<String> = heads.iter().map(|h| h.id.to_string()).collect();
204 let mut items_by_id = Self::load_items_batch(&conn, &ids).map_err(map_db_error)?;
205 let mut out = Vec::with_capacity(heads.len());
206 for mut head in heads {
207 head.items = items_by_id.remove(&head.id.to_string()).unwrap_or_default();
208 out.push(head);
209 }
210 Ok(out)
211 }
212
213 fn map_line(
214 &self,
215 id: PurgatoryOrderId,
216 line_id: PurgatoryLineItemId,
217 input: MapPurgatoryLine,
218 ) -> Result<PurgatoryOrder> {
219 let id_str = id.to_string();
220 let line_str = line_id.to_string();
221 let now = Utc::now().to_rfc3339();
222 with_immediate_transaction(&self.pool, |tx| {
223 let mut sets: Vec<String> = vec![];
224 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
225 if let Some(product_id) = input.product_id {
226 sets.push("product_id = ?".into());
227 params.push(Box::new(product_id.to_string()));
228 }
229 if let Some(ignore) = input.ignore_item {
230 sets.push("ignore_item = ?".into());
231 params.push(Box::new(ignore as i32));
232 }
233 if let Some(non_physical) = input.non_physical {
234 sets.push("non_physical = ?".into());
235 params.push(Box::new(non_physical as i32));
236 }
237 if !sets.is_empty() {
238 let sql = format!(
239 "UPDATE purgatory_line_items SET {} WHERE id = ? AND purgatory_order_id = ?",
240 sets.join(", ")
241 );
242 params.push(Box::new(line_str.clone()));
243 params.push(Box::new(id_str.clone()));
244 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
245 params.iter().map(|p| p.as_ref()).collect();
246 tx.execute(&sql, param_refs.as_slice())?;
247 }
248 tx.execute(
249 "UPDATE purgatory_orders SET updated_at = ? WHERE id = ?",
250 rusqlite::params![&now, &id_str],
251 )?;
252 Self::load_full(tx, &id_str)
253 })
254 }
255
256 fn post(&self, id: PurgatoryOrderId) -> Result<PurgatoryOrder> {
257 let id_str = id.to_string();
258 let now = Utc::now().to_rfc3339();
259 with_immediate_transaction(&self.pool, |tx| {
260 let order = Self::load_full(tx, &id_str)?;
261 if order.is_posted {
262 return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
263 CommerceError::Conflict("order is already posted".into()),
264 )));
265 }
266 if !order.is_ready_to_post() {
267 return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
268 CommerceError::ValidationError(format!(
269 "{} line(s) still unresolved",
270 order.unresolved_count()
271 )),
272 )));
273 }
274 tx.execute(
275 "UPDATE purgatory_orders SET is_posted = 1, hold_reason = NULL, updated_at = ? WHERE id = ?",
276 rusqlite::params![&now, &id_str],
277 )?;
278 Self::load_full(tx, &id_str)
279 })
280 }
281
282 fn delete(&self, id: PurgatoryOrderId) -> Result<()> {
283 let id_str = id.to_string();
284 with_immediate_transaction(&self.pool, |tx| {
285 tx.execute("DELETE FROM purgatory_line_items WHERE purgatory_order_id = ?", [&id_str])?;
286 tx.execute("DELETE FROM purgatory_orders WHERE id = ?", [&id_str])?;
287 Ok(())
288 })
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::DatabaseConfig;
296 use crate::sqlite::SqliteDatabase;
297 use rust_decimal_macros::dec;
298 use stateset_core::{IngestLineItem, ProductId};
299
300 fn test_repo() -> SqlitePurgatoryRepository {
301 let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
302 SqlitePurgatoryRepository::new(db.pool().clone())
303 }
304
305 fn ingest(repo: &SqlitePurgatoryRepository, mapped: bool) -> PurgatoryOrder {
306 repo.ingest(IngestOrder {
307 channel_id: None,
308 external_order_id: "SHOP-1001".into(),
309 external_status: Some("paid".into()),
310 metadata: serde_json::json!({"src": "shopify"}),
311 items: vec![IngestLineItem {
312 external_sku: "EXT-1".into(),
313 quantity: dec!(2),
314 product_id: if mapped { Some(ProductId::new()) } else { None },
315 }],
316 })
317 .expect("ingest")
318 }
319
320 #[test]
321 fn ingest_rejects_empty() {
322 let repo = test_repo();
323 let res = repo.ingest(IngestOrder {
324 channel_id: None,
325 external_order_id: "X".into(),
326 external_status: None,
327 metadata: serde_json::Value::Null,
328 items: vec![],
329 });
330 assert!(res.is_err());
331 }
332
333 #[test]
334 fn unmapped_order_cannot_post() {
335 let repo = test_repo();
336 let o = ingest(&repo, false);
337 assert!(!o.is_ready_to_post());
338 assert!(repo.post(o.id).is_err());
339 }
340
341 #[test]
342 fn map_line_then_post() {
343 let repo = test_repo();
344 let o = ingest(&repo, false);
345 let line = o.items[0].id;
346 let mapped = repo
347 .map_line(
348 o.id,
349 line,
350 MapPurgatoryLine { product_id: Some(ProductId::new()), ..Default::default() },
351 )
352 .expect("map");
353 assert!(mapped.is_ready_to_post());
354 let posted = repo.post(o.id).expect("post");
355 assert!(posted.is_posted);
356 assert!(repo.post(o.id).is_err());
358 }
359
360 #[test]
361 fn ignore_flag_resolves_line() {
362 let repo = test_repo();
363 let o = ingest(&repo, false);
364 let line = o.items[0].id;
365 repo.map_line(
366 o.id,
367 line,
368 MapPurgatoryLine { ignore_item: Some(true), ..Default::default() },
369 )
370 .expect("ignore");
371 assert!(repo.post(o.id).is_ok());
372 }
373
374 #[test]
375 fn list_defaults_to_non_posted() {
376 let repo = test_repo();
377 let a = ingest(&repo, true);
378 ingest(&repo, true);
379 repo.post(a.id).expect("post");
380 let pending = repo.list(PurgatoryFilter::default()).expect("list");
382 assert_eq!(pending.len(), 1);
383 let posted = repo
384 .list(PurgatoryFilter { is_posted: Some(true), ..Default::default() })
385 .expect("list");
386 assert_eq!(posted.len(), 1);
387 }
388}