1use std::cell::RefCell;
27use std::collections::HashMap;
28
29use omgbase_properties::Bound;
30use omgbase_search::{cosine_bytes, sanitize_fts_query};
31use oqx::semantics::{builtin_function, builtin_method_with, make_range};
32use oqx::{DataContext, Object, OqxError, RegexDialect, Value};
33use rusqlite::types::{Value as SqlValue, ValueRef};
34use rusqlite::{Connection, OptionalExtension, params_from_iter};
35
36use crate::error::SurfaceError;
37
38pub const TAG_KEY: &str = "__oqx_target";
40const REPO_TAG: &str = "$repo";
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum Target {
45 Docs,
46 Blocks,
47 Nodes,
48 Edges,
49}
50
51impl Target {
52 fn as_str(self) -> &'static str {
53 match self {
54 Target::Docs => "docs",
55 Target::Blocks => "blocks",
56 Target::Nodes => "nodes",
57 Target::Edges => "edges",
58 }
59 }
60
61 fn parse(s: &str) -> Option<Self> {
62 Some(match s {
63 "docs" => Target::Docs,
64 "blocks" => Target::Blocks,
65 "nodes" => Target::Nodes,
66 "edges" => Target::Edges,
67 _ => return None,
68 })
69 }
70}
71
72const RESERVED_DOC_BASENAMES: [&str; 5] = ["id", "path", "updated_at", "content_hash", "body"];
75
76#[derive(Clone, Debug, PartialEq)]
79pub struct SemanticVec {
80 pub model: String,
81 pub vec: Vec<u8>,
82}
83
84pub struct StoreContext<'a> {
86 conn: &'a Connection,
87 repo_id: String,
88 semantic: HashMap<String, SemanticVec>,
89 pending: RefCell<Option<SurfaceError>>,
90}
91
92fn sql_value(v: ValueRef<'_>) -> Value {
93 match v {
94 ValueRef::Null => Value::Null,
95 ValueRef::Integer(i) => Value::Number(i as f64),
96 ValueRef::Real(f) => Value::Number(f),
97 ValueRef::Text(t) => Value::Str(String::from_utf8_lossy(t).into_owned()),
98 ValueRef::Blob(b) => Value::Str(omgbase_format::hash::hex(b)),
99 }
100}
101
102fn to_sql(v: &Value) -> SqlValue {
104 match v {
105 Value::Undefined | Value::Null | Value::Range(_) => SqlValue::Null,
106 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
107 Value::Number(n) => SqlValue::Real(*n),
108 Value::Str(s) => SqlValue::Text(s.clone()),
109 Value::Array(_) | Value::Object(_) => SqlValue::Text(v.to_string()),
110 }
111}
112
113fn js_string(v: &Value) -> String {
115 v.to_string()
116}
117
118fn arg_or_empty(args: &[Value], i: usize) -> String {
120 match args.get(i) {
121 None | Some(Value::Undefined) | Some(Value::Null) => String::new(),
122 Some(v) => js_string(v),
123 }
124}
125
126fn parse_json(v: &Value) -> Value {
128 match v {
129 Value::Str(s) => {
130 serde_json::from_str::<serde_json::Value>(s).map_or_else(|_| v.clone(), Value::from)
131 }
132 Value::Null | Value::Undefined => Value::Undefined,
133 other => other.clone(),
134 }
135}
136
137fn from_hex(s: &str) -> Vec<u8> {
138 (0..s.len() / 2)
139 .filter_map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok())
140 .collect()
141}
142
143pub fn target_of(row: &Value) -> Option<Target> {
145 row.as_object()
146 .and_then(|o| o.get(TAG_KEY))
147 .and_then(Value::as_str)
148 .and_then(Target::parse)
149}
150
151fn is_repo_root(row: &Value) -> bool {
152 row.as_object()
153 .and_then(|o| o.get(TAG_KEY))
154 .and_then(Value::as_str)
155 == Some(REPO_TAG)
156}
157
158fn col<'v>(row: &'v Value, key: &str) -> &'v Value {
159 row.as_object()
160 .and_then(|o| o.get(key))
161 .unwrap_or(&Value::Undefined)
162}
163
164fn col_str(row: &Value, key: &str) -> String {
165 match col(row, key) {
166 Value::Undefined | Value::Null => String::new(),
167 v => js_string(v),
168 }
169}
170
171#[must_use]
173pub fn strip_tags(v: Value) -> Value {
174 match v {
175 Value::Object(o) => Value::Object(
176 o.into_iter()
177 .filter(|(k, _)| k != TAG_KEY)
178 .map(|(k, x)| (k, strip_tags(x)))
179 .collect(),
180 ),
181 Value::Array(a) => Value::Array(a.into_iter().map(strip_tags).collect()),
182 other => other,
183 }
184}
185
186impl<'a> StoreContext<'a> {
187 #[must_use]
190 pub fn new(
191 conn: &'a Connection,
192 repo_id: &str,
193 semantic: HashMap<String, SemanticVec>,
194 ) -> Self {
195 Self {
196 conn,
197 repo_id: repo_id.to_owned(),
198 semantic,
199 pending: RefCell::new(None),
200 }
201 }
202
203 pub fn take_pending(&self) -> Option<SurfaceError> {
206 self.pending.borrow_mut().take()
207 }
208
209 fn stash(&self, e: SurfaceError) {
210 let mut p = self.pending.borrow_mut();
211 if p.is_none() {
212 *p = Some(e);
213 }
214 }
215
216 fn stash_sql(&self, e: rusqlite::Error) {
217 self.stash(SurfaceError::other(format!("sqlite: {e}")));
218 }
219
220 fn all(&self, sql: &str, params: &[SqlValue]) -> Vec<Object> {
223 match self.try_all(sql, params) {
224 Ok(rows) => rows,
225 Err(e) => {
226 self.stash_sql(e);
227 Vec::new()
228 }
229 }
230 }
231
232 fn try_all(&self, sql: &str, params: &[SqlValue]) -> rusqlite::Result<Vec<Object>> {
233 let mut stmt = self.conn.prepare_cached(sql)?;
234 let names: Vec<String> = stmt
235 .column_names()
236 .iter()
237 .map(|s| (*s).to_owned())
238 .collect();
239 let rows = stmt.query_map(params_from_iter(params.iter()), |r| {
240 let mut o = Object::with_capacity(names.len());
241 for (i, name) in names.iter().enumerate() {
242 o.insert(name.as_str(), sql_value(r.get_ref(i)?));
243 }
244 Ok(o)
245 })?;
246 rows.collect()
247 }
248
249 fn one(&self, sql: &str, params: &[SqlValue]) -> Option<Object> {
250 self.all(sql, params).into_iter().next()
251 }
252
253 fn scalar(&self, sql: &str, params: &[SqlValue]) -> Value {
254 self.one(sql, params)
255 .and_then(|o| o.values().next().cloned())
256 .unwrap_or(Value::Undefined)
257 }
258
259 fn exists(&self, sql: &str, params: &[SqlValue]) -> rusqlite::Result<bool> {
260 let mut stmt = self.conn.prepare_cached(sql)?;
261 stmt.exists(params_from_iter(params.iter()))
262 }
263
264 fn tag_all(rows: Vec<Object>, t: Target) -> Value {
265 Value::Array(rows.into_iter().map(|r| Self::tag(r, t)).collect())
266 }
267
268 fn tag(mut row: Object, t: Target) -> Value {
269 row.insert(TAG_KEY, Value::Str(t.as_str().to_owned()));
270 Value::Object(row)
271 }
272
273 fn repo_root(&self) -> Value {
274 let mut o = Object::with_capacity(1);
275 o.insert(TAG_KEY, Value::Str(REPO_TAG.to_owned()));
276 Value::Object(o)
277 }
278
279 fn root_scan(&self, t: Target) -> Value {
282 let repo = [SqlValue::Text(self.repo_id.clone())];
283 match t {
284 Target::Docs => Self::tag_all(
285 self.all(
286 "SELECT * FROM docs WHERE repo_id = ?1 AND deleted_commit IS NULL ORDER BY path, doc_id",
287 &repo,
288 ),
289 t,
290 ),
291 Target::Blocks => Self::tag_all(
292 self.all(
293 "SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id
294 WHERE b.repo_id = ?1 AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL
295 ORDER BY d.path, b.block_id",
296 &repo,
297 ),
298 t,
299 ),
300 Target::Nodes => Self::tag_all(
301 self.all(
302 "SELECT n.*, d.path AS __path FROM nodes n JOIN docs d ON d.doc_id = n.doc_id
303 WHERE n.repo_id = ?1 AND d.deleted_commit IS NULL ORDER BY d.path, n.node_id",
304 &repo,
305 ),
306 t,
307 ),
308 Target::Edges => Self::tag_all(
309 self.all(
310 "SELECT e.*, d.path AS __path FROM edges e JOIN docs d ON d.doc_id = e.src_doc
311 WHERE e.repo_id = ?1 AND e.to_commit IS NULL AND d.deleted_commit IS NULL
312 ORDER BY d.path, e.edge_id",
313 &repo,
314 ),
315 t,
316 ),
317 }
318 }
319
320 fn decode_prop(r: &Object) -> Value {
325 let get = |k: &str| r.get(k).cloned().unwrap_or(Value::Undefined);
326 match get("type").as_str().unwrap_or("") {
327 "number" => get("val_num"),
328 "bool" => Value::Bool(get("val_bool").truthy()),
329 "null" => Value::Null,
330 "json" => parse_json(&get("val_json")),
331 _ => get("val_text"),
332 }
333 }
334
335 fn doc_prop(&self, doc_id: &str, key: &str, source: Option<&str>) -> Value {
338 let rows = match source {
339 Some(s) => self.all(
340 "SELECT * FROM properties WHERE doc_id = ?1 AND key = ?2 AND source = ?3 AND deleted_commit IS NULL ORDER BY ord",
341 &[SqlValue::Text(doc_id.to_owned()), SqlValue::Text(key.to_owned()), SqlValue::Text(s.to_owned())],
342 ),
343 None => self.all(
344 "SELECT * FROM properties WHERE doc_id = ?1 AND key = ?2 AND deleted_commit IS NULL ORDER BY ord",
345 &[SqlValue::Text(doc_id.to_owned()), SqlValue::Text(key.to_owned())],
346 ),
347 };
348 if rows.is_empty() {
349 return self.doc_prop_object(doc_id, key, source);
350 }
351 if rows.len() == 1 && rows[0].get("card").and_then(Value::as_str) == Some("scalar") {
352 return Self::decode_prop(&rows[0]);
353 }
354 Value::Array(rows.iter().map(Self::decode_prop).collect())
355 }
356
357 fn doc_prop_object(&self, doc_id: &str, prefix: &str, source: Option<&str>) -> Value {
360 let like = SqlValue::Text(format!("{prefix}.%"));
361 let rows = match source {
362 Some(s) => self.all(
363 "SELECT * FROM properties WHERE doc_id = ?1 AND key LIKE ?2 AND source = ?3 AND deleted_commit IS NULL ORDER BY ord",
364 &[SqlValue::Text(doc_id.to_owned()), like, SqlValue::Text(s.to_owned())],
365 ),
366 None => self.all(
367 "SELECT * FROM properties WHERE doc_id = ?1 AND key LIKE ?2 AND deleted_commit IS NULL ORDER BY ord",
368 &[SqlValue::Text(doc_id.to_owned()), like],
369 ),
370 };
371 if rows.is_empty() {
372 return Value::Undefined;
373 }
374 let mut out = Object::new();
375 for r in &rows {
376 let key = r.get("key").and_then(Value::as_str).unwrap_or("");
377 let rest: Vec<&str> = key[(prefix.len() + 1).min(key.len())..]
378 .split('.')
379 .collect();
380 set_nested(&mut out, &rest, Self::decode_prop(r));
381 }
382 Value::Object(out)
383 }
384
385 fn doc_prop_bag(&self, doc_id: &str, source: &str) -> Value {
388 let keys = self.all(
389 "SELECT DISTINCT key FROM properties WHERE doc_id = ?1 AND source = ?2 AND deleted_commit IS NULL ORDER BY key",
390 &[SqlValue::Text(doc_id.to_owned()), SqlValue::Text(source.to_owned())],
391 );
392 let mut out = Object::new();
393 for k in keys {
394 let key = k.get("key").and_then(Value::as_str).unwrap_or("");
395 let top = key.split('.').next().unwrap_or("");
396 if !out.contains_key(top) {
397 let v = self.doc_prop(doc_id, top, Some(source));
398 out.insert(top, v);
399 }
400 }
401 Value::Object(out)
402 }
403
404 fn top_ordinal(&self, block: &Value) -> Value {
409 let ordinal = col(block, "ordinal").clone();
410 if col(block, "parent_block").is_absent() {
411 return ordinal;
412 }
413 let ap = col_str(block, "ancestor_path");
414 let Some(first) = ap.split('/').find(|s| !s.is_empty()) else {
415 return ordinal;
416 };
417 let r = self.scalar(
418 "SELECT ordinal FROM blocks WHERE doc_id = ?1 AND block_id = ?2",
419 &[
420 SqlValue::Text(col_str(block, "doc_id")),
421 SqlValue::Text(first.to_owned()),
422 ],
423 );
424 if r.is_absent() { ordinal } else { r }
425 }
426
427 fn doc_blocks_preorder(&self, doc_id: &str, path: &str) -> Vec<Value> {
431 let rows = self.all(
432 "SELECT b.*, ?1 AS __path FROM blocks b WHERE b.doc_id = ?2 AND b.deleted_commit IS NULL ORDER BY b.ordinal, b.block_id",
433 &[SqlValue::Text(path.to_owned()), SqlValue::Text(doc_id.to_owned())],
434 );
435 let ids: Vec<String> = rows
436 .iter()
437 .map(|r| {
438 r.get("block_id")
439 .and_then(Value::as_str)
440 .unwrap_or("")
441 .to_owned()
442 })
443 .collect();
444 let parent_index: Vec<Option<usize>> = rows
445 .iter()
446 .map(|r| {
447 r.get("parent_block")
448 .and_then(Value::as_str)
449 .and_then(|p| ids.iter().position(|id| id == p))
450 })
451 .collect();
452 let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
453 let mut roots = Vec::new();
454 for (i, p) in parent_index.iter().enumerate() {
455 match p {
456 Some(p) => children[*p].push(i),
457 None => roots.push(i),
458 }
459 }
460 fn walk(i: usize, children: &[Vec<usize>], order: &mut Vec<usize>) {
461 order.push(i);
462 for &c in &children[i] {
463 walk(c, children, order);
464 }
465 }
466 let mut order = Vec::with_capacity(rows.len());
467 for r in roots {
468 walk(r, &children, &mut order);
469 }
470 let mut slots: Vec<Option<Object>> = rows.into_iter().map(Some).collect();
471 order
472 .into_iter()
473 .map(|i| Self::tag(slots[i].take().expect("visited once"), Target::Blocks))
474 .collect()
475 }
476
477 fn jattr(row: &Value, k: &str) -> Value {
478 match parse_json(col(row, "attrs")) {
479 Value::Object(o) => o.get(k).cloned().unwrap_or(Value::Undefined),
480 _ => Value::Undefined,
481 }
482 }
483
484 fn relation(&self, row: &Value, t: Target, key: &str) -> Option<Value> {
485 let path = || SqlValue::Text(col_str(row, "__path"));
486 let doc_id = || SqlValue::Text(col_str(row, "doc_id"));
487 let doc_path = || SqlValue::Text(col_str(row, "path"));
488 let block_id = || SqlValue::Text(col_str(row, "block_id"));
489 let repo = || SqlValue::Text(self.repo_id.clone());
490 Some(match (t, key) {
491 (Target::Docs, "nodes") => {
492 let rows = self.all(
495 "SELECT n.*, ?1 AS __path FROM nodes n WHERE n.doc_id = ?2 ORDER BY n.node_id",
496 &[doc_path(), doc_id()],
497 );
498 let blocks = self.doc_blocks_preorder(&col_str(row, "doc_id"), &col_str(row, "path"));
499 let rank: HashMap<String, usize> = blocks
500 .iter()
501 .enumerate()
502 .map(|(i, b)| (col_str(b, "block_id"), i))
503 .collect();
504 let mut keyed: Vec<((usize, usize, f64, String), Object)> = rows
505 .into_iter()
506 .map(|r| {
507 let block = r.get("block_id").and_then(Value::as_str);
508 let (has_block, rk) = match block {
509 None => (0, 0),
510 Some(b) => (1, rank.get(b).copied().unwrap_or(usize::MAX)),
511 };
512 let span = r.get("span_start").and_then(Value::as_f64).unwrap_or(-1.0);
513 let id = r.get("node_id").and_then(Value::as_str).unwrap_or("").to_owned();
514 ((has_block, rk, span, id), r)
515 })
516 .collect();
517 keyed.sort_by(|a, b| {
518 a.0.0
519 .cmp(&b.0.0)
520 .then(a.0.1.cmp(&b.0.1))
521 .then(a.0.2.total_cmp(&b.0.2))
522 .then(a.0.3.cmp(&b.0.3))
523 });
524 Value::Array(keyed.into_iter().map(|(_, r)| Self::tag(r, Target::Nodes)).collect())
525 }
526 (Target::Docs, "blocks") => {
527 Value::Array(self.doc_blocks_preorder(&col_str(row, "doc_id"), &col_str(row, "path")))
528 }
529 (Target::Docs, "out") => Self::tag_all(
530 self.all(
531 "SELECT DISTINCT d2.* FROM docs d2 JOIN edges e ON e.dst_node = d2.doc_id
532 WHERE e.src_doc = ?1 AND e.to_commit IS NULL AND d2.repo_id = ?2 AND d2.deleted_commit IS NULL ORDER BY d2.path, d2.doc_id",
533 &[doc_id(), repo()],
534 ),
535 Target::Docs,
536 ),
537 (Target::Docs, "in") => Self::tag_all(
538 self.all(
539 "SELECT DISTINCT d2.* FROM docs d2 JOIN edges e ON e.src_doc = d2.doc_id
540 WHERE e.dst_node = ?1 AND e.to_commit IS NULL AND d2.repo_id = ?2 AND d2.deleted_commit IS NULL ORDER BY d2.path, d2.doc_id",
541 &[doc_id(), repo()],
542 ),
543 Target::Docs,
544 ),
545 (Target::Docs, "out_edges") => Self::tag_all(
546 self.all(
547 "SELECT e.*, ?1 AS __path FROM edges e WHERE e.src_doc = ?2 AND e.to_commit IS NULL ORDER BY e.predicate, e.edge_id",
548 &[doc_path(), doc_id()],
549 ),
550 Target::Edges,
551 ),
552 (Target::Docs, "in_edges") => Self::tag_all(
553 self.all(
554 "SELECT e.*, d.path AS __path FROM edges e JOIN docs d ON d.doc_id = e.src_doc
555 WHERE e.dst_node = ?1 AND e.to_commit IS NULL AND d.deleted_commit IS NULL ORDER BY e.predicate, e.edge_id",
556 &[doc_id()],
557 ),
558 Target::Edges,
559 ),
560 (Target::Blocks, "children") => Self::tag_all(
561 self.all(
562 "SELECT b.*, ?1 AS __path FROM blocks b WHERE b.parent_block = ?2 AND b.deleted_commit IS NULL ORDER BY b.ordinal, b.block_id",
563 &[path(), block_id()],
564 ),
565 Target::Blocks,
566 ),
567 (Target::Blocks, "nodes") => Self::tag_all(
568 self.all(
569 "SELECT n.*, ?1 AS __path FROM nodes n WHERE n.block_id = ?2 ORDER BY n.span_start, n.node_id",
570 &[path(), block_id()],
571 ),
572 Target::Nodes,
573 ),
574 (Target::Blocks, "out_edges") => Self::tag_all(
575 self.all(
576 "SELECT e.*, ?1 AS __path FROM edges e WHERE e.src_block = ?2 AND e.to_commit IS NULL ORDER BY e.predicate, e.edge_id",
577 &[path(), block_id()],
578 ),
579 Target::Edges,
580 ),
581 (Target::Blocks, "section") => {
582 let top = to_sql(&self.top_ordinal(row));
583 Self::tag_all(
584 self.all(
585 "SELECT n.*, ?1 AS __path FROM nodes n WHERE n.doc_id = ?2 AND n.kind = 'md:section'
586 AND json_extract(n.attrs,'$.first_ordinal') <= ?3 AND json_extract(n.attrs,'$.last_ordinal') >= ?4
587 ORDER BY json_extract(n.attrs,'$.first_ordinal'), n.node_id",
588 &[path(), doc_id(), top.clone(), top],
589 ),
590 Target::Nodes,
591 )
592 }
593 (Target::Nodes, "blocks") => {
594 let (f, l) = (Self::jattr(row, "first_ordinal"), Self::jattr(row, "last_ordinal"));
595 if f.is_absent() || l.is_absent() {
596 return Some(Value::Array(Vec::new()));
597 }
598 let (f, l) = (
599 f.as_f64().unwrap_or(f64::NAN),
600 l.as_f64().unwrap_or(f64::NAN),
601 );
602 let rows = self.doc_blocks_preorder(&col_str(row, "doc_id"), &col_str(row, "__path"));
603 let kept: Vec<Value> = rows
604 .into_iter()
605 .filter(|b| {
606 let t = self.top_ordinal(b).as_f64().unwrap_or(f64::NAN);
607 t >= f && t <= l
608 })
609 .collect();
610 Value::Array(kept)
611 }
612 (Target::Nodes, "subsections") => {
613 let (f, l, lvl) = (
614 Self::jattr(row, "first_ordinal"),
615 Self::jattr(row, "last_ordinal"),
616 Self::jattr(row, "level"),
617 );
618 if f.is_absent() {
619 return Some(Value::Array(Vec::new()));
620 }
621 Self::tag_all(
622 self.all(
623 "SELECT n.*, ?1 AS __path FROM nodes n WHERE n.doc_id = ?2 AND n.kind = 'md:section'
624 AND json_extract(n.attrs,'$.first_ordinal') >= ?3 AND json_extract(n.attrs,'$.last_ordinal') <= ?4
625 AND json_extract(n.attrs,'$.level') > ?5 ORDER BY json_extract(n.attrs,'$.first_ordinal'), n.node_id",
626 &[path(), doc_id(), to_sql(&f), to_sql(&l), to_sql(&lvl)],
627 ),
628 Target::Nodes,
629 )
630 }
631 (Target::Nodes, "children") => {
632 let (f, l, lvl) = (
633 Self::jattr(row, "first_ordinal"),
634 Self::jattr(row, "last_ordinal"),
635 Self::jattr(row, "level"),
636 );
637 if f.is_absent() {
638 return Some(Value::Array(Vec::new()));
639 }
640 Self::tag_all(
641 self.all(
642 "SELECT i.*, ?1 AS __path FROM nodes i WHERE i.doc_id = ?2 AND i.kind = 'md:section'
643 AND json_extract(i.attrs,'$.level') > ?3
644 AND json_extract(i.attrs,'$.first_ordinal') >= ?4 AND json_extract(i.attrs,'$.last_ordinal') <= ?5
645 AND NOT EXISTS (SELECT 1 FROM nodes m WHERE m.doc_id = i.doc_id AND m.kind = 'md:section'
646 AND json_extract(m.attrs,'$.level') > ?6 AND json_extract(m.attrs,'$.level') < json_extract(i.attrs,'$.level')
647 AND json_extract(m.attrs,'$.first_ordinal') <= json_extract(i.attrs,'$.first_ordinal')
648 AND json_extract(m.attrs,'$.last_ordinal') >= json_extract(i.attrs,'$.last_ordinal'))
649 ORDER BY json_extract(i.attrs,'$.first_ordinal'), i.node_id",
650 &[path(), doc_id(), to_sql(&lvl), to_sql(&f), to_sql(&l), to_sql(&lvl)],
651 ),
652 Target::Nodes,
653 )
654 }
655 _ => return None,
656 })
657 }
658
659 fn owning_doc(&self, row: &Value) -> Value {
660 let id = match col(row, "doc_id") {
661 Value::Undefined | Value::Null => col(row, "src_doc").clone(),
662 v => v.clone(),
663 };
664 self.one("SELECT * FROM docs WHERE doc_id = ?1", &[to_sql(&id)])
665 .map_or(Value::Undefined, |o| Self::tag(o, Target::Docs))
666 }
667
668 fn owning_block(&self, row: &Value) -> Value {
669 let id = col(row, "block_id");
670 if !id.truthy() {
671 return Value::Undefined;
672 }
673 self.one(
674 "SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ?1",
675 &[to_sql(id)],
676 )
677 .map_or(Value::Undefined, |o| Self::tag(o, Target::Blocks))
678 }
679
680 fn null_if_absent(v: Value) -> Value {
683 if v.is_absent() { Value::Null } else { v }
684 }
685
686 fn intrinsic(&self, row: &Value, t: Target, name: &str) -> Value {
687 if name == "$self" {
688 return row.clone();
689 }
690 let c = |k: &str| col(row, k).clone();
691 match (t, name) {
692 (Target::Docs, "$id") => c("doc_id"),
693 (Target::Docs, "$path") => c("path"),
694 (Target::Docs, "$content_hash") => Self::null_if_absent(c("file_hash")),
695 (Target::Docs, "$updated_at") => Self::null_if_absent(self.scalar(
696 "SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ?1",
697 &[to_sql(&c("current_rev"))],
698 )),
699 (Target::Docs, "$body") => {
700 match omgbase_store::read::reconstruct(self.conn, &col_str(row, "doc_id")) {
701 Ok(Some(s)) => Value::Str(s),
702 Ok(None) => Value::Null,
703 Err(e) => {
704 self.stash(SurfaceError::from(e));
705 Value::Null
706 }
707 }
708 }
709 (Target::Docs, "$title") => {
710 Self::null_if_absent(self.doc_prop(&col_str(row, "doc_id"), "$title", Some("computed")))
711 }
712 (Target::Docs, "$tags") => {
713 Self::null_if_absent(self.doc_prop(&col_str(row, "doc_id"), "$tags", Some("computed")))
714 }
715 (Target::Blocks, "$id") => c("block_id"),
716 (Target::Blocks, "$doc") => c("doc_id"),
717 (Target::Blocks, "$path") => c("__path"),
718 (Target::Blocks, "$ordinal") => c("ordinal"),
719 (Target::Blocks, "$depth") => c("depth"),
720 (Target::Blocks, "$body") => c("text"),
721 (Target::Blocks, "$content_hash") => Self::null_if_absent(c("raw_hash")),
722 (Target::Blocks, "$updated_at") => Self::null_if_absent(self.scalar(
723 "SELECT MAX(c.ts) FROM block_changes bc JOIN commits c ON c.commit_id = bc.commit_id WHERE bc.block_id = ?1",
724 &[to_sql(&c("block_id"))],
725 )),
726 (Target::Nodes, "$id" | "$node_id") => c("node_id"),
727 (Target::Nodes, "$doc_id") => c("doc_id"),
728 (Target::Nodes, "$block_id") => c("block_id"),
729 (Target::Nodes, "$path") => c("__path"),
730 (Target::Edges, "$id") => c("edge_id"),
731 (Target::Edges, "$src") => c("src_doc"),
732 (Target::Edges, "$dst") => c("dst_node"),
733 (Target::Edges, "$src_block") => c("src_block"),
734 (Target::Edges, "$via") => c("via_node"),
735 (Target::Edges, "$from_commit") => c("from_commit"),
736 (Target::Edges, "$path") => c("__path"),
737 (Target::Edges, "$dst_path") => Self::null_if_absent(self.scalar(
738 "SELECT path FROM docs WHERE doc_id = ?1",
739 &[to_sql(&c("dst_node"))],
740 )),
741 (Target::Edges, "$dst_uri") => Self::null_if_absent(self.scalar(
742 "SELECT uri FROM external_nodes WHERE node_id = ?1",
743 &[to_sql(&c("dst_node"))],
744 )),
745 _ => Value::Undefined,
746 }
747 }
748
749 fn filter_invalid(msg: String) -> Option<oqx::Result<Value>> {
752 Some(Err(OqxError::eval(msg)))
753 }
754
755 fn require_target(t: Target, want: Target, name: &str) -> Option<oqx::Result<Value>> {
756 (t != want).then(|| {
757 Err(OqxError::eval(format!(
758 "{name}() is only available on the {} target",
759 want.as_str()
760 )))
761 })
762 }
763
764 fn sql_result(&self, r: rusqlite::Result<bool>) -> oqx::Result<Value> {
765 match r {
766 Ok(b) => Ok(Value::Bool(b)),
767 Err(e) => {
768 self.stash_sql(e);
769 Err(OqxError::eval("query failed"))
770 }
771 }
772 }
773
774 fn row_method(
775 &self,
776 name: &str,
777 row: &Value,
778 t: Target,
779 args: &[Value],
780 ) -> Option<oqx::Result<Value>> {
781 let c = |k: &str| col(row, k).clone();
782 match name {
783 "text" => Some(self.text_match(t, row, &arg_or_empty(args, 0))),
784 "semantic" => Some(self.semantic_score(t, row, &arg_or_empty(args, 0))),
785 "has_anchor" => Self::require_target(t, Target::Blocks, name).or_else(|| {
786 Some(self.sql_result(self.exists(
787 "SELECT 1 FROM edges WHERE src_block = ?1 AND anchor IS NOT NULL LIMIT 1",
788 &[to_sql(&c("block_id"))],
789 )))
790 }),
791 "child_count" => Self::require_target(t, Target::Blocks, name).or_else(|| {
792 Some(Ok(self.scalar(
793 "SELECT COUNT(*) FROM blocks WHERE parent_block = ?1 AND deleted_commit IS NULL",
794 &[to_sql(&c("block_id"))],
795 )))
796 }),
797 "parent_type" => Self::require_target(t, Target::Blocks, name).or_else(|| {
798 Some(Ok(Self::null_if_absent(self.scalar(
799 "SELECT type FROM blocks WHERE block_id = ?1",
800 &[to_sql(&c("parent_block"))],
801 ))))
802 }),
803 "has_edge" => {
804 let pred = js_string(args.first().unwrap_or(&Value::Undefined));
805 let (src_col, src_val) = if t == Target::Blocks {
806 ("src_block", c("block_id"))
807 } else {
808 ("src_doc", c("doc_id"))
809 };
810 let r = if args.len() >= 2 {
811 self.exists(
812 &format!("SELECT 1 FROM edges WHERE {src_col} = ?1 AND predicate = ?2 AND to_commit IS NULL AND dst_node = ?3 LIMIT 1"),
813 &[to_sql(&src_val), SqlValue::Text(pred), to_sql(&args[1])],
814 )
815 } else {
816 self.exists(
817 &format!("SELECT 1 FROM edges WHERE {src_col} = ?1 AND predicate = ?2 AND to_commit IS NULL LIMIT 1"),
818 &[to_sql(&src_val), SqlValue::Text(pred)],
819 )
820 };
821 Some(self.sql_result(r))
822 }
823 "under" => Self::require_target(t, Target::Blocks, name).or_else(|| {
824 let target = js_string(args.first().unwrap_or(&Value::Undefined));
825 let ap = col_str(row, "ancestor_path");
826 Some(Ok(Value::Bool(
827 ap.contains(&format!("/{target}/")) || col_str(row, "block_id") == target,
828 )))
829 }),
830 "under_heading" => Self::require_target(t, Target::Blocks, name).or_else(|| {
831 let text = js_string(args.first().unwrap_or(&Value::Undefined));
832 let top = to_sql(&self.top_ordinal(row));
833 Some(self.sql_result(self.exists(
834 "SELECT 1 FROM sections s JOIN blocks hb ON hb.block_id = s.heading_block
835 WHERE s.doc_id = ?1 AND lower(hb.text) LIKE '%' || lower(?2) || '%' AND s.first_ordinal <= ?3 AND s.last_ordinal >= ?4 LIMIT 1",
836 &[to_sql(&c("doc_id")), SqlValue::Text(text), top.clone(), top],
837 )))
838 }),
839 "within" => Self::require_target(t, Target::Blocks, name).or_else(|| {
840 let target = js_string(args.first().unwrap_or(&Value::Undefined));
841 if target.starts_with("d_") {
842 return Some(Ok(Value::Bool(col_str(row, "doc_id") == target)));
843 }
844 if target.contains('*') {
845 let like = glob_to_like(&target, false);
846 return Some(self.sql_result(self.exists(
847 "SELECT 1 WHERE ?1 LIKE ?2 ESCAPE '\\'",
848 &[SqlValue::Text(col_str(row, "__path")), SqlValue::Text(like)],
849 )));
850 }
851 Some(Ok(Value::Bool(col_str(row, "__path") == target)))
852 }),
853 "under_kind" => Self::require_target(t, Target::Blocks, name).or_else(|| {
854 let kind = js_string(args.first().unwrap_or(&Value::Undefined));
855 let ap: Vec<String> = col_str(row, "ancestor_path")
856 .split('/')
857 .filter(|s| !s.is_empty())
858 .map(str::to_owned)
859 .collect();
860 if ap.is_empty() {
861 return Some(Ok(Value::Bool(false)));
862 }
863 let placeholders: Vec<String> = (1..=ap.len()).map(|i| format!("?{i}")).collect();
864 let placeholders = placeholders.join(",");
865 let mut params: Vec<SqlValue> = ap.into_iter().map(SqlValue::Text).collect();
866 let n = params.len();
867 params.push(SqlValue::Text(kind));
868 let r = match args.get(1) {
869 Some(v) if !v.is_absent() => {
870 let nm = js_string(v);
871 params.push(SqlValue::Text(nm.clone()));
872 params.push(SqlValue::Text(nm));
873 self.exists(
874 &format!(
875 "SELECT 1 FROM blocks WHERE block_id IN ({placeholders}) AND type = ?{} AND (lower(text) LIKE '%' || lower(?{}) || '%' OR json_extract(attrs,'$.key') = ?{}) LIMIT 1",
876 n + 1,
877 n + 2,
878 n + 3
879 ),
880 ¶ms,
881 )
882 }
883 _ => self.exists(
884 &format!(
885 "SELECT 1 FROM blocks WHERE block_id IN ({placeholders}) AND type = ?{} LIMIT 1",
886 n + 1
887 ),
888 ¶ms,
889 ),
890 };
891 Some(self.sql_result(r))
892 }),
893 "yaml_path" => Self::require_target(t, Target::Blocks, name).or_else(|| {
894 Some(Ok(Self::key_path(row, &js_string(args.first().unwrap_or(&Value::Undefined)), "yaml")))
895 }),
896 "json_pointer" => Self::require_target(t, Target::Blocks, name).or_else(|| {
897 Some(Ok(Self::key_path(row, &js_string(args.first().unwrap_or(&Value::Undefined)), "json")))
898 }),
899 _ => None,
900 }
901 }
902
903 fn key_path(row: &Value, path: &str, kind: &str) -> Value {
904 let key = if kind == "json" {
905 let mut p = path;
906 p = p.strip_prefix('#').unwrap_or(p);
907 p = p.strip_prefix('/').unwrap_or(p);
908 p.split('/').collect::<Vec<_>>().join(".")
909 } else {
910 path.to_owned()
911 };
912 let leaf = key.rsplit('.').next().unwrap_or("").to_owned();
913 if !col_str(row, "type").starts_with(&format!("{kind}:")) {
914 return Value::Bool(false);
915 }
916 let k = Self::jattr(row, "key");
917 Value::Bool(k == Value::Str(leaf) || k == Value::Str(key))
918 }
919
920 fn text_match(&self, t: Target, row: &Value, terms: &str) -> oqx::Result<Value> {
921 if t == Target::Edges {
922 return Err(OqxError::eval(
923 "text(...) is not available on the edges target",
924 ));
925 }
926 let m = sanitize_fts_query(terms);
927 if m.is_empty() {
928 return Ok(Value::Bool(false));
929 }
930 let r = match t {
931 Target::Docs => self.exists(
932 "SELECT 1 FROM blocks_fts JOIN blocks b ON b.rowid = blocks_fts.rowid WHERE b.doc_id = ?1 AND blocks_fts MATCH ?2 LIMIT 1",
933 &[to_sql(col(row, "doc_id")), SqlValue::Text(m)],
934 ),
935 Target::Nodes => self.exists(
936 "SELECT 1 FROM nodes_fts WHERE rowid = (SELECT rowid FROM nodes WHERE node_id = ?1) AND nodes_fts MATCH ?2",
937 &[to_sql(col(row, "node_id")), SqlValue::Text(m)],
938 ),
939 _ => self.exists(
940 "SELECT 1 FROM blocks_fts WHERE rowid = (SELECT rowid FROM blocks WHERE block_id = ?1) AND blocks_fts MATCH ?2",
941 &[to_sql(col(row, "block_id")), SqlValue::Text(m)],
942 ),
943 };
944 self.sql_result(r)
945 }
946
947 fn semantic_score(&self, t: Target, row: &Value, phrase: &str) -> oqx::Result<Value> {
948 if matches!(t, Target::Nodes | Target::Edges) {
949 return Err(OqxError::eval(
950 "semantic(...) is available on the docs and blocks targets",
951 ));
952 }
953 let Some(resolved) = self.semantic.get(phrase) else {
954 return Err(OqxError::eval(format!(
955 "semantic({}) needs an embedding provider; none is configured for this query",
956 serde_json::Value::String(phrase.to_owned())
957 )));
958 };
959 let vec: rusqlite::Result<Option<Vec<u8>>> = match t {
960 Target::Docs => self
961 .conn
962 .query_row(
963 "SELECT vec FROM doc_embeddings WHERE doc_id = ?1 AND model = ?2",
964 rusqlite::params![col_str(row, "doc_id"), resolved.model],
965 |r| r.get(0),
966 )
967 .optional(),
968 _ => self
969 .conn
970 .query_row(
971 "SELECT vec FROM embeddings WHERE content_hash = ?1 AND model = ?2 LIMIT 1",
972 rusqlite::params![from_hex(&col_str(row, "raw_hash")), resolved.model],
973 |r| r.get(0),
974 )
975 .optional(),
976 };
977 match vec {
978 Ok(Some(v)) => Ok(Value::Number(cosine_bytes(&v, &resolved.vec))),
979 Ok(None) => Ok(Value::Null),
980 Err(e) => {
981 self.stash_sql(e);
982 Err(OqxError::eval("query failed"))
983 }
984 }
985 }
986}
987
988fn set_nested(out: &mut Object, path: &[&str], leaf: Value) {
991 let Some((first, rest)) = path.split_first() else {
992 return;
993 };
994 if rest.is_empty() {
995 out.insert(*first, leaf);
996 return;
997 }
998 let mut child = match out.get(first) {
999 Some(Value::Object(o)) => o.clone(),
1000 _ => Object::new(),
1001 };
1002 set_nested(&mut child, rest, leaf);
1003 out.insert(*first, Value::Object(child));
1004}
1005
1006#[must_use]
1009pub fn glob_to_like(glob: &str, escape_backslash: bool) -> String {
1010 let mut out = String::with_capacity(glob.len() + 4);
1011 for ch in glob.chars() {
1012 match ch {
1013 '%' | '_' => {
1014 out.push('\\');
1015 out.push(ch);
1016 }
1017 '\\' if escape_backslash => out.push_str("\\\\"),
1018 '*' => out.push('%'),
1019 c => out.push(c),
1020 }
1021 }
1022 out
1023}
1024
1025impl DataContext for StoreContext<'_> {
1026 fn root(&self, name: &str) -> Value {
1027 if name == "$repo" {
1028 return self.repo_root();
1029 }
1030 match Target::parse(name) {
1031 Some(t) => self.root_scan(t),
1032 None => Value::Undefined,
1033 }
1034 }
1035
1036 fn get(&self, row: &Value, key: &str) -> Value {
1037 if row.is_absent() {
1038 return Value::Undefined;
1039 }
1040 if key == "$repo" {
1043 return self.repo_root();
1044 }
1045 if is_repo_root(row) {
1046 if key == "$id" {
1047 return Value::Str(self.repo_id.clone());
1048 }
1049 return match Target::parse(key) {
1050 Some(t) => self.root_scan(t),
1051 None => Value::Undefined,
1052 };
1053 }
1054 let Some(t) = target_of(row) else {
1055 return match row {
1057 Value::Object(o) => o.get(key).cloned().unwrap_or(Value::Undefined),
1058 Value::Array(a) => match key.parse::<usize>() {
1059 Ok(i) if key == i.to_string() => a.get(i).cloned().unwrap_or(Value::Undefined),
1060 _ => Value::Undefined,
1061 },
1062 _ => Value::Undefined,
1063 };
1064 };
1065 if key.starts_with('$') {
1066 return self.intrinsic(row, t, key);
1067 }
1068 match (t, key) {
1070 (Target::Docs, "doc") | (Target::Blocks, "block") | (Target::Nodes, "section") => {
1071 return row.clone();
1072 }
1073 (_, "doc") => return self.owning_doc(row),
1074 (Target::Nodes, "block") => return self.owning_block(row),
1075 _ => {}
1076 }
1077 if let Some(v) = self.relation(row, t, key) {
1078 return v;
1079 }
1080 let c = |k: &str| col(row, k).clone();
1081 match t {
1082 Target::Docs => {
1083 if key == "format" {
1084 return c("format");
1085 }
1086 let doc_id = col_str(row, "doc_id");
1087 if key == "frontmatter" || key == "inline" {
1088 return self.doc_prop_bag(&doc_id, key);
1089 }
1090 if RESERVED_DOC_BASENAMES.contains(&key) {
1091 self.stash(SurfaceError::filter_invalid(
1092 format!(
1093 "bare '{key}' reads a frontmatter key; did you mean the intrinsic ${key}? (use frontmatter.{key} to force the property)"
1094 ),
1095 "10 §2",
1096 ));
1097 return Value::Undefined;
1098 }
1099 self.doc_prop(&doc_id, key, None)
1100 }
1101 Target::Blocks => match key {
1102 "type" => c("type"),
1103 "text" => c("text"),
1104 "attrs" => parse_json(&c("attrs")),
1105 _ => Self::jattr(row, key),
1106 },
1107 Target::Nodes => match key {
1108 "kind" => c("kind"),
1109 "name" => c("name"),
1110 "value" => c("value"),
1111 "attrs" => parse_json(&c("attrs")),
1112 _ => Self::jattr(row, key),
1113 },
1114 Target::Edges => match key {
1115 "predicate" | "provenance" | "dst_kind" | "anchor" | "src_field" => c(key),
1116 _ => Value::Undefined,
1117 },
1118 }
1119 }
1120
1121 fn to_rows(&self, value: &Value) -> Vec<Value> {
1122 match value {
1123 Value::Undefined | Value::Null => Vec::new(),
1124 Value::Array(a) => a.clone(),
1125 other => vec![other.clone()],
1126 }
1127 }
1128
1129 fn identity(&self, row: &Value) -> Value {
1130 match target_of(row) {
1131 Some(Target::Docs) => col(row, "doc_id").clone(),
1132 Some(Target::Blocks) => col(row, "block_id").clone(),
1133 Some(Target::Nodes) => col(row, "node_id").clone(),
1134 Some(Target::Edges) => col(row, "edge_id").clone(),
1135 None => row.clone(),
1136 }
1137 }
1138
1139 fn call_function(&self, name: &str, args: &[Value]) -> Option<oqx::Result<Value>> {
1140 if name == "range" {
1141 let x = args.first().unwrap_or(&Value::Undefined);
1142 return Some(Ok(match x {
1143 Value::Range(_) => x.clone(),
1144 Value::Str(s) => match omgbase_properties::detect_range(s) {
1145 Some(r) => {
1146 let b = |b: &Bound| match b {
1147 Bound::Open => Value::Undefined,
1148 Bound::Num(n) => Value::Number(*n),
1149 Bound::Iso(s) => Value::Str(s.clone()),
1150 };
1151 Value::from(make_range(b(&r.lo), b(&r.hi), r.exclusive_end))
1152 }
1153 None => Value::Null,
1154 },
1155 _ => Value::Null,
1156 }));
1157 }
1158 builtin_function(name, args)
1159 }
1160
1161 fn call_method(&self, name: &str, recv: &Value, args: &[Value]) -> Option<oqx::Result<Value>> {
1162 if let Some(t) = target_of(recv) {
1163 if let Some(r) = self.row_method(name, recv, t, args) {
1164 return Some(r);
1165 }
1166 } else if matches!(
1167 name,
1168 "text"
1169 | "semantic"
1170 | "under"
1171 | "under_heading"
1172 | "within"
1173 | "under_kind"
1174 | "yaml_path"
1175 | "json_pointer"
1176 | "has_edge"
1177 | "has_anchor"
1178 | "child_count"
1179 | "parent_type"
1180 ) {
1181 return Self::filter_invalid(format!("{name}() needs a docs/blocks/nodes/edges row"));
1182 }
1183 builtin_method_with(RegexDialect::Oqx, name, recv, args)
1184 }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189 use super::*;
1190
1191 #[test]
1192 fn glob_to_like_escapes() {
1193 assert_eq!(glob_to_like("a*/b_%", true), "a%/b\\_\\%");
1194 assert_eq!(glob_to_like("a\\b*", true), "a\\\\b%");
1195 assert_eq!(glob_to_like("a\\b*", false), "a\\b%");
1196 }
1197
1198 #[test]
1199 fn nested_property_objects_rebuild() {
1200 let mut o = Object::new();
1201 set_nested(&mut o, &["a", "b"], Value::Number(1.0));
1202 set_nested(&mut o, &["a", "c"], Value::Number(2.0));
1203 set_nested(&mut o, &["d"], Value::Str("x".into()));
1204 let a = o.get("a").unwrap().as_object().unwrap();
1205 assert_eq!(a.get("b"), Some(&Value::Number(1.0)));
1206 assert_eq!(a.get("c"), Some(&Value::Number(2.0)));
1207 assert_eq!(o.get("d"), Some(&Value::Str("x".into())));
1208 set_nested(&mut o, &["d", "e"], Value::Bool(true));
1210 assert!(o.get("d").unwrap().as_object().is_some());
1211 }
1212
1213 #[test]
1214 fn json_and_sql_bridges() {
1215 assert_eq!(
1216 parse_json(&Value::Str("{\"a\":1}".into()))
1217 .as_object()
1218 .unwrap()
1219 .get("a"),
1220 Some(&Value::Number(1.0))
1221 );
1222 assert_eq!(
1223 parse_json(&Value::Str("nope".into())),
1224 Value::Str("nope".into())
1225 );
1226 assert_eq!(parse_json(&Value::Null), Value::Undefined);
1227 assert_eq!(from_hex("00ff"), vec![0, 255]);
1228 assert_eq!(arg_or_empty(&[], 0), "");
1229 assert_eq!(arg_or_empty(&[Value::Number(2.0)], 0), "2");
1230 }
1231}