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        }
166    }
167
168    #[test]
169    fn renumber_shifts_params_past_single_removal() {
170        let out = renumber_sql_params("SELECT ?1, ?2, ?4, ?5, ?6", &[4]);
171        assert_eq!(out, "SELECT ?1, ?2, ?4, ?4, ?5");
172    }
173
174    #[test]
175    fn renumber_shifts_params_past_two_removals() {
176        let out = renumber_sql_params("SELECT ?1, ?2, ?5, ?6, ?7", &[3, 4]);
177        assert_eq!(out, "SELECT ?1, ?2, ?3, ?4, ?5");
178    }
179
180    #[test]
181    fn strip_removes_union_arm_between_markers() {
182        let sql = "\
183SELECT c.logical_id AS logical_id
184                    FROM fts_node_chunks c
185                    WHERE fts_node_chunks MATCH ?1
186                      AND c.kind = ?2
187                        UNION
188                        SELECT fp.node_logical_id AS logical_id
189                        FROM fts_node_properties fp
190                        WHERE fts_node_properties MATCH ?3
191                          AND fp.kind = ?4
192                    ) u";
193        let out = strip_prop_fts_union_arm(sql);
194        assert!(!out.contains("fts_node_properties"));
195        assert!(out.contains(") u"));
196        assert!(out.contains("fts_node_chunks MATCH ?1"));
197    }
198
199    /// When the per-kind property FTS table exists the helper must:
200    /// - substitute the per-kind table name for `fts_node_properties`
201    /// - remove the `fp.kind = ?4` clause
202    /// - renumber params past `?4`
203    /// - drop the ?4 bind (index 3 in the 0-based binds vec)
204    #[test]
205    fn adapt_fts_for_kind_table_exists_rewrites_and_drops_kind_bind() {
206        let sql = "\
207SELECT ... FROM fts_node_properties fp WHERE fts_node_properties MATCH ?3
208                          AND fp.kind = ?4
209                          AND extra = ?5";
210        let binds = vec![
211            BindValue::Text("chunk-text".to_owned()),
212            BindValue::Text("Goal".to_owned()),
213            BindValue::Text("prop-text".to_owned()),
214            BindValue::Text("Goal".to_owned()),
215            BindValue::Text("extra".to_owned()),
216        ];
217        let compiled = mk_compiled(sql, binds);
218        let (new_sql, new_binds) = compiled.adapt_fts_for_kind(true, "fts_props_goal");
219
220        assert!(new_sql.contains("fts_props_goal"));
221        assert!(!new_sql.contains("fts_node_properties"));
222        assert!(!new_sql.contains("AND fp.kind = ?4"));
223        // ?5 should have been renumbered to ?4 after removing ?4.
224        assert!(new_sql.contains("extra = ?4"));
225        assert_eq!(new_binds.len(), 4);
226        assert_eq!(new_binds[0], BindValue::Text("chunk-text".to_owned()));
227        assert_eq!(new_binds[1], BindValue::Text("Goal".to_owned()));
228        assert_eq!(new_binds[2], BindValue::Text("prop-text".to_owned()));
229        assert_eq!(new_binds[3], BindValue::Text("extra".to_owned()));
230    }
231
232    /// When the per-kind property FTS table does NOT exist the helper must:
233    /// - strip the UNION ... `fts_node_properties` ... ) u arm entirely
234    /// - renumber params past `?3` and `?4` (both removed)
235    /// - drop both the ?3 and ?4 binds (indices 2 and 3)
236    #[test]
237    fn adapt_fts_for_kind_table_missing_strips_union_and_drops_two_binds() {
238        let sql = "\
239SELECT c.logical_id AS logical_id
240                    FROM fts_node_chunks c
241                    WHERE fts_node_chunks MATCH ?1
242                      AND c.kind = ?2
243                        UNION
244                        SELECT fp.node_logical_id AS logical_id
245                        FROM fts_node_properties fp
246                        WHERE fts_node_properties MATCH ?3
247                          AND fp.kind = ?4
248                    ) u
249                    WHERE extra = ?5";
250        let binds = vec![
251            BindValue::Text("chunk-text".to_owned()),
252            BindValue::Text("Goal".to_owned()),
253            BindValue::Text("prop-text".to_owned()),
254            BindValue::Text("Goal".to_owned()),
255            BindValue::Text("extra".to_owned()),
256        ];
257        let compiled = mk_compiled(sql, binds);
258        let (new_sql, new_binds) = compiled.adapt_fts_for_kind(false, "fts_props_goal");
259
260        assert!(!new_sql.contains("fts_node_properties"));
261        assert!(!new_sql.contains("UNION"));
262        // ?5 should have been renumbered to ?3 after removing ?3 and ?4.
263        assert!(new_sql.contains("extra = ?3"));
264        assert_eq!(new_binds.len(), 3);
265        assert_eq!(new_binds[0], BindValue::Text("chunk-text".to_owned()));
266        assert_eq!(new_binds[1], BindValue::Text("Goal".to_owned()));
267        assert_eq!(new_binds[2], BindValue::Text("extra".to_owned()));
268    }
269}