1use oqx::Consumer;
27use oqx::ast::{Expr, Follow, FollowDestination, 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::{REVISION_COLS, REVISION_FROM, Target, fetch_rows, tag_rows};
33use crate::translate::{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::Links => ("e", "d"),
42 Target::Revisions => ("r", "d"),
43 }
44}
45
46pub(crate) fn from_clause(t: Target) -> &'static str {
51 match t {
52 Target::Docs => "docs d",
53 Target::Blocks => "blocks b JOIN docs d ON d.doc_id = b.doc_id",
54 Target::Nodes => "nodes n JOIN docs d ON d.doc_id = n.doc_id",
55 Target::Links => "edges e JOIN docs d ON d.doc_id = e.src_doc",
56 Target::Revisions => REVISION_FROM,
57 }
58}
59
60pub(crate) fn columns(t: Target) -> &'static str {
63 match t {
64 Target::Docs => "d.*",
65 Target::Blocks => "b.*, d.path AS __path",
66 Target::Nodes => "n.*, d.path AS __path",
67 Target::Links => "e.*, d.path AS __path",
68 Target::Revisions => REVISION_COLS,
69 }
70}
71
72pub(crate) fn order_clause(t: Target) -> &'static str {
74 match t {
75 Target::Docs => "d.path, d.doc_id",
76 Target::Blocks => "d.path, b.block_id",
77 Target::Nodes => "d.path, n.node_id",
78 Target::Links => "d.path, e.edge_id",
79 Target::Revisions => "r.path, r.rev_id",
80 }
81}
82
83pub(crate) fn guards(t: Target) -> &'static str {
88 match t {
89 Target::Docs => "d.repo_id = ? AND d.deleted_commit IS NULL",
90 Target::Blocks => "b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL",
91 Target::Nodes => "n.repo_id = ? AND d.deleted_commit IS NULL",
92 Target::Links => "e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL",
93 Target::Revisions => "d.repo_id = ?",
94 }
95}
96
97pub(crate) fn from_by_doc(t: Target) -> &'static str {
103 match t {
104 Target::Docs | Target::Revisions => from_clause(t),
105 Target::Blocks => "docs d CROSS JOIN blocks b ON b.doc_id = d.doc_id",
106 Target::Nodes => "docs d CROSS JOIN nodes n ON n.doc_id = d.doc_id",
107 Target::Links => "docs d CROSS JOIN edges e ON e.src_doc = d.doc_id",
108 }
109}
110
111pub(crate) fn guards_by_doc(t: Target) -> &'static str {
116 match t {
117 Target::Docs | Target::Revisions => guards(t),
118 Target::Blocks => {
119 "d.repo_id = ? AND +b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL"
120 }
121 Target::Nodes => "d.repo_id = ? AND +n.repo_id = ? AND d.deleted_commit IS NULL",
122 Target::Links => {
123 "d.repo_id = ? AND +e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL"
124 }
125 }
126}
127
128pub(crate) fn root_target(source: &Expr) -> Option<Target> {
135 match source {
136 Expr::Ident { name, .. } => Target::parse(name),
137 _ => None,
138 }
139}
140
141fn is_target_relation_of(target: Target, name: &str) -> bool {
145 matches!(
146 (target, name),
147 (Target::Docs, "nodes" | "blocks" | "revisions")
148 | (Target::Blocks, "nodes")
149 | (Target::Nodes, "blocks")
150 )
151}
152
153fn reach_through(head: &str) -> Option<Target> {
156 match head {
157 "doc" | "dst" => Some(Target::Docs),
158 "block" => Some(Target::Blocks),
159 "section" => Some(Target::Nodes),
160 _ => None,
161 }
162}
163
164fn residual_may_raise(w: &Where, target: Target) -> bool {
185 where_may_raise(w, target, true)
186}
187
188fn where_may_raise(w: &Where, target: Target, root: bool) -> bool {
189 match w {
190 Where::And { parts, .. } | Where::Or { parts, .. } => {
191 parts.iter().any(|p| where_may_raise(p, target, root))
192 }
193 Where::Not { expr, .. } => where_may_raise(expr, target, root),
194 Where::Scalar { expr, .. } => expr_may_raise(expr, target, root),
195 Where::Op(op) => op_may_raise(op, target, root),
196 }
197}
198
199fn op_may_raise(op: &OpNode, target: Target, root: bool) -> bool {
200 op.op == Consumer::Single
201 || expr_may_raise(&op.receiver, target, root)
202 || subquery_may_raise(&op.sub, target)
203}
204
205fn subquery_may_raise(sub: &Subquery, target: Target) -> bool {
206 let inner = |e: &Expr| expr_may_raise(e, target, false);
207 sub.from.iter().any(inner)
208 || sub
209 .r#where
210 .as_ref()
211 .is_some_and(|w| where_may_raise(w, target, false))
212 || sub.select.iter().any(|item| match item {
213 SelectItem::Field { expr, lift, .. } => *lift > 0 || inner(expr),
214 SelectItem::Collect { op, .. } => op_may_raise(op, target, false),
215 })
216 || sub.order_by.iter().flatten().any(|o| inner(&o.expr))
217 || sub
218 .follow
219 .as_ref()
220 .is_some_and(|f| follow_may_raise(f, target))
221 || sub.frontier.as_ref().is_some_and(inner)
222 || sub.flatten.iter().any(|f| inner(&f.expr))
224 || sub
227 .group_by
228 .as_ref()
229 .is_some_and(|g| g.keys.iter().any(|k| inner(&k.expr)))
230 || sub
231 .having
232 .as_ref()
233 .is_some_and(|h| where_may_raise(h, target, false))
234 || sub.limit.as_ref().is_some_and(inner)
235 || sub.offset.as_ref().is_some_and(inner)
236}
237
238fn follow_may_raise(f: &Follow, target: Target) -> bool {
241 let inner = |e: &Expr| expr_may_raise(e, target, false);
242 f.destinations.iter().any(|d| match d {
243 FollowDestination::Relation(e) => inner(e),
244 FollowDestination::Block(op) => op_may_raise(op, target, false),
245 })
246}
247
248fn expr_may_raise(e: &Expr, target: Target, root: bool) -> bool {
249 let again = |e: &Expr| expr_may_raise(e, target, root);
250 match e {
251 Expr::Lit { .. } | Expr::Binding { .. } => false,
252 Expr::Ident { name, .. } if Target::parse(name).is_some() => {
256 !root || !is_target_relation_of(target, name)
257 }
258 Expr::Ident { .. } => false,
260 Expr::Outer { .. } | Expr::Call { .. } => true,
261 Expr::Member { recv, name, .. } => {
264 (Target::parse(name).is_some()
265 && !matches!(&**recv, Expr::Ident { name: head, .. }
266 if reach_through(head).is_some_and(|t| is_target_relation_of(t, name))))
267 || again(recv)
268 }
269 Expr::Unary { expr, .. } => again(expr),
270 Expr::Required { .. } => true,
273 Expr::Op(op) => op_may_raise(op, target, root),
274 Expr::Binary {
277 op, left, right, ..
278 } => op.is_regex() || again(left) || again(right),
279 Expr::Logical { left, right, .. } | Expr::In { left, right, .. } => {
280 again(left) || again(right)
281 }
282 Expr::Range { lo, hi, .. } => {
283 lo.as_deref().is_some_and(again) || hi.as_deref().is_some_and(again)
284 }
285 Expr::Tuple { items, .. } => items.iter().any(again),
287 Expr::Template { parts, .. } => oqx::walk::template_exprs(parts).next().is_some(),
291 Expr::Aggregate { .. } => true,
295 }
296}
297
298#[derive(Clone, Debug, PartialEq)]
301pub struct Compiled {
302 pub target: Target,
303 pub sql: String,
304 pub params: Vec<SqlValue>,
305 pub residual: Query,
306}
307
308#[must_use]
319pub fn compile(query: &Query, params: &[Value], repo_id: &str) -> Option<Compiled> {
320 if query.follow.is_some()
321 || !query.from.is_empty()
322 || query.group_by.is_some()
323 || !query.flatten.is_empty()
324 {
325 return None;
326 }
327 let target = root_target(&query.source)?;
328 if target == Target::Revisions {
331 return None;
332 }
333 let (self_alias, doc_alias) = aliases(target);
334 let ctx = TranslateCtx {
335 target,
336 self_alias,
337 doc_alias,
338 params,
339 };
340 let (pushed, residual) = partition_pushable(query.r#where.as_ref(), |e| {
341 translate_predicate(e, &ctx).is_some()
342 });
343 if pushed.is_empty() {
344 return None;
345 }
346 if residual
349 .as_ref()
350 .is_some_and(|w| residual_may_raise(w, target))
351 {
352 return None;
353 }
354 let mut where_sql = guards(target).to_owned();
355 let mut sql_params = vec![SqlValue::Text(repo_id.to_owned())];
356 for e in &pushed {
357 let frag = translate_predicate(e, &ctx).expect("accepted by partition_pushable");
358 where_sql.push_str(" AND (");
359 where_sql.push_str(&frag.sql);
360 where_sql.push(')');
361 sql_params.extend(frag.params);
362 }
363 let sql = format!(
364 "SELECT {} FROM {} WHERE {where_sql} ORDER BY {}",
365 columns(target),
366 from_clause(target),
367 order_clause(target)
368 );
369 Some(Compiled {
370 target,
371 sql,
372 params: sql_params,
373 residual: residual_query(query, residual),
374 })
375}
376
377pub struct SqlitePlanner<'a> {
379 conn: &'a Connection,
380 repo_id: String,
381}
382
383impl<'a> SqlitePlanner<'a> {
384 #[must_use]
385 pub fn new(conn: &'a Connection, repo_id: &str) -> Self {
386 Self {
387 conn,
388 repo_id: repo_id.to_owned(),
389 }
390 }
391
392 pub fn try_plan(&self, query: &Query, params: &[Value]) -> rusqlite::Result<Option<Plan>> {
397 let Some(compiled) = compile(query, params, &self.repo_id) else {
398 return Ok(None);
399 };
400 let rows = fetch_rows(self.conn, &compiled.sql, &compiled.params)?;
401 Ok(Some(Plan::new(
402 tag_rows(rows, compiled.target),
403 compiled.residual,
404 )))
405 }
406}
407
408impl QueryPlanner for SqlitePlanner<'_> {
409 fn plan(&self, query: &Query, params: &[Value]) -> Option<Plan> {
412 self.try_plan(query, params).ok().flatten()
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use oqx::ROWS_ROOT;
420 use oqx::ast::Where;
421
422 fn parse(src: &str) -> Query {
423 oqx::parse_string(src).expect("parses")
424 }
425
426 fn text(s: &str) -> SqlValue {
427 SqlValue::Text(s.to_owned())
428 }
429
430 #[test]
431 fn a_pushable_scan_compiles_to_one_statement_in_root_order() {
432 let c =
433 compile(&parse("from docs where path == \"index.md\""), &[], "r_1").expect("planned");
434 assert_eq!(c.target, Target::Docs);
435 assert_eq!(
436 c.sql,
437 "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"
438 );
439 assert_eq!(c.params, vec![text("r_1"), text("index.md")]);
440 assert_eq!(
441 c.residual.source,
442 Expr::Ident {
443 name: ROWS_ROOT.to_owned(),
444 span: oqx::Span::EMPTY,
445 }
446 );
447 assert_eq!(c.residual.r#where, None);
448 }
449
450 #[test]
451 fn every_target_has_its_join_columns_guards_and_order() {
452 let b = compile(
453 &parse("from blocks where path.startsWith(\"lab/\")"),
454 &[],
455 "r",
456 )
457 .unwrap();
458 assert_eq!(
459 b.sql,
460 "SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id \
461 WHERE b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL \
462 AND ((substr(('/' || d.path), 1, length(?)) = ?)) ORDER BY d.path, b.block_id"
463 );
464 assert_eq!(b.params, vec![text("r"), text("lab/"), text("lab/")]);
465 let n = compile(
466 &parse("nodes count { where kind == \"md:task\" }"),
467 &[],
468 "r",
469 )
470 .unwrap();
471 assert_eq!(n.target, Target::Nodes);
472 assert!(n.sql.starts_with(
473 "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 ?))"
474 ));
475 assert!(n.sql.ends_with("ORDER BY d.path, n.node_id"));
476 let e = compile(
477 &parse("from links where predicate == \"references\""),
478 &[],
479 "r",
480 )
481 .unwrap();
482 assert!(e.sql.starts_with(
483 "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 ?))"
484 ));
485 assert!(e.sql.ends_with("ORDER BY d.path, e.edge_id"));
486 }
487
488 #[test]
489 fn mixed_conjunctions_push_the_translatable_parts_and_keep_the_rest() {
490 let q = parse(
491 "from docs where path.startsWith(\"processes/\") && nodes exists { where kind == \"md:task\" } && layer == \"canon\"",
492 );
493 let c = compile(&q, &[], "r").expect("planned");
494 assert!(
495 c.sql
496 .contains("(substr(('/' || d.path), 1, length(?)) = ?)"),
497 "{}",
498 c.sql
499 );
500 assert!(c.sql.contains("p.key = 'layer'"), "{}", c.sql);
501 assert_eq!(
503 c.params,
504 vec![
505 text("r"),
506 text("processes/"),
507 text("processes/"),
508 text("canon")
509 ]
510 );
511 assert!(
513 matches!(c.residual.r#where, Some(Where::Op(_))),
514 "{:?}",
515 c.residual.r#where
516 );
517 assert!(c.residual.from.is_empty());
518 assert_eq!(c.residual.select, q.select);
519 assert_eq!(c.residual.consumer, q.consumer);
520 }
521
522 #[test]
523 fn declined_shapes_return_none() {
524 assert!(compile(&parse("from docs"), &[], "r").is_none());
526 assert!(compile(&parse("from docs where era in 800..1680"), &[], "r").is_none());
527 assert!(compile(&parse("from docs where !verified"), &[], "r").is_none());
528 assert!(
529 compile(
530 &parse("from docs where path == \"a\" || path == \"b\""),
531 &[],
532 "r"
533 )
534 .is_none()
535 );
536 assert!(
537 compile(
538 &parse("from docs where nodes exists { where kind == \"md:task\" }"),
539 &[],
540 "r"
541 )
542 .is_none()
543 );
544 assert!(
546 compile(
547 &parse("from docs where path == \"a.md\" follow distinct out_links collect { from dst }"),
548 &[],
549 "r"
550 )
551 .is_none()
552 );
553 assert!(
555 compile(
556 &parse("select path, t from docs where layer == \"canon\" flatten tags as t"),
557 &[],
558 "r"
559 )
560 .is_none()
561 );
562 assert!(
563 compile(
564 &parse("from docs where layer == \"canon\" flatten blocks { where type == \"task\" } as b"),
565 &[],
566 "r"
567 )
568 .is_none()
569 );
570 assert!(
573 compile(
574 &parse("from docs where path == \"a.md\" follow out_links collect { from dst }, in_links collect { from doc }"),
575 &[],
576 "r"
577 )
578 .is_none()
579 );
580 assert!(
581 compile(
582 &parse(
583 "from docs where path == \"a.md\" follow ^docs collect { where after.contains(^path) }"
584 ),
585 &[],
586 "r"
587 )
588 .is_none()
589 );
590 assert!(
592 compile(
593 &parse("from docs where path == \"a.md\" frontier type == \"hub\" follow distinct out_links collect { from dst frontier type == \"practitioner\" }"),
594 &[],
595 "r"
596 )
597 .is_none()
598 );
599 assert!(
601 compile(
602 &parse("select layer, n: count() from docs where path == \"a.md\" group by layer"),
603 &[],
604 "r"
605 )
606 .is_none()
607 );
608 assert!(compile(&parse("from things where path == \"a.md\""), &[], "r").is_none());
610 assert!(compile(&parse("from nope where path == \"a.md\""), &[], "r").is_none());
611 assert!(compile(&parse("from docs.nodes where kind == \"x\""), &[], "r").is_none());
612 let mut q = parse("from docs where path == \"a.md\"");
614 q.from.push(Expr::Ident {
615 name: "nodes".to_owned(),
616 span: oqx::Span::EMPTY,
617 });
618 assert!(compile(&q, &[], "r").is_none());
619 }
620
621 #[test]
624 fn a_residual_that_could_raise_declines_the_whole_query() {
625 let declined = |src: &str| {
626 assert!(
627 compile(&parse(src), &[], "r").is_none(),
628 "should decline: {src}"
629 );
630 };
631 let planned = |src: &str| {
632 assert!(
633 compile(&parse(src), &[], "r").is_some(),
634 "should plan: {src}"
635 );
636 };
637 planned("from docs where path == \"x\" && path == \"nope.md\"");
640 planned("from docs where path == \"nope.md\" && !raw");
641 planned("from docs where path == \"nope.md\" && updated_at");
642 planned("from docs where path == \"nope.md\" && tags");
643 planned("from blocks where path == \"x\" && doc.path == \"y\"");
645 planned("from blocks where path == \"x\" && nodes exists { where doc.path == \"y\" }");
646 declined("from docs where path.matches(\"[\") && path == \"nope.md\"");
648 declined("from docs where nope(\"x\") && path == \"nope.md\"");
649 declined("from docs where path == \"x\" && size(tags) > 1");
650 declined("from docs where path == \"x\" && nodes exists { where name.lower() == \"a\" }");
651 declined(
652 "from docs where path == \"x\" && nodes count { where kind == \"a\" order by size(name) } > 1",
653 );
654 declined("from docs where path == \"x\" && ^slug == \"y\"");
656 declined("from docs where path == \"x\" && nodes exists { where name == ^title }");
657 declined("from docs where path == \"x\" && nodes collect { ^first_task: name }");
658 declined(
660 "from docs where path == \"x\" && blocks exists { select t: nodes single { where kind == \"md:task\" } }",
661 );
662 planned("from docs where path.startsWith(\"lab/\") && path == \"x\"");
664 planned("from docs where path == \"x\" && era in 800..1680");
666 planned("from docs where path == \"x\" && !verified");
667 planned("from docs where path == \"x\" && (layer == \"a\" || layer == \"b\")");
668 planned("from docs where path == \"x\" && verified == true");
669 planned("from docs where path == \"x\" && nodes exists { where kind == \"md:task\" }");
670 planned(
671 "from docs where path == \"x\" && nodes count { where kind == \"md:task\" limit 5 } > 1",
672 );
673 planned("from blocks where path == \"x\" && !path");
676 planned("from docs where path == \"x\" && nodes exists { where path == \"y\" }");
677 planned("from docs where path == \"x\" && frontmatter.path == \"y\"");
678 let fm = compile(
682 &parse("from docs where frontmatter.layer == \"canon\""),
683 &[],
684 "r",
685 )
686 .expect("planned");
687 assert!(fm.sql.contains("p.source = 'frontmatter'"), "{}", fm.sql);
688 let data =
689 compile(&parse("from docs where data.layer == \"canon\""), &[], "r").expect("planned");
690 assert!(!data.sql.contains("p.source"), "{}", data.sql);
691 planned("from blocks where doc.inline.element == \"fire\"");
692 planned("from docs where path == \"x\" && frontmatter == null");
693 declined("from docs where path == \"x\" && entries(frontmatter) exists { }");
694 }
695
696 #[test]
697 fn a_top_level_not_or_or_is_the_whole_residual_and_declines() {
698 assert!(compile(&parse("from docs where !(path == \"a\")"), &[], "r").is_none());
701 assert!(
702 compile(
703 &parse("from docs where (path == \"a\" || path == \"b\") && layer == \"canon\""),
704 &[],
705 "r"
706 )
707 .is_some()
708 );
709 }
710}