feat(cafes): add SQL repository, HTTP client, and backup support
Implement SqlCafeRepository with CRUD operations and timeline event creation. Add CafesClient for CLI HTTP access. Wire cafes into backup export/restore and empty-database verification.
This commit is contained in:
parent
c6ad6a02c7
commit
677b77d70d
5 changed files with 443 additions and 1 deletions
|
|
@ -7,8 +7,9 @@ use serde_json::{from_str, to_string};
|
||||||
|
|
||||||
use crate::domain::bags::Bag;
|
use crate::domain::bags::Bag;
|
||||||
use crate::domain::brews::Brew;
|
use crate::domain::brews::Brew;
|
||||||
|
use crate::domain::cafes::Cafe;
|
||||||
use crate::domain::gear::{Gear, GearCategory};
|
use crate::domain::gear::{Gear, GearCategory};
|
||||||
use crate::domain::ids::{BagId, BrewId, GearId, RoastId, RoasterId, TimelineEventId};
|
use crate::domain::ids::{BagId, BrewId, CafeId, GearId, RoastId, RoasterId, TimelineEventId};
|
||||||
use crate::domain::roasters::Roaster;
|
use crate::domain::roasters::Roaster;
|
||||||
use crate::domain::roasts::Roast;
|
use crate::domain::roasts::Roast;
|
||||||
use crate::domain::timeline::{TimelineBrewData, TimelineEvent, TimelineEventDetail};
|
use crate::domain::timeline::{TimelineBrewData, TimelineEvent, TimelineEventDetail};
|
||||||
|
|
@ -23,6 +24,8 @@ pub struct BackupData {
|
||||||
pub roasts: Vec<Roast>,
|
pub roasts: Vec<Roast>,
|
||||||
pub bags: Vec<Bag>,
|
pub bags: Vec<Bag>,
|
||||||
pub brews: Vec<Brew>,
|
pub brews: Vec<Brew>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cafes: Vec<Cafe>,
|
||||||
pub timeline_events: Vec<TimelineEvent>,
|
pub timeline_events: Vec<TimelineEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +44,7 @@ impl BackupService {
|
||||||
let roasts = self.export_roasts().await?;
|
let roasts = self.export_roasts().await?;
|
||||||
let bags = self.export_bags().await?;
|
let bags = self.export_bags().await?;
|
||||||
let brews = self.export_brews().await?;
|
let brews = self.export_brews().await?;
|
||||||
|
let cafes = self.export_cafes().await?;
|
||||||
let timeline_events = self.export_timeline_events().await?;
|
let timeline_events = self.export_timeline_events().await?;
|
||||||
|
|
||||||
Ok(BackupData {
|
Ok(BackupData {
|
||||||
|
|
@ -51,6 +55,7 @@ impl BackupService {
|
||||||
roasts,
|
roasts,
|
||||||
bags,
|
bags,
|
||||||
brews,
|
brews,
|
||||||
|
cafes,
|
||||||
timeline_events,
|
timeline_events,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +74,7 @@ impl BackupService {
|
||||||
self.restore_roasts(&mut tx, &data.roasts).await?;
|
self.restore_roasts(&mut tx, &data.roasts).await?;
|
||||||
self.restore_bags(&mut tx, &data.bags).await?;
|
self.restore_bags(&mut tx, &data.bags).await?;
|
||||||
self.restore_brews(&mut tx, &data.brews).await?;
|
self.restore_brews(&mut tx, &data.brews).await?;
|
||||||
|
self.restore_cafes(&mut tx, &data.cafes).await?;
|
||||||
self.restore_timeline_events(&mut tx, &data.timeline_events)
|
self.restore_timeline_events(&mut tx, &data.timeline_events)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -143,6 +149,17 @@ impl BackupService {
|
||||||
Ok(records.into_iter().map(BrewRecord::into_domain).collect())
|
Ok(records.into_iter().map(BrewRecord::into_domain).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn export_cafes(&self) -> anyhow::Result<Vec<Cafe>> {
|
||||||
|
let records = sqlx::query_as::<_, CafeRecord>(
|
||||||
|
"SELECT id, name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at FROM cafes ORDER BY id",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.context("failed to export cafes")?;
|
||||||
|
|
||||||
|
Ok(records.into_iter().map(CafeRecord::into_domain).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn export_timeline_events(&self) -> anyhow::Result<Vec<TimelineEvent>> {
|
async fn export_timeline_events(&self) -> anyhow::Result<Vec<TimelineEvent>> {
|
||||||
let records = sqlx::query_as::<_, TimelineEventRecord>(
|
let records = sqlx::query_as::<_, TimelineEventRecord>(
|
||||||
"SELECT id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json FROM timeline_events ORDER BY id",
|
"SELECT id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json FROM timeline_events ORDER BY id",
|
||||||
|
|
@ -166,6 +183,7 @@ impl BackupService {
|
||||||
"bags",
|
"bags",
|
||||||
"gear",
|
"gear",
|
||||||
"brews",
|
"brews",
|
||||||
|
"cafes",
|
||||||
"timeline_events",
|
"timeline_events",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -325,6 +343,34 @@ impl BackupService {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn restore_cafes(
|
||||||
|
&self,
|
||||||
|
tx: &mut DatabaseTransaction<'_>,
|
||||||
|
cafes: &[Cafe],
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
for cafe in cafes {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO cafes (id, name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(i64::from(cafe.id))
|
||||||
|
.bind(&cafe.name)
|
||||||
|
.bind(&cafe.slug)
|
||||||
|
.bind(&cafe.city)
|
||||||
|
.bind(&cafe.country)
|
||||||
|
.bind(cafe.latitude)
|
||||||
|
.bind(cafe.longitude)
|
||||||
|
.bind(cafe.website.as_deref())
|
||||||
|
.bind(cafe.notes.as_deref())
|
||||||
|
.bind(cafe.created_at)
|
||||||
|
.bind(cafe.updated_at)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.context("failed to restore cafe")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn restore_timeline_events(
|
async fn restore_timeline_events(
|
||||||
&self,
|
&self,
|
||||||
tx: &mut DatabaseTransaction<'_>,
|
tx: &mut DatabaseTransaction<'_>,
|
||||||
|
|
@ -521,6 +567,39 @@ impl BrewRecord {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct CafeRecord {
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
slug: String,
|
||||||
|
city: String,
|
||||||
|
country: String,
|
||||||
|
latitude: f64,
|
||||||
|
longitude: f64,
|
||||||
|
website: Option<String>,
|
||||||
|
notes: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CafeRecord {
|
||||||
|
fn into_domain(self) -> Cafe {
|
||||||
|
Cafe {
|
||||||
|
id: CafeId::from(self.id),
|
||||||
|
name: self.name,
|
||||||
|
slug: self.slug,
|
||||||
|
city: self.city,
|
||||||
|
country: self.country,
|
||||||
|
latitude: self.latitude,
|
||||||
|
longitude: self.longitude,
|
||||||
|
website: self.website,
|
||||||
|
notes: self.notes,
|
||||||
|
created_at: self.created_at,
|
||||||
|
updated_at: self.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct TimelineEventRecord {
|
struct TimelineEventRecord {
|
||||||
id: i64,
|
id: i64,
|
||||||
|
|
|
||||||
82
src/infrastructure/client/cafes.rs
Normal file
82
src/infrastructure/client/cafes.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
|
use crate::domain::cafes::{Cafe, NewCafe, UpdateCafe};
|
||||||
|
use crate::domain::ids::CafeId;
|
||||||
|
|
||||||
|
use super::BrewlogClient;
|
||||||
|
|
||||||
|
pub struct CafesClient<'a> {
|
||||||
|
inner: &'a BrewlogClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> CafesClient<'a> {
|
||||||
|
pub(crate) fn new(inner: &'a BrewlogClient) -> Self {
|
||||||
|
Self { inner }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(&self, payload: &NewCafe) -> Result<Cafe> {
|
||||||
|
let url = self.inner.endpoint("api/v1/cafes")?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.request(reqwest::Method::POST, url)
|
||||||
|
.json(payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue create cafe request")?;
|
||||||
|
|
||||||
|
self.inner.handle_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(&self) -> Result<Vec<Cafe>> {
|
||||||
|
let url = self.inner.endpoint("api/v1/cafes")?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.request(reqwest::Method::GET, url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue list cafes request")?;
|
||||||
|
|
||||||
|
self.inner.handle_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, id: CafeId) -> Result<Cafe> {
|
||||||
|
let url = self.inner.endpoint(&format!("api/v1/cafes/{id}"))?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.request(reqwest::Method::GET, url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue get cafe request")?;
|
||||||
|
|
||||||
|
self.inner.handle_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(&self, id: CafeId, payload: &UpdateCafe) -> Result<Cafe> {
|
||||||
|
let url = self.inner.endpoint(&format!("api/v1/cafes/{id}"))?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.request(reqwest::Method::PUT, url)
|
||||||
|
.json(payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue update cafe request")?;
|
||||||
|
|
||||||
|
self.inner.handle_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, id: CafeId) -> Result<()> {
|
||||||
|
let url = self.inner.endpoint(&format!("api/v1/cafes/{id}"))?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.request(reqwest::Method::DELETE, url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue delete cafe request")?;
|
||||||
|
|
||||||
|
match response.status() {
|
||||||
|
StatusCode::NO_CONTENT => Ok(()),
|
||||||
|
_ => Err(self.inner.response_error(response).await),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod bags;
|
pub mod bags;
|
||||||
pub mod brews;
|
pub mod brews;
|
||||||
|
pub mod cafes;
|
||||||
pub mod gear;
|
pub mod gear;
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
pub mod roasts;
|
pub mod roasts;
|
||||||
|
|
@ -66,6 +67,10 @@ impl BrewlogClient {
|
||||||
brews::BrewsClient::new(self)
|
brews::BrewsClient::new(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn cafes(&self) -> cafes::CafesClient<'_> {
|
||||||
|
cafes::CafesClient::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn endpoint(&self, path: &str) -> Result<Url> {
|
pub(crate) fn endpoint(&self, path: &str) -> Result<Url> {
|
||||||
self.base_url
|
self.base_url
|
||||||
.join(path)
|
.join(path)
|
||||||
|
|
|
||||||
275
src/infrastructure/repositories/cafes.rs
Normal file
275
src/infrastructure/repositories/cafes.rs
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::{QueryBuilder, query, query_as};
|
||||||
|
|
||||||
|
use super::macros::push_update_field;
|
||||||
|
use crate::domain::RepositoryError;
|
||||||
|
use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
|
||||||
|
use crate::domain::ids::CafeId;
|
||||||
|
use crate::domain::listing::{ListRequest, Page, SortDirection};
|
||||||
|
use crate::domain::repositories::CafeRepository;
|
||||||
|
use crate::domain::timeline::TimelineEventDetail;
|
||||||
|
use crate::infrastructure::database::DatabasePool;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SqlCafeRepository {
|
||||||
|
pool: DatabasePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlCafeRepository {
|
||||||
|
pub fn new(pool: DatabasePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn order_clause(request: &ListRequest<CafeSortKey>) -> String {
|
||||||
|
let dir_sql = match request.sort_direction() {
|
||||||
|
SortDirection::Asc => "ASC",
|
||||||
|
SortDirection::Desc => "DESC",
|
||||||
|
};
|
||||||
|
|
||||||
|
match request.sort_key() {
|
||||||
|
CafeSortKey::CreatedAt => format!("created_at {dir_sql}, name ASC"),
|
||||||
|
CafeSortKey::Name => format!("LOWER(name) {dir_sql}, created_at DESC"),
|
||||||
|
CafeSortKey::City => format!("LOWER(city) {dir_sql}, LOWER(name) ASC"),
|
||||||
|
CafeSortKey::Country => format!("LOWER(country) {dir_sql}, LOWER(name) ASC"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_domain(record: CafeRecord) -> Cafe {
|
||||||
|
let CafeRecord {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
city,
|
||||||
|
country,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
website,
|
||||||
|
notes,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
} = record;
|
||||||
|
|
||||||
|
Cafe {
|
||||||
|
id: CafeId::from(id),
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
city,
|
||||||
|
country,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
website,
|
||||||
|
notes,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn details_for_cafe(cafe: &Cafe) -> Result<String, RepositoryError> {
|
||||||
|
let website_value = cafe
|
||||||
|
.website
|
||||||
|
.as_ref()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "—".to_string());
|
||||||
|
|
||||||
|
let details = vec![
|
||||||
|
TimelineEventDetail {
|
||||||
|
label: "City".to_string(),
|
||||||
|
value: cafe.city.clone(),
|
||||||
|
},
|
||||||
|
TimelineEventDetail {
|
||||||
|
label: "Country".to_string(),
|
||||||
|
value: cafe.country.clone(),
|
||||||
|
},
|
||||||
|
TimelineEventDetail {
|
||||||
|
label: "Website".to_string(),
|
||||||
|
value: website_value,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
serde_json::to_string(&details).map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!("failed to encode timeline event details: {err}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CafeRepository for SqlCafeRepository {
|
||||||
|
async fn insert(&self, new_cafe: NewCafe) -> Result<Cafe, RepositoryError> {
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let new_cafe = new_cafe.normalize();
|
||||||
|
let slug = new_cafe.slug();
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
let record = query_as::<_, CafeRecord>(
|
||||||
|
"INSERT INTO cafes (name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\
|
||||||
|
RETURNING id, name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(&new_cafe.name)
|
||||||
|
.bind(&slug)
|
||||||
|
.bind(&new_cafe.city)
|
||||||
|
.bind(&new_cafe.country)
|
||||||
|
.bind(new_cafe.latitude)
|
||||||
|
.bind(new_cafe.longitude)
|
||||||
|
.bind(new_cafe.website.as_deref())
|
||||||
|
.bind(new_cafe.notes.as_deref())
|
||||||
|
.bind(now)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
if let sqlx::Error::Database(db_err) = &err
|
||||||
|
&& db_err.is_unique_violation()
|
||||||
|
{
|
||||||
|
return RepositoryError::conflict(
|
||||||
|
"A cafe with this name and city already exists",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
RepositoryError::unexpected(err.to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let cafe = Self::into_domain(record);
|
||||||
|
let details_json = Self::details_for_cafe(&cafe)?;
|
||||||
|
|
||||||
|
query(
|
||||||
|
"INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind("cafe")
|
||||||
|
.bind(i64::from(cafe.id))
|
||||||
|
.bind("added")
|
||||||
|
.bind(cafe.created_at)
|
||||||
|
.bind(&cafe.name)
|
||||||
|
.bind(details_json)
|
||||||
|
.bind::<Option<&str>>(None)
|
||||||
|
.bind(&cafe.slug)
|
||||||
|
.bind::<Option<&str>>(None)
|
||||||
|
.bind::<Option<&str>>(None)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
Ok(cafe)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, id: CafeId) -> Result<Cafe, RepositoryError> {
|
||||||
|
let record = query_as::<_, CafeRecord>(
|
||||||
|
"SELECT id, name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at FROM cafes WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(i64::from(id))
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
match record {
|
||||||
|
Some(record) => Ok(Self::into_domain(record)),
|
||||||
|
None => Err(RepositoryError::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_slug(&self, slug: &str) -> Result<Cafe, RepositoryError> {
|
||||||
|
let record = query_as::<_, CafeRecord>(
|
||||||
|
"SELECT id, name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at FROM cafes WHERE slug = ?",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
match record {
|
||||||
|
Some(record) => Ok(Self::into_domain(record)),
|
||||||
|
None => Err(RepositoryError::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
request: &ListRequest<CafeSortKey>,
|
||||||
|
search: Option<&str>,
|
||||||
|
) -> Result<Page<Cafe>, RepositoryError> {
|
||||||
|
use crate::infrastructure::repositories::pagination::SearchFilter;
|
||||||
|
|
||||||
|
let order_clause = Self::order_clause(request);
|
||||||
|
let base_query = "SELECT id, name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at FROM cafes";
|
||||||
|
let count_query = "SELECT COUNT(*) FROM cafes";
|
||||||
|
let sf = search.and_then(|t| SearchFilter::new(t, vec!["name", "city", "country"]));
|
||||||
|
|
||||||
|
crate::infrastructure::repositories::pagination::paginate(
|
||||||
|
&self.pool,
|
||||||
|
request,
|
||||||
|
base_query,
|
||||||
|
count_query,
|
||||||
|
&order_clause,
|
||||||
|
sf.as_ref(),
|
||||||
|
|record| Ok(Self::into_domain(record)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(&self, id: CafeId, changes: UpdateCafe) -> Result<Cafe, RepositoryError> {
|
||||||
|
let mut builder = QueryBuilder::new("UPDATE cafes SET updated_at = CURRENT_TIMESTAMP");
|
||||||
|
let mut sep = true;
|
||||||
|
|
||||||
|
push_update_field!(builder, sep, "name", changes.name);
|
||||||
|
push_update_field!(builder, sep, "city", changes.city);
|
||||||
|
push_update_field!(builder, sep, "country", changes.country);
|
||||||
|
push_update_field!(builder, sep, "latitude", changes.latitude);
|
||||||
|
push_update_field!(builder, sep, "longitude", changes.longitude);
|
||||||
|
push_update_field!(builder, sep, "website", changes.website);
|
||||||
|
push_update_field!(builder, sep, "notes", changes.notes);
|
||||||
|
let _ = sep;
|
||||||
|
|
||||||
|
builder.push(" WHERE id = ");
|
||||||
|
builder.push_bind(i64::from(id));
|
||||||
|
|
||||||
|
let result = builder
|
||||||
|
.build()
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(RepositoryError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.get(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: CafeId) -> Result<(), RepositoryError> {
|
||||||
|
let result = query("DELETE FROM cafes WHERE id = ?")
|
||||||
|
.bind(i64::from(id))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(RepositoryError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct CafeRecord {
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
slug: String,
|
||||||
|
city: String,
|
||||||
|
country: String,
|
||||||
|
latitude: f64,
|
||||||
|
longitude: f64,
|
||||||
|
website: Option<String>,
|
||||||
|
notes: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod bags;
|
pub mod bags;
|
||||||
pub mod brews;
|
pub mod brews;
|
||||||
|
pub mod cafes;
|
||||||
pub mod gear;
|
pub mod gear;
|
||||||
mod macros;
|
mod macros;
|
||||||
pub mod pagination;
|
pub mod pagination;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue