1use oqx::ast::{Expr, Query};
27use oqx::{Plan, QueryPlanner, Value, partition_pushable, residual_query};
28use rusqlite::Connection;
29use rusqlite::types::Value as SqlValue;
30
31use crate::context::{Target, fetch_rows, tag_rows};
32use crate::translate::{TranslateCtx, translate_predicate};
33
34fn aliases(t: Target) -> (&'static str, &'static str) {
36 match t {
37 Target::Docs => ("d", "d"),
38 Target::Blocks => ("b", "d"),
39 Target::Nodes => ("n", "d"),
40 Target::Edges => ("e", "d"),
41 }
42}
43
44fn from_clause(t: Target) -> &'static str {
45 match t {
46 Target::Docs => "docs d",
47 Target::Blocks => "blocks b JOIN docs d ON d.doc_id = b.doc_id",
48 Target::Nodes => "nodes n JOIN docs d ON d.doc_id = n.doc_id",
49 Target::Edges => "edges e JOIN docs d ON d.doc_id = e.src_doc",
50 }
51}
52
53fn columns(t: Target) -> &'static str {
56 match t {
57 Target::Docs => "d.*",
58 Target::Blocks => "b.*, d.path AS __path",
59 Target::Nodes => "n.*, d.path AS __path",
60 Target::Edges => "e.*, d.path AS __path",
61 }
62}
63
64fn order_clause(t: Target) -> &'static str {
66 match t {
67 Target::Docs => "d.path, d.doc_id",
68 Target::Blocks => "d.path, b.block_id",
69 Target::Nodes => "d.path, n.node_id",
70 Target::Edges => "d.path, e.edge_id",
71 }
72}
73
74fn guards(t: Target) -> &'static str {
76 match t {
77 Target::Docs => "d.repo_id = ? AND d.deleted_commit IS NULL",
78 Target::Blocks => "b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL",
79 Target::Nodes => "n.repo_id = ? AND d.deleted_commit IS NULL",
80 Target::Edges => "e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL",
81 }
82}
83
84fn root_target(source: &Expr) -> Option<Target> {
87 match source {
88 Expr::Ident { name } => Target::parse(name),
89 Expr::Member { recv, name } => match &**recv {
90 Expr::Ident { name: r } if r == "$repo" => Target::parse(name),
91 _ => None,
92 },
93 _ => None,
94 }
95}
96
97#[derive(Clone, Debug, PartialEq)]
100pub struct Compiled {
101 pub target: Target,
102 pub sql: String,
103 pub params: Vec<SqlValue>,
104 pub residual: Query,
105}
106
107#[must_use]
111pub fn compile(query: &Query, params: &[Value], repo_id: &str) -> Option<Compiled> {
112 if query.follow.is_some() || !query.from.is_empty() {
113 return None;
114 }
115 let target = root_target(&query.source)?;
116 let (self_alias, doc_alias) = aliases(target);
117 let ctx = TranslateCtx {
118 target,
119 self_alias,
120 doc_alias,
121 params,
122 };
123 let (pushed, residual) = partition_pushable(query.r#where.as_ref(), |e| {
124 translate_predicate(e, &ctx).is_some()
125 });
126 if pushed.is_empty() {
127 return None;
128 }
129 let mut where_sql = guards(target).to_owned();
130 let mut sql_params = vec![SqlValue::Text(repo_id.to_owned())];
131 for e in &pushed {
132 let frag = translate_predicate(e, &ctx).expect("accepted by partition_pushable");
133 where_sql.push_str(" AND (");
134 where_sql.push_str(&frag.sql);
135 where_sql.push(')');
136 sql_params.extend(frag.params);
137 }
138 let sql = format!(
139 "SELECT {} FROM {} WHERE {where_sql} ORDER BY {}",
140 columns(target),
141 from_clause(target),
142 order_clause(target)
143 );
144 Some(Compiled {
145 target,
146 sql,
147 params: sql_params,
148 residual: residual_query(query, residual),
149 })
150}
151
152pub struct SqlitePlanner<'a> {
154 conn: &'a Connection,
155 repo_id: String,
156}
157
158impl<'a> SqlitePlanner<'a> {
159 #[must_use]
160 pub fn new(conn: &'a Connection, repo_id: &str) -> Self {
161 Self {
162 conn,
163 repo_id: repo_id.to_owned(),
164 }
165 }
166
167 pub fn try_plan(&self, query: &Query, params: &[Value]) -> rusqlite::Result<Option<Plan>> {
172 let Some(compiled) = compile(query, params, &self.repo_id) else {
173 return Ok(None);
174 };
175 let rows = fetch_rows(self.conn, &compiled.sql, &compiled.params)?;
176 Ok(Some(Plan::new(
177 tag_rows(rows, compiled.target),
178 compiled.residual,
179 )))
180 }
181}
182
183impl QueryPlanner for SqlitePlanner<'_> {
184 fn plan(&self, query: &Query, params: &[Value]) -> Option<Plan> {
187 self.try_plan(query, params).ok().flatten()
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use oqx::ROWS_ROOT;
195 use oqx::ast::Where;
196
197 fn parse(src: &str) -> Query {
198 oqx::parse_string(src).expect("parses")
199 }
200
201 fn text(s: &str) -> SqlValue {
202 SqlValue::Text(s.to_owned())
203 }
204
205 #[test]
206 fn a_pushable_scan_compiles_to_one_statement_in_root_order() {
207 let c =
208 compile(&parse("from docs where $path == \"index.md\""), &[], "r_1").expect("planned");
209 assert_eq!(c.target, Target::Docs);
210 assert_eq!(
211 c.sql,
212 "SELECT d.* FROM docs d WHERE d.repo_id = ? AND d.deleted_commit IS NULL AND ((d.path IS ?)) ORDER BY d.path, d.doc_id"
213 );
214 assert_eq!(c.params, vec![text("r_1"), text("index.md")]);
215 assert_eq!(
216 c.residual.source,
217 Expr::Ident {
218 name: ROWS_ROOT.to_owned()
219 }
220 );
221 assert_eq!(c.residual.r#where, None);
222 }
223
224 #[test]
225 fn every_target_has_its_join_columns_guards_and_order() {
226 let b = compile(
227 &parse("from blocks where $path.startsWith(\"lab/\")"),
228 &[],
229 "r",
230 )
231 .unwrap();
232 assert_eq!(
233 b.sql,
234 "SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id \
235 WHERE b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL \
236 AND ((substr(d.path, 1, length(?)) = ?)) ORDER BY d.path, b.block_id"
237 );
238 assert_eq!(b.params, vec![text("r"), text("lab/"), text("lab/")]);
239 let n = compile(
240 &parse("$repo.nodes count { where kind == \"md:task\" }"),
241 &[],
242 "r",
243 )
244 .unwrap();
245 assert_eq!(n.target, Target::Nodes);
246 assert!(n.sql.starts_with(
247 "SELECT n.*, d.path AS __path FROM nodes n JOIN docs d ON d.doc_id = n.doc_id WHERE n.repo_id = ? AND d.deleted_commit IS NULL AND ((n.kind IS ?))"
248 ));
249 assert!(n.sql.ends_with("ORDER BY d.path, n.node_id"));
250 let e = compile(
251 &parse("from edges where predicate == \"references\""),
252 &[],
253 "r",
254 )
255 .unwrap();
256 assert!(e.sql.starts_with(
257 "SELECT e.*, d.path AS __path FROM edges e JOIN docs d ON d.doc_id = e.src_doc WHERE e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL AND ((e.predicate IS ?))"
258 ));
259 assert!(e.sql.ends_with("ORDER BY d.path, e.edge_id"));
260 }
261
262 #[test]
263 fn mixed_conjunctions_push_the_translatable_parts_and_keep_the_rest() {
264 let q = parse(
265 "from docs where $path.startsWith(\"processes/\") && nodes exists { where kind == \"md:task\" } && layer == \"canon\"",
266 );
267 let c = compile(&q, &[], "r").expect("planned");
268 assert!(
269 c.sql.contains("(substr(d.path, 1, length(?)) = ?)"),
270 "{}",
271 c.sql
272 );
273 assert!(c.sql.contains("p.key = 'layer'"), "{}", c.sql);
274 assert_eq!(
276 c.params,
277 vec![
278 text("r"),
279 text("processes/"),
280 text("processes/"),
281 text("canon")
282 ]
283 );
284 assert!(
286 matches!(c.residual.r#where, Some(Where::Op(_))),
287 "{:?}",
288 c.residual.r#where
289 );
290 assert!(c.residual.from.is_empty());
291 assert_eq!(c.residual.select, q.select);
292 assert_eq!(c.residual.consumer, q.consumer);
293 }
294
295 #[test]
296 fn declined_shapes_return_none() {
297 assert!(compile(&parse("from docs"), &[], "r").is_none());
299 assert!(compile(&parse("from docs where era in 800..1680"), &[], "r").is_none());
300 assert!(compile(&parse("from docs where !verified"), &[], "r").is_none());
301 assert!(
302 compile(
303 &parse("from docs where $path == \"a\" || $path == \"b\""),
304 &[],
305 "r"
306 )
307 .is_none()
308 );
309 assert!(
310 compile(
311 &parse("from docs where nodes exists { where kind == \"md:task\" }"),
312 &[],
313 "r"
314 )
315 .is_none()
316 );
317 assert!(
319 compile(
320 &parse("from docs where $path == \"a.md\" follow distinct doc.out"),
321 &[],
322 "r"
323 )
324 .is_none()
325 );
326 assert!(compile(&parse("from things where $path == \"a.md\""), &[], "r").is_none());
328 assert!(compile(&parse("from $repo where $path == \"a.md\""), &[], "r").is_none());
329 assert!(compile(&parse("from docs.nodes where kind == \"x\""), &[], "r").is_none());
330 let mut q = parse("from docs where $path == \"a.md\"");
332 q.from.push(Expr::Ident {
333 name: "nodes".to_owned(),
334 });
335 assert!(compile(&q, &[], "r").is_none());
336 }
337
338 #[test]
339 fn a_top_level_not_or_or_is_the_whole_residual_and_declines() {
340 assert!(compile(&parse("from docs where !($path == \"a\")"), &[], "r").is_none());
343 assert!(
344 compile(
345 &parse("from docs where ($path == \"a\" || $path == \"b\") && layer == \"canon\""),
346 &[],
347 "r"
348 )
349 .is_some()
350 );
351 }
352}