Skip to main content

monster_service/
texts.rs

1//! Persistence for rules text (spells and conditions), alongside the
2//! monsters and shared and scoped the same way.
3
4use chrono::{DateTime, Utc};
5use edda_types::rules_text::{Entry, RulesText};
6use sqlx::{PgExecutor, types::Json};
7use uuid::Uuid;
8
9use crate::{
10    MonsterStore,
11    error::ApiError,
12    store::{Imported, Scope, now},
13};
14
15type Row = (
16    Uuid,
17    Option<Uuid>,
18    Json<serde_json::Value>,
19    i64,
20    DateTime<Utc>,
21    DateTime<Utc>,
22);
23
24/// The columns a [`Row`] is read from; a macro so queries stay `'static`.
25macro_rules! columns {
26    () => {
27        "id, campaign, body, version, created_at, updated_at"
28    };
29}
30
31impl MonsterStore {
32    /// The shared ones suiting the campaign's edition and the campaign's
33    /// own, by name.
34    pub async fn texts<T: RulesText>(&self, scope: Scope) -> Result<Vec<Entry<T>>, ApiError> {
35        let rows: Vec<Row> = sqlx::query_as(concat!(
36            "SELECT ",
37            columns!(),
38            " FROM rules_text WHERE kind = $1 AND (campaign = $2 \
39             OR (campaign IS NULL AND (edition IS NULL OR edition = $3))) \
40             ORDER BY lower(name), campaign IS NULL"
41        ))
42        .bind(T::KIND)
43        .bind(scope.campaign)
44        .bind(scope.edition.to_string())
45        .fetch_all(self.pool())
46        .await?;
47        rows.into_iter().map(from_row).collect()
48    }
49
50    /// A shared one (of any edition) or one of the campaign's own.
51    pub async fn text<T: RulesText>(&self, scope: Scope, id: Uuid) -> Result<Entry<T>, ApiError> {
52        let row: Option<Row> = sqlx::query_as(concat!(
53            "SELECT ",
54            columns!(),
55            " FROM rules_text WHERE kind = $1 AND id = $2 AND (campaign IS NULL OR campaign = $3)"
56        ))
57        .bind(T::KIND)
58        .bind(id)
59        .bind(scope.campaign)
60        .fetch_optional(self.pool())
61        .await?;
62        row.map(from_row).transpose()?.ok_or(ApiError::NotFound)
63    }
64
65    /// How many texts of this kind of its own `campaign` keeps.
66    pub async fn count_own_texts<T: RulesText>(&self, campaign: Uuid) -> Result<i64, ApiError> {
67        let (count,): (i64,) =
68            sqlx::query_as("SELECT COUNT(*) FROM rules_text WHERE kind = $1 AND campaign = $2")
69                .bind(T::KIND)
70                .bind(campaign)
71                .fetch_one(self.pool())
72                .await?;
73        Ok(count)
74    }
75
76    /// Adds one to the campaign's own.
77    pub async fn create_text<T: RulesText>(
78        &self,
79        scope: Scope,
80        text: T,
81    ) -> Result<Entry<T>, ApiError> {
82        text.validate()?;
83        insert(self.pool(), Some(scope.campaign), &text).await
84    }
85
86    /// Replaces one of the campaign's own.
87    pub async fn replace_text<T: RulesText>(
88        &self,
89        scope: Scope,
90        id: Uuid,
91        text: T,
92    ) -> Result<Entry<T>, ApiError> {
93        text.validate()?;
94        self.owned_text::<T>(scope, id).await?;
95        update(self.pool(), id, &text).await
96    }
97
98    /// Deletes one of the campaign's own.
99    pub async fn delete_text<T: RulesText>(&self, scope: Scope, id: Uuid) -> Result<(), ApiError> {
100        self.owned_text::<T>(scope, id).await?;
101        sqlx::query("DELETE FROM rules_text WHERE id = $1")
102            .bind(id)
103            .execute(self.pool())
104            .await?;
105        Ok(())
106    }
107
108    /// The campaign's own texts of this kind, as they are.
109    pub async fn export_texts<T: RulesText>(
110        &self,
111        campaign: Uuid,
112    ) -> Result<Vec<Entry<T>>, ApiError> {
113        let rows: Vec<Row> = sqlx::query_as(concat!(
114            "SELECT ",
115            columns!(),
116            " FROM rules_text WHERE kind = $1 AND campaign = $2 ORDER BY created_at, id"
117        ))
118        .bind(T::KIND)
119        .bind(campaign)
120        .fetch_all(self.pool())
121        .await?;
122        rows.into_iter().map(from_row).collect()
123    }
124
125    async fn owned_text<T: RulesText>(&self, scope: Scope, id: Uuid) -> Result<(), ApiError> {
126        match self.text::<T>(scope, id).await?.campaign {
127            Some(_) => Ok(()),
128            None => Err(ApiError::Shared),
129        }
130    }
131
132    /// Like [`MonsterStore::import`]: into the shared library or a
133    /// campaign, updating ones with the same name, source and edition; all
134    /// or nothing.
135    pub async fn import_texts<T: RulesText>(
136        &self,
137        campaign: Option<Uuid>,
138        texts: &[T],
139    ) -> Result<Imported, ApiError> {
140        for text in texts {
141            text.validate()?;
142        }
143        let mut tx = self.pool().begin().await?;
144        let mut imported = Imported::default();
145        for text in texts {
146            let existing: Option<(Uuid,)> = sqlx::query_as(
147                "SELECT id FROM rules_text WHERE kind = $1 AND campaign IS NOT DISTINCT FROM $2 \
148                 AND name = $3 AND source = $4 AND edition IS NOT DISTINCT FROM $5",
149            )
150            .bind(T::KIND)
151            .bind(campaign)
152            .bind(text.name())
153            .bind(text.source())
154            .bind(text.edition().map(|e| e.to_string()))
155            .fetch_optional(&mut *tx)
156            .await?;
157            if let Some((id,)) = existing {
158                update(&mut *tx, id, text).await?;
159                imported.updated += 1;
160            } else {
161                insert(&mut *tx, campaign, text).await?;
162                imported.added += 1;
163            }
164        }
165        tx.commit().await?;
166        Ok(imported)
167    }
168}
169
170async fn insert<T: RulesText>(
171    executor: impl PgExecutor<'_>,
172    campaign: Option<Uuid>,
173    text: &T,
174) -> Result<Entry<T>, ApiError> {
175    let now = now();
176    let entry = Entry {
177        id: Uuid::new_v4(),
178        campaign,
179        text: text.clone(),
180        version: 1,
181        created_at: now,
182        updated_at: now,
183    };
184    insert_as_is(executor, &entry).await?;
185    Ok(entry)
186}
187
188/// Stores `entry` as it is, id and version included.
189pub(crate) async fn insert_as_is<T: RulesText>(
190    executor: impl PgExecutor<'_>,
191    entry: &Entry<T>,
192) -> Result<(), ApiError> {
193    let text = &entry.text;
194    sqlx::query(
195        "INSERT INTO rules_text (id, kind, campaign, name, source, edition, body, version, \
196         created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
197    )
198    .bind(entry.id)
199    .bind(T::KIND)
200    .bind(entry.campaign)
201    .bind(text.name())
202    .bind(text.source())
203    .bind(text.edition().map(|e| e.to_string()))
204    .bind(Json(text))
205    .bind(i64::try_from(entry.version).map_err(|e| ApiError::Corrupt(e.to_string()))?)
206    .bind(entry.created_at)
207    .bind(entry.updated_at)
208    .execute(executor)
209    .await?;
210    Ok(())
211}
212
213async fn update<T: RulesText>(
214    executor: impl PgExecutor<'_>,
215    id: Uuid,
216    text: &T,
217) -> Result<Entry<T>, ApiError> {
218    let row: Option<Row> = sqlx::query_as(concat!(
219        "UPDATE rules_text SET name = $1, source = $2, edition = $3, body = $4, \
220         version = version + 1, updated_at = $5 WHERE id = $6 RETURNING ",
221        columns!()
222    ))
223    .bind(text.name())
224    .bind(text.source())
225    .bind(text.edition().map(|e| e.to_string()))
226    .bind(Json(text))
227    .bind(now())
228    .bind(id)
229    .fetch_optional(executor)
230    .await?;
231    row.map(from_row).transpose()?.ok_or(ApiError::NotFound)
232}
233
234fn from_row<T: RulesText>(
235    (id, campaign, Json(body), version, created_at, updated_at): Row,
236) -> Result<Entry<T>, ApiError> {
237    Ok(Entry {
238        id,
239        campaign,
240        text: serde_json::from_value(body).map_err(|e| ApiError::Corrupt(e.to_string()))?,
241        version: u64::try_from(version).map_err(|e| ApiError::Corrupt(e.to_string()))?,
242        created_at,
243        updated_at,
244    })
245}