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}