use super::{
map_db_error, parse_datetime_row, parse_json_row, parse_uuid_row, with_immediate_transaction,
};
use chrono::Utc;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use stateset_core::{
CommerceError, CreateShippingZone, Result, ShippingZone, ShippingZoneFilter, ShippingZoneId,
ShippingZoneRepository, UpdateShippingZone,
};
#[derive(Debug)]
pub struct SqliteShippingZoneRepository {
pool: Pool<SqliteConnectionManager>,
}
impl SqliteShippingZoneRepository {
#[must_use]
pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
Self { pool }
}
fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
}
fn row_to_zone(row: &rusqlite::Row<'_>) -> rusqlite::Result<ShippingZone> {
let countries_json: String = row.get("countries")?;
let regions_json: String = row.get("regions")?;
let postal_codes_json: String = row.get("postal_codes")?;
Ok(ShippingZone {
id: parse_uuid_row(&row.get::<_, String>("id")?, "shipping_zone", "id")?.into(),
name: row.get("name")?,
countries: parse_json_row(&countries_json, "shipping_zone", "countries")?,
regions: parse_json_row(®ions_json, "shipping_zone", "regions")?,
postal_codes: parse_json_row(&postal_codes_json, "shipping_zone", "postal_codes")?,
priority: row.get("priority")?,
is_active: row.get::<_, i32>("is_active")? != 0,
created_at: parse_datetime_row(
&row.get::<_, String>("created_at")?,
"shipping_zone",
"created_at",
)?,
updated_at: parse_datetime_row(
&row.get::<_, String>("updated_at")?,
"shipping_zone",
"updated_at",
)?,
})
}
}
impl ShippingZoneRepository for SqliteShippingZoneRepository {
fn create(&self, input: CreateShippingZone) -> Result<ShippingZone> {
let id = ShippingZoneId::new();
let now = Utc::now();
let id_str = id.to_string();
let now_str = now.to_rfc3339();
let countries_json = serde_json::to_string(&input.countries)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let regions_json = serde_json::to_string(&input.regions)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let postal_codes_json = serde_json::to_string(&input.postal_codes)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
with_immediate_transaction(&self.pool, |tx| {
tx.execute(
"INSERT INTO shipping_zones (id, name, countries, regions, postal_codes, priority, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)",
rusqlite::params![
&id_str,
&input.name,
&countries_json,
®ions_json,
&postal_codes_json,
input.priority.unwrap_or(0),
&now_str,
&now_str,
],
)?;
tx.query_row("SELECT * FROM shipping_zones WHERE id = ?", [&id_str], Self::row_to_zone)
})
}
fn get(&self, id: ShippingZoneId) -> Result<Option<ShippingZone>> {
let conn = self.conn()?;
match conn.query_row(
"SELECT * FROM shipping_zones WHERE id = ?",
[id.to_string()],
Self::row_to_zone,
) {
Ok(z) => Ok(Some(z)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(map_db_error(e)),
}
}
fn update(&self, id: ShippingZoneId, input: UpdateShippingZone) -> Result<ShippingZone> {
let id_str = id.to_string();
let now_str = Utc::now().to_rfc3339();
with_immediate_transaction(&self.pool, |tx| {
let mut sets = vec!["updated_at = ?".to_string()];
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(now_str.clone())];
if let Some(ref name) = input.name {
sets.push("name = ?".into());
params.push(Box::new(name.clone()));
}
if let Some(ref countries) = input.countries {
let json = serde_json::to_string(countries).map_err(|e| {
rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
e.to_string(),
)))
})?;
sets.push("countries = ?".into());
params.push(Box::new(json));
}
if let Some(ref regions) = input.regions {
let json = serde_json::to_string(regions).map_err(|e| {
rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
e.to_string(),
)))
})?;
sets.push("regions = ?".into());
params.push(Box::new(json));
}
if let Some(ref postal_codes) = input.postal_codes {
let json = serde_json::to_string(postal_codes).map_err(|e| {
rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
e.to_string(),
)))
})?;
sets.push("postal_codes = ?".into());
params.push(Box::new(json));
}
if let Some(priority) = input.priority {
sets.push("priority = ?".into());
params.push(Box::new(priority));
}
if let Some(is_active) = input.is_active {
sets.push("is_active = ?".into());
params.push(Box::new(is_active as i32));
}
let sql = format!("UPDATE shipping_zones SET {} WHERE id = ?", sets.join(", "));
params.push(Box::new(id_str.clone()));
let param_refs: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|p| p.as_ref()).collect();
tx.execute(&sql, param_refs.as_slice())?;
tx.query_row("SELECT * FROM shipping_zones WHERE id = ?", [&id_str], Self::row_to_zone)
})
}
fn list(&self, filter: ShippingZoneFilter) -> Result<Vec<ShippingZone>> {
let conn = self.conn()?;
let mut sql = "SELECT * FROM shipping_zones WHERE 1=1".to_string();
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
if let Some(ref country) = filter.country {
sql.push_str(" AND countries LIKE ?");
params.push(Box::new(format!("%\"{country}\"%")));
}
if let Some(is_active) = filter.is_active {
sql.push_str(" AND is_active = ?");
params.push(Box::new(is_active as i32));
}
sql.push_str(" ORDER BY priority ASC, created_at DESC");
crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
let param_refs: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
let rows = stmt
.query_map(param_refs.as_slice(), Self::row_to_zone)
.map_err(map_db_error)?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(map_db_error)?;
Ok(rows)
}
fn delete(&self, id: ShippingZoneId) -> Result<()> {
let conn = self.conn()?;
conn.execute("DELETE FROM shipping_zones WHERE id = ?", [id.to_string()])
.map_err(map_db_error)?;
Ok(())
}
fn find_matching_zones(
&self,
country: &str,
region: Option<&str>,
postal_code: Option<&str>,
) -> Result<Vec<ShippingZone>> {
let all_active =
self.list(ShippingZoneFilter { is_active: Some(true), ..Default::default() })?;
let matched: Vec<ShippingZone> = all_active
.into_iter()
.filter(|zone| {
let country_match =
zone.countries.is_empty() || zone.countries.iter().any(|c| c == country);
if !country_match {
return false;
}
if !zone.regions.is_empty() {
if let Some(r) = region {
if !zone.regions.iter().any(|zr| zr == r) {
return false;
}
} else {
return false;
}
}
if !zone.postal_codes.is_empty() {
if let Some(pc) = postal_code {
if !zone.postal_codes.iter().any(|pattern| {
if pattern.ends_with('*') {
pc.starts_with(&pattern[..pattern.len() - 1])
} else {
pc == pattern
}
}) {
return false;
}
} else {
return false;
}
}
true
})
.collect();
Ok(matched)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DatabaseConfig;
use crate::sqlite::SqliteDatabase;
fn test_repo() -> SqliteShippingZoneRepository {
let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
let conn = db.conn().expect("conn");
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS shipping_zones (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
countries TEXT NOT NULL DEFAULT '[]',
regions TEXT NOT NULL DEFAULT '[]',
postal_codes TEXT NOT NULL DEFAULT '[]',
priority INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);",
)
.expect("create tables");
SqliteShippingZoneRepository::new(db.pool().clone())
}
#[test]
fn create_and_get_zone() {
let repo = test_repo();
let zone = repo
.create(CreateShippingZone {
name: "Domestic US".into(),
countries: vec!["US".into()],
regions: vec![],
postal_codes: vec![],
priority: Some(1),
})
.expect("create");
assert_eq!(zone.name, "Domestic US");
assert_eq!(zone.countries, vec!["US".to_string()]);
assert!(zone.is_active);
let fetched = repo.get(zone.id).expect("get").expect("found");
assert_eq!(fetched.id, zone.id);
}
#[test]
fn list_and_delete_zones() {
let repo = test_repo();
repo.create(CreateShippingZone {
name: "US".into(),
countries: vec!["US".into()],
regions: vec![],
postal_codes: vec![],
priority: None,
})
.expect("create US");
repo.create(CreateShippingZone {
name: "EU".into(),
countries: vec!["DE".into(), "FR".into()],
regions: vec![],
postal_codes: vec![],
priority: None,
})
.expect("create EU");
let all = repo.list(ShippingZoneFilter::default()).expect("list");
assert_eq!(all.len(), 2);
repo.delete(all[0].id).expect("delete");
let remaining = repo.list(ShippingZoneFilter::default()).expect("list after delete");
assert_eq!(remaining.len(), 1);
}
}