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        if request.tags.is_some() {
62            builder = builder.add_field("cachedTags");
63        }
64
65        builder
66    }
67
68    /// Add a field to the UPDATE list
69    #[must_use]
70    pub fn add_field(mut self, field_name: &str) -> Self {
71        self.updates.push(format!("{field_name} = ?"));
72        self
73    }
74
75    /// Check if any fields have been set
76    #[must_use]
77    pub fn is_empty(&self) -> bool {
78        self.updates.is_empty()
79    }
80
81    /// Get the number of fields being updated
82    #[must_use]
83    pub fn len(&self) -> usize {
84        self.updates.len()
85    }
86
87    /// Build the complete UPDATE query string
88    ///
89    /// Always includes userModificationDate update
90    #[must_use]
91    pub fn build_query_string(&self) -> String {
92        if self.updates.is_empty() {
93            // Even with no fields, still update modification date for consistency
94            return "UPDATE TMTask SET userModificationDate = ? WHERE uuid = ?".to_string();
95        }
96
97        let mut all_updates = self.updates.clone();
98        all_updates.push("userModificationDate = ?".to_string());
99        format!(
100            "UPDATE TMTask SET {} WHERE uuid = ?",
101            all_updates.join(", ")
102        )
103    }
104
105    /// Get the field names being updated (for validation and logging)
106    #[must_use]
107    pub fn fields(&self) -> Vec<String> {
108        self.updates
109            .iter()
110            .map(|u| u.split(" = ").next().unwrap_or("").to_string())
111            .collect()
112    }
113}
114
115impl Default for TaskUpdateBuilder {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::models::TaskStatus;
125    use chrono::NaiveDate;
126    use uuid::Uuid;
127
128    #[test]
129    fn test_task_update_builder_empty() {
130        let builder = TaskUpdateBuilder::new();
131        assert!(builder.is_empty());
132        assert_eq!(builder.len(), 0);
133        // Empty builder should still generate a valid query (update modification date only)
134        let query = builder.build_query_string();
135        assert!(query.contains("userModificationDate = ?"));
136    }
137
138    #[test]
139    fn test_task_update_builder_single_field() {
140        let builder = TaskUpdateBuilder::new().add_field("title");
141        assert!(!builder.is_empty());
142        assert_eq!(builder.len(), 1);
143        let query = builder.build_query_string();
144        assert!(query.contains("title = ?"));
145        assert!(query.contains("userModificationDate = ?"));
146    }
147
148    #[test]
149    fn test_task_update_builder_multiple_fields() {
150        let builder = TaskUpdateBuilder::new()
151            .add_field("title")
152            .add_field("notes")
153            .add_field("status");
154        assert_eq!(builder.len(), 3);
155        let query = builder.build_query_string();
156        assert!(query.contains("title = ?"));
157        assert!(query.contains("notes = ?"));
158        assert!(query.contains("status = ?"));
159    }
160
161    #[test]
162    fn test_task_update_builder_from_request() {
163        let request = UpdateTaskRequest {
164            uuid: Uuid::new_v4(),
165            title: Some("Updated Title".to_string()),
166            notes: Some("Updated Notes".to_string()),
167            start_date: Some(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap()),
168            deadline: Some(NaiveDate::from_ymd_opt(2025, 2, 1).unwrap()),
169            status: Some(TaskStatus::Incomplete),
170            project_uuid: Some(Uuid::new_v4()),
171            area_uuid: Some(Uuid::new_v4()),
172            tags: Some(vec!["tag1".to_string(), "tag2".to_string()]),
173        };
174
175        let builder = TaskUpdateBuilder::from_request(&request);
176        assert_eq!(builder.len(), 8);
177
178        let query = builder.build_query_string();
179        assert!(query.contains("title = ?"));
180        assert!(query.contains("notes = ?"));
181        assert!(query.contains("startDate = ?"));
182        assert!(query.contains("deadline = ?"));
183        assert!(query.contains("status = ?"));
184        assert!(query.contains("project = ?"));
185        assert!(query.contains("area = ?"));
186        assert!(query.contains("cachedTags = ?"));
187    }
188
189    #[test]
190    fn test_task_update_builder_from_partial_request() {
191        let request = UpdateTaskRequest {
192            uuid: Uuid::new_v4(),
193            title: Some("Updated Title".to_string()),
194            notes: None,
195            start_date: None,
196            deadline: None,
197            status: None,
198            project_uuid: None,
199            area_uuid: None,
200            tags: None,
201        };
202
203        let builder = TaskUpdateBuilder::from_request(&request);
204        assert_eq!(builder.len(), 1);
205
206        let query = builder.build_query_string();
207        assert!(query.contains("title = ?"));
208        assert!(!query.contains("notes = ?"));
209        assert!(!query.contains("status = ?"));
210    }
211
212    #[test]
213    fn test_task_update_builder_fields() {
214        let builder = TaskUpdateBuilder::new()
215            .add_field("title")
216            .add_field("status");
217        let fields = builder.fields();
218        assert_eq!(fields.len(), 2);
219        assert!(fields.contains(&"title".to_string()));
220        assert!(fields.contains(&"status".to_string()));
221    }
222}