1use std::cmp::Ordering;
7use std::collections::HashMap;
8
9use omgbase_search::EmbeddingProvider;
10use omgbase_store::Store;
11use serde_json::{Map, Value as Json, json};
12
13use crate::error::{Result, SurfaceError};
14use crate::query::{QueryOptions, query};
15use crate::read::find_doc_by_ref;
16
17const DEFAULT_DEGREES: i64 = 1;
18const DEFAULT_MAX_DOCUMENTS: i64 = 200;
19const MAX_DEPTH: i64 = 8;
20
21#[derive(Clone, Debug, Default)]
23pub struct GraphArgs {
24 pub roots: Vec<String>,
25 pub degrees: Option<i64>,
26 pub direction: Option<String>,
28 pub predicate: Option<String>,
29 pub select: Vec<String>,
30 pub max_documents: Option<i64>,
31}
32
33const EDGE_COLLECT: &str = "{ id: $id, src: $src, dst: $dst, dst_path: $dst_path, dst_uri: $dst_uri, dst_kind: dst_kind, predicate: predicate, provenance: provenance, anchor: anchor, src_field: src_field }";
34
35fn build_user_select(select: &[String]) -> (String, Vec<Option<String>>) {
36 let mut items = Vec::new();
37 let mut out_names: Vec<Option<String>> = vec![None; select.len()];
38 let mut used: Vec<String> = Vec::new();
39 for (i, expr) in select.iter().enumerate() {
40 let trimmed = expr.trim();
41 if trimmed.is_empty() {
42 continue;
43 }
44 let ident = trimmed.strip_prefix('$').unwrap_or(trimmed);
45 let is_ident = ident
46 .chars()
47 .next()
48 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
49 && ident.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
50 let mut name = if is_ident {
51 ident.to_owned()
52 } else {
53 format!("sel_{i}")
54 };
55 while used.contains(&name) {
56 name = format!("{name}_{i}");
57 }
58 used.push(name.clone());
59 out_names[i] = Some(name);
60 items.push(format!("_u{i}: {trimmed}"));
61 }
62 let clause = if items.is_empty() {
63 String::new()
64 } else {
65 format!(", {}", items.join(", "))
66 };
67 (clause, out_names)
68}
69
70fn build_query(seed: &str, dir: &str, depth: i64, user_select: &str) -> String {
71 format!(
72 "select _depth: $depth, _stop: $stop, _edges: doc.{dir}_edges collect {EDGE_COLLECT}{user_select} from docs where {seed} follow distinct doc.{dir} {{ depth {depth} }}"
73 )
74}
75
76fn locale_compare(a: &str, b: &str) -> Ordering {
78 a.cmp(b)
79}
80
81struct Doc {
82 id: String,
83 path: String,
84 degree: i64,
85 extra: Vec<(String, Json)>,
86}
87
88pub fn graph_neighborhood(
90 store: &Store,
91 repo_id: &str,
92 args: &GraphArgs,
93 provider: Option<&dyn EmbeddingProvider>,
94) -> Result<Json> {
95 if args.roots.is_empty() {
96 return Err(SurfaceError::new(
97 "target_missing",
98 "graph requires at least one root (path or id)",
99 ));
100 }
101 let mut root_ids: Vec<String> = Vec::new();
102 for r in &args.roots {
103 let Some(info) = find_doc_by_ref(store.conn(), repo_id, r)? else {
104 return Err(SurfaceError::with_data(
105 "doc_missing",
106 format!("no document for {}", Json::String(r.clone())),
107 json!({ "root": r }),
108 ));
109 };
110 if !root_ids.contains(&info.doc_id) {
111 root_ids.push(info.doc_id);
112 }
113 }
114 let degrees = args.degrees.unwrap_or(DEFAULT_DEGREES).max(0);
115 let depth = MAX_DEPTH.min(degrees + 1);
116 let effective_degrees = depth - 1;
117 let direction = args.direction.clone().unwrap_or_else(|| "both".to_owned());
118 let max_documents =
119 usize::try_from(args.max_documents.unwrap_or(DEFAULT_MAX_DOCUMENTS).max(1)).unwrap_or(1);
120 let dirs: Vec<&str> = match direction.as_str() {
121 "both" => vec!["out", "in"],
122 "in" => vec!["in"],
123 _ => vec!["out"],
124 };
125 let seed = root_ids
126 .iter()
127 .map(|id| format!("$id == {}", Json::String(id.clone())))
128 .collect::<Vec<_>>()
129 .join(" || ");
130 let (user_select, out_names) = build_user_select(&args.select);
131
132 let mut queries = Vec::new();
133 let mut docs: Vec<Doc> = Vec::new();
134 let mut edges: Vec<Json> = Vec::new();
135 let mut query_truncated = false;
136 for dir in &dirs {
137 let q = build_query(&seed, dir, depth, &user_select);
138 queries.push(q.clone());
139 let res = query(
140 store,
141 repo_id,
142 &q,
143 QueryOptions {
144 limit: Some(max_documents + 1),
145 cursor: None,
146 provider,
147 in_memory: false,
148 },
149 )?;
150 if res.truncated {
151 query_truncated = true;
152 }
153 for hit in &res.hits {
154 let id = hit["id"].as_str().unwrap_or_default().to_owned();
155 let path = hit["path"].as_str().unwrap_or_default().to_owned();
156 let hop = hit["_depth"].as_f64().unwrap_or(f64::NAN);
157 let degree = (hop - 1.0) as i64;
158 let replace = docs
159 .iter()
160 .position(|d| d.id == id)
161 .map(|i| (i, degree < docs[i].degree));
162 match replace {
163 Some((_, false)) => {}
164 found => {
165 let extra: Vec<(String, Json)> = out_names
166 .iter()
167 .enumerate()
168 .filter_map(|(i, n)| {
169 n.as_ref().map(|name| {
170 (
171 name.clone(),
172 hit.get(format!("_u{i}")).cloned().unwrap_or(Json::Null),
173 )
174 })
175 })
176 .collect();
177 let doc = Doc {
178 id: id.clone(),
179 path,
180 degree,
181 extra,
182 };
183 match found {
184 Some((i, true)) => docs[i] = doc,
185 _ => docs.push(doc),
186 }
187 }
188 }
189 if let Some(raw) = hit["_edges"].as_array() {
190 for e in raw {
191 let eid = e["id"].as_str().unwrap_or_default();
192 if !edges.iter().any(|x| x["id"].as_str() == Some(eid)) {
193 edges.push(e.clone());
194 }
195 }
196 }
197 }
198 }
199
200 if let Some(p) = &args.predicate {
201 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
202 for e in &edges {
203 if e["predicate"].as_str() != Some(p.as_str()) {
204 continue;
205 }
206 let src = e["src"].as_str().unwrap_or_default().to_owned();
207 let dst = e["dst"].as_str().unwrap_or_default().to_owned();
208 if dirs.contains(&"out") {
209 adj.entry(src.clone()).or_default().push(dst.clone());
210 }
211 if dirs.contains(&"in") {
212 adj.entry(dst).or_default().push(src);
213 }
214 }
215 let mut depth_of: Vec<(String, i64)> = root_ids.iter().map(|id| (id.clone(), 0)).collect();
216 let mut wave: Vec<String> = root_ids.clone();
217 let mut lvl = 1;
218 while lvl <= effective_degrees && !wave.is_empty() {
219 let mut next = Vec::new();
220 for from in &wave {
221 for to in adj.get(from).map_or(&[][..], Vec::as_slice) {
222 if docs.iter().any(|d| &d.id == to) && !depth_of.iter().any(|(id, _)| id == to)
223 {
224 depth_of.push((to.clone(), lvl));
225 next.push(to.clone());
226 }
227 }
228 }
229 wave = next;
230 lvl += 1;
231 }
232 let mut restricted: Vec<Doc> = Vec::new();
233 for (id, d) in depth_of {
234 if let Some(orig) = docs.iter().find(|x| x.id == id) {
235 restricted.push(Doc {
236 id: orig.id.clone(),
237 path: orig.path.clone(),
238 degree: d,
239 extra: orig.extra.clone(),
240 });
241 }
242 }
243 docs = restricted;
244 }
245
246 docs.sort_by(|a, b| {
247 a.degree
248 .cmp(&b.degree)
249 .then_with(|| locale_compare(&a.path, &b.path))
250 });
251 let capped = docs.len() > max_documents;
252 docs.truncate(max_documents);
253 let truncated = query_truncated || capped;
254
255 let mut frontier = Vec::new();
256 let documents: Vec<Json> = docs
257 .iter()
258 .map(|d| {
259 let is_frontier = d.degree == effective_degrees;
260 if is_frontier {
261 frontier.push(json!({ "id": d.id, "path": d.path, "degree": d.degree }));
262 }
263 let mut m = Map::new();
264 m.insert("id".to_owned(), json!(d.id));
265 m.insert("path".to_owned(), json!(d.path));
266 m.insert("degree".to_owned(), json!(d.degree));
267 m.insert("frontier".to_owned(), json!(is_frontier));
268 for (k, v) in &d.extra {
269 m.insert(k.clone(), v.clone());
270 }
271 Json::Object(m)
272 })
273 .collect();
274
275 let reached = |id: &str| docs.iter().any(|d| d.id == id);
276 let mut kept: Vec<Json> = edges
277 .into_iter()
278 .filter(|e| {
279 if let Some(p) = &args.predicate {
280 if e["predicate"].as_str() != Some(p.as_str()) {
281 return false;
282 }
283 }
284 let src_in = reached(e["src"].as_str().unwrap_or_default());
285 let dst_kind = e["dst_kind"].as_str().unwrap_or_default();
286 let dst_dangling =
287 dst_kind == "external" || (dst_kind == "document" && e["dst_path"].is_null());
288 let dst_in = reached(e["dst"].as_str().unwrap_or_default()) || dst_dangling;
289 src_in && dst_in
290 })
291 .collect();
292 kept.sort_by(|a, b| {
293 let (sa, sb) = (
294 a["src"].as_str().unwrap_or_default(),
295 b["src"].as_str().unwrap_or_default(),
296 );
297 if sa == sb {
298 locale_compare(
299 a["id"].as_str().unwrap_or_default(),
300 b["id"].as_str().unwrap_or_default(),
301 )
302 } else {
303 locale_compare(sa, sb)
304 }
305 });
306
307 Ok(json!({
308 "roots": root_ids,
309 "degrees": effective_degrees,
310 "direction": direction,
311 "documents": documents,
312 "edges": kept,
313 "frontier": frontier,
314 "truncated": truncated,
315 "queries": queries,
316 }))
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn user_select_aliases() {
325 let (clause, names) = build_user_select(&[
326 "layer".into(),
327 "$path".into(),
328 "a + 1".into(),
329 "layer".into(),
330 ]);
331 assert_eq!(clause, ", _u0: layer, _u1: $path, _u2: a + 1, _u3: layer");
332 assert_eq!(
333 names,
334 [
335 Some("layer".into()),
336 Some("path".into()),
337 Some("sel_2".into()),
338 Some("layer_3".into())
339 ]
340 );
341 assert_eq!(build_user_select(&[]).0, "");
342 }
343
344 #[test]
345 fn query_shape() {
346 let q = build_query("$id == \"d_0\"", "out", 2, "");
347 assert!(
348 q.starts_with("select _depth: $depth, _stop: $stop, _edges: doc.out_edges collect {")
349 );
350 assert!(q.ends_with("from docs where $id == \"d_0\" follow distinct doc.out { depth 2 }"));
351 assert!(oqx::parse_string(&q).is_ok());
352 }
353
354 #[test]
355 fn order_is_bytewise() {
356 assert_eq!(locale_compare("B", "a"), Ordering::Less);
357 assert_eq!(locale_compare("a", "b"), Ordering::Less);
358 }
359}