use super::map_db_error;
use chrono::{DateTime, Utc};
use sqlx::FromRow;
use sqlx::postgres::PgPool;
use stateset_core::{
BoostRule, CommerceError, CreateSearchConfig, FacetConfig, Result, SearchConfig,
SearchConfigFilter, SearchConfigId, SearchConfigRepository, SearchField, SynonymGroup,
UpdateSearchConfig,
};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct PgSearchConfigRepository {
pool: PgPool,
}
#[derive(FromRow)]
struct SearchConfigRow {
id: Uuid,
name: String,
description: Option<String>,
searchable_fields: serde_json::Value,
facets: serde_json::Value,
synonyms: serde_json::Value,
boost_rules: serde_json::Value,
is_active: bool,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl PgSearchConfigRepository {
pub const fn new(pool: PgPool) -> Self {
Self { pool }
}
fn row_to_config(row: SearchConfigRow) -> Result<SearchConfig> {
let SearchConfigRow {
id,
name,
description,
searchable_fields,
facets,
synonyms,
boost_rules,
is_active,
created_at,
updated_at,
} = row;
let searchable_fields: Vec<SearchField> = serde_json::from_value(searchable_fields)
.map_err(|e| {
CommerceError::DatabaseError(format!(
"Invalid search_config.searchable_fields: {}",
e
))
})?;
let facets: Vec<FacetConfig> = serde_json::from_value(facets).map_err(|e| {
CommerceError::DatabaseError(format!("Invalid search_config.facets: {}", e))
})?;
let synonyms: Vec<SynonymGroup> = serde_json::from_value(synonyms).map_err(|e| {
CommerceError::DatabaseError(format!("Invalid search_config.synonyms: {}", e))
})?;
let boost_rules: Vec<BoostRule> = serde_json::from_value(boost_rules).map_err(|e| {
CommerceError::DatabaseError(format!("Invalid search_config.boost_rules: {}", e))
})?;
Ok(SearchConfig {
id: SearchConfigId::from(id),
name,
description,
searchable_fields,
facets,
synonyms,
boost_rules,
is_active,
created_at,
updated_at,
})
}
pub async fn create_async(&self, input: CreateSearchConfig) -> Result<SearchConfig> {
let id = Uuid::new_v4();
let now = Utc::now();
let fields_json = serde_json::to_value(&input.searchable_fields)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let facets_json = serde_json::to_value(&input.facets)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let synonyms_json = serde_json::to_value(&input.synonyms)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let boost_rules_json = serde_json::to_value(&input.boost_rules)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
sqlx::query(
"INSERT INTO search_configs (id, name, description, searchable_fields, facets, synonyms, boost_rules, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, false, $8, $9)",
)
.bind(id)
.bind(&input.name)
.bind(&input.description)
.bind(&fields_json)
.bind(&facets_json)
.bind(&synonyms_json)
.bind(&boost_rules_json)
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_err(map_db_error)?;
self.get_async(SearchConfigId::from(id)).await?.ok_or(CommerceError::NotFound)
}
pub async fn get_async(&self, id: SearchConfigId) -> Result<Option<SearchConfig>> {
let row = sqlx::query_as::<_, SearchConfigRow>(
"SELECT id, name, description, searchable_fields, facets, synonyms,
boost_rules, is_active, created_at, updated_at
FROM search_configs WHERE id = $1",
)
.bind(id.into_uuid())
.fetch_optional(&self.pool)
.await
.map_err(map_db_error)?;
row.map(Self::row_to_config).transpose()
}
pub async fn update_async(
&self,
id: SearchConfigId,
input: UpdateSearchConfig,
) -> Result<SearchConfig> {
let now = Utc::now();
let mut sets = vec!["updated_at = $1".to_string()];
let mut param_idx: u32 = 2;
let has_name = input.name.is_some();
let has_description = input.description.is_some();
let has_fields = input.searchable_fields.is_some();
let has_facets = input.facets.is_some();
let has_synonyms = input.synonyms.is_some();
let has_boost_rules = input.boost_rules.is_some();
let has_is_active = input.is_active.is_some();
if has_name {
sets.push(format!("name = ${param_idx}"));
param_idx += 1;
}
if has_description {
sets.push(format!("description = ${param_idx}"));
param_idx += 1;
}
if has_fields {
sets.push(format!("searchable_fields = ${param_idx}"));
param_idx += 1;
}
if has_facets {
sets.push(format!("facets = ${param_idx}"));
param_idx += 1;
}
if has_synonyms {
sets.push(format!("synonyms = ${param_idx}"));
param_idx += 1;
}
if has_boost_rules {
sets.push(format!("boost_rules = ${param_idx}"));
param_idx += 1;
}
if has_is_active {
sets.push(format!("is_active = ${param_idx}"));
param_idx += 1;
}
let sql = format!("UPDATE search_configs SET {} WHERE id = ${param_idx}", sets.join(", "));
let mut query = sqlx::query(&sql).bind(now);
if let Some(ref name) = input.name {
query = query.bind(name.clone());
}
if let Some(ref description) = input.description {
query = query.bind(description.clone());
}
if let Some(ref fields) = input.searchable_fields {
let json = serde_json::to_value(fields)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
query = query.bind(json);
}
if let Some(ref facets) = input.facets {
let json = serde_json::to_value(facets)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
query = query.bind(json);
}
if let Some(ref synonyms) = input.synonyms {
let json = serde_json::to_value(synonyms)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
query = query.bind(json);
}
if let Some(ref boost_rules) = input.boost_rules {
let json = serde_json::to_value(boost_rules)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
query = query.bind(json);
}
if let Some(is_active) = input.is_active {
query = query.bind(is_active);
}
query = query.bind(id.into_uuid());
query.execute(&self.pool).await.map_err(map_db_error)?;
self.get_async(id).await?.ok_or(CommerceError::NotFound)
}
pub async fn list_async(&self, filter: SearchConfigFilter) -> Result<Vec<SearchConfig>> {
let mut sql = String::from(
"SELECT id, name, description, searchable_fields, facets, synonyms,
boost_rules, is_active, created_at, updated_at
FROM search_configs WHERE 1=1",
);
let mut param_idx: u32 = 1;
if filter.is_active.is_some() {
sql.push_str(&format!(" AND is_active = ${param_idx}"));
param_idx += 1;
}
if filter.name.is_some() {
sql.push_str(&format!(" AND name ILIKE ${param_idx}"));
param_idx += 1;
}
sql.push_str(" ORDER BY created_at DESC");
sql.push_str(&format!(" LIMIT ${param_idx}"));
param_idx += 1;
if filter.offset.is_some() {
sql.push_str(&format!(" OFFSET ${param_idx}"));
let _ = param_idx;
}
let mut query = sqlx::query_as::<_, SearchConfigRow>(&sql);
if let Some(is_active) = filter.is_active {
query = query.bind(is_active);
}
if let Some(ref name) = filter.name {
query = query.bind(format!("%{name}%"));
}
query = query.bind(super::effective_limit(filter.limit));
if let Some(offset) = filter.offset {
query = query.bind(offset as i64);
}
let rows = query.fetch_all(&self.pool).await.map_err(map_db_error)?;
rows.into_iter().map(Self::row_to_config).collect()
}
pub async fn delete_async(&self, id: SearchConfigId) -> Result<()> {
sqlx::query("DELETE FROM search_configs WHERE id = $1")
.bind(id.into_uuid())
.execute(&self.pool)
.await
.map_err(map_db_error)?;
Ok(())
}
pub async fn get_active_async(&self) -> Result<Option<SearchConfig>> {
let row = sqlx::query_as::<_, SearchConfigRow>(
"SELECT id, name, description, searchable_fields, facets, synonyms,
boost_rules, is_active, created_at, updated_at
FROM search_configs WHERE is_active = true LIMIT 1",
)
.fetch_optional(&self.pool)
.await
.map_err(map_db_error)?;
row.map(Self::row_to_config).transpose()
}
pub async fn set_active_async(&self, id: SearchConfigId) -> Result<SearchConfig> {
let now = Utc::now();
let mut tx = self.pool.begin().await.map_err(map_db_error)?;
sqlx::query("UPDATE search_configs SET is_active = false, updated_at = $1")
.bind(now)
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
sqlx::query("UPDATE search_configs SET is_active = true, updated_at = $1 WHERE id = $2")
.bind(now)
.bind(id.into_uuid())
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
tx.commit().await.map_err(map_db_error)?;
self.get_async(id).await?.ok_or(CommerceError::NotFound)
}
}
impl SearchConfigRepository for PgSearchConfigRepository {
fn create(&self, input: CreateSearchConfig) -> Result<SearchConfig> {
super::block_on(self.create_async(input))
}
fn get(&self, id: SearchConfigId) -> Result<Option<SearchConfig>> {
super::block_on(self.get_async(id))
}
fn update(&self, id: SearchConfigId, input: UpdateSearchConfig) -> Result<SearchConfig> {
super::block_on(self.update_async(id, input))
}
fn list(&self, filter: SearchConfigFilter) -> Result<Vec<SearchConfig>> {
super::block_on(self.list_async(filter))
}
fn delete(&self, id: SearchConfigId) -> Result<()> {
super::block_on(self.delete_async(id))
}
fn get_active(&self) -> Result<Option<SearchConfig>> {
super::block_on(self.get_active_async())
}
fn set_active(&self, id: SearchConfigId) -> Result<SearchConfig> {
super::block_on(self.set_active_async(id))
}
}