Skip to main content

fathomdb_query/
sql_adapt.rs

1//! SQL adaptation helpers for rewriting `compile_query`-generated SQL to match
2//! per-kind FTS/vec table layouts at execution time.
3//!
4//! These helpers operate purely on SQL strings and bind vectors; they do not
5//! depend on `rusqlite`. The coordinator is responsible for any `sqlite_master`
6//! existence checks and passes the resolved booleans/table names in.
7
8use crate::compile::{BindValue, CompiledQuery};
9
10/// Renumber `SQLite` positional parameters in `sql` after removing the given
11/// 1-based parameter numbers from `removed` (sorted ascending).
12///
13/// Each `?N` in the SQL where `N` is in `removed` is left in place (the caller
14/// must have already deleted those references from the SQL). Every `?N` where
15/// `N` is greater than any removed parameter is decremented by the count of
16/// removed parameters that are less than `N`.
17///
18/// Example: if `removed = [4]` then `?5` → `?4`, `?6` → `?5`, etc.
19/// Example: if `removed = [3, 4]` then `?5` → `?3`, `?6` → `?4`, etc.
20pub fn renumber_sql_params(sql: &str, removed: &[usize]) -> String {
21    // We walk the string looking for `?` followed by decimal digits and
22    // replace the number according to the removal offset.
23    let mut result = String::with_capacity(sql.len());
24    let bytes = sql.as_bytes();
25    let mut i = 0;
26    while i < bytes.len() {
27        if bytes[i] == b'?' {
28            // Check if next chars are digits.
29            let num_start = i + 1;
30            let mut j = num_start;
31            while j < bytes.len() && bytes[j].is_ascii_digit() {
32                j += 1;
33            }
34            if j > num_start {
35                // Parse the parameter number (1-based).
36                let num_str = &sql[num_start..j];
37                if let Ok(n) = num_str.parse::<usize>() {
38                    // Count how many removed params are < n.
39                    let offset = removed.iter().filter(|&&r| r < n).count();
40                    result.push('?');
41                    result.push_str(&(n - offset).to_string());
42                    i = j;
43                    continue;
44                }
45            }
46        }
47        result.push(bytes[i] as char);
48        i += 1;
49    }
50    result
51}
52
53/// Strip the property FTS UNION arm from a `compile_query`-generated
54/// `DrivingTable::FtsNodes` SQL string.
55///
56/// When the per-kind `fts_props_<kind>` table does not yet exist the
57/// `UNION SELECT ... FROM fts_node_properties ...` arm must be removed so the
58/// query degrades to chunk-only results instead of failing with "no such table".
59///
60/// The SQL structure from `compile_query` (fathomdb-query) is stable:
61/// ```text
62///                     UNION
63///                     SELECT fp.node_logical_id AS logical_id
64///                     FROM fts_node_properties fp
65///                     ...
66///                     WHERE fts_node_properties MATCH ?3
67///                       AND fp.kind = ?4
68///                 ) u
69/// ```
70/// We locate the `UNION` that precedes `fts_node_properties` and cut
71/// everything from it to the closing `) u`.
72pub fn strip_prop_fts_union_arm(sql: &str) -> String {
73    // The UNION arm in compile_query-generated FtsNodes SQL has:
74    //   - UNION with 24 spaces of indentation
75    //   - SELECT fp.node_logical_id with 24 spaces of indentation
76    //   - ending at "\n                    ) u" (20 spaces before ") u")
77    // Match the UNION that is immediately followed by the property arm.
78    let union_marker =
79        "                        UNION\n                        SELECT fp.node_logical_id";
80    if let Some(start) = sql.find(union_marker) {
81        // Find the closing ") u" after the property arm.
82        let end_marker = "\n                    ) u";
83        if let Some(rel_end) = sql[start..].find(end_marker) {
84            let end = start + rel_end;
85            // Remove from UNION start to (but not including) the "\n                    ) u" closing.
86            return format!("{}{}", &sql[..start], &sql[end..]);
87        }
88    }
89    // Fallback: return unchanged if pattern not found (shouldn't happen).
90    sql.to_owned()
91}
92
93impl CompiledQuery {
94    /// Adapt a `DrivingTable::FtsNodes` compiled query's SQL and binds for the
95    /// per-kind FTS property table layout.
96    ///
97    /// `compile_query` produces SQL that references the legacy global
98    /// `fts_node_properties` table. At execution time the coordinator must
99    /// decide whether to rewrite that reference to the per-kind
100    /// `fts_props_<kind>` table (when it exists) or strip the property FTS
101    /// UNION arm entirely (when it does not).
102    ///
103    /// The caller (the coordinator) performs the `sqlite_master` existence
104    /// check and resolves the per-kind table name. `fathomdb-query` has no
105    /// `rusqlite` dependency, so the check must remain outside this crate.
106    ///
107    /// Bind positions in `compile_query`-generated FTS SQL are fixed:
108    /// * `?1` = text (chunk FTS)
109    /// * `?2` = kind (chunk filter)
110    /// * `?3` = text (prop FTS)
111    /// * `?4` = kind (prop filter)
112    /// * `?5+` = fusable/residual predicates
113    ///
114    /// When `prop_table_exists` is `true` the helper removes the `fp.kind = ?4`
115    /// clause (the per-kind table is already filtered by construction) and
116    /// drops `?4` from the bind list.
117    ///
118    /// When `prop_table_exists` is `false` the helper strips the entire
119    /// property FTS UNION arm and drops both `?3` and `?4` from the bind list.
120    #[must_use]
121    pub fn adapt_fts_for_kind(
122        &self,
123        prop_table_exists: bool,
124        prop_table_name: &str,
125    ) -> (String, Vec<BindValue>) {
126        let (new_sql, removed_bind_positions) = if prop_table_exists {
127            let s = self
128                .sql
129                .replace("fts_node_properties", prop_table_name)
130                .replace("\n                          AND fp.kind = ?4", "");
131            (renumber_sql_params(&s, &[4]), vec![3usize])
132        } else {
133            let s = strip_prop_fts_union_arm(&self.sql);
134            (renumber_sql_params(&s, &[3, 4]), vec![2usize, 3])
135        };
136
137        let new_binds: Vec<BindValue> = self
138            .binds
139            .iter()
140            .enumerate()
141            .filter(|(i, _)| !removed_bind_positions.contains(i))
142            .map(|(_, b)| b.clone())
143            .collect();
144
145        (new_sql, new_binds)
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::compile::ShapeHash;
153    use crate::plan::{DrivingTable, ExecutionHints};
154
155    fn mk_compiled(sql: &str, binds: Vec<BindValue>) -> CompiledQuery {
156        CompiledQuery {
157            sql: sql.to_owned(),
158            binds,
159            shape_hash: ShapeHash(0),
160            driving_table: DrivingTable::FtsNodes,
161            hints: ExecutionHints {
162                recursion_limit: 0,
163                hard_limit: 0,
164            },
165            semantic_search: None,
166            raw_vector_search: None,
167        }
168    }
169
170    #[test]
171    fn renumber_shifts_params_past_single_removal() {
172        let out = renumber_sql_params("SELECT ?1, ?2, ?4, ?5, ?6", &[4]);
173        assert_eq!(out, "SELECT ?1, ?2, ?4, ?4, ?5");
174    }
175
176    #[test]
177    fn renumber_shifts_params_past_two_removals() {
178        let out = renumber_sql_params("SELECT ?1, ?2, ?5, ?6, ?7", &[3, 4]);
179        assert_eq!(out, "SELECT ?1, ?2, ?3, ?4, ?5");
180    }
181
182    #[test]
183    fn strip_removes_union_arm_between_markers() {
184        let sql = "\
185SELECT c.logical_id AS logical_id
186                    FROM fts_node_chunks c
187                    WHERE fts_node_chunks MATCH ?1
188                      AND c.kind = ?2
189                        UNION
190                        SELECT fp.node_logical_id AS logical_id
191                        FROM fts_node_properties fp
192                        WHERE fts_node_properties MATCH ?3
193                          AND fp.kind = ?4
194                    ) u";
195        let out = strip_prop_fts_union_arm(sql);
196        assert!(!out.contains("fts_node_properties"));
197        assert!(out.contains(") u"));
198        assert!(out.contains("fts_node_chunks MATCH ?1"));
199    }
200
201    /// When the per-kind property FTS table exists the helper must:
202    /// - substitute the per-kind table name for `fts_node_properties`
203    /// - remove the `fp.kind = ?4` clause
204    /// - renumber params past `?4`
205    /// - drop the ?4 bind (index 3 in the 0-based binds vec)
206    #[test]
207    fn adapt_fts_for_kind_table_exists_rewrites_and_drops_kind_bind() {
208        let sql = "\
209SELECT ... FROM fts_node_properties fp WHERE fts_node_properties MATCH ?3
210                          AND fp.kind = ?4
211                          AND extra = ?5";
212        let binds = vec![
213            BindValue::Text("chunk-text".to_owned()),
214            BindValue::Text("Goal".to_owned()),
215            BindValue::Text("prop-text".to_owned()),
216            BindValue::Text("Goal".to_owned()),
217            BindValue::Text("extra".to_owned()),
218        ];
219        let compiled = mk_compiled(sql, binds);
220        let (new_sql, new_binds) = compiled.adapt_fts_for_kind(true, "fts_props_goal");
221
222        assert!(new_sql.contains("fts_props_goal"));
223        assert!(!new_sql.contains("fts_node_properties"));
224        assert!(!new_sql.contains("AND fp.kind = ?4"));
225        // ?5 should have been renumbered to ?4 after removing ?4.
226        assert!(new_sql.contains("extra = ?4"));
227        assert_eq!(new_binds.len(), 4);
228        assert_eq!(new_binds[0], BindValue::Text("chunk-text".to_owned()));
229        assert_eq!(new_binds[1], BindValue::Text("Goal".to_owned()));
230        assert_eq!(new_binds[2], BindValue::Text("prop-text".to_owned()));
231        assert_eq!(new_binds[3], BindValue::Text("extra".to_owned()));
232    }
233
234    /// When the per-kind property FTS table does NOT exist the helper must:
235    /// - strip the UNION ... `fts_node_properties` ... ) u arm entirely
236    /// - renumber params past `?3` and `?4` (both removed)
237    /// - drop both the ?3 and ?4 binds (indices 2 and 3)
238    #[test]
239    fn adapt_fts_for_kind_table_missing_strips_union_and_drops_two_binds() {
240        let sql = "\
241SELECT c.logical_id AS logical_id
242                    FROM fts_node_chunks c
243                    WHERE fts_node_chunks MATCH ?1
244                      AND c.kind = ?2
245                        UNION
246                        SELECT fp.node_logical_id AS logical_id
247                        FROM fts_node_properties fp
248                        WHERE fts_node_properties MATCH ?3
249                          AND fp.kind = ?4
250                    ) u
251                    WHERE extra = ?5";
252        let binds = vec![
253            BindValue::Text("chunk-text".to_owned()),
254            BindValue::Text("Goal".to_owned()),
255            BindValue::Text("prop-text".to_owned()),
256            BindValue::Text("Goal".to_owned()),
257            BindValue::Text("extra".to_owned()),
258        ];
259        let compiled = mk_compiled(sql, binds);
260        let (new_sql, new_binds) = compiled.adapt_fts_for_kind(false, "fts_props_goal");
261
262        assert!(!new_sql.contains("fts_node_properties"));
263        assert!(!new_sql.contains("UNION"));
264        // ?5 should have been renumbered to ?3 after removing ?3 and ?4.
265        assert!(new_sql.contains("extra = ?3"));
266        assert_eq!(new_binds.len(), 3);
267        assert_eq!(new_binds[0], BindValue::Text("chunk-text".to_owned()));
268        assert_eq!(new_binds[1], BindValue::Text("Goal".to_owned()));
269        assert_eq!(new_binds[2], BindValue::Text("extra".to_owned()));
270    }
271}