1pub const EPHEMERAL_TABLES: &[&str] = &[
16 "workers",
17 "file_locks",
18 "claim_sequence",
19 "tasks_fts",
20 "attachments_fts",
21];
22
23pub const PROJECT_TABLES: &[&str] = &[
27 "tasks",
28 "dependencies",
29 "attachments",
30 "task_tags",
31 "task_needed_tags",
32 "task_wanted_tags",
33 "task_sequence",
34];
35
36use crate::types::{
37 Attachment, Dependency, ExportTables, TaskNeededTagRow, TaskSequenceEvent, TaskTagRow,
38 TaskWantedTagRow,
39};
40use anyhow::Result;
41
42use super::Database;
43use super::tasks::parse_task_row;
44
45#[derive(Debug, Clone, Default)]
47pub struct ExportOptions {
48 pub exclude_deleted: bool,
50 pub tables: Option<Vec<String>>,
52}
53
54impl Database {
55 pub fn export_tables(&self, options: &ExportOptions) -> Result<ExportTables> {
66 let tables_to_export = options.tables.as_ref();
67
68 let should_export =
69 |table: &str| -> bool { tables_to_export.is_none_or(|t| t.iter().any(|s| s == table)) };
70
71 let mut export = ExportTables::default();
72
73 if should_export("tasks") {
74 export.tasks = Some(self.export_tasks(options.exclude_deleted)?);
75 }
76
77 if should_export("dependencies") {
78 export.dependencies = Some(self.export_dependencies()?);
79 }
80
81 if should_export("attachments") {
82 export.attachments = Some(self.export_attachments()?);
83 }
84
85 if should_export("task_tags") {
86 export.task_tags = Some(self.export_task_tags()?);
87 }
88
89 if should_export("task_needed_tags") {
90 export.task_needed_tags = Some(self.export_task_needed_tags()?);
91 }
92
93 if should_export("task_wanted_tags") {
94 export.task_wanted_tags = Some(self.export_task_wanted_tags()?);
95 }
96
97 if should_export("task_sequence") {
98 export.task_sequence = Some(self.export_task_sequence()?);
99 }
100
101 Ok(export)
102 }
103
104 fn export_tasks(&self, exclude_deleted: bool) -> Result<Vec<crate::types::Task>> {
106 self.with_conn(|conn| {
107 let sql = if exclude_deleted {
108 "SELECT * FROM tasks WHERE deleted_at IS NULL ORDER BY id"
109 } else {
110 "SELECT * FROM tasks ORDER BY id"
111 };
112
113 let mut stmt = conn.prepare(sql)?;
114 let tasks = stmt
115 .query_map([], parse_task_row)?
116 .filter_map(|r| r.ok())
117 .collect();
118 Ok(tasks)
119 })
120 }
121
122 fn export_dependencies(&self) -> Result<Vec<Dependency>> {
124 self.with_conn(|conn| {
125 let mut stmt = conn.prepare(
126 "SELECT from_task_id, to_task_id, dep_type
127 FROM dependencies
128 ORDER BY from_task_id, to_task_id, dep_type",
129 )?;
130
131 let deps = stmt
132 .query_map([], |row| {
133 Ok(Dependency {
134 from_task_id: row.get(0)?,
135 to_task_id: row.get(1)?,
136 dep_type: row.get(2)?,
137 })
138 })?
139 .filter_map(|r| r.ok())
140 .collect();
141
142 Ok(deps)
143 })
144 }
145
146 fn export_attachments(&self) -> Result<Vec<Attachment>> {
148 self.with_conn(|conn| {
149 let mut stmt = conn.prepare(
150 "SELECT task_id, attachment_type, sequence, name, mime_type, content, file_path, created_at
151 FROM attachments
152 ORDER BY task_id, attachment_type, sequence",
153 )?;
154
155 let attachments = stmt
156 .query_map([], |row| {
157 Ok(Attachment {
158 task_id: row.get(0)?,
159 attachment_type: row.get(1)?,
160 sequence: row.get(2)?,
161 name: row.get(3)?,
162 mime_type: row.get(4)?,
163 content: row.get(5)?,
164 file_path: row.get(6)?,
165 created_at: row.get(7)?,
166 })
167 })?
168 .filter_map(|r| r.ok())
169 .collect();
170
171 Ok(attachments)
172 })
173 }
174
175 fn export_task_tags(&self) -> Result<Vec<TaskTagRow>> {
177 self.with_conn(|conn| {
178 let mut stmt =
179 conn.prepare("SELECT task_id, tag FROM task_tags ORDER BY task_id, tag")?;
180
181 let tags = stmt
182 .query_map([], |row| {
183 Ok(TaskTagRow {
184 task_id: row.get(0)?,
185 tag: row.get(1)?,
186 })
187 })?
188 .filter_map(|r| r.ok())
189 .collect();
190
191 Ok(tags)
192 })
193 }
194
195 fn export_task_needed_tags(&self) -> Result<Vec<TaskNeededTagRow>> {
197 self.with_conn(|conn| {
198 let mut stmt =
199 conn.prepare("SELECT task_id, tag FROM task_needed_tags ORDER BY task_id, tag")?;
200
201 let tags = stmt
202 .query_map([], |row| {
203 Ok(TaskNeededTagRow {
204 task_id: row.get(0)?,
205 tag: row.get(1)?,
206 })
207 })?
208 .filter_map(|r| r.ok())
209 .collect();
210
211 Ok(tags)
212 })
213 }
214
215 fn export_task_wanted_tags(&self) -> Result<Vec<TaskWantedTagRow>> {
217 self.with_conn(|conn| {
218 let mut stmt =
219 conn.prepare("SELECT task_id, tag FROM task_wanted_tags ORDER BY task_id, tag")?;
220
221 let tags = stmt
222 .query_map([], |row| {
223 Ok(TaskWantedTagRow {
224 task_id: row.get(0)?,
225 tag: row.get(1)?,
226 })
227 })?
228 .filter_map(|r| r.ok())
229 .collect();
230
231 Ok(tags)
232 })
233 }
234
235 fn export_task_sequence(&self) -> Result<Vec<TaskSequenceEvent>> {
237 self.with_conn(|conn| {
238 let mut stmt = conn.prepare(
239 "SELECT id, task_id, worker_id, status, phase, reason, timestamp, end_timestamp
240 FROM task_sequence
241 ORDER BY task_id, id",
242 )?;
243
244 let events = stmt
245 .query_map([], |row| {
246 Ok(TaskSequenceEvent {
247 id: row.get(0)?,
248 task_id: row.get(1)?,
249 worker_id: row.get(2)?,
250 status: row.get(3)?,
251 phase: row.get(4)?,
252 reason: row.get(5)?,
253 timestamp: row.get(6)?,
254 end_timestamp: row.get(7)?,
255 })
256 })?
257 .filter_map(|r| r.ok())
258 .collect();
259
260 Ok(events)
261 })
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::config::{DependenciesConfig, IdsConfig, StatesConfig};
269
270 fn default_states_config() -> StatesConfig {
271 StatesConfig::default()
272 }
273
274 fn default_deps_config() -> DependenciesConfig {
275 DependenciesConfig::default()
276 }
277
278 #[test]
279 fn test_export_empty_database() {
280 let db = Database::open_in_memory().unwrap();
281 let options = ExportOptions::default();
282 let export = db.export_tables(&options).unwrap();
283
284 assert!(export.tasks.as_ref().unwrap().is_empty());
285 assert!(export.dependencies.as_ref().unwrap().is_empty());
286 assert!(export.attachments.as_ref().unwrap().is_empty());
287 assert!(export.task_tags.as_ref().unwrap().is_empty());
288 assert!(export.task_needed_tags.as_ref().unwrap().is_empty());
289 assert!(export.task_wanted_tags.as_ref().unwrap().is_empty());
290 assert!(export.task_sequence.as_ref().unwrap().is_empty());
291 }
292
293 #[test]
294 fn test_export_selective_tables() {
295 let db = Database::open_in_memory().unwrap();
296 let options = ExportOptions {
297 exclude_deleted: false,
298 tables: Some(vec!["tasks".to_string(), "dependencies".to_string()]),
299 };
300 let export = db.export_tables(&options).unwrap();
301
302 assert!(export.tasks.is_some());
304 assert!(export.dependencies.is_some());
305
306 assert!(export.attachments.is_none());
308 assert!(export.task_tags.is_none());
309 assert!(export.task_needed_tags.is_none());
310 assert!(export.task_wanted_tags.is_none());
311 assert!(export.task_sequence.is_none());
312 }
313
314 #[test]
315 fn test_export_tasks_ordered_by_id() {
316 let db = Database::open_in_memory().unwrap();
317 let states_config = default_states_config();
318
319 db.create_task(
321 Some("z-task".to_string()),
322 "Z Task".to_string(),
323 None,
324 None,
325 None, None,
327 None,
328 None,
329 None,
330 None,
331 None,
332 &states_config,
333 &IdsConfig::default(),
334 )
335 .unwrap();
336 db.create_task(
337 Some("a-task".to_string()),
338 "A Task".to_string(),
339 None,
340 None,
341 None, None,
343 None,
344 None,
345 None,
346 None,
347 None,
348 &states_config,
349 &IdsConfig::default(),
350 )
351 .unwrap();
352 db.create_task(
353 Some("m-task".to_string()),
354 "M Task".to_string(),
355 None,
356 None,
357 None, None,
359 None,
360 None,
361 None,
362 None,
363 None,
364 &states_config,
365 &IdsConfig::default(),
366 )
367 .unwrap();
368
369 let options = ExportOptions::default();
370 let export = db.export_tables(&options).unwrap();
371 let tasks = export.tasks.unwrap();
372
373 assert_eq!(tasks.len(), 3);
374 assert_eq!(tasks[0].id, "a-task");
375 assert_eq!(tasks[1].id, "m-task");
376 assert_eq!(tasks[2].id, "z-task");
377 }
378
379 #[test]
380 fn test_export_excludes_deleted_tasks_when_requested() {
381 let db = Database::open_in_memory().unwrap();
382 let states_config = default_states_config();
383
384 db.create_task(
386 Some("task-1".to_string()),
387 "Task 1".to_string(),
388 None,
389 None,
390 None, None,
392 None,
393 None,
394 None,
395 None,
396 None,
397 &states_config,
398 &IdsConfig::default(),
399 )
400 .unwrap();
401
402 db.create_task(
404 Some("task-2".to_string()),
405 "Task 2".to_string(),
406 None,
407 None,
408 None, None,
410 None,
411 None,
412 None,
413 None,
414 None,
415 &states_config,
416 &IdsConfig::default(),
417 )
418 .unwrap();
419 db.delete_task("task-2", "test-worker", false, None, false, true)
421 .unwrap();
422
423 let options = ExportOptions {
425 exclude_deleted: false,
426 tables: None,
427 };
428 let export = db.export_tables(&options).unwrap();
429 assert_eq!(export.tasks.as_ref().unwrap().len(), 2);
430
431 let options = ExportOptions {
433 exclude_deleted: true,
434 tables: None,
435 };
436 let export = db.export_tables(&options).unwrap();
437 assert_eq!(export.tasks.as_ref().unwrap().len(), 1);
438 assert_eq!(export.tasks.as_ref().unwrap()[0].id, "task-1");
439 }
440
441 #[test]
442 fn test_export_dependencies_ordered() {
443 let db = Database::open_in_memory().unwrap();
444 let states_config = default_states_config();
445 let deps_config = default_deps_config();
446
447 for id in ["a", "b", "c"] {
449 db.create_task(
450 Some(id.to_string()),
451 format!("Task {}", id),
452 None,
453 None,
454 None, None,
456 None,
457 None,
458 None,
459 None,
460 None,
461 &states_config,
462 &IdsConfig::default(),
463 )
464 .unwrap();
465 }
466
467 db.add_dependency("c", "a", "blocks", &deps_config).unwrap();
469 db.add_dependency("a", "b", "follows", &deps_config)
470 .unwrap();
471 db.add_dependency("a", "b", "blocks", &deps_config).unwrap();
472
473 let options = ExportOptions::default();
474 let export = db.export_tables(&options).unwrap();
475 let deps = export.dependencies.unwrap();
476
477 assert_eq!(deps.len(), 3);
478 assert_eq!(
480 (
481 deps[0].from_task_id.as_str(),
482 deps[0].to_task_id.as_str(),
483 deps[0].dep_type.as_str()
484 ),
485 ("a", "b", "blocks")
486 );
487 assert_eq!(
488 (
489 deps[1].from_task_id.as_str(),
490 deps[1].to_task_id.as_str(),
491 deps[1].dep_type.as_str()
492 ),
493 ("a", "b", "follows")
494 );
495 assert_eq!(
496 (
497 deps[2].from_task_id.as_str(),
498 deps[2].to_task_id.as_str(),
499 deps[2].dep_type.as_str()
500 ),
501 ("c", "a", "blocks")
502 );
503 }
504
505 #[test]
506 fn test_export_task_tags_ordered() {
507 let db = Database::open_in_memory().unwrap();
508 let states_config = default_states_config();
509
510 db.create_task(
512 Some("task-b".to_string()),
513 "Task B".to_string(),
514 None,
515 None,
516 None,
517 None,
518 None,
519 None,
520 None,
521 None, Some(vec!["zebra".to_string(), "apple".to_string()]), &states_config,
524 &IdsConfig::default(),
525 )
526 .unwrap();
527 db.create_task(
528 Some("task-a".to_string()),
529 "Task A".to_string(),
530 None,
531 None,
532 None,
533 None,
534 None,
535 None,
536 None,
537 None, Some(vec!["mango".to_string()]), &states_config,
540 &IdsConfig::default(),
541 )
542 .unwrap();
543
544 let options = ExportOptions::default();
545 let export = db.export_tables(&options).unwrap();
546 let tags = export.task_tags.unwrap();
547
548 assert_eq!(tags.len(), 3);
549 assert_eq!(
551 (tags[0].task_id.as_str(), tags[0].tag.as_str()),
552 ("task-a", "mango")
553 );
554 assert_eq!(
555 (tags[1].task_id.as_str(), tags[1].tag.as_str()),
556 ("task-b", "apple")
557 );
558 assert_eq!(
559 (tags[2].task_id.as_str(), tags[2].tag.as_str()),
560 ("task-b", "zebra")
561 );
562 }
563}