Skip to main content

things3_core/database/
query_builders.rs

1//! SQL Query Builder utilities for type-safe query construction
2//!
3//! This module provides builder patterns for constructing SQL queries,
4//! reducing the risk of SQL injection and making complex queries easier to maintain.
5
6use crate::models::UpdateTaskRequest;
7
8/// Builder for UPDATE queries on the TMTask table
9///
10/// Provides a type-safe API for building dynamic UPDATE statements
11/// based on which fields are being updated.
12#[derive(Debug, Clone)]
13pub struct TaskUpdateBuilder {
14    updates: Vec<String>,
15}
16
17impl TaskUpdateBuilder {
18    /// Create a new TaskUpdateBuilder
19    #[must_use]
20    pub fn new() -> Self {
21        Self {
22            updates: Vec::new(),
23        }
24    }
25
26    /// Create a builder from an `UpdateTaskRequest`
27    ///
28    /// Automatically marks all fields that are present in the request
29    #[must_use]
30    pub fn from_request(request: &UpdateTaskRequest) -> Self {
31        let mut builder = Self::new();
32
33        if request.title.is_some() {
34            builder = builder.add_field("title");
35        }
36
37        if request.notes.is_some() {
38            builder = builder.add_field("notes");
39        }
40
41        if request.start_date.is_some() {
42            builder = builder.add_field("startDate");
43        }
44
45        if request.deadline.is_some() {
46            builder = builder.add_field("deadline");
47        }
48
49        if request.status.is_some() {
50            builder = builder.add_field("status");
51        }
52
53        if request.project_uuid.is_some() {
54            builder = builder.add_field("project");
55        }
56
57        if request.area_uuid.is_some() {
58            builder = builder.add_field("area");
59        }
60
61        builder
62    }
63
64    /// Add a field to the UPDATE list
65    #[must_use]
66    pub fn add_field(mut self, field_name: &str) -> Self {
67        self.updates.push(format!("{field_name} = ?"));
68        self
69    }
70
71    /// Check if any fields have been set
72    #[must_use]
73    pub fn is_empty(&self) -> bool {
74        self.updates.is_empty()
75    }
76
77    /// Get the number of fields being updated
78    #[must_use]
79    pub fn len(&self) -> usize {
80        self.updates.len()
81    }
82
83    /// Build the complete UPDATE query string
84    ///
85    /// Always includes userModificationDate update
86    #[must_use]
87    pub fn build_query_string(&self) -> String {
88        if self.updates.is_empty() {
89            // Even with no fields, still update modification date for consistency
90            return "UPDATE TMTask SET userModificationDate = ? WHERE uuid = ?".to_string();
91        }
92
93        let mut all_updates = self.updates.clone();
94        all_updates.push("userModificationDate = ?".to_string());
95        format!(
96            "UPDATE TMTask SET {} WHERE uuid = ?",
97            all_updates.join(", ")
98        )
99    }
100
101    /// Get the field names being updated (for validation and logging)
102    #[must_use]
103    pub fn fields(&self) -> Vec<String> {
104        self.updates
105            .iter()
106            .map(|u| u.split(" = ").next().unwrap_or("").to_string())
107            .collect()
108    }
109}
110
111impl Default for TaskUpdateBuilder {
112    fn default() -> Self {
113        Self::new()
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::models::TaskStatus;
121    use crate::models::ThingsId;
122    use chrono::NaiveDate;
123
124    #[test]
125    fn test_task_update_builder_empty() {
126        let builder = TaskUpdateBuilder::new();
127        assert!(builder.is_empty());
128        assert_eq!(builder.len(), 0);
129        // Empty builder should still generate a valid query (update modification date only)
130        let query = builder.build_query_string();
131        assert!(query.contains("userModificationDate = ?"));
132    }
133
134    #[test]
135    fn test_task_update_builder_single_field() {
136        let builder = TaskUpdateBuilder::new().add_field("title");
137        assert!(!builder.is_empty());
138        assert_eq!(builder.len(), 1);
139        let query = builder.build_query_string();
140        assert!(query.contains("title = ?"));
141        assert!(query.contains("userModificationDate = ?"));
142    }
143
144    #[test]
145    fn test_task_update_builder_multiple_fields() {
146        let builder = TaskUpdateBuilder::new()
147            .add_field("title")
148            .add_field("notes")
149            .add_field("status");
150        assert_eq!(builder.len(), 3);
151        let query = builder.build_query_string();
152        assert!(query.contains("title = ?"));
153        assert!(query.contains("notes = ?"));
154        assert!(query.contains("status = ?"));
155    }
156
157    #[test]
158    fn test_task_update_builder_from_request() {
159        let request = UpdateTaskRequest {
160            uuid: ThingsId::new_v4(),
161            title: Some("Updated Title".to_string()),
162            notes: Some("Updated Notes".to_string()),
163            start_date: Some(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap()),
164            deadline: Some(NaiveDate::from_ymd_opt(2025, 2, 1).unwrap()),
165            status: Some(TaskStatus::Incomplete),
166            project_uuid: Some(ThingsId::new_v4()),
167            area_uuid: Some(ThingsId::new_v4()),
168            tags: Some(vec!["tag1".to_string(), "tag2".to_string()]),
169        };
170
171        let builder = TaskUpdateBuilder::from_request(&request);
172        assert_eq!(builder.len(), 7);
173
174        let query = builder.build_query_string();
175        assert!(query.contains("title = ?"));
176        assert!(query.contains("notes = ?"));
177        assert!(query.contains("startDate = ?"));
178        assert!(query.contains("deadline = ?"));
179        assert!(query.contains("status = ?"));
180        assert!(query.contains("project = ?"));
181        assert!(query.contains("area = ?"));
182        assert!(!query.contains("cachedTags = ?"));
183    }
184
185    #[test]
186    fn test_task_update_builder_from_partial_request() {
187        let request = UpdateTaskRequest {
188            uuid: ThingsId::new_v4(),
189            title: Some("Updated Title".to_string()),
190            notes: None,
191            start_date: None,
192            deadline: None,
193            status: None,
194            project_uuid: None,
195            area_uuid: None,
196            tags: None,
197        };
198
199        let builder = TaskUpdateBuilder::from_request(&request);
200        assert_eq!(builder.len(), 1);
201
202        let query = builder.build_query_string();
203        assert!(query.contains("title = ?"));
204        assert!(!query.contains("notes = ?"));
205        assert!(!query.contains("status = ?"));
206    }
207
208    #[test]
209    fn test_task_update_builder_fields() {
210        let builder = TaskUpdateBuilder::new()
211            .add_field("title")
212            .add_field("status");
213        let fields = builder.fields();
214        assert_eq!(fields.len(), 2);
215        assert!(fields.contains(&"title".to_string()));
216        assert!(fields.contains(&"status".to_string()));
217    }
218}