1use oqx::Consumer;
27use oqx::ast::{Expr, Follow, OpNode, Query, SelectItem, Subquery, Where};
28use oqx::{Plan, QueryPlanner, Value, partition_pushable, residual_query};
29use rusqlite::Connection;
30use rusqlite::types::Value as SqlValue;
31
32use crate::context::{Target, fetch_rows, tag_rows};
33use crate::translate::{RESERVED_DOC_BASENAMES, TranslateCtx, translate_predicate};
34
35fn aliases(t: Target) -> (&'static str, &'static str) {
37 match t {
38 Target::Docs => ("d", "d"),
39 Target::Blocks => ("b", "d"),
40 Target::Nodes => ("n", "d"),
41 Target::Edges => ("e", "d"),
42 }
43}
44
45fn from_clause(t: Target) -> &'static str {
46 match t {
47 Target::Docs => "docs d",
48 Target::Blocks => "blocks b JOIN docs d ON d.doc_id = b.doc_id",
49 Target::Nodes => "nodes n JOIN docs d ON d.doc_id = n.doc_id",
50 Target::Edges => "edges e JOIN docs d ON d.doc_id = e.src_doc",
51 }
52}
53
54fn columns(t: Target) -> &'static str {
57 match t {
58 Target::Docs => "d.*",
59 Target::Blocks => "b.*, d.path AS __path",
60 Target::Nodes => "n.*, d.path AS __path",
61 Target::Edges => "e.*, d.path AS __path",
62 }
63}
64
65fn order_clause(t: Target) -> &'static str {
67 match t {
68 Target::Docs => "d.path, d.doc_id",
69 Target::Blocks => "d.path, b.block_id",
70 Target::Nodes => "d.path, n.node_id",
71 Target::Edges => "d.path, e.edge_id",
72 }
73}
74
75fn guards(t: Target) -> &'static str {
77 match t {
78 Target::Docs => "d.repo_id = ? AND d.deleted_commit IS NULL",
79 Target::Blocks => "b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL",
80 Target::Nodes => "n.repo_id = ? AND d.deleted_commit IS NULL",
81 Target::Edges => "e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL",
82 }
83}
84
85fn root_target(source: &Expr) -> Option<Target> {
88 match source {
89 Expr::Ident { name } => Target::parse(name),
90 Expr::Member { recv, name } => match &**recv {
91 Expr::Ident { name: r } if r == "$repo" => Target::parse(name),
92 _ => None,
93 },
94 _ => None,
95 }
96}
97
98fn residual_may_raise(w: &Where, target: Target) -> bool {
120 where_may_raise(w, target, true)
121}
122
123fn where_may_raise(w: &Where, target: Target, root: bool) -> bool {
124 match w {
125 Where::And { parts } | Where::Or { parts } => {
126 parts.iter().any(|p| where_may_raise(p, target, root))
127 }
128 Where::Not { expr } => where_may_raise(expr, target, root),
129 Where::Scalar { expr } => expr_may_raise(expr, target, root),
130 Where::Op(op) => op_may_raise(op, target, root),
131 }
132}
133
134fn op_may_raise(op: &OpNode, target: Target, root: bool) -> bool {
135 op.op == Consumer::Single
136 || expr_may_raise(&op.receiver, target, root)
137 || subquery_may_raise(&op.sub, target)
138}
139
140fn subquery_may_raise(sub: &Subquery, target: Target) -> bool {
141 let inner = |e: &Expr| expr_may_raise(e, target, false);
142 sub.from.iter().any(inner)
143 || sub
144 .r#where
145 .as_ref()
146 .is_some_and(|w| where_may_raise(w, target, false))
147 || sub.select.iter().any(|item| match item {
148 SelectItem::Field { expr, lift, .. } => *lift > 0 || inner(expr),
149 SelectItem::Collect { op, .. } => op_may_raise(op, target, false),
150 })
151 || sub.order_by.iter().flatten().any(|o| inner(&o.expr))
152 || sub
153 .follow
154 .as_ref()
155 .is_some_and(|f| follow_may_raise(f, target))
156 || sub.limit.as_ref().is_some_and(inner)
157 || sub.offset.as_ref().is_some_and(inner)
158}
159
160fn follow_may_raise(f: &Follow, target: Target) -> bool {
161 let inner = |e: &Expr| expr_may_raise(e, target, false);
162 inner(&f.receiver)
163 || f.r#where.as_ref().is_some_and(inner)
164 || f.frontier.as_ref().is_some_and(inner)
165 || f.by.as_ref().is_some_and(inner)
166}
167
168fn is_reserved(name: &str) -> bool {
169 RESERVED_DOC_BASENAMES.contains(&name)
170}
171
172fn expr_may_raise(e: &Expr, target: Target, root: bool) -> bool {
173 let again = |e: &Expr| expr_may_raise(e, target, root);
174 match e {
175 Expr::Lit(_) | Expr::Binding { .. } => false,
176 Expr::Ident { name } => root && target == Target::Docs && is_reserved(name),
177 Expr::Outer { .. } | Expr::Call { .. } => true,
178 Expr::Member { recv, name } => {
181 (is_reserved(name) && matches!(&**recv, Expr::Ident { name } if name == "doc"))
182 || again(recv)
183 }
184 Expr::Index { recv, index } => again(recv) || again(index),
185 Expr::Unary { expr, .. } => again(expr),
186 Expr::Binary { left, right, .. }
187 | Expr::Logical { left, right, .. }
188 | Expr::In { left, right } => again(left) || again(right),
189 Expr::Range { lo, hi, .. } => {
190 lo.as_deref().is_some_and(again) || hi.as_deref().is_some_and(again)
191 }
192 }
193}
194
195#[derive(Clone, Debug, PartialEq)]
198pub struct Compiled {
199 pub target: Target,
200 pub sql: String,
201 pub params: Vec<SqlValue>,
202 pub residual: Query,
203}
204
205#[must_use]
209pub fn compile(query: &Query, params: &[Value], repo_id: &str) -> Option<Compiled> {
210 if query.follow.is_some() || !query.from.is_empty() {
211 return None;
212 }
213 let target = root_target(&query.source)?;
214 let (self_alias, doc_alias) = aliases(target);
215 let ctx = TranslateCtx {
216 target,
217 self_alias,
218 doc_alias,
219 params,
220 };
221 let (pushed, residual) = partition_pushable(query.r#where.as_ref(), |e| {
222 translate_predicate(e, &ctx).is_some()
223 });
224 if pushed.is_empty() {
225 return None;
226 }
227 if residual
230 .as_ref()
231 .is_some_and(|w| residual_may_raise(w, target))
232 {
233 return None;
234 }
235 let mut where_sql = guards(target).to_owned();
236 let mut sql_params = vec![SqlValue::Text(repo_id.to_owned())];
237 for e in &pushed {
238 let frag = translate_predicate(e, &ctx).expect("accepted by partition_pushable");
239 where_sql.push_str(" AND (");
240 where_sql.push_str(&frag.sql);
241 where_sql.push(')');
242 sql_params.extend(frag.params);
243 }
244 let sql = format!(
245 "SELECT {} FROM {} WHERE {where_sql} ORDER BY {}",
246 columns(target),
247 from_clause(target),
248 order_clause(target)
249 );
250 Some(Compiled {
251 target,
252 sql,
253 params: sql_params,
254 residual: residual_query(query, residual),
255 })
256}
257
258pub struct SqlitePlanner<'a> {
260 conn: &'a Connection,
261 repo_id: String,
262}
263
264impl<'a> SqlitePlanner<'a> {
265 #[must_use]
266 pub fn new(conn: &'a Connection, repo_id: &str) -> Self {
267 Self {
268 conn,
269 repo_id: repo_id.to_owned(),
270 }
271 }
272
273 pub fn try_plan(&self, query: &Query, params: &[Value]) -> rusqlite::Result<Option<Plan>> {
278 let Some(compiled) = compile(query, params, &self.repo_id) else {
279 return Ok(None);
280 };
281 let rows = fetch_rows(self.conn, &compiled.sql, &compiled.params)?;
282 Ok(Some(Plan::new(
283 tag_rows(rows, compiled.target),
284 compiled.residual,
285 )))
286 }
287}
288
289impl QueryPlanner for SqlitePlanner<'_> {
290 fn plan(&self, query: &Query, params: &[Value]) -> Option<Plan> {
293 self.try_plan(query, params).ok().flatten()
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use oqx::ROWS_ROOT;
301 use oqx::ast::Where;
302
303 fn parse(src: &str) -> Query {
304 oqx::parse_string(src).expect("parses")
305 }
306
307 fn text(s: &str) -> SqlValue {
308 SqlValue::Text(s.to_owned())
309 }
310
311 #[test]
312 fn a_pushable_scan_compiles_to_one_statement_in_root_order() {
313 let c =
314 compile(&parse("from docs where $path == \"index.md\""), &[], "r_1").expect("planned");
315 assert_eq!(c.target, Target::Docs);
316 assert_eq!(
317 c.sql,
318 "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"
319 );
320 assert_eq!(c.params, vec![text("r_1"), text("index.md")]);
321 assert_eq!(
322 c.residual.source,
323 Expr::Ident {
324 name: ROWS_ROOT.to_owned()
325 }
326 );
327 assert_eq!(c.residual.r#where, None);
328 }
329
330 #[test]
331 fn every_target_has_its_join_columns_guards_and_order() {
332 let b = compile(
333 &parse("from blocks where $path.startsWith(\"lab/\")"),
334 &[],
335 "r",
336 )
337 .unwrap();
338 assert_eq!(
339 b.sql,
340 "SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id \
341 WHERE b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL \
342 AND ((substr(d.path, 1, length(?)) = ?)) ORDER BY d.path, b.block_id"
343 );
344 assert_eq!(b.params, vec![text("r"), text("lab/"), text("lab/")]);
345 let n = compile(
346 &parse("$repo.nodes count { where kind == \"md:task\" }"),
347 &[],
348 "r",
349 )
350 .unwrap();
351 assert_eq!(n.target, Target::Nodes);
352 assert!(n.sql.starts_with(
353 "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 ?))"
354 ));
355 assert!(n.sql.ends_with("ORDER BY d.path, n.node_id"));
356 let e = compile(
357 &parse("from edges where predicate == \"references\""),
358 &[],
359 "r",
360 )
361 .unwrap();
362 assert!(e.sql.starts_with(
363 "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 ?))"
364 ));
365 assert!(e.sql.ends_with("ORDER BY d.path, e.edge_id"));
366 }
367
368 #[test]
369 fn mixed_conjunctions_push_the_translatable_parts_and_keep_the_rest() {
370 let q = parse(
371 "from docs where $path.startsWith(\"processes/\") && nodes exists { where kind == \"md:task\" } && layer == \"canon\"",
372 );
373 let c = compile(&q, &[], "r").expect("planned");
374 assert!(
375 c.sql.contains("(substr(d.path, 1, length(?)) = ?)"),
376 "{}",
377 c.sql
378 );
379 assert!(c.sql.contains("p.key = 'layer'"), "{}", c.sql);
380 assert_eq!(
382 c.params,
383 vec![
384 text("r"),
385 text("processes/"),
386 text("processes/"),
387 text("canon")
388 ]
389 );
390 assert!(
392 matches!(c.residual.r#where, Some(Where::Op(_))),
393 "{:?}",
394 c.residual.r#where
395 );
396 assert!(c.residual.from.is_empty());
397 assert_eq!(c.residual.select, q.select);
398 assert_eq!(c.residual.consumer, q.consumer);
399 }
400
401 #[test]
402 fn declined_shapes_return_none() {
403 assert!(compile(&parse("from docs"), &[], "r").is_none());
405 assert!(compile(&parse("from docs where era in 800..1680"), &[], "r").is_none());
406 assert!(compile(&parse("from docs where !verified"), &[], "r").is_none());
407 assert!(
408 compile(
409 &parse("from docs where $path == \"a\" || $path == \"b\""),
410 &[],
411 "r"
412 )
413 .is_none()
414 );
415 assert!(
416 compile(
417 &parse("from docs where nodes exists { where kind == \"md:task\" }"),
418 &[],
419 "r"
420 )
421 .is_none()
422 );
423 assert!(
425 compile(
426 &parse("from docs where $path == \"a.md\" follow distinct doc.out"),
427 &[],
428 "r"
429 )
430 .is_none()
431 );
432 assert!(compile(&parse("from things where $path == \"a.md\""), &[], "r").is_none());
434 assert!(compile(&parse("from $repo where $path == \"a.md\""), &[], "r").is_none());
435 assert!(compile(&parse("from docs.nodes where kind == \"x\""), &[], "r").is_none());
436 let mut q = parse("from docs where $path == \"a.md\"");
438 q.from.push(Expr::Ident {
439 name: "nodes".to_owned(),
440 });
441 assert!(compile(&q, &[], "r").is_none());
442 }
443
444 #[test]
447 fn a_residual_that_could_raise_declines_the_whole_query() {
448 let declined = |src: &str| {
449 assert!(
450 compile(&parse(src), &[], "r").is_none(),
451 "should decline: {src}"
452 );
453 };
454 let planned = |src: &str| {
455 assert!(
456 compile(&parse(src), &[], "r").is_some(),
457 "should plan: {src}"
458 );
459 };
460 declined("from docs where path == \"x\" && $path == \"nope.md\"");
462 declined("from docs where $path == \"nope.md\" && !body");
463 declined("from blocks where $path == \"x\" && doc.path == \"y\"");
465 declined("from blocks where $path == \"x\" && nodes exists { where doc.path == \"y\" }");
466 declined("from docs where $path.matches(\"[\") && $path == \"nope.md\"");
468 declined("from docs where nope(\"x\") && $path == \"nope.md\"");
469 declined("from docs where $path == \"x\" && size(tags) > 1");
470 declined("from docs where $path == \"x\" && nodes exists { where name.lower() == \"a\" }");
471 declined(
472 "from docs where $path == \"x\" && nodes count { where kind == \"a\" order by size(name) } > 1",
473 );
474 declined("from docs where $path == \"x\" && ^slug == \"y\"");
476 declined("from docs where $path == \"x\" && nodes exists { where name == ^title }");
477 declined("from docs where $path == \"x\" && nodes collect { ^first_task: name }");
478 declined(
480 "from docs where $path == \"x\" && blocks exists { select t: nodes single { where kind == \"md:task\" } }",
481 );
482 planned("from docs where $path.startsWith(\"lab/\") && $path == \"x\"");
484 planned("from docs where $path == \"x\" && era in 800..1680");
486 planned("from docs where $path == \"x\" && !verified");
487 planned("from docs where $path == \"x\" && (layer == \"a\" || layer == \"b\")");
488 planned("from docs where $path == \"x\" && verified == true");
489 planned("from docs where $path == \"x\" && nodes exists { where kind == \"md:task\" }");
490 planned(
491 "from docs where $path == \"x\" && nodes count { where kind == \"md:task\" limit 5 } > 1",
492 );
493 planned("from blocks where $path == \"x\" && !path");
496 planned("from docs where $path == \"x\" && nodes exists { where path == \"y\" }");
497 planned("from docs where $path == \"x\" && frontmatter.path == \"y\"");
498 }
499
500 #[test]
501 fn a_top_level_not_or_or_is_the_whole_residual_and_declines() {
502 assert!(compile(&parse("from docs where !($path == \"a\")"), &[], "r").is_none());
505 assert!(
506 compile(
507 &parse("from docs where ($path == \"a\" || $path == \"b\") && layer == \"canon\""),
508 &[],
509 "r"
510 )
511 .is_some()
512 );
513 }
514}