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::{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
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::Edges => "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::Edges => "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::Edges => "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::Edges => "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::Edges => "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::Edges => {
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> {
120 match source {
121 Expr::Ident { name, .. } => Target::parse(name),
122 Expr::Member { recv, name, .. } => match &**recv {
123 Expr::Ident { name: r, .. } if r == "$repo" => Target::parse(name),
124 _ => None,
125 },
126 _ => None,
127 }
128}
129
130fn residual_may_raise(w: &Where, target: Target) -> bool {
152 where_may_raise(w, target, true)
153}
154
155fn where_may_raise(w: &Where, target: Target, root: bool) -> bool {
156 match w {
157 Where::And { parts, .. } | Where::Or { parts, .. } => {
158 parts.iter().any(|p| where_may_raise(p, target, root))
159 }
160 Where::Not { expr, .. } => where_may_raise(expr, target, root),
161 Where::Scalar { expr, .. } => expr_may_raise(expr, target, root),
162 Where::Op(op) => op_may_raise(op, target, root),
163 }
164}
165
166fn op_may_raise(op: &OpNode, target: Target, root: bool) -> bool {
167 op.op == Consumer::Single
168 || expr_may_raise(&op.receiver, target, root)
169 || subquery_may_raise(&op.sub, target)
170}
171
172fn subquery_may_raise(sub: &Subquery, target: Target) -> bool {
173 let inner = |e: &Expr| expr_may_raise(e, target, false);
174 sub.from.iter().any(inner)
175 || sub
176 .r#where
177 .as_ref()
178 .is_some_and(|w| where_may_raise(w, target, false))
179 || sub.select.iter().any(|item| match item {
180 SelectItem::Field { expr, lift, .. } => *lift > 0 || inner(expr),
181 SelectItem::Collect { op, .. } => op_may_raise(op, target, false),
182 })
183 || sub.order_by.iter().flatten().any(|o| inner(&o.expr))
184 || sub
185 .follow
186 .as_ref()
187 .is_some_and(|f| follow_may_raise(f, target))
188 || sub.limit.as_ref().is_some_and(inner)
189 || sub.offset.as_ref().is_some_and(inner)
190}
191
192fn follow_may_raise(f: &Follow, target: Target) -> bool {
193 let inner = |e: &Expr| expr_may_raise(e, target, false);
194 f.destinations.iter().any(|d| match d {
195 FollowDestination::Relation(e) => inner(e),
196 FollowDestination::Block(op) => op_may_raise(op, target, false),
197 }) || f.r#where.as_ref().is_some_and(inner)
198 || f.frontier.as_ref().is_some_and(inner)
199 || f.by.as_ref().is_some_and(inner)
200}
201
202fn is_reserved(name: &str) -> bool {
203 RESERVED_DOC_BASENAMES.contains(&name)
204}
205
206fn expr_may_raise(e: &Expr, target: Target, root: bool) -> bool {
207 let again = |e: &Expr| expr_may_raise(e, target, root);
208 match e {
209 Expr::Lit { .. } | Expr::Binding { .. } => false,
210 Expr::Ident { name, .. } => root && target == Target::Docs && is_reserved(name),
211 Expr::Outer { .. } | Expr::Call { .. } => true,
212 Expr::Member { recv, name, .. } => {
215 (is_reserved(name) && matches!(&**recv, Expr::Ident { name, .. } if name == "doc"))
216 || again(recv)
217 }
218 Expr::Unary { expr, .. } => again(expr),
219 Expr::Binary { left, right, .. }
220 | Expr::Logical { left, right, .. }
221 | Expr::In { left, right, .. } => again(left) || again(right),
222 Expr::Range { lo, hi, .. } => {
223 lo.as_deref().is_some_and(again) || hi.as_deref().is_some_and(again)
224 }
225 }
226}
227
228#[derive(Clone, Debug, PartialEq)]
231pub struct Compiled {
232 pub target: Target,
233 pub sql: String,
234 pub params: Vec<SqlValue>,
235 pub residual: Query,
236}
237
238#[must_use]
242pub fn compile(query: &Query, params: &[Value], repo_id: &str) -> Option<Compiled> {
243 if query.follow.is_some() || !query.from.is_empty() {
244 return None;
245 }
246 let target = root_target(&query.source)?;
247 let (self_alias, doc_alias) = aliases(target);
248 let ctx = TranslateCtx {
249 target,
250 self_alias,
251 doc_alias,
252 params,
253 };
254 let (pushed, residual) = partition_pushable(query.r#where.as_ref(), |e| {
255 translate_predicate(e, &ctx).is_some()
256 });
257 if pushed.is_empty() {
258 return None;
259 }
260 if residual
263 .as_ref()
264 .is_some_and(|w| residual_may_raise(w, target))
265 {
266 return None;
267 }
268 let mut where_sql = guards(target).to_owned();
269 let mut sql_params = vec![SqlValue::Text(repo_id.to_owned())];
270 for e in &pushed {
271 let frag = translate_predicate(e, &ctx).expect("accepted by partition_pushable");
272 where_sql.push_str(" AND (");
273 where_sql.push_str(&frag.sql);
274 where_sql.push(')');
275 sql_params.extend(frag.params);
276 }
277 let sql = format!(
278 "SELECT {} FROM {} WHERE {where_sql} ORDER BY {}",
279 columns(target),
280 from_clause(target),
281 order_clause(target)
282 );
283 Some(Compiled {
284 target,
285 sql,
286 params: sql_params,
287 residual: residual_query(query, residual),
288 })
289}
290
291pub struct SqlitePlanner<'a> {
293 conn: &'a Connection,
294 repo_id: String,
295}
296
297impl<'a> SqlitePlanner<'a> {
298 #[must_use]
299 pub fn new(conn: &'a Connection, repo_id: &str) -> Self {
300 Self {
301 conn,
302 repo_id: repo_id.to_owned(),
303 }
304 }
305
306 pub fn try_plan(&self, query: &Query, params: &[Value]) -> rusqlite::Result<Option<Plan>> {
311 let Some(compiled) = compile(query, params, &self.repo_id) else {
312 return Ok(None);
313 };
314 let rows = fetch_rows(self.conn, &compiled.sql, &compiled.params)?;
315 Ok(Some(Plan::new(
316 tag_rows(rows, compiled.target),
317 compiled.residual,
318 )))
319 }
320}
321
322impl QueryPlanner for SqlitePlanner<'_> {
323 fn plan(&self, query: &Query, params: &[Value]) -> Option<Plan> {
326 self.try_plan(query, params).ok().flatten()
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use oqx::ROWS_ROOT;
334 use oqx::ast::Where;
335
336 fn parse(src: &str) -> Query {
337 oqx::parse_string(src).expect("parses")
338 }
339
340 fn text(s: &str) -> SqlValue {
341 SqlValue::Text(s.to_owned())
342 }
343
344 #[test]
345 fn a_pushable_scan_compiles_to_one_statement_in_root_order() {
346 let c =
347 compile(&parse("from docs where $path == \"index.md\""), &[], "r_1").expect("planned");
348 assert_eq!(c.target, Target::Docs);
349 assert_eq!(
350 c.sql,
351 "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"
352 );
353 assert_eq!(c.params, vec![text("r_1"), text("index.md")]);
354 assert_eq!(
355 c.residual.source,
356 Expr::Ident {
357 name: ROWS_ROOT.to_owned(),
358 span: oqx::Span::EMPTY,
359 }
360 );
361 assert_eq!(c.residual.r#where, None);
362 }
363
364 #[test]
365 fn every_target_has_its_join_columns_guards_and_order() {
366 let b = compile(
367 &parse("from blocks where $path.startsWith(\"lab/\")"),
368 &[],
369 "r",
370 )
371 .unwrap();
372 assert_eq!(
373 b.sql,
374 "SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id \
375 WHERE b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL \
376 AND ((substr(d.path, 1, length(?)) = ?)) ORDER BY d.path, b.block_id"
377 );
378 assert_eq!(b.params, vec![text("r"), text("lab/"), text("lab/")]);
379 let n = compile(
380 &parse("$repo.nodes count { where kind == \"md:task\" }"),
381 &[],
382 "r",
383 )
384 .unwrap();
385 assert_eq!(n.target, Target::Nodes);
386 assert!(n.sql.starts_with(
387 "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 ?))"
388 ));
389 assert!(n.sql.ends_with("ORDER BY d.path, n.node_id"));
390 let e = compile(
391 &parse("from edges where predicate == \"references\""),
392 &[],
393 "r",
394 )
395 .unwrap();
396 assert!(e.sql.starts_with(
397 "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 ?))"
398 ));
399 assert!(e.sql.ends_with("ORDER BY d.path, e.edge_id"));
400 }
401
402 #[test]
403 fn mixed_conjunctions_push_the_translatable_parts_and_keep_the_rest() {
404 let q = parse(
405 "from docs where $path.startsWith(\"processes/\") && nodes exists { where kind == \"md:task\" } && layer == \"canon\"",
406 );
407 let c = compile(&q, &[], "r").expect("planned");
408 assert!(
409 c.sql.contains("(substr(d.path, 1, length(?)) = ?)"),
410 "{}",
411 c.sql
412 );
413 assert!(c.sql.contains("p.key = 'layer'"), "{}", c.sql);
414 assert_eq!(
416 c.params,
417 vec![
418 text("r"),
419 text("processes/"),
420 text("processes/"),
421 text("canon")
422 ]
423 );
424 assert!(
426 matches!(c.residual.r#where, Some(Where::Op(_))),
427 "{:?}",
428 c.residual.r#where
429 );
430 assert!(c.residual.from.is_empty());
431 assert_eq!(c.residual.select, q.select);
432 assert_eq!(c.residual.consumer, q.consumer);
433 }
434
435 #[test]
436 fn declined_shapes_return_none() {
437 assert!(compile(&parse("from docs"), &[], "r").is_none());
439 assert!(compile(&parse("from docs where era in 800..1680"), &[], "r").is_none());
440 assert!(compile(&parse("from docs where !verified"), &[], "r").is_none());
441 assert!(
442 compile(
443 &parse("from docs where $path == \"a\" || $path == \"b\""),
444 &[],
445 "r"
446 )
447 .is_none()
448 );
449 assert!(
450 compile(
451 &parse("from docs where nodes exists { where kind == \"md:task\" }"),
452 &[],
453 "r"
454 )
455 .is_none()
456 );
457 assert!(
459 compile(
460 &parse("from docs where $path == \"a.md\" follow distinct doc.out"),
461 &[],
462 "r"
463 )
464 .is_none()
465 );
466 assert!(
469 compile(
470 &parse("from docs where $path == \"a.md\" follow doc.out, doc.in"),
471 &[],
472 "r"
473 )
474 .is_none()
475 );
476 assert!(
477 compile(
478 &parse(
479 "from docs where $path == \"a.md\" follow $repo.docs collect { where after.contains(^$path) }"
480 ),
481 &[],
482 "r"
483 )
484 .is_none()
485 );
486 assert!(compile(&parse("from things where $path == \"a.md\""), &[], "r").is_none());
488 assert!(compile(&parse("from $repo where $path == \"a.md\""), &[], "r").is_none());
489 assert!(compile(&parse("from docs.nodes where kind == \"x\""), &[], "r").is_none());
490 let mut q = parse("from docs where $path == \"a.md\"");
492 q.from.push(Expr::Ident {
493 name: "nodes".to_owned(),
494 span: oqx::Span::EMPTY,
495 });
496 assert!(compile(&q, &[], "r").is_none());
497 }
498
499 #[test]
502 fn a_residual_that_could_raise_declines_the_whole_query() {
503 let declined = |src: &str| {
504 assert!(
505 compile(&parse(src), &[], "r").is_none(),
506 "should decline: {src}"
507 );
508 };
509 let planned = |src: &str| {
510 assert!(
511 compile(&parse(src), &[], "r").is_some(),
512 "should plan: {src}"
513 );
514 };
515 declined("from docs where path == \"x\" && $path == \"nope.md\"");
517 declined("from docs where $path == \"nope.md\" && !body");
518 declined("from blocks where $path == \"x\" && doc.path == \"y\"");
520 declined("from blocks where $path == \"x\" && nodes exists { where doc.path == \"y\" }");
521 declined("from docs where $path.matches(\"[\") && $path == \"nope.md\"");
523 declined("from docs where nope(\"x\") && $path == \"nope.md\"");
524 declined("from docs where $path == \"x\" && size(tags) > 1");
525 declined("from docs where $path == \"x\" && nodes exists { where name.lower() == \"a\" }");
526 declined(
527 "from docs where $path == \"x\" && nodes count { where kind == \"a\" order by size(name) } > 1",
528 );
529 declined("from docs where $path == \"x\" && ^slug == \"y\"");
531 declined("from docs where $path == \"x\" && nodes exists { where name == ^title }");
532 declined("from docs where $path == \"x\" && nodes collect { ^first_task: name }");
533 declined(
535 "from docs where $path == \"x\" && blocks exists { select t: nodes single { where kind == \"md:task\" } }",
536 );
537 planned("from docs where $path.startsWith(\"lab/\") && $path == \"x\"");
539 planned("from docs where $path == \"x\" && era in 800..1680");
541 planned("from docs where $path == \"x\" && !verified");
542 planned("from docs where $path == \"x\" && (layer == \"a\" || layer == \"b\")");
543 planned("from docs where $path == \"x\" && verified == true");
544 planned("from docs where $path == \"x\" && nodes exists { where kind == \"md:task\" }");
545 planned(
546 "from docs where $path == \"x\" && nodes count { where kind == \"md:task\" limit 5 } > 1",
547 );
548 planned("from blocks where $path == \"x\" && !path");
551 planned("from docs where $path == \"x\" && nodes exists { where path == \"y\" }");
552 planned("from docs where $path == \"x\" && frontmatter.path == \"y\"");
553 }
554
555 #[test]
556 fn a_top_level_not_or_or_is_the_whole_residual_and_declines() {
557 assert!(compile(&parse("from docs where !($path == \"a\")"), &[], "r").is_none());
560 assert!(
561 compile(
562 &parse("from docs where ($path == \"a\" || $path == \"b\") && layer == \"canon\""),
563 &[],
564 "r"
565 )
566 .is_some()
567 );
568 }
569}