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
205#[cfg(test)]
206mod test {
207 use super::*;
208 use crate::test_helper::*;
209
210 async fn insert_file(tx: &mut PgConnection, name: &str) -> Uuid {
211 crate::file_uploads::insert(
212 tx,
213 name,
214 &format!("tmc/{name}"),
215 "application/octet-stream",
216 None,
217 None,
218 )
219 .await
220 .unwrap()
221 }
222
223 #[tokio::test]
224 async fn a_save_that_drops_a_file_releases_only_that_one() {
225 insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
226 let kept = insert_file(tx.as_mut(), "kept").await;
227 let dropped = insert_file(tx.as_mut(), "dropped").await;
228 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[kept, dropped])
229 .await
230 .unwrap();
231
232 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[kept])
233 .await
234 .unwrap();
235
236 let declared = get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
237 .await
238 .unwrap();
239 assert_eq!(declared, vec![kept]);
240 }
241
242 #[tokio::test]
245 async fn replacing_one_kind_leaves_the_others_alone() {
246 insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
247 let in_private = insert_file(tx.as_mut(), "teacher-example").await;
248 let in_public = insert_file(tx.as_mut(), "stub-archive").await;
249 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[in_private])
250 .await
251 .unwrap();
252 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Public, &[in_public])
253 .await
254 .unwrap();
255
256 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Public, &[])
257 .await
258 .unwrap();
259
260 assert_eq!(
261 get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
262 .await
263 .unwrap(),
264 vec![in_private]
265 );
266 assert!(
267 get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Public)
268 .await
269 .unwrap()
270 .is_empty()
271 );
272 }
273
274 #[tokio::test]
277 async fn re_declaring_the_same_file_keeps_its_row() {
278 insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
279 let file_id = insert_file(tx.as_mut(), "unchanged").await;
280 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
281 .await
282 .unwrap();
283 let first = row_count(tx.as_mut(), task_id).await;
284
285 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
286 .await
287 .unwrap();
288
289 assert_eq!(row_count(tx.as_mut(), task_id).await, first);
290 assert_eq!(
291 get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
292 .await
293 .unwrap(),
294 vec![file_id]
295 );
296 }
297
298 #[tokio::test]
299 async fn refuses_a_file_the_host_has_no_upload_for() {
300 insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
301 let known = insert_file(tx.as_mut(), "known").await;
302
303 replace_for_exercise_task(
304 tx.as_mut(),
305 task_id,
306 SpecKind::Private,
307 &[known, Uuid::new_v4()],
308 )
309 .await
310 .expect_err("an unknown id is the exercise service's bug, reported as such");
311
312 assert!(
313 get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
314 .await
315 .unwrap()
316 .is_empty()
317 );
318 }
319
320 #[tokio::test]
323 async fn refuses_a_file_the_reaper_has_already_reclaimed() {
324 insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
325 let file_id = insert_file(tx.as_mut(), "reclaimed").await;
326 crate::exercise_spec_uploads::insert_many(tx.as_mut(), "tmc", None, &[file_id])
327 .await
328 .unwrap();
329 let recorded = crate::exercise_spec_uploads::get_by_file_upload_id(tx.as_mut(), file_id)
330 .await
331 .unwrap()
332 .expect("recorded");
333 assert!(
334 crate::exercise_spec_uploads::mark_reaped(tx.as_mut(), recorded.id)
335 .await
336 .unwrap()
337 );
338
339 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
340 .await
341 .expect_err("the file's object is already on its way out");
342
343 assert!(
344 get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
345 .await
346 .unwrap()
347 .is_empty()
348 );
349 }
350
351 #[tokio::test]
352 async fn groups_declarations_by_task() {
353 insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
354 let file_id = insert_file(tx.as_mut(), "declared").await;
355 replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
356 .await
357 .unwrap();
358
359 let by_task = get_by_exercise_task_ids(tx.as_mut(), &[task_id], SpecKind::Private)
360 .await
361 .unwrap();
362
363 assert_eq!(by_task.get(&task_id), Some(&vec![file_id]));
364 assert!(
365 get_by_exercise_task_ids(tx.as_mut(), &[task_id], SpecKind::Public)
366 .await
367 .unwrap()
368 .is_empty()
369 );
370 }
371
372 async fn row_count(conn: &mut PgConnection, exercise_task_id: Uuid) -> i64 {
376 sqlx::query_scalar(
377 "SELECT COUNT(*) FROM exercise_task_spec_files WHERE exercise_task_id = $1",
378 )
379 .bind(exercise_task_id)
380 .fetch_one(conn)
381 .await
382 .unwrap()
383 }
384}