Skip to main content

things3_core/database/
mappers.rs

1//! Row mapping utilities for converting database rows to domain models.
2//!
3//! `uuid` columns in the Things 3 SQLite database hold strings the database
4//! itself produced — either Things-native 21–22-char base62 IDs or hyphenated
5//! UUIDs that `SqlxBackend` generated for new entities. Both are valid
6//! [`ThingsId`] values; we wrap them via [`ThingsId::from_trusted`] without
7//! re-validating, since the DB is the source of truth.
8
9use crate::{
10    database::{safe_timestamp_convert, things_date_to_naive_date},
11    error::Result as ThingsResult,
12    models::{Project, Task, TaskStatus, TaskType, ThingsId},
13};
14use chrono::{DateTime, Utc};
15use sqlx::sqlite::SqliteRow;
16use sqlx::Row;
17
18/// Wrap a `uuid`-column string from the database as a [`ThingsId`].
19///
20/// No validation happens; the DB is authoritative.
21fn id_from_row(s: String) -> ThingsId {
22    ThingsId::from_trusted(s)
23}
24
25/// Wrap an optional `uuid`-column string as `Option<ThingsId>`.
26fn optional_id_from_row(opt: Option<String>) -> Option<ThingsId> {
27    opt.map(ThingsId::from_trusted)
28}
29
30/// Map a database row to a Task struct
31///
32/// This function centralizes all the logic for constructing a Task from
33/// a SQLite row, including UUID parsing, date conversion, and field mapping.
34///
35/// # Errors
36///
37/// Returns an error if required fields are missing or cannot be converted
38pub fn map_task_row(row: &SqliteRow) -> ThingsResult<Task> {
39    let uuid = id_from_row(row.get("uuid"));
40
41    let title: String = row.get("title");
42
43    let status_i32: i32 = row.get("status");
44    let status = match status_i32 {
45        2 => TaskStatus::Canceled,
46        3 => TaskStatus::Completed,
47        _ => TaskStatus::Incomplete, // 0 = Incomplete; Trashed is filtered via trashed column, never a status value
48    };
49
50    let type_i32: i32 = row.get("type");
51    let task_type = match type_i32 {
52        1 => TaskType::Project,
53        2 => TaskType::Heading,
54        _ => TaskType::Todo,
55    };
56
57    let notes: Option<String> = row.get("notes");
58
59    let start_date = row
60        .get::<Option<i64>, _>("startDate")
61        .and_then(things_date_to_naive_date);
62
63    let deadline = row
64        .get::<Option<i64>, _>("deadline")
65        .and_then(things_date_to_naive_date);
66
67    let creation_ts: f64 = row.get("creationDate");
68    let created = {
69        let ts = safe_timestamp_convert(creation_ts);
70        DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
71    };
72
73    let modification_ts: f64 = row.get("userModificationDate");
74    let modified = {
75        let ts = safe_timestamp_convert(modification_ts);
76        DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
77    };
78
79    let stop_date = row.get::<Option<f64>, _>("stopDate").and_then(|ts| {
80        let ts_i64 = safe_timestamp_convert(ts);
81        DateTime::from_timestamp(ts_i64, 0)
82    });
83
84    let project_uuid = optional_id_from_row(row.get::<Option<String>, _>("project"));
85    let area_uuid = optional_id_from_row(row.get::<Option<String>, _>("area"));
86    let parent_uuid = optional_id_from_row(row.get::<Option<String>, _>("heading"));
87
88    // Read tags from the tags_csv column (GROUP_CONCAT of TMTaskTag join)
89    let tags = row
90        .get::<Option<String>, _>("tags_csv")
91        .map(|s| {
92            s.split('\x1f')
93                .filter(|t| !t.is_empty())
94                .map(str::to_owned)
95                .collect()
96        })
97        .unwrap_or_default();
98
99    Ok(Task {
100        uuid,
101        title,
102        status,
103        task_type,
104        notes,
105        start_date,
106        deadline,
107        created,
108        modified,
109        stop_date,
110        project_uuid,
111        area_uuid,
112        parent_uuid,
113        tags,
114        children: Vec::new(),
115    })
116}
117
118/// Map a `TMTask` row (where `type = 1`) into a [`Project`].
119pub fn map_project_row(row: &SqliteRow) -> Project {
120    Project {
121        uuid: id_from_row(row.get("uuid")),
122        title: row.get("title"),
123        status: match row.get::<i32, _>("status") {
124            1 => TaskStatus::Completed,
125            2 => TaskStatus::Canceled,
126            3 => TaskStatus::Trashed,
127            _ => TaskStatus::Incomplete,
128        },
129        area_uuid: optional_id_from_row(row.get::<Option<String>, _>("area")),
130        notes: row.get("notes"),
131        deadline: row
132            .get::<Option<i64>, _>("deadline")
133            .and_then(|ts| DateTime::from_timestamp(ts, 0))
134            .map(|dt| dt.date_naive()),
135        start_date: row
136            .get::<Option<i64>, _>("startDate")
137            .and_then(|ts| DateTime::from_timestamp(ts, 0))
138            .map(|dt| dt.date_naive()),
139        tags: Vec::new(),
140        tasks: Vec::new(),
141        created: {
142            let ts = safe_timestamp_convert(row.get::<f64, _>("creationDate"));
143            DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
144        },
145        modified: {
146            let ts = safe_timestamp_convert(row.get::<f64, _>("userModificationDate"));
147            DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
148        },
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn id_from_row_preserves_native_things_id() {
158        let id = id_from_row("R4t2G8Q63aGZq4epMHNeCr".to_string());
159        assert_eq!(id.as_str(), "R4t2G8Q63aGZq4epMHNeCr");
160    }
161
162    #[test]
163    fn id_from_row_preserves_hyphenated_uuid() {
164        let id = id_from_row("550e8400-e29b-41d4-a716-446655440000".to_string());
165        assert_eq!(id.as_str(), "550e8400-e29b-41d4-a716-446655440000");
166    }
167
168    #[test]
169    fn optional_id_from_row_passes_through_none() {
170        assert!(optional_id_from_row(None).is_none());
171    }
172
173    #[test]
174    fn optional_id_from_row_wraps_some() {
175        let opt = optional_id_from_row(Some("ABC123XYZ456789012345".to_string()));
176        assert_eq!(opt.unwrap().as_str(), "ABC123XYZ456789012345");
177    }
178}