1use crate::prelude::*;
15use std::collections::HashMap;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
19#[sqlx(type_name = "exercise_spec_kind", rename_all = "snake_case")]
20#[serde(rename_all = "snake_case")]
21pub enum SpecKind {
22 Private,
23 Public,
24 ModelSolution,
25}
26
27pub async fn replace_for_exercise_task(
39 conn: &mut PgConnection,
40 exercise_task_id: Uuid,
41 spec_kind: SpecKind,
42 file_upload_ids: &[Uuid],
43) -> ModelResult<()> {
44 let mut tx = conn.begin().await?;
45 let recorded = sqlx::query!(
49 "
50SELECT file_upload_id,
51 deleted_at
52FROM exercise_spec_uploads
53WHERE file_upload_id = ANY($1)
54FOR UPDATE
55",
56 file_upload_ids
57 )
58 .fetch_all(&mut *tx)
59 .await?;
60 let reclaimed: Vec<Uuid> = recorded
61 .iter()
62 .filter(|upload| upload.deleted_at.is_some())
63 .map(|upload| upload.file_upload_id)
64 .collect();
65 if !reclaimed.is_empty() {
66 tx.rollback().await?;
67 return Err(model_err!(
68 PreconditionFailed,
69 format!(
70 "An exercise service declared files the reaper has already reclaimed: {}",
71 comma_separated(&reclaimed)
72 )
73 ));
74 }
75 let unknown: Vec<Uuid> = sqlx::query_scalar!(
76 "
77SELECT declared.file_upload_id
78FROM UNNEST($1::uuid []) AS declared(file_upload_id)
79WHERE NOT EXISTS (
80 SELECT 1
81 FROM file_uploads
82 WHERE id = declared.file_upload_id
83 AND deleted_at IS NULL
84 )
85",
86 file_upload_ids
87 )
88 .fetch_all(&mut *tx)
89 .await?
90 .into_iter()
91 .flatten()
92 .collect();
93 if !unknown.is_empty() {
94 tx.rollback().await?;
95 return Err(model_err!(
96 PreconditionFailed,
97 format!(
98 "An exercise service declared files the host has no upload for: {}",
99 comma_separated(&unknown)
100 )
101 ));
102 }
103 sqlx::query!(
104 "
105UPDATE exercise_task_spec_files
106SET deleted_at = now()
107WHERE exercise_task_id = $1
108 AND spec_kind = $3
109 AND deleted_at IS NULL
110 AND NOT file_upload_id = ANY($2)
111",
112 exercise_task_id,
113 file_upload_ids,
114 spec_kind as SpecKind
115 )
116 .execute(&mut *tx)
117 .await?;
118 sqlx::query!(
119 "
120INSERT INTO exercise_task_spec_files (exercise_task_id, file_upload_id, spec_kind)
121SELECT $1,
122 file_upload_id,
123 $3
124FROM UNNEST($2::uuid []) AS t(file_upload_id)
125WHERE NOT EXISTS (
126 SELECT 1
127 FROM exercise_task_spec_files AS existing
128 WHERE existing.exercise_task_id = $1
129 AND existing.file_upload_id = t.file_upload_id
130 AND existing.spec_kind = $3
131 AND existing.deleted_at IS NULL
132 )
133",
134 exercise_task_id,
135 file_upload_ids,
136 spec_kind as SpecKind
137 )
138 .execute(&mut *tx)
139 .await?;
140 tx.commit().await?;
141 Ok(())
142}
143
144fn comma_separated(ids: &[Uuid]) -> String {
145 ids.iter()
146 .map(|id| id.to_string())
147 .collect::<Vec<_>>()
148 .join(", ")
149}
150
151pub async fn get_by_exercise_task_ids(
154 conn: &mut PgConnection,
155 exercise_task_ids: &[Uuid],
156 spec_kind: SpecKind,
157) -> ModelResult<HashMap<Uuid, Vec<Uuid>>> {
158 let rows = sqlx::query!(
159 "
160SELECT exercise_task_id,
161 file_upload_id
162FROM exercise_task_spec_files
163WHERE exercise_task_id = ANY($1)
164 AND spec_kind = $2
165 AND deleted_at IS NULL
166",
167 exercise_task_ids,
168 spec_kind as SpecKind
169 )
170 .fetch_all(conn)
171 .await?;
172 let mut by_task: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
173 for row in rows {
174 by_task
175 .entry(row.exercise_task_id)
176 .or_default()
177 .push(row.file_upload_id);
178 }
179 Ok(by_task)
180}
181
182pub async fn get_for_exercise_task(
185 conn: &mut PgConnection,
186 exercise_task_id: Uuid,
187 spec_kind: SpecKind,
188) -> ModelResult<Vec<Uuid>> {
189 let res = sqlx::query_scalar!(
190 "
191SELECT file_upload_id
192FROM exercise_task_spec_files
193WHERE exercise_task_id = $1
194 AND spec_kind = $2
195 AND deleted_at IS NULL
196",
197 exercise_task_id,
198 spec_kind as SpecKind
199 )
200 .fetch_all(conn)
201 .await?;
202 Ok(res)
203}
204
205pub async fn count_for_exercise_task_including_deleted(
208 conn: &mut PgConnection,
209 exercise_task_id: Uuid,
210) -> ModelResult<i64> {
211 let count = sqlx::query_scalar!(
212 r#"
213SELECT COUNT(*) AS "count!"
214FROM exercise_task_spec_files
215WHERE exercise_task_id = $1
216 "#,
217 exercise_task_id
218 )
219 .fetch_one(conn)
220 .await?;
221 Ok(count)
222}
223
224#[cfg(test)]
225mod test {
226 use super::*;
227 use crate::test_helper::*;
228
229 async fn insert_file(tx: &mut PgConnection, name: &str) -> Uuid {
230 crate::file_uploads::insert(
231 tx,
232 name,
233 &format!("tmc/{name}"),
234 "application/octet-stream",
235 None,
236 None,
237 )
238 .await
239 .unwrap()
240 }
241
242 #[tokio::test]
243 async fn a_save_that_drops_a_file_releases_only_that_one() {
244 insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
245 let kept = insert_file(tx.as_mut(), "kept").await;
246 let dropped = insert_file(tx.as_mut(), "dropped").await;
247 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[kept, dropped])
248 .await
249 .unwrap();
250
251 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[kept])
252 .await
253 .unwrap();
254
255 let declared = get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
256 .await
257 .unwrap();
258 assert_eq!(declared, vec![kept]);
259 }
260
261 #[tokio::test]
264 async fn replacing_one_kind_leaves_the_others_alone() {
265 insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
266 let in_private = insert_file(tx.as_mut(), "teacher-example").await;
267 let in_public = insert_file(tx.as_mut(), "stub-archive").await;
268 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[in_private])
269 .await
270 .unwrap();
271 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Public, &[in_public])
272 .await
273 .unwrap();
274
275 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Public, &[])
276 .await
277 .unwrap();
278
279 assert_eq!(
280 get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
281 .await
282 .unwrap(),
283 vec![in_private]
284 );
285 assert!(
286 get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Public)
287 .await
288 .unwrap()
289 .is_empty()
290 );
291 }
292
293 #[tokio::test]
296 async fn re_declaring_the_same_file_keeps_its_row() {
297 insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
298 let file_id = insert_file(tx.as_mut(), "unchanged").await;
299 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
300 .await
301 .unwrap();
302 let first = row_count(tx.as_mut(), task_id).await;
303
304 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
305 .await
306 .unwrap();
307
308 assert_eq!(row_count(tx.as_mut(), task_id).await, first);
309 assert_eq!(
310 get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
311 .await
312 .unwrap(),
313 vec![file_id]
314 );
315 }
316
317 #[tokio::test]
318 async fn refuses_a_file_the_host_has_no_upload_for() {
319 insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
320 let known = insert_file(tx.as_mut(), "known").await;
321
322 replace_for_exercise_task(
323 tx.as_mut(),
324 task_id,
325 SpecKind::Private,
326 &[known, Uuid::new_v4()],
327 )
328 .await
329 .expect_err("an unknown id is the exercise service's bug, reported as such");
330
331 assert!(
332 get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
333 .await
334 .unwrap()
335 .is_empty()
336 );
337 }
338
339 #[tokio::test]
342 async fn refuses_a_file_the_reaper_has_already_reclaimed() {
343 insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
344 let file_id = insert_file(tx.as_mut(), "reclaimed").await;
345 crate::exercise_spec_uploads::insert_many(tx.as_mut(), "tmc", None, &[file_id])
346 .await
347 .unwrap();
348 let recorded = crate::exercise_spec_uploads::get_by_file_upload_id(tx.as_mut(), file_id)
349 .await
350 .unwrap()
351 .expect("recorded");
352 assert!(
353 crate::exercise_spec_uploads::mark_reaped(tx.as_mut(), recorded.id)
354 .await
355 .unwrap()
356 );
357
358 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
359 .await
360 .expect_err("the file's object is already on its way out");
361
362 assert!(
363 get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
364 .await
365 .unwrap()
366 .is_empty()
367 );
368 }
369
370 #[tokio::test]
371 async fn groups_declarations_by_task() {
372 insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
373 let file_id = insert_file(tx.as_mut(), "declared").await;
374 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
375 .await
376 .unwrap();
377
378 let by_task = get_by_exercise_task_ids(tx.as_mut(), &[task_id], SpecKind::Private)
379 .await
380 .unwrap();
381
382 assert_eq!(by_task.get(&task_id), Some(&vec![file_id]));
383 assert!(
384 get_by_exercise_task_ids(tx.as_mut(), &[task_id], SpecKind::Public)
385 .await
386 .unwrap()
387 .is_empty()
388 );
389 }
390
391 async fn row_count(conn: &mut PgConnection, exercise_task_id: Uuid) -> i64 {
392 count_for_exercise_task_including_deleted(conn, exercise_task_id)
393 .await
394 .unwrap()
395 }
396}