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