1use std::cmp::Ordering;
11use std::collections::HashMap;
12
13use omgbase_search::EmbeddingProvider;
14use omgbase_store::Store;
15use oqx::ast::{BinaryOp, Consumer, FollowDestination, SelectItem, Where};
16use oqx::{build, print_query};
17use serde_json::{Map, Value as Json, json};
18
19use crate::error::{Result, SurfaceError};
20use crate::query::{QueryOptions, query};
21use crate::read::find_doc_by_ref;
22
23const DEFAULT_DEGREES: i64 = 1;
24const DEFAULT_MAX_DOCUMENTS: i64 = 200;
25const MAX_DEPTH: i64 = 8;
26
27#[derive(Clone, Debug, Default)]
29pub struct GraphArgs {
30 pub roots: Vec<String>,
31 pub degrees: Option<i64>,
32 pub direction: Option<String>,
34 pub predicate: Option<String>,
35 pub select: Vec<String>,
36 pub max_documents: Option<i64>,
37}
38
39const EDGE_FIELDS: [&str; 12] = [
45 "id",
46 "doc_id",
47 "block_id",
48 "via_id",
49 "dst_id",
50 "dst_path",
51 "uri",
52 "dst_kind",
53 "predicate",
54 "provenance",
55 "anchor",
56 "field",
57];
58
59fn build_user_select(select: &[String]) -> Result<(Vec<SelectItem>, Vec<Option<String>>)> {
66 let mut items = Vec::new();
67 let mut out_names: Vec<Option<String>> = vec![None; select.len()];
68 let mut used: Vec<String> = Vec::new();
69 for (i, expr) in select.iter().enumerate() {
70 let trimmed = expr.trim();
71 if trimmed.is_empty() {
72 continue;
73 }
74 let ident = trimmed.strip_prefix('$').unwrap_or(trimmed);
79 let is_ident = ident
80 .chars()
81 .next()
82 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
83 && ident.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
84 let mut name = if is_ident {
85 trimmed.to_owned()
86 } else {
87 format!("sel_{i}")
88 };
89 if name != "id" && name != "path" {
93 while used.contains(&name) {
94 name = format!("{name}_{i}");
95 }
96 used.push(name.clone());
97 }
98 out_names[i] = Some(name);
99 let parsed = oqx::parse_string(&format!("select _u{i}: {trimmed} from docs"))?;
100 items.extend(parsed.select);
101 }
102 Ok((items, out_names))
103}
104
105fn build_query(root_ids: &[String], dir: &str, depth: u32, user_select: &[SelectItem]) -> String {
114 let mut edges_body = build::subquery();
115 edges_body.select = EDGE_FIELDS
116 .iter()
117 .map(|name| build::field(name, build::ident(name)))
118 .collect();
119 let edges = build::op(
120 build::path(&["doc", &format!("{dir}_links")]),
121 Consumer::Collect,
122 edges_body,
123 );
124 let mut seed: Vec<Where> = root_ids
125 .iter()
126 .map(|id| {
127 build::scalar(build::binary(
128 BinaryOp::Eq,
129 build::ident("id"),
130 build::lit(id.as_str()),
131 ))
132 })
133 .collect();
134 let mut q = build::query(build::ident("docs"));
135 q.select = vec![
136 build::field("_depth", build::ident("$depth")),
137 build::field("_stop", build::ident("$stop")),
138 build::collect("_links", edges),
139 ];
140 q.select.extend(user_select.iter().cloned());
141 q.r#where = Some(if seed.len() == 1 {
142 seed.pop().expect("one seed")
143 } else {
144 build::or(seed)
145 });
146 let mut step = build::subquery();
148 step.from = vec![build::ident(if dir == "out" { "dst" } else { "doc" })];
149 step.depth = Some(depth);
150 let mut walk = build::follow(vec![FollowDestination::Block(Box::new(build::op(
151 build::ident(&format!("{dir}_links")),
152 Consumer::Collect,
153 step,
154 )))]);
155 walk.distinct = true;
156 q.follow = Some(walk);
157 print_query(&q).expect("a built query has no bindings")
158}
159
160fn locale_compare(a: &str, b: &str) -> Ordering {
162 a.cmp(b)
163}
164
165struct Doc {
166 id: String,
167 path: String,
168 degree: i64,
169 extra: Vec<(String, Json)>,
170}
171
172pub fn graph_neighborhood(
174 store: &Store,
175 repo_id: &str,
176 args: &GraphArgs,
177 provider: Option<&dyn EmbeddingProvider>,
178 now_ms: Option<i64>,
179) -> Result<Json> {
180 if args.roots.is_empty() {
181 return Err(SurfaceError::new(
182 "target_missing",
183 "graph requires at least one root (path or id)",
184 ));
185 }
186 let mut root_ids: Vec<String> = Vec::new();
187 for r in &args.roots {
188 let Some(info) = find_doc_by_ref(store.conn(), repo_id, r)? else {
189 return Err(SurfaceError::with_data(
190 "doc_missing",
191 format!("no document for {}", Json::String(r.clone())),
192 json!({ "root": r }),
193 ));
194 };
195 if !root_ids.contains(&info.doc_id) {
196 root_ids.push(info.doc_id);
197 }
198 }
199 let degrees = args.degrees.unwrap_or(DEFAULT_DEGREES).max(0);
200 let depth = MAX_DEPTH.min(degrees + 1);
201 let walk_depth = u32::try_from(depth).expect("1..=8");
202 let effective_degrees = depth - 1;
203 let direction = args.direction.clone().unwrap_or_else(|| "both".to_owned());
204 let max_documents =
205 usize::try_from(args.max_documents.unwrap_or(DEFAULT_MAX_DOCUMENTS).max(1)).unwrap_or(1);
206 let dirs: Vec<&str> = match direction.as_str() {
207 "both" => vec!["out", "in"],
208 "in" => vec!["in"],
209 _ => vec!["out"],
210 };
211 let (user_select, out_names) = build_user_select(&args.select)?;
212
213 let mut queries = Vec::new();
214 let mut docs: Vec<Doc> = Vec::new();
215 let mut edges: Vec<Json> = Vec::new();
216 let mut query_truncated = false;
217 for dir in &dirs {
218 let q = build_query(&root_ids, dir, walk_depth, &user_select);
219 queries.push(q.clone());
220 let res = query(
221 store,
222 repo_id,
223 &q,
224 QueryOptions {
225 limit: Some(max_documents + 1),
226 cursor: None,
227 provider,
228 in_memory: false,
229 bare: false,
230 unknown_names: None,
231 vocabulary: None,
232 now_ms,
233 request: None,
234 },
235 )?;
236 if res.truncated {
237 query_truncated = true;
238 }
239 for hit in &res.hits {
240 let id = hit["id"].as_str().unwrap_or_default().to_owned();
243 let path = hit["path"].as_str().unwrap_or_default().to_owned();
244 let hop = hit["_depth"].as_f64().unwrap_or(f64::NAN);
245 let degree = (hop - 1.0) as i64;
246 let replace = docs
247 .iter()
248 .position(|d| d.id == id)
249 .map(|i| (i, degree < docs[i].degree));
250 match replace {
251 Some((_, false)) => {}
252 found => {
253 let extra: Vec<(String, Json)> = out_names
254 .iter()
255 .enumerate()
256 .filter_map(|(i, n)| {
257 n.as_ref().map(|name| {
258 (
259 name.clone(),
260 hit.get(format!("_u{i}")).cloned().unwrap_or(Json::Null),
261 )
262 })
263 })
264 .collect();
265 let doc = Doc {
266 id: id.clone(),
267 path,
268 degree,
269 extra,
270 };
271 match found {
272 Some((i, true)) => docs[i] = doc,
273 _ => docs.push(doc),
274 }
275 }
276 }
277 if let Some(raw) = hit["_links"].as_array() {
278 for e in raw {
279 let eid = e["id"].as_str().unwrap_or_default();
280 if !edges.iter().any(|x| x["id"].as_str() == Some(eid)) {
281 edges.push(e.clone());
282 }
283 }
284 }
285 }
286 }
287
288 if let Some(p) = &args.predicate {
289 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
290 for e in &edges {
291 if e["predicate"].as_str() != Some(p.as_str()) {
292 continue;
293 }
294 let src = e["doc_id"].as_str().unwrap_or_default().to_owned();
295 let dst = e["dst_id"].as_str().unwrap_or_default().to_owned();
296 if dirs.contains(&"out") {
297 adj.entry(src.clone()).or_default().push(dst.clone());
298 }
299 if dirs.contains(&"in") {
300 adj.entry(dst).or_default().push(src);
301 }
302 }
303 let mut depth_of: Vec<(String, i64)> = root_ids.iter().map(|id| (id.clone(), 0)).collect();
304 let mut wave: Vec<String> = root_ids.clone();
305 let mut lvl = 1;
306 while lvl <= effective_degrees && !wave.is_empty() {
307 let mut next = Vec::new();
308 for from in &wave {
309 for to in adj.get(from).map_or(&[][..], Vec::as_slice) {
310 if docs.iter().any(|d| &d.id == to) && !depth_of.iter().any(|(id, _)| id == to)
311 {
312 depth_of.push((to.clone(), lvl));
313 next.push(to.clone());
314 }
315 }
316 }
317 wave = next;
318 lvl += 1;
319 }
320 let mut restricted: Vec<Doc> = Vec::new();
321 for (id, d) in depth_of {
322 if let Some(orig) = docs.iter().find(|x| x.id == id) {
323 restricted.push(Doc {
324 id: orig.id.clone(),
325 path: orig.path.clone(),
326 degree: d,
327 extra: orig.extra.clone(),
328 });
329 }
330 }
331 docs = restricted;
332 }
333
334 docs.sort_by(|a, b| {
335 a.degree
336 .cmp(&b.degree)
337 .then_with(|| locale_compare(&a.path, &b.path))
338 });
339 let capped = docs.len() > max_documents;
340 docs.truncate(max_documents);
341 let truncated = query_truncated || capped;
342
343 let mut frontier = Vec::new();
344 let documents: Vec<Json> = docs
345 .iter()
346 .map(|d| {
347 let is_frontier = d.degree == effective_degrees;
348 if is_frontier {
349 frontier.push(json!({ "id": d.id, "path": d.path, "degree": d.degree }));
350 }
351 let mut m = Map::new();
352 m.insert("id".to_owned(), json!(d.id));
353 m.insert("path".to_owned(), json!(d.path));
354 m.insert("degree".to_owned(), json!(d.degree));
355 m.insert("frontier".to_owned(), json!(is_frontier));
356 for (k, v) in &d.extra {
357 m.insert(k.clone(), v.clone());
358 }
359 Json::Object(m)
360 })
361 .collect();
362
363 let reached = |id: &str| docs.iter().any(|d| d.id == id);
364 let mut kept: Vec<Json> = edges
365 .into_iter()
366 .filter(|e| {
367 if let Some(p) = &args.predicate {
368 if e["predicate"].as_str() != Some(p.as_str()) {
369 return false;
370 }
371 }
372 let src_in = reached(e["doc_id"].as_str().unwrap_or_default());
373 let dst_kind = e["dst_kind"].as_str().unwrap_or_default();
374 let dst_dangling =
375 dst_kind == "external" || (dst_kind == "document" && e["dst_path"].is_null());
376 let dst_in = reached(e["dst_id"].as_str().unwrap_or_default()) || dst_dangling;
377 src_in && dst_in
378 })
379 .collect();
380 kept.sort_by(|a, b| {
381 let (sa, sb) = (
382 a["doc_id"].as_str().unwrap_or_default(),
383 b["doc_id"].as_str().unwrap_or_default(),
384 );
385 if sa == sb {
386 locale_compare(
387 a["id"].as_str().unwrap_or_default(),
388 b["id"].as_str().unwrap_or_default(),
389 )
390 } else {
391 locale_compare(sa, sb)
392 }
393 });
394
395 Ok(json!({
396 "roots": root_ids,
397 "degrees": effective_degrees,
398 "direction": direction,
399 "documents": documents,
400 "links": kept,
401 "frontier": frontier,
402 "truncated": truncated,
403 "queries": queries,
404 }))
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn user_select_aliases() {
413 let (items, names) = build_user_select(&[
414 "layer".into(),
415 "path".into(),
416 "a + 1".into(),
417 "layer".into(),
418 ])
419 .unwrap();
420 let printed: Vec<String> = items
421 .iter()
422 .map(|it| oqx::print(oqx::Node::Select(it)).unwrap())
423 .collect();
424 assert_eq!(
425 printed,
426 ["_u0: layer", "_u1: path", "_u2: a + 1", "_u3: layer"]
427 );
428 assert_eq!(
429 names,
430 [
431 Some("layer".into()),
432 Some("path".into()),
433 Some("sel_2".into()),
434 Some("layer_3".into())
435 ]
436 );
437 assert!(build_user_select(&[]).unwrap().0.is_empty());
438 let e = build_user_select(&["a +".into()]).unwrap_err();
439 assert_eq!(e.code, "filter_invalid");
440 }
441
442 #[test]
443 fn query_shape() {
444 let q = build_query(&["d_0".to_owned()], "out", 2, &[]);
445 assert!(
446 q.starts_with("select _depth: $depth, _stop: $stop, _links: doc.out_links collect {")
447 );
448 assert!(q.ends_with(
449 "from docs where id == \"d_0\" follow distinct out_links collect { from dst depth 2 }"
450 ));
451 assert!(oqx::parse_string(&q).is_ok());
452 let q = build_query(&["d_14".to_owned(), "d_15".to_owned()], "in", 8, &[]);
456 assert_eq!(
457 q,
458 "select _depth: $depth, _stop: $stop, _links: doc.in_links collect { id: id, doc_id: doc_id, block_id: block_id, via_id: via_id, dst_id: dst_id, dst_path: dst_path, uri: uri, dst_kind: dst_kind, predicate: predicate, provenance: provenance, anchor: anchor, field: field } from docs where id == \"d_14\" or id == \"d_15\" follow distinct in_links collect { from doc depth 8 }"
459 );
460 }
461
462 #[test]
463 fn order_is_bytewise() {
464 assert_eq!(locale_compare("B", "a"), Ordering::Less);
465 assert_eq!(locale_compare("a", "b"), Ordering::Less);
466 }
467}