1use 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
24macro_rules! columns {
26 () => {
27 "id, campaign, body, version, created_at, updated_at"
28 };
29}
30
31impl MonsterStore {
32 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 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 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 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 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 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 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 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
188pub(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}