Skip to main content

monster_service/
store.rs

1//! Persistence for monsters, in Postgres.
2
3use chrono::{DateTime, SubsecRound, Utc};
4use edda_auth::protocol::Claims;
5use edda_types::{
6    Edition,
7    monster::{Monster, MonsterSheet},
8};
9use sqlx::{PgExecutor, PgPool, types::Json};
10use uuid::Uuid;
11
12use crate::error::ApiError;
13
14type Row = (
15    Uuid,
16    Option<Uuid>,
17    Json<MonsterSheet>,
18    i64,
19    DateTime<Utc>,
20    DateTime<Utc>,
21);
22
23/// The columns a [`Row`] is read from; a macro so queries stay `'static`.
24macro_rules! columns {
25    () => {
26        "id, campaign, sheet, version, created_at, updated_at"
27    };
28}
29
30/// Whose monsters a request may see and change: the shared library's (of
31/// its edition) and its campaign's own.
32#[derive(Debug, Clone, Copy)]
33pub struct Scope {
34    pub campaign: Uuid,
35    pub edition: Edition,
36}
37
38impl From<&Claims> for Scope {
39    fn from(claims: &Claims) -> Self {
40        Self {
41            campaign: claims.campaign,
42            edition: claims.edition,
43        }
44    }
45}
46
47/// What an import did.
48#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
49pub struct Imported {
50    pub added: usize,
51    pub updated: usize,
52}
53
54/// Monsters, spells and conditions, shared and each campaign's own, in
55/// Postgres.
56#[derive(Clone)]
57pub struct MonsterStore {
58    pool: PgPool,
59}
60
61impl MonsterStore {
62    /// A store in `pool`'s database.
63    pub fn new(pool: PgPool) -> Self {
64        Self { pool }
65    }
66
67    /// The database.
68    pub fn pool(&self) -> &PgPool {
69        &self.pool
70    }
71
72    /// The shared monsters suiting the campaign's edition and the
73    /// campaign's own, by name (the campaign's first, where both have one).
74    pub async fn list(&self, scope: Scope) -> Result<Vec<Monster>, ApiError> {
75        let rows: Vec<Row> = sqlx::query_as(concat!(
76            "SELECT ",
77            columns!(),
78            " FROM monsters \
79             WHERE campaign = $1 OR (campaign IS NULL AND (edition IS NULL OR edition = $2)) \
80             ORDER BY lower(name), campaign IS NULL"
81        ))
82        .bind(scope.campaign)
83        .bind(scope.edition.to_string())
84        .fetch_all(&self.pool)
85        .await?;
86        rows.into_iter().map(from_row).collect()
87    }
88
89    /// A shared monster (of any edition) or one of the campaign's own.
90    pub async fn get(&self, scope: Scope, id: Uuid) -> Result<Monster, ApiError> {
91        let row: Option<Row> = sqlx::query_as(concat!(
92            "SELECT ",
93            columns!(),
94            " FROM monsters WHERE id = $1 AND (campaign IS NULL OR campaign = $2)"
95        ))
96        .bind(id)
97        .bind(scope.campaign)
98        .fetch_optional(&self.pool)
99        .await?;
100        row.map(from_row).transpose()?.ok_or(ApiError::NotFound)
101    }
102
103    /// How many monsters of its own `campaign` keeps.
104    pub async fn count_own(&self, campaign: Uuid) -> Result<i64, ApiError> {
105        let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM monsters WHERE campaign = $1")
106            .bind(campaign)
107            .fetch_one(&self.pool)
108            .await?;
109        Ok(count)
110    }
111
112    /// Adds a monster to the campaign's own.
113    pub async fn create(&self, scope: Scope, sheet: MonsterSheet) -> Result<Monster, ApiError> {
114        sheet.validate()?;
115        insert(&self.pool, Some(scope.campaign), &sheet).await
116    }
117
118    /// Replaces one of the campaign's own monsters.
119    pub async fn replace(
120        &self,
121        scope: Scope,
122        id: Uuid,
123        sheet: MonsterSheet,
124    ) -> Result<Monster, ApiError> {
125        sheet.validate()?;
126        self.owned(scope, id).await?;
127        update(&self.pool, id, &sheet).await
128    }
129
130    /// Deletes one of the campaign's own monsters.
131    pub async fn delete(&self, scope: Scope, id: Uuid) -> Result<(), ApiError> {
132        self.owned(scope, id).await?;
133        sqlx::query("DELETE FROM monsters WHERE id = $1")
134            .bind(id)
135            .execute(&self.pool)
136            .await?;
137        Ok(())
138    }
139
140    /// Fails unless `id` is the campaign's own: not found if it can't see
141    /// it, forbidden if it's shared.
142    async fn owned(&self, scope: Scope, id: Uuid) -> Result<(), ApiError> {
143        match self.get(scope, id).await?.campaign {
144            Some(_) => Ok(()),
145            None => Err(ApiError::Shared),
146        }
147    }
148
149    /// Adds stat blocks to the shared library (`campaign` `None`) or a
150    /// campaign. One with the same name, source and edition as an existing
151    /// one there replaces it, so importing the same file again updates it.
152    /// All or nothing: every stat block is checked first.
153    pub async fn import(
154        &self,
155        campaign: Option<Uuid>,
156        sheets: &[MonsterSheet],
157    ) -> Result<Imported, ApiError> {
158        for sheet in sheets {
159            sheet.validate()?;
160        }
161        let mut tx = self.pool.begin().await?;
162        let mut imported = Imported::default();
163        for sheet in sheets {
164            let existing: Option<(Uuid,)> = sqlx::query_as(
165                "SELECT id FROM monsters WHERE campaign IS NOT DISTINCT FROM $1 AND name = $2 \
166                 AND source = $3 AND edition IS NOT DISTINCT FROM $4",
167            )
168            .bind(campaign)
169            .bind(&sheet.name)
170            .bind(&sheet.source)
171            .bind(sheet.edition.map(|e| e.to_string()))
172            .fetch_optional(&mut *tx)
173            .await?;
174            if let Some((id,)) = existing {
175                update(&mut *tx, id, sheet).await?;
176                imported.updated += 1;
177            } else {
178                insert(&mut *tx, campaign, sheet).await?;
179                imported.added += 1;
180            }
181        }
182        tx.commit().await?;
183        Ok(imported)
184    }
185
186    /// Deletes a campaign's own monsters, spells and conditions; returns
187    /// how many monsters there were.
188    /// The campaign's own monsters, as they are.
189    pub async fn export_own(&self, campaign: Uuid) -> Result<Vec<Monster>, ApiError> {
190        let rows: Vec<Row> = sqlx::query_as(concat!(
191            "SELECT ",
192            columns!(),
193            " FROM monsters WHERE campaign = $1 ORDER BY created_at, id"
194        ))
195        .bind(campaign)
196        .fetch_all(&self.pool)
197        .await?;
198        rows.into_iter().map(from_row).collect()
199    }
200
201    pub async fn delete_campaign(&self, campaign: Uuid) -> Result<u64, ApiError> {
202        let mut tx = self.pool.begin().await?;
203        sqlx::query("DELETE FROM rules_text WHERE campaign = $1")
204            .bind(campaign)
205            .execute(&mut *tx)
206            .await?;
207        let result = sqlx::query("DELETE FROM monsters WHERE campaign = $1")
208            .bind(campaign)
209            .execute(&mut *tx)
210            .await?;
211        tx.commit().await?;
212        Ok(result.rows_affected())
213    }
214}
215
216/// The time, as precisely as Postgres keeps it (microseconds), so what's
217/// returned matches what's read back later.
218pub(crate) fn now() -> DateTime<Utc> {
219    Utc::now().trunc_subsecs(6)
220}
221
222async fn insert(
223    executor: impl PgExecutor<'_>,
224    campaign: Option<Uuid>,
225    sheet: &MonsterSheet,
226) -> Result<Monster, ApiError> {
227    let now = now();
228    let monster = Monster {
229        id: Uuid::new_v4(),
230        campaign,
231        sheet: sheet.clone(),
232        version: 1,
233        created_at: now,
234        updated_at: now,
235    };
236    insert_as_is(executor, &monster).await?;
237    Ok(monster)
238}
239
240/// Stores `monster` as it is, id and version included.
241pub(crate) async fn insert_as_is(
242    executor: impl PgExecutor<'_>,
243    monster: &Monster,
244) -> Result<(), ApiError> {
245    let sheet = &monster.sheet;
246    sqlx::query(
247        "INSERT INTO monsters (id, campaign, name, source, edition, sheet, version, created_at, \
248         updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
249    )
250    .bind(monster.id)
251    .bind(monster.campaign)
252    .bind(&sheet.name)
253    .bind(&sheet.source)
254    .bind(sheet.edition.map(|e| e.to_string()))
255    .bind(Json(sheet))
256    .bind(i64::try_from(monster.version).map_err(|e| ApiError::Corrupt(e.to_string()))?)
257    .bind(monster.created_at)
258    .bind(monster.updated_at)
259    .execute(executor)
260    .await?;
261    Ok(())
262}
263
264async fn update(
265    executor: impl PgExecutor<'_>,
266    id: Uuid,
267    sheet: &MonsterSheet,
268) -> Result<Monster, ApiError> {
269    let row: Option<Row> = sqlx::query_as(concat!(
270        "UPDATE monsters SET name = $1, source = $2, edition = $3, sheet = $4, \
271         version = version + 1, updated_at = $5 WHERE id = $6 RETURNING ",
272        columns!()
273    ))
274    .bind(&sheet.name)
275    .bind(&sheet.source)
276    .bind(sheet.edition.map(|e| e.to_string()))
277    .bind(Json(sheet))
278    .bind(now())
279    .bind(id)
280    .fetch_optional(executor)
281    .await?;
282    row.map(from_row).transpose()?.ok_or(ApiError::NotFound)
283}
284
285fn from_row(
286    (id, campaign, Json(sheet), version, created_at, updated_at): Row,
287) -> Result<Monster, ApiError> {
288    Ok(Monster {
289        id,
290        campaign,
291        sheet,
292        version: u64::try_from(version).map_err(|e| ApiError::Corrupt(e.to_string()))?,
293        created_at,
294        updated_at,
295    })
296}