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::{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 }
43}
44
45pub(crate) fn 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::Links => "edges e JOIN docs d ON d.doc_id = e.src_doc",
51 }
52}
53
54pub(crate) fn 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::Links => "e.*, d.path AS __path",
62 }
63}
64
65pub(crate) fn 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::Links => "d.path, e.edge_id",
72 }
73}
74
75pub(crate) fn 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::Links => "e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL",
82 }
83}
84
85pub(crate) fn from_by_doc(t: Target) -> &'static str {
91 match t {
92 Target::Docs => from_clause(t),
93 Target::Blocks => "docs d CROSS JOIN blocks b ON b.doc_id = d.doc_id",
94 Target::Nodes => "docs d CROSS JOIN nodes n ON n.doc_id = d.doc_id",
95 Target::Links => "docs d CROSS JOIN edges e ON e.src_doc = d.doc_id",
96 }
97}
98
99pub(crate) fn guards_by_doc(t: Target) -> &'static str {
104 match t {
105 Target::Docs => guards(t),
106 Target::Blocks => {
107 "d.repo_id = ? AND +b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL"
108 }
109 Target::Nodes => "d.repo_id = ? AND +n.repo_id = ? AND d.deleted_commit IS NULL",
110 Target::Links => {
111 "d.repo_id = ? AND +e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL"
112 }
113 }
114}
115
116pub(crate) fn root_target(source: &Expr) -> Option<Target> {
121 match source {
122 Expr::Ident { name, .. } => Target::parse(name),
123 _ => None,
124 }
125}
126
127fn is_target_relation_of(target: Target, name: &str) -> bool {
130 matches!(
131 (target, name),
132 (Target::Docs, "nodes" | "blocks") | (Target::Blocks, "nodes") | (Target::Nodes, "blocks")
133 )
134}
135
136fn reach_through(head: &str) -> Option<Target> {
139 match head {
140 "doc" | "dst" => Some(Target::Docs),
141 "block" => Some(Target::Blocks),
142 "section" => Some(Target::Nodes),
143 _ => None,
144 }
145}
146
147fn residual_may_raise(w: &Where, target: Target) -> bool {
168 where_may_raise(w, target, true)
169}
170
171fn where_may_raise(w: &Where, target: Target, root: bool) -> bool {
172 match w {
173 Where::And { parts, .. } | Where::Or { parts, .. } => {
174 parts.iter().any(|p| where_may_raise(p, target, root))
175 }
176 Where::Not { expr, .. } => where_may_raise(expr, target, root),
177 Where::Scalar { expr, .. } => expr_may_raise(expr, target, root),
178 Where::Op(op) => op_may_raise(op, target, root),
179 }
180}
181
182fn op_may_raise(op: &OpNode, target: Target, root: bool) -> bool {
183 op.op == Consumer::Single
184 || expr_may_raise(&op.receiver, target, root)
185 || subquery_may_raise(&op.sub, target)
186}
187
188fn subquery_may_raise(sub: &Subquery, target: Target) -> bool {
189 let inner = |e: &Expr| expr_may_raise(e, target, false);
190 sub.from.iter().any(inner)
191 || sub
192 .r#where
193 .as_ref()
194 .is_some_and(|w| where_may_raise(w, target, false))
195 || sub.select.iter().any(|item| match item {
196 SelectItem::Field { expr, lift, .. } => *lift > 0 || inner(expr),
197 SelectItem::Collect { op, .. } => op_may_raise(op, target, false),
198 })
199 || sub.order_by.iter().flatten().any(|o| inner(&o.expr))
200 || sub
201 .follow
202 .as_ref()
203 .is_some_and(|f| follow_may_raise(f, target))
204 || sub.frontier.as_ref().is_some_and(inner)
205 || sub.limit.as_ref().is_some_and(inner)
206 || sub.offset.as_ref().is_some_and(inner)
207}
208
209fn follow_may_raise(f: &Follow, target: Target) -> bool {
212 let inner = |e: &Expr| expr_may_raise(e, target, false);
213 f.destinations.iter().any(|d| match d {
214 FollowDestination::Relation(e) => inner(e),
215 FollowDestination::Block(op) => op_may_raise(op, target, false),
216 })
217}
218
219fn expr_may_raise(e: &Expr, target: Target, root: bool) -> bool {
220 let again = |e: &Expr| expr_may_raise(e, target, root);
221 match e {
222 Expr::Lit { .. } | Expr::Binding { .. } => false,
223 Expr::Ident { name, .. } if Target::parse(name).is_some() => {
227 !root || !is_target_relation_of(target, name)
228 }
229 Expr::Ident { .. } => false,
231 Expr::Outer { .. } | Expr::Call { .. } => true,
232 Expr::Member { recv, name, .. } => {
235 (Target::parse(name).is_some()
236 && !matches!(&**recv, Expr::Ident { name: head, .. }
237 if reach_through(head).is_some_and(|t| is_target_relation_of(t, name))))
238 || again(recv)
239 }
240 Expr::Unary { expr, .. } => again(expr),
241 Expr::Required { .. } => true,
244 Expr::Op(op) => op_may_raise(op, target, root),
245 Expr::Binary { left, right, .. }
246 | Expr::Logical { left, right, .. }
247 | Expr::In { left, right, .. } => again(left) || again(right),
248 Expr::Range { lo, hi, .. } => {
249 lo.as_deref().is_some_and(again) || hi.as_deref().is_some_and(again)
250 }
251 Expr::Tuple { items, .. } => items.iter().any(again),
253 Expr::Template { parts, .. } => oqx::walk::template_exprs(parts).next().is_some(),
257 }
258}
259
260#[derive(Clone, Debug, PartialEq)]
263pub struct Compiled {
264 pub target: Target,
265 pub sql: String,
266 pub params: Vec<SqlValue>,
267 pub residual: Query,
268}
269
270#[must_use]
274pub fn compile(query: &Query, params: &[Value], repo_id: &str) -> Option<Compiled> {
275 if query.follow.is_some() || !query.from.is_empty() {
276 return None;
277 }
278 let target = root_target(&query.source)?;
279 let (self_alias, doc_alias) = aliases(target);
280 let ctx = TranslateCtx {
281 target,
282 self_alias,
283 doc_alias,
284 params,
285 };
286 let (pushed, residual) = partition_pushable(query.r#where.as_ref(), |e| {
287 translate_predicate(e, &ctx).is_some()
288 });
289 if pushed.is_empty() {
290 return None;
291 }
292 if residual
295 .as_ref()
296 .is_some_and(|w| residual_may_raise(w, target))
297 {
298 return None;
299 }
300 let mut where_sql = guards(target).to_owned();
301 let mut sql_params = vec![SqlValue::Text(repo_id.to_owned())];
302 for e in &pushed {
303 let frag = translate_predicate(e, &ctx).expect("accepted by partition_pushable");
304 where_sql.push_str(" AND (");
305 where_sql.push_str(&frag.sql);
306 where_sql.push(')');
307 sql_params.extend(frag.params);
308 }
309 let sql = format!(
310 "SELECT {} FROM {} WHERE {where_sql} ORDER BY {}",
311 columns(target),
312 from_clause(target),
313 order_clause(target)
314 );
315 Some(Compiled {
316 target,
317 sql,
318 params: sql_params,
319 residual: residual_query(query, residual),
320 })
321}
322
323pub struct SqlitePlanner<'a> {
325 conn: &'a Connection,
326 repo_id: String,
327}
328
329impl<'a> SqlitePlanner<'a> {
330 #[must_use]
331 pub fn new(conn: &'a Connection, repo_id: &str) -> Self {
332 Self {
333 conn,
334 repo_id: repo_id.to_owned(),
335 }
336 }
337
338 pub fn try_plan(&self, query: &Query, params: &[Value]) -> rusqlite::Result<Option<Plan>> {
343 let Some(compiled) = compile(query, params, &self.repo_id) else {
344 return Ok(None);
345 };
346 let rows = fetch_rows(self.conn, &compiled.sql, &compiled.params)?;
347 Ok(Some(Plan::new(
348 tag_rows(rows, compiled.target),
349 compiled.residual,
350 )))
351 }
352}
353
354impl QueryPlanner for SqlitePlanner<'_> {
355 fn plan(&self, query: &Query, params: &[Value]) -> Option<Plan> {
358 self.try_plan(query, params).ok().flatten()
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use oqx::ROWS_ROOT;
366 use oqx::ast::Where;
367
368 fn parse(src: &str) -> Query {
369 oqx::parse_string(src).expect("parses")
370 }
371
372 fn text(s: &str) -> SqlValue {
373 SqlValue::Text(s.to_owned())
374 }
375
376 #[test]
377 fn a_pushable_scan_compiles_to_one_statement_in_root_order() {
378 let c =
379 compile(&parse("from docs where path == \"index.md\""), &[], "r_1").expect("planned");
380 assert_eq!(c.target, Target::Docs);
381 assert_eq!(
382 c.sql,
383 "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"
384 );
385 assert_eq!(c.params, vec![text("r_1"), text("index.md")]);
386 assert_eq!(
387 c.residual.source,
388 Expr::Ident {
389 name: ROWS_ROOT.to_owned(),
390 span: oqx::Span::EMPTY,
391 }
392 );
393 assert_eq!(c.residual.r#where, None);
394 }
395
396 #[test]
397 fn every_target_has_its_join_columns_guards_and_order() {
398 let b = compile(
399 &parse("from blocks where path.startsWith(\"lab/\")"),
400 &[],
401 "r",
402 )
403 .unwrap();
404 assert_eq!(
405 b.sql,
406 "SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id \
407 WHERE b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL \
408 AND ((substr(('/' || d.path), 1, length(?)) = ?)) ORDER BY d.path, b.block_id"
409 );
410 assert_eq!(b.params, vec![text("r"), text("lab/"), text("lab/")]);
411 let n = compile(
412 &parse("nodes count { where kind == \"md:task\" }"),
413 &[],
414 "r",
415 )
416 .unwrap();
417 assert_eq!(n.target, Target::Nodes);
418 assert!(n.sql.starts_with(
419 "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 ?))"
420 ));
421 assert!(n.sql.ends_with("ORDER BY d.path, n.node_id"));
422 let e = compile(
423 &parse("from links where predicate == \"references\""),
424 &[],
425 "r",
426 )
427 .unwrap();
428 assert!(e.sql.starts_with(
429 "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 ?))"
430 ));
431 assert!(e.sql.ends_with("ORDER BY d.path, e.edge_id"));
432 }
433
434 #[test]
435 fn mixed_conjunctions_push_the_translatable_parts_and_keep_the_rest() {
436 let q = parse(
437 "from docs where path.startsWith(\"processes/\") && nodes exists { where kind == \"md:task\" } && layer == \"canon\"",
438 );
439 let c = compile(&q, &[], "r").expect("planned");
440 assert!(
441 c.sql
442 .contains("(substr(('/' || d.path), 1, length(?)) = ?)"),
443 "{}",
444 c.sql
445 );
446 assert!(c.sql.contains("p.key = 'layer'"), "{}", c.sql);
447 assert_eq!(
449 c.params,
450 vec![
451 text("r"),
452 text("processes/"),
453 text("processes/"),
454 text("canon")
455 ]
456 );
457 assert!(
459 matches!(c.residual.r#where, Some(Where::Op(_))),
460 "{:?}",
461 c.residual.r#where
462 );
463 assert!(c.residual.from.is_empty());
464 assert_eq!(c.residual.select, q.select);
465 assert_eq!(c.residual.consumer, q.consumer);
466 }
467
468 #[test]
469 fn declined_shapes_return_none() {
470 assert!(compile(&parse("from docs"), &[], "r").is_none());
472 assert!(compile(&parse("from docs where era in 800..1680"), &[], "r").is_none());
473 assert!(compile(&parse("from docs where !verified"), &[], "r").is_none());
474 assert!(
475 compile(
476 &parse("from docs where path == \"a\" || path == \"b\""),
477 &[],
478 "r"
479 )
480 .is_none()
481 );
482 assert!(
483 compile(
484 &parse("from docs where nodes exists { where kind == \"md:task\" }"),
485 &[],
486 "r"
487 )
488 .is_none()
489 );
490 assert!(
492 compile(
493 &parse("from docs where path == \"a.md\" follow distinct out_links collect { from dst }"),
494 &[],
495 "r"
496 )
497 .is_none()
498 );
499 assert!(
502 compile(
503 &parse("from docs where path == \"a.md\" follow out_links collect { from dst }, in_links collect { from doc }"),
504 &[],
505 "r"
506 )
507 .is_none()
508 );
509 assert!(
510 compile(
511 &parse(
512 "from docs where path == \"a.md\" follow ^docs collect { where after.contains(^path) }"
513 ),
514 &[],
515 "r"
516 )
517 .is_none()
518 );
519 assert!(
521 compile(
522 &parse("from docs where path == \"a.md\" frontier type == \"hub\" follow distinct out_links collect { from dst frontier type == \"practitioner\" }"),
523 &[],
524 "r"
525 )
526 .is_none()
527 );
528 assert!(compile(&parse("from things where path == \"a.md\""), &[], "r").is_none());
530 assert!(compile(&parse("from nope where path == \"a.md\""), &[], "r").is_none());
531 assert!(compile(&parse("from docs.nodes where kind == \"x\""), &[], "r").is_none());
532 let mut q = parse("from docs where path == \"a.md\"");
534 q.from.push(Expr::Ident {
535 name: "nodes".to_owned(),
536 span: oqx::Span::EMPTY,
537 });
538 assert!(compile(&q, &[], "r").is_none());
539 }
540
541 #[test]
544 fn a_residual_that_could_raise_declines_the_whole_query() {
545 let declined = |src: &str| {
546 assert!(
547 compile(&parse(src), &[], "r").is_none(),
548 "should decline: {src}"
549 );
550 };
551 let planned = |src: &str| {
552 assert!(
553 compile(&parse(src), &[], "r").is_some(),
554 "should plan: {src}"
555 );
556 };
557 planned("from docs where path == \"x\" && path == \"nope.md\"");
560 planned("from docs where path == \"nope.md\" && !raw");
561 planned("from docs where path == \"nope.md\" && updated_at");
562 planned("from docs where path == \"nope.md\" && tags");
563 planned("from blocks where path == \"x\" && doc.path == \"y\"");
565 planned("from blocks where path == \"x\" && nodes exists { where doc.path == \"y\" }");
566 declined("from docs where path.matches(\"[\") && path == \"nope.md\"");
568 declined("from docs where nope(\"x\") && path == \"nope.md\"");
569 declined("from docs where path == \"x\" && size(tags) > 1");
570 declined("from docs where path == \"x\" && nodes exists { where name.lower() == \"a\" }");
571 declined(
572 "from docs where path == \"x\" && nodes count { where kind == \"a\" order by size(name) } > 1",
573 );
574 declined("from docs where path == \"x\" && ^slug == \"y\"");
576 declined("from docs where path == \"x\" && nodes exists { where name == ^title }");
577 declined("from docs where path == \"x\" && nodes collect { ^first_task: name }");
578 declined(
580 "from docs where path == \"x\" && blocks exists { select t: nodes single { where kind == \"md:task\" } }",
581 );
582 planned("from docs where path.startsWith(\"lab/\") && path == \"x\"");
584 planned("from docs where path == \"x\" && era in 800..1680");
586 planned("from docs where path == \"x\" && !verified");
587 planned("from docs where path == \"x\" && (layer == \"a\" || layer == \"b\")");
588 planned("from docs where path == \"x\" && verified == true");
589 planned("from docs where path == \"x\" && nodes exists { where kind == \"md:task\" }");
590 planned(
591 "from docs where path == \"x\" && nodes count { where kind == \"md:task\" limit 5 } > 1",
592 );
593 planned("from blocks where path == \"x\" && !path");
596 planned("from docs where path == \"x\" && nodes exists { where path == \"y\" }");
597 planned("from docs where path == \"x\" && frontmatter.path == \"y\"");
598 let fm = compile(
602 &parse("from docs where frontmatter.layer == \"canon\""),
603 &[],
604 "r",
605 )
606 .expect("planned");
607 assert!(fm.sql.contains("p.source = 'frontmatter'"), "{}", fm.sql);
608 let data =
609 compile(&parse("from docs where data.layer == \"canon\""), &[], "r").expect("planned");
610 assert!(!data.sql.contains("p.source"), "{}", data.sql);
611 planned("from blocks where doc.inline.element == \"fire\"");
612 planned("from docs where path == \"x\" && frontmatter == null");
613 declined("from docs where path == \"x\" && entries(frontmatter) exists { }");
614 }
615
616 #[test]
617 fn a_top_level_not_or_or_is_the_whole_residual_and_declines() {
618 assert!(compile(&parse("from docs where !(path == \"a\")"), &[], "r").is_none());
621 assert!(
622 compile(
623 &parse("from docs where (path == \"a\" || path == \"b\") && layer == \"canon\""),
624 &[],
625 "r"
626 )
627 .is_some()
628 );
629 }
630}