sync_engine/search/
sql_translator.rs

1// Copyright (c) 2025-2026 Adrian Robinson. Licensed under the AGPL-3.0.
2// See LICENSE file in the project root for full license text.
3
4//! SQL Translator
5//!
6//! Translates Query AST to SQL WHERE clauses for JSON column queries.
7//! Uses MySQL JSON_EXTRACT syntax for querying JSON documents.
8//!
9//! # SQL Syntax Generated
10//!
11//! ```sql
12//! JSON_EXTRACT(data, '$.field') = 'value'              -- Exact match
13//! JSON_EXTRACT(data, '$.field') LIKE '%value%'         -- Contains
14//! JSON_EXTRACT(data, '$.age') BETWEEN 25 AND 40        -- Range
15//! JSON_EXTRACT(data, '$.field') LIKE 'prefix%'         -- Prefix
16//! JSON_CONTAINS(data->'$.tags', '"value"')             -- Tag membership
17//! ```
18
19use super::query_builder::{FieldOperator, FieldQuery, Query, QueryNode, QueryValue};
20
21/// SQL query translator for JSON column queries
22pub struct SqlTranslator;
23
24/// SQL query result with parameterized placeholders
25#[derive(Debug, Clone)]
26pub struct SqlQuery {
27    /// The WHERE clause (without "WHERE" keyword)
28    pub clause: String,
29    /// The parameter values in order
30    pub params: Vec<SqlParam>,
31}
32
33/// SQL parameter value
34#[derive(Debug, Clone, PartialEq)]
35pub enum SqlParam {
36    Text(String),
37    Numeric(f64),
38    Boolean(bool),
39}
40
41impl SqlTranslator {
42    /// Translate Query AST to parameterized SQL WHERE clause
43    ///
44    /// Uses `?` placeholders for parameters (MySQL style)
45    pub fn translate(query: &Query, json_column: &str) -> SqlQuery {
46        let mut params = Vec::new();
47        let clause = Self::translate_node(&query.root, json_column, &mut params);
48        SqlQuery { clause, params }
49    }
50
51    /// Translate Query AST to SQL WHERE clause with inline values
52    ///
53    /// Warning: Only use for debugging, not for actual queries (SQL injection risk)
54    pub fn translate_inline(query: &Query, json_column: &str) -> String {
55        let mut params = Vec::new();
56        let clause = Self::translate_node(&query.root, json_column, &mut params);
57
58        // Replace placeholders with actual values
59        let mut result = clause;
60        for param in params {
61            let value = match param {
62                SqlParam::Text(s) => format!("'{}'", s.replace('\'', "''")),
63                SqlParam::Numeric(n) => n.to_string(),
64                SqlParam::Boolean(b) => if b { "TRUE" } else { "FALSE" }.to_string(),
65            };
66            result = result.replacen('?', &value, 1);
67        }
68        result
69    }
70
71    fn translate_node(node: &QueryNode, json_col: &str, params: &mut Vec<SqlParam>) -> String {
72        match node {
73            QueryNode::Field(field_query) => Self::translate_field(field_query, json_col, params),
74            QueryNode::And(nodes) => {
75                // Filter out vector nodes (not supported in SQL)
76                let parts: Vec<String> = nodes
77                    .iter()
78                    .filter(|n| !matches!(n, QueryNode::Vector(_)))
79                    .map(|n| Self::translate_node(n, json_col, params))
80                    .collect();
81                if parts.is_empty() {
82                    // Only had vector query, return true (match all)
83                    "1=1".to_string()
84                } else if parts.len() == 1 {
85                    parts[0].clone()
86                } else {
87                    format!("({})", parts.join(" AND "))
88                }
89            }
90            QueryNode::Or(nodes) => {
91                let parts: Vec<String> = nodes
92                    .iter()
93                    .filter(|n| !matches!(n, QueryNode::Vector(_)))
94                    .map(|n| Self::translate_node(n, json_col, params))
95                    .collect();
96                if parts.is_empty() {
97                    "1=1".to_string()
98                } else if parts.len() == 1 {
99                    parts[0].clone()
100                } else {
101                    format!("({})", parts.join(" OR "))
102                }
103            }
104            QueryNode::Not(inner) => {
105                format!("NOT ({})", Self::translate_node(inner, json_col, params))
106            }
107            QueryNode::Vector(_) => {
108                // Vector search is not supported in SQL fallback
109                // Return a condition that matches nothing (or we could panic)
110                // For graceful degradation, we return 1=0 (false)
111                "1=0 /* vector search not supported in SQL */".to_string()
112            }
113        }
114    }
115
116    fn translate_field(
117        field: &FieldQuery,
118        json_col: &str,
119        params: &mut Vec<SqlParam>,
120    ) -> String {
121        let json_path = Self::json_path(&field.field);
122
123        match (&field.operator, &field.value) {
124            (FieldOperator::Equals, QueryValue::Text(text)) => {
125                params.push(SqlParam::Text(text.clone()));
126                format!("JSON_UNQUOTE(JSON_EXTRACT({}, '{}')) = ?", json_col, json_path)
127            }
128            (FieldOperator::Equals, QueryValue::Numeric(num)) => {
129                params.push(SqlParam::Numeric(*num));
130                format!("JSON_EXTRACT({}, '{}') = ?", json_col, json_path)
131            }
132            (FieldOperator::Equals, QueryValue::Boolean(b)) => {
133                params.push(SqlParam::Boolean(*b));
134                format!("JSON_EXTRACT({}, '{}') = ?", json_col, json_path)
135            }
136            (FieldOperator::Contains, QueryValue::Text(text)) => {
137                params.push(SqlParam::Text(format!("%{}%", text)));
138                format!(
139                    "JSON_UNQUOTE(JSON_EXTRACT({}, '{}')) LIKE ?",
140                    json_col, json_path
141                )
142            }
143            (FieldOperator::Range, QueryValue::NumericRange { min, max }) => {
144                match (min, max) {
145                    (Some(min_val), Some(max_val)) => {
146                        params.push(SqlParam::Numeric(*min_val));
147                        params.push(SqlParam::Numeric(*max_val));
148                        format!(
149                            "JSON_EXTRACT({}, '{}') BETWEEN ? AND ?",
150                            json_col, json_path
151                        )
152                    }
153                    (Some(min_val), None) => {
154                        params.push(SqlParam::Numeric(*min_val));
155                        format!("JSON_EXTRACT({}, '{}') >= ?", json_col, json_path)
156                    }
157                    (None, Some(max_val)) => {
158                        params.push(SqlParam::Numeric(*max_val));
159                        format!("JSON_EXTRACT({}, '{}') <= ?", json_col, json_path)
160                    }
161                    (None, None) => "1=1".to_string(), // Always true - no bounds
162                }
163            }
164            (FieldOperator::In, QueryValue::Tags(tags)) => {
165                // Use JSON_CONTAINS for tag membership
166                // Check if any of the tags are in the array
167                let conditions: Vec<String> = tags
168                    .iter()
169                    .map(|tag| {
170                        params.push(SqlParam::Text(format!("\"{}\"", tag)));
171                        format!("JSON_CONTAINS({}->'{}', ?)", json_col, json_path)
172                    })
173                    .collect();
174
175                if conditions.len() == 1 {
176                    conditions[0].clone()
177                } else {
178                    format!("({})", conditions.join(" OR "))
179                }
180            }
181            (FieldOperator::Prefix, QueryValue::Text(text)) => {
182                params.push(SqlParam::Text(format!("{}%", text)));
183                format!(
184                    "JSON_UNQUOTE(JSON_EXTRACT({}, '{}')) LIKE ?",
185                    json_col, json_path
186                )
187            }
188            (FieldOperator::Fuzzy, QueryValue::Text(text)) => {
189                // SQL doesn't have native fuzzy matching
190                // Fallback to contains for now
191                params.push(SqlParam::Text(format!("%{}%", text)));
192                format!(
193                    "JSON_UNQUOTE(JSON_EXTRACT({}, '{}')) LIKE ?",
194                    json_col, json_path
195                )
196            }
197            _ => {
198                // Fallback - always false for unsupported combinations
199                "1=0".to_string()
200            }
201        }
202    }
203
204    fn json_path(field: &str) -> String {
205        // Support dot notation for nested fields
206        // e.g., "user.name" -> "$.user.name"
207        if field.starts_with('$') {
208            field.to_string()
209        } else {
210            format!("$.{}", field)
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_simple_field_query() {
221        let query = Query::field_eq("name", "Alice");
222        let sql = SqlTranslator::translate(&query, "data");
223        assert_eq!(sql.clause, "JSON_UNQUOTE(JSON_EXTRACT(data, '$.name')) = ?");
224        assert_eq!(sql.params, vec![SqlParam::Text("Alice".to_string())]);
225    }
226
227    #[test]
228    fn test_inline_simple_field() {
229        let query = Query::field_eq("name", "Alice");
230        let sql = SqlTranslator::translate_inline(&query, "data");
231        assert_eq!(sql, "JSON_UNQUOTE(JSON_EXTRACT(data, '$.name')) = 'Alice'");
232    }
233
234    #[test]
235    fn test_numeric_range() {
236        let query = Query::numeric_range("age", Some(25.0), Some(40.0));
237        let sql = SqlTranslator::translate(&query, "data");
238        assert_eq!(sql.clause, "JSON_EXTRACT(data, '$.age') BETWEEN ? AND ?");
239        assert_eq!(
240            sql.params,
241            vec![SqlParam::Numeric(25.0), SqlParam::Numeric(40.0)]
242        );
243    }
244
245    #[test]
246    fn test_numeric_range_unbounded_min() {
247        let query = Query::numeric_range("age", None, Some(40.0));
248        let sql = SqlTranslator::translate(&query, "data");
249        assert_eq!(sql.clause, "JSON_EXTRACT(data, '$.age') <= ?");
250        assert_eq!(sql.params, vec![SqlParam::Numeric(40.0)]);
251    }
252
253    #[test]
254    fn test_numeric_range_unbounded_max() {
255        let query = Query::numeric_range("score", Some(100.0), None);
256        let sql = SqlTranslator::translate(&query, "data");
257        assert_eq!(sql.clause, "JSON_EXTRACT(data, '$.score') >= ?");
258        assert_eq!(sql.params, vec![SqlParam::Numeric(100.0)]);
259    }
260
261    #[test]
262    fn test_tag_query() {
263        let query = Query::tags("tags", vec!["rust".to_string(), "database".to_string()]);
264        let sql = SqlTranslator::translate(&query, "data");
265        assert_eq!(
266            sql.clause,
267            "(JSON_CONTAINS(data->'$.tags', ?) OR JSON_CONTAINS(data->'$.tags', ?))"
268        );
269        assert_eq!(
270            sql.params,
271            vec![
272                SqlParam::Text("\"rust\"".to_string()),
273                SqlParam::Text("\"database\"".to_string())
274            ]
275        );
276    }
277
278    #[test]
279    fn test_and_query() {
280        let query =
281            Query::field_eq("name", "Alice").and(Query::numeric_range("age", Some(25.0), Some(40.0)));
282        let sql = SqlTranslator::translate(&query, "data");
283        assert_eq!(
284            sql.clause,
285            "(JSON_UNQUOTE(JSON_EXTRACT(data, '$.name')) = ? AND JSON_EXTRACT(data, '$.age') BETWEEN ? AND ?)"
286        );
287        assert_eq!(
288            sql.params,
289            vec![
290                SqlParam::Text("Alice".to_string()),
291                SqlParam::Numeric(25.0),
292                SqlParam::Numeric(40.0)
293            ]
294        );
295    }
296
297    #[test]
298    fn test_or_query() {
299        let query =
300            Query::field_eq("status", "active").or(Query::field_eq("status", "pending"));
301        let sql = SqlTranslator::translate(&query, "data");
302        assert_eq!(
303            sql.clause,
304            "(JSON_UNQUOTE(JSON_EXTRACT(data, '$.status')) = ? OR JSON_UNQUOTE(JSON_EXTRACT(data, '$.status')) = ?)"
305        );
306    }
307
308    #[test]
309    fn test_not_query() {
310        let query = Query::field_eq("deleted", "true").negate();
311        let sql = SqlTranslator::translate(&query, "data");
312        assert_eq!(
313            sql.clause,
314            "NOT (JSON_UNQUOTE(JSON_EXTRACT(data, '$.deleted')) = ?)"
315        );
316    }
317
318    #[test]
319    fn test_contains_query() {
320        let query = Query::text_search("description", "database");
321        let sql = SqlTranslator::translate(&query, "data");
322        assert_eq!(
323            sql.clause,
324            "JSON_UNQUOTE(JSON_EXTRACT(data, '$.description')) LIKE ?"
325        );
326        assert_eq!(sql.params, vec![SqlParam::Text("%database%".to_string())]);
327    }
328
329    #[test]
330    fn test_prefix_query() {
331        let query = Query::prefix("email", "admin");
332        let sql = SqlTranslator::translate(&query, "data");
333        assert_eq!(
334            sql.clause,
335            "JSON_UNQUOTE(JSON_EXTRACT(data, '$.email')) LIKE ?"
336        );
337        assert_eq!(sql.params, vec![SqlParam::Text("admin%".to_string())]);
338    }
339
340    #[test]
341    fn test_nested_field() {
342        let query = Query::field_eq("user.profile.name", "Alice");
343        let sql = SqlTranslator::translate(&query, "data");
344        assert_eq!(
345            sql.clause,
346            "JSON_UNQUOTE(JSON_EXTRACT(data, '$.user.profile.name')) = ?"
347        );
348    }
349
350    #[test]
351    fn test_complex_query() {
352        let alice_query =
353            Query::field_eq("name", "Alice").and(Query::numeric_range("age", Some(25.0), Some(40.0)));
354
355        let bob_query = Query::field_eq("name", "Bob")
356            .and(Query::tags("tags", vec!["rust".to_string()]));
357
358        let query = alice_query.or(bob_query);
359        let sql = SqlTranslator::translate(&query, "data");
360
361        // Should be ((name=Alice AND age BETWEEN) OR (name=Bob AND tags CONTAINS))
362        assert!(sql.clause.starts_with("(("));
363        assert!(sql.clause.contains(" AND "));
364        assert!(sql.clause.contains(" OR "));
365    }
366}