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}