1use std::collections::BTreeMap;
16use std::fs;
17use std::sync::Arc;
18
19use omnigraph_compiler::catalog::Catalog;
20use omnigraph_compiler::query::ast::QueryDecl;
21use omnigraph_compiler::query::parser::parse_query;
22use omnigraph_compiler::query::typecheck::typecheck_query_decl;
23use omnigraph_compiler::types::{PropType, ScalarType};
24
25use crate::config::{OmnigraphConfig, QueryEntry};
26
27#[derive(Debug, Clone)]
32pub struct StoredQuery {
33 pub name: String,
35 pub source: Arc<str>,
37 pub decl: QueryDecl,
39 pub expose: bool,
44 pub tool_name: Option<String>,
46}
47
48impl StoredQuery {
49 pub fn is_mutation(&self) -> bool {
52 !self.decl.mutations.is_empty()
53 }
54
55 pub fn effective_tool_name(&self) -> &str {
61 self.tool_name.as_deref().unwrap_or(&self.name)
62 }
63}
64
65#[derive(Debug, Clone, Default)]
67pub struct QueryRegistry {
68 by_name: BTreeMap<String, StoredQuery>,
69}
70
71#[derive(Debug, Clone)]
74pub struct RegistrySpec {
75 pub name: String,
76 pub source: String,
77 pub expose: bool,
78 pub tool_name: Option<String>,
79}
80
81#[derive(Debug, Clone)]
85pub struct LoadError {
86 pub query: Option<String>,
88 pub message: String,
89}
90
91impl std::fmt::Display for LoadError {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 match &self.query {
94 Some(name) => write!(f, "stored query '{name}': {}", self.message),
95 None => write!(f, "stored query registry: {}", self.message),
96 }
97 }
98}
99
100impl QueryRegistry {
101 pub fn from_specs(specs: Vec<RegistrySpec>) -> Result<Self, Vec<LoadError>> {
106 let mut by_name = BTreeMap::new();
107 let mut errors = Vec::new();
108
109 for spec in specs {
110 match parse_query(&spec.source) {
111 Ok(file) => {
112 match file.queries.into_iter().find(|q| q.name == spec.name) {
113 Some(decl) => {
114 by_name.insert(
115 spec.name.clone(),
116 StoredQuery {
117 name: spec.name,
118 source: Arc::from(spec.source),
119 decl,
120 expose: spec.expose,
121 tool_name: spec.tool_name,
122 },
123 );
124 }
125 None => errors.push(LoadError {
126 query: Some(spec.name.clone()),
127 message: format!(
128 "no `query {}` declaration found in its `.gq` file \
129 (the registry key must match the query symbol)",
130 spec.name
131 ),
132 }),
133 }
134 }
135 Err(err) => errors.push(LoadError {
136 query: Some(spec.name),
137 message: format!("parse error: {err}"),
138 }),
139 }
140 }
141
142 {
151 let mut claimed: BTreeMap<&str, &str> = BTreeMap::new();
152 for query in by_name.values().filter(|q| q.expose) {
153 let tool = query.effective_tool_name();
154 if let Some(winner) = claimed.insert(tool, &query.name) {
155 errors.push(LoadError {
156 query: Some(query.name.clone()),
157 message: format!(
158 "MCP tool name '{tool}' already claimed by exposed query '{winner}'"
159 ),
160 });
161 }
162 }
163 }
164
165 if errors.is_empty() {
166 Ok(Self { by_name })
167 } else {
168 Err(errors)
169 }
170 }
171
172 pub fn load(
177 config: &OmnigraphConfig,
178 entries: &BTreeMap<String, QueryEntry>,
179 ) -> Result<Self, Vec<LoadError>> {
180 let mut specs = Vec::with_capacity(entries.len());
181 let mut errors = Vec::new();
182 for (name, entry) in entries {
183 let path = config.resolve_query_file(&entry.file);
184 match fs::read_to_string(&path) {
185 Ok(source) => specs.push(RegistrySpec {
186 name: name.clone(),
187 source,
188 expose: entry.mcp.expose,
189 tool_name: entry.mcp.tool_name.clone(),
190 }),
191 Err(err) => errors.push(LoadError {
192 query: Some(name.clone()),
193 message: format!("cannot read '{}': {err}", path.display()),
194 }),
195 }
196 }
197
198 match Self::from_specs(specs) {
204 Ok(registry) if errors.is_empty() => Ok(registry),
205 Ok(_) => Err(errors),
206 Err(spec_errors) => {
207 errors.extend(spec_errors);
208 Err(errors)
209 }
210 }
211 }
212
213 pub fn lookup(&self, name: &str) -> Option<&StoredQuery> {
214 self.by_name.get(name)
215 }
216
217 pub fn iter(&self) -> impl Iterator<Item = &StoredQuery> {
218 self.by_name.values()
219 }
220
221 pub fn is_empty(&self) -> bool {
222 self.by_name.is_empty()
223 }
224
225 pub fn len(&self) -> usize {
226 self.by_name.len()
227 }
228}
229
230#[derive(Debug, Clone)]
236pub struct Breakage {
237 pub query: String,
238 pub message: String,
239}
240
241#[derive(Debug, Clone)]
245pub struct Warning {
246 pub query: String,
247 pub message: String,
248}
249
250#[derive(Debug, Clone, Default)]
253pub struct CheckReport {
254 pub breakages: Vec<Breakage>,
255 pub warnings: Vec<Warning>,
256}
257
258impl CheckReport {
259 pub fn has_breakages(&self) -> bool {
260 !self.breakages.is_empty()
261 }
262
263 pub fn is_clean(&self) -> bool {
264 self.breakages.is_empty() && self.warnings.is_empty()
265 }
266}
267
268pub fn check(registry: &QueryRegistry, catalog: &Catalog) -> CheckReport {
286 let mut report = CheckReport::default();
287 for query in registry.iter() {
288 if let Err(err) = typecheck_query_decl(catalog, &query.decl) {
289 report.breakages.push(Breakage {
290 query: query.name.clone(),
291 message: err.to_string(),
292 });
293 }
294 if query.expose {
295 for param in &query.decl.params {
296 let is_vector = PropType::from_param_type_name(¶m.type_name, param.nullable)
301 .is_some_and(|pt| matches!(pt.scalar, ScalarType::Vector(_)));
302 if is_vector {
303 report.warnings.push(Warning {
304 query: query.name.clone(),
305 message: format!(
306 "MCP-exposed query declares a `{}` parameter `${}` that agents \
307 cannot supply; use a `String` parameter for server-side embedding",
308 param.type_name, param.name
309 ),
310 });
311 }
312 }
313 }
314 }
315 report
316}
317
318pub fn format_check_breakages(label: &str, report: &CheckReport) -> String {
321 let joined = report
322 .breakages
323 .iter()
324 .map(|b| format!("query '{}': {}", b.query, b.message))
325 .collect::<Vec<_>>()
326 .join("\n ");
327 format!(
328 "graph '{label}': {} stored quer{} failed the schema check:\n {joined}",
329 report.breakages.len(),
330 if report.breakages.len() == 1 {
331 "y"
332 } else {
333 "ies"
334 }
335 )
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 fn spec(name: &str, source: &str, expose: bool) -> RegistrySpec {
343 RegistrySpec {
344 name: name.to_string(),
345 source: source.to_string(),
346 expose,
347 tool_name: None,
348 }
349 }
350
351 fn spec_tool(name: &str, source: &str, expose: bool, tool_name: &str) -> RegistrySpec {
352 RegistrySpec {
353 name: name.to_string(),
354 source: source.to_string(),
355 expose,
356 tool_name: Some(tool_name.to_string()),
357 }
358 }
359
360 #[test]
361 fn key_equal_symbol_loads() {
362 let reg = QueryRegistry::from_specs(vec![spec(
363 "find_user",
364 "query find_user($id: String) { match { $u: User } return { $u.name } }",
365 true,
366 )])
367 .unwrap();
368 let q = reg.lookup("find_user").unwrap();
369 assert_eq!(q.name, "find_user");
370 assert!(q.expose);
371 assert_eq!(q.decl.params.len(), 1);
372 assert!(!q.is_mutation());
373 assert_eq!(q.effective_tool_name(), "find_user");
375
376 let with_tool = QueryRegistry::from_specs(vec![spec_tool(
378 "find_user",
379 "query find_user($id: String) { match { $u: User } return { $u.name } }",
380 true,
381 "lookup_user",
382 )])
383 .unwrap();
384 assert_eq!(
385 with_tool.lookup("find_user").unwrap().effective_tool_name(),
386 "lookup_user"
387 );
388 }
389
390 #[test]
391 fn key_mismatch_is_an_identity_error() {
392 let errors = QueryRegistry::from_specs(vec![spec(
393 "find_user",
394 "query lookup($id: String) { match { $u: User } return { $u.name } }",
396 false,
397 )])
398 .unwrap_err();
399 assert_eq!(errors.len(), 1);
400 assert_eq!(errors[0].query.as_deref(), Some("find_user"));
401 assert!(errors[0].message.contains("must match the query symbol"));
402 }
403
404 #[test]
405 fn multi_query_file_selects_the_matching_symbol() {
406 let source = "query a($x: I64) { match { $u: User } return { $u.name } }\n\
407 query b($y: String) { match { $u: User } return { $u.name } }";
408 let reg = QueryRegistry::from_specs(vec![spec("b", source, false)]).unwrap();
409 let q = reg.lookup("b").unwrap();
410 assert_eq!(q.name, "b");
411 assert_eq!(q.decl.params[0].name, "y");
412 assert!(reg.lookup("a").is_none(), "only the selected symbol is registered");
413 }
414
415 #[test]
416 fn duplicate_exposed_tool_name_is_a_load_error() {
417 let errors = QueryRegistry::from_specs(vec![
421 spec_tool("a", "query a() { match { $u: User } return { $u.name } }", true, "dup"),
422 spec_tool("b", "query b() { match { $u: User } return { $u.name } }", true, "dup"),
423 ])
424 .unwrap_err();
425 assert_eq!(errors.len(), 1);
426 let msg = errors[0].to_string();
427 assert!(msg.contains("'dup'"), "names the contested tool: {msg}");
428 assert!(msg.contains("'a'"), "names the winning query: {msg}");
429 assert!(msg.contains("'b'"), "names the losing query: {msg}");
430 }
431
432 #[test]
433 fn duplicate_tool_name_among_unexposed_is_allowed() {
434 let reg = QueryRegistry::from_specs(vec![
437 spec_tool("a", "query a() { match { $u: User } return { $u.name } }", false, "dup"),
438 spec_tool("b", "query b() { match { $u: User } return { $u.name } }", false, "dup"),
439 ])
440 .unwrap();
441 assert_eq!(reg.len(), 2);
442 }
443
444 #[test]
445 fn parse_error_surfaces_per_entry() {
446 let errors =
447 QueryRegistry::from_specs(vec![spec("broken", "query broken( {{ not valid", false)])
448 .unwrap_err();
449 assert_eq!(errors[0].query.as_deref(), Some("broken"));
450 assert!(errors[0].message.contains("parse error"));
451 }
452
453 #[test]
454 fn errors_collect_rather_than_fail_fast() {
455 let errors = QueryRegistry::from_specs(vec![
456 spec("good", "query good() { match { $u: User } return { $u.name } }", false),
457 spec("mismatch", "query other() { match { $u: User } return { $u.name } }", false),
458 spec("broken", "query broken(", false),
459 ])
460 .unwrap_err();
461 assert_eq!(errors.len(), 2);
464 }
465
466 #[test]
467 fn mutation_body_classifies_as_mutation() {
468 let reg = QueryRegistry::from_specs(vec![spec(
469 "add_user",
470 "query add_user($name: String) { insert User { name: $name } }",
471 false,
472 )])
473 .unwrap();
474 assert!(reg.lookup("add_user").unwrap().is_mutation());
475 }
476
477 use omnigraph_compiler::catalog::build_catalog;
480 use omnigraph_compiler::schema::parser::parse_schema;
481
482 fn test_catalog() -> Catalog {
483 let schema = parse_schema(
484 r#"
485node User {
486name: String
487age: I32?
488embedding: Vector(4)
489}
490"#,
491 )
492 .unwrap();
493 build_catalog(&schema).unwrap()
494 }
495
496 #[test]
497 fn check_passes_for_valid_query() {
498 let reg = QueryRegistry::from_specs(vec![spec(
499 "find_user",
500 "query find_user($name: String) { match { $u: User { name: $name } } return { $u.age } }",
501 false,
502 )])
503 .unwrap();
504 let report = check(®, &test_catalog());
505 assert!(report.is_clean(), "unexpected: {:?}", report);
506 }
507
508 #[test]
509 fn check_reports_unknown_type_as_breakage() {
510 let reg = QueryRegistry::from_specs(vec![spec(
511 "ghost",
512 "query ghost() { match { $w: Widget } return { $w.name } }",
514 false,
515 )])
516 .unwrap();
517 let report = check(®, &test_catalog());
518 assert!(report.has_breakages());
519 assert_eq!(report.breakages[0].query, "ghost");
520 }
521
522 #[test]
523 fn check_reports_unknown_property_as_breakage() {
524 let reg = QueryRegistry::from_specs(vec![spec(
525 "bad_prop",
526 "query bad_prop() { match { $u: User } return { $u.nickname } }",
528 false,
529 )])
530 .unwrap();
531 let report = check(®, &test_catalog());
532 assert!(report.has_breakages());
533 assert_eq!(report.breakages[0].query, "bad_prop");
534 }
535
536 #[test]
537 fn check_collects_every_breakage_not_fail_fast() {
538 let reg = QueryRegistry::from_specs(vec![
539 spec("a", "query a() { match { $w: Widget } return { $w.x } }", false),
540 spec("b", "query b() { match { $g: Gadget } return { $g.y } }", false),
541 spec(
542 "ok",
543 "query ok() { match { $u: User } return { $u.name } }",
544 false,
545 ),
546 ])
547 .unwrap();
548 let report = check(®, &test_catalog());
549 assert_eq!(report.breakages.len(), 2, "both bad queries reported: {:?}", report);
550 }
551
552 #[test]
553 fn vector_param_on_exposed_query_warns() {
554 let reg = QueryRegistry::from_specs(vec![spec(
555 "vec_search",
556 "query vec_search($q: Vector(4)) { match { $u: User } return { $u.name } \
557 order { nearest($u.embedding, $q) } limit 3 }",
558 true, )])
560 .unwrap();
561 let report = check(®, &test_catalog());
562 assert!(!report.has_breakages(), "valid query: {:?}", report);
563 assert_eq!(report.warnings.len(), 1);
564 assert_eq!(report.warnings[0].query, "vec_search");
565 }
566
567 #[test]
568 fn vector_param_on_unexposed_query_is_silent() {
569 let reg = QueryRegistry::from_specs(vec![spec(
570 "vec_search",
571 "query vec_search($q: Vector(4)) { match { $u: User } return { $u.name } \
572 order { nearest($u.embedding, $q) } limit 3 }",
573 false, )])
575 .unwrap();
576 let report = check(®, &test_catalog());
577 assert!(report.is_clean(), "unexpected: {:?}", report);
578 }
579
580 #[test]
581 fn non_vector_param_on_exposed_query_does_not_warn() {
582 let reg = QueryRegistry::from_specs(vec![spec(
587 "search",
588 "query search($name: String) { match { $u: User { name: $name } } return { $u.name } }",
589 true,
590 )])
591 .unwrap();
592 let report = check(®, &test_catalog());
593 assert!(report.is_clean(), "no breakage or warning expected: {:?}", report);
594 }
595
596 #[test]
599 fn catalog_entry_projects_every_param_kind() {
600 use crate::api::{self, ParamKind};
601 let reg = QueryRegistry::from_specs(vec![spec_tool(
602 "all_types",
603 "query all_types($s: String, $i: I32, $big: I64, $u: U64, $f: F64, $b: Bool, \
604 $d: Date, $dt: DateTime, $blob: Blob, $opt: String?, $list: [I32], $vec: Vector(4)) \
605 { match { $x: User } return { $x.name } }",
606 true,
607 "all",
608 )])
609 .unwrap();
610 let entry = api::query_catalog_entry(reg.lookup("all_types").unwrap());
611 assert_eq!(entry.name, "all_types");
612 assert_eq!(entry.tool_name, "all");
613 assert!(!entry.mutation);
614
615 let by: std::collections::HashMap<_, _> =
616 entry.params.iter().map(|p| (p.name.as_str(), p)).collect();
617 assert_eq!(by["s"].kind, ParamKind::String);
618 assert_eq!(by["i"].kind, ParamKind::Int);
619 assert_eq!(by["big"].kind, ParamKind::BigInt, "I64 → bigint (string on the wire)");
620 assert_eq!(by["u"].kind, ParamKind::BigInt, "U64 → bigint");
621 assert_eq!(by["f"].kind, ParamKind::Float);
622 assert_eq!(by["b"].kind, ParamKind::Bool);
623 assert_eq!(by["d"].kind, ParamKind::Date);
624 assert_eq!(by["dt"].kind, ParamKind::DateTime);
625 assert_eq!(by["blob"].kind, ParamKind::Blob);
626 assert!(!by["s"].nullable);
627 assert!(by["opt"].nullable, "String? → nullable");
628 assert_eq!(by["list"].kind, ParamKind::List);
629 assert_eq!(by["list"].item_kind, Some(ParamKind::Int), "[I32] → list of int");
630 assert_eq!(by["vec"].kind, ParamKind::Vector);
631 assert_eq!(by["vec"].vector_dim, Some(4));
632 }
633
634 #[test]
635 fn catalog_entry_flags_mutation_and_empty_params() {
636 use crate::api;
637 let reg = QueryRegistry::from_specs(vec![spec(
638 "add_user",
639 "query add_user($name: String) { insert User { name: $name } }",
640 true,
641 )])
642 .unwrap();
643 let entry = api::query_catalog_entry(reg.lookup("add_user").unwrap());
644 assert!(entry.mutation, "insert body → mutation flag");
645
646 let reg2 = QueryRegistry::from_specs(vec![spec(
647 "no_params",
648 "query no_params() { match { $u: User } return { $u.name } }",
649 true,
650 )])
651 .unwrap();
652 let entry2 = api::query_catalog_entry(reg2.lookup("no_params").unwrap());
653 assert!(entry2.params.is_empty(), "no declared params → empty list");
654 }
655
656 #[test]
659 fn load_collects_io_and_parse_errors_in_one_pass() {
660 use crate::config::load_config;
661 let temp = tempfile::tempdir().unwrap();
662 std::fs::write(
663 temp.path().join("good.gq"),
664 "query good() { match { $u: User } return { $u.name } }",
665 )
666 .unwrap();
667 std::fs::write(temp.path().join("broken.gq"), "query broken( {{ not valid").unwrap();
668 std::fs::write(
670 temp.path().join("omnigraph.yaml"),
671 "queries:\n good:\n file: ./good.gq\n \
672 missing:\n file: ./missing.gq\n broken:\n file: ./broken.gq\n",
673 )
674 .unwrap();
675 let config = load_config(Some(&temp.path().join("omnigraph.yaml"))).unwrap();
676
677 let errors = QueryRegistry::load(&config, config.query_entries()).unwrap_err();
678 let joined = errors.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("\n");
679 assert!(joined.contains("missing"), "I/O error must surface: {joined}");
682 assert!(
683 joined.contains("broken") && joined.contains("parse error"),
684 "the parse error in a readable file must surface in the same pass: {joined}"
685 );
686 assert!(!joined.contains("'good'"), "the valid entry is not an error: {joined}");
687 }
688}