1use std::collections::BTreeMap;
16use std::sync::Arc;
17
18use omnigraph_compiler::catalog::Catalog;
19use omnigraph_compiler::query::ast::QueryDecl;
20use omnigraph_compiler::query::parser::parse_query;
21use omnigraph_compiler::query::typecheck::typecheck_query_decl;
22use omnigraph_compiler::types::{PropType, ScalarType};
23
24#[derive(Debug, Clone)]
29pub struct StoredQuery {
30 pub name: String,
32 pub source: Arc<str>,
34 pub decl: QueryDecl,
36 pub expose: bool,
41 pub tool_name: Option<String>,
43}
44
45impl StoredQuery {
46 pub fn is_mutation(&self) -> bool {
49 !self.decl.mutations.is_empty()
50 }
51
52 pub fn effective_tool_name(&self) -> &str {
58 self.tool_name.as_deref().unwrap_or(&self.name)
59 }
60}
61
62#[derive(Debug, Clone, Default)]
64pub struct QueryRegistry {
65 by_name: BTreeMap<String, StoredQuery>,
66}
67
68#[derive(Debug, Clone)]
72pub struct RegistrySpec {
73 pub name: String,
74 pub source: String,
75 pub expose: bool,
76 pub tool_name: Option<String>,
77}
78
79#[derive(Debug, Clone)]
83pub struct LoadError {
84 pub query: Option<String>,
86 pub message: String,
87}
88
89impl std::fmt::Display for LoadError {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 match &self.query {
92 Some(name) => write!(f, "stored query '{name}': {}", self.message),
93 None => write!(f, "stored query registry: {}", self.message),
94 }
95 }
96}
97
98impl QueryRegistry {
99 pub fn from_specs(specs: Vec<RegistrySpec>) -> Result<Self, Vec<LoadError>> {
104 let mut by_name = BTreeMap::new();
105 let mut errors = Vec::new();
106
107 for spec in specs {
108 match parse_query(&spec.source) {
109 Ok(file) => {
110 match file.queries.into_iter().find(|q| q.name == spec.name) {
111 Some(decl) => {
112 by_name.insert(
113 spec.name.clone(),
114 StoredQuery {
115 name: spec.name,
116 source: Arc::from(spec.source),
117 decl,
118 expose: spec.expose,
119 tool_name: spec.tool_name,
120 },
121 );
122 }
123 None => errors.push(LoadError {
124 query: Some(spec.name.clone()),
125 message: format!(
126 "no `query {}` declaration found in its `.gq` file \
127 (the registry key must match the query symbol)",
128 spec.name
129 ),
130 }),
131 }
132 }
133 Err(err) => errors.push(LoadError {
134 query: Some(spec.name),
135 message: format!("parse error: {err}"),
136 }),
137 }
138 }
139
140 {
149 let mut claimed: BTreeMap<&str, &str> = BTreeMap::new();
150 for query in by_name.values().filter(|q| q.expose) {
151 let tool = query.effective_tool_name();
152 if let Some(winner) = claimed.insert(tool, &query.name) {
153 errors.push(LoadError {
154 query: Some(query.name.clone()),
155 message: format!(
156 "MCP tool name '{tool}' already claimed by exposed query '{winner}'"
157 ),
158 });
159 }
160 }
161 }
162
163 if errors.is_empty() {
164 Ok(Self { by_name })
165 } else {
166 Err(errors)
167 }
168 }
169
170 pub fn lookup(&self, name: &str) -> Option<&StoredQuery> {
171 self.by_name.get(name)
172 }
173
174 pub fn iter(&self) -> impl Iterator<Item = &StoredQuery> {
175 self.by_name.values()
176 }
177
178 pub fn is_empty(&self) -> bool {
179 self.by_name.is_empty()
180 }
181
182 pub fn len(&self) -> usize {
183 self.by_name.len()
184 }
185}
186
187#[derive(Debug, Clone)]
193pub struct Breakage {
194 pub query: String,
195 pub message: String,
196}
197
198#[derive(Debug, Clone)]
202pub struct Warning {
203 pub query: String,
204 pub message: String,
205}
206
207#[derive(Debug, Clone, Default)]
210pub struct CheckReport {
211 pub breakages: Vec<Breakage>,
212 pub warnings: Vec<Warning>,
213}
214
215impl CheckReport {
216 pub fn has_breakages(&self) -> bool {
217 !self.breakages.is_empty()
218 }
219
220 pub fn is_clean(&self) -> bool {
221 self.breakages.is_empty() && self.warnings.is_empty()
222 }
223}
224
225pub fn check(registry: &QueryRegistry, catalog: &Catalog) -> CheckReport {
243 let mut report = CheckReport::default();
244 for query in registry.iter() {
245 if let Err(err) = typecheck_query_decl(catalog, &query.decl) {
246 report.breakages.push(Breakage {
247 query: query.name.clone(),
248 message: err.to_string(),
249 });
250 }
251 if query.expose {
252 for param in &query.decl.params {
253 let is_vector = PropType::from_param_type_name(¶m.type_name, param.nullable)
258 .is_some_and(|pt| matches!(pt.scalar, ScalarType::Vector(_)));
259 if is_vector {
260 report.warnings.push(Warning {
261 query: query.name.clone(),
262 message: format!(
263 "MCP-exposed query declares a `{}` parameter `${}` that agents \
264 cannot supply; use a `String` parameter for server-side embedding",
265 param.type_name, param.name
266 ),
267 });
268 }
269 }
270 }
271 }
272 report
273}
274
275pub fn format_check_breakages(label: &str, report: &CheckReport) -> String {
278 let joined = report
279 .breakages
280 .iter()
281 .map(|b| format!("query '{}': {}", b.query, b.message))
282 .collect::<Vec<_>>()
283 .join("\n ");
284 format!(
285 "graph '{label}': {} stored quer{} failed the schema check:\n {joined}",
286 report.breakages.len(),
287 if report.breakages.len() == 1 {
288 "y"
289 } else {
290 "ies"
291 }
292 )
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 fn spec(name: &str, source: &str, expose: bool) -> RegistrySpec {
300 RegistrySpec {
301 name: name.to_string(),
302 source: source.to_string(),
303 expose,
304 tool_name: None,
305 }
306 }
307
308 fn spec_tool(name: &str, source: &str, expose: bool, tool_name: &str) -> RegistrySpec {
309 RegistrySpec {
310 name: name.to_string(),
311 source: source.to_string(),
312 expose,
313 tool_name: Some(tool_name.to_string()),
314 }
315 }
316
317 #[test]
318 fn key_equal_symbol_loads() {
319 let reg = QueryRegistry::from_specs(vec![spec(
320 "find_user",
321 "query find_user($id: String) { match { $u: User } return { $u.name } }",
322 true,
323 )])
324 .unwrap();
325 let q = reg.lookup("find_user").unwrap();
326 assert_eq!(q.name, "find_user");
327 assert!(q.expose);
328 assert_eq!(q.decl.params.len(), 1);
329 assert!(!q.is_mutation());
330 assert_eq!(q.effective_tool_name(), "find_user");
332
333 let with_tool = QueryRegistry::from_specs(vec![spec_tool(
335 "find_user",
336 "query find_user($id: String) { match { $u: User } return { $u.name } }",
337 true,
338 "lookup_user",
339 )])
340 .unwrap();
341 assert_eq!(
342 with_tool.lookup("find_user").unwrap().effective_tool_name(),
343 "lookup_user"
344 );
345 }
346
347 #[test]
348 fn key_mismatch_is_an_identity_error() {
349 let errors = QueryRegistry::from_specs(vec![spec(
350 "find_user",
351 "query lookup($id: String) { match { $u: User } return { $u.name } }",
353 false,
354 )])
355 .unwrap_err();
356 assert_eq!(errors.len(), 1);
357 assert_eq!(errors[0].query.as_deref(), Some("find_user"));
358 assert!(errors[0].message.contains("must match the query symbol"));
359 }
360
361 #[test]
362 fn multi_query_file_selects_the_matching_symbol() {
363 let source = "query a($x: I64) { match { $u: User } return { $u.name } }\n\
364 query b($y: String) { match { $u: User } return { $u.name } }";
365 let reg = QueryRegistry::from_specs(vec![spec("b", source, false)]).unwrap();
366 let q = reg.lookup("b").unwrap();
367 assert_eq!(q.name, "b");
368 assert_eq!(q.decl.params[0].name, "y");
369 assert!(reg.lookup("a").is_none(), "only the selected symbol is registered");
370 }
371
372 #[test]
373 fn duplicate_exposed_tool_name_is_a_load_error() {
374 let errors = QueryRegistry::from_specs(vec![
378 spec_tool("a", "query a() { match { $u: User } return { $u.name } }", true, "dup"),
379 spec_tool("b", "query b() { match { $u: User } return { $u.name } }", true, "dup"),
380 ])
381 .unwrap_err();
382 assert_eq!(errors.len(), 1);
383 let msg = errors[0].to_string();
384 assert!(msg.contains("'dup'"), "names the contested tool: {msg}");
385 assert!(msg.contains("'a'"), "names the winning query: {msg}");
386 assert!(msg.contains("'b'"), "names the losing query: {msg}");
387 }
388
389 #[test]
390 fn duplicate_tool_name_among_unexposed_is_allowed() {
391 let reg = QueryRegistry::from_specs(vec![
394 spec_tool("a", "query a() { match { $u: User } return { $u.name } }", false, "dup"),
395 spec_tool("b", "query b() { match { $u: User } return { $u.name } }", false, "dup"),
396 ])
397 .unwrap();
398 assert_eq!(reg.len(), 2);
399 }
400
401 #[test]
402 fn parse_error_surfaces_per_entry() {
403 let errors =
404 QueryRegistry::from_specs(vec![spec("broken", "query broken( {{ not valid", false)])
405 .unwrap_err();
406 assert_eq!(errors[0].query.as_deref(), Some("broken"));
407 assert!(errors[0].message.contains("parse error"));
408 }
409
410 #[test]
411 fn errors_collect_rather_than_fail_fast() {
412 let errors = QueryRegistry::from_specs(vec![
413 spec("good", "query good() { match { $u: User } return { $u.name } }", false),
414 spec("mismatch", "query other() { match { $u: User } return { $u.name } }", false),
415 spec("broken", "query broken(", false),
416 ])
417 .unwrap_err();
418 assert_eq!(errors.len(), 2);
421 }
422
423 #[test]
424 fn mutation_body_classifies_as_mutation() {
425 let reg = QueryRegistry::from_specs(vec![spec(
426 "add_user",
427 "query add_user($name: String) { insert User { name: $name } }",
428 false,
429 )])
430 .unwrap();
431 assert!(reg.lookup("add_user").unwrap().is_mutation());
432 }
433
434 use omnigraph_compiler::catalog::build_catalog;
437 use omnigraph_compiler::schema::parser::parse_schema;
438
439 fn test_catalog() -> Catalog {
440 let schema = parse_schema(
441 r#"
442node User {
443name: String
444age: I32?
445embedding: Vector(4)
446}
447"#,
448 )
449 .unwrap();
450 build_catalog(&schema).unwrap()
451 }
452
453 #[test]
454 fn check_passes_for_valid_query() {
455 let reg = QueryRegistry::from_specs(vec![spec(
456 "find_user",
457 "query find_user($name: String) { match { $u: User { name: $name } } return { $u.age } }",
458 false,
459 )])
460 .unwrap();
461 let report = check(®, &test_catalog());
462 assert!(report.is_clean(), "unexpected: {:?}", report);
463 }
464
465 #[test]
466 fn check_reports_unknown_type_as_breakage() {
467 let reg = QueryRegistry::from_specs(vec![spec(
468 "ghost",
469 "query ghost() { match { $w: Widget } return { $w.name } }",
471 false,
472 )])
473 .unwrap();
474 let report = check(®, &test_catalog());
475 assert!(report.has_breakages());
476 assert_eq!(report.breakages[0].query, "ghost");
477 }
478
479 #[test]
480 fn check_reports_unknown_property_as_breakage() {
481 let reg = QueryRegistry::from_specs(vec![spec(
482 "bad_prop",
483 "query bad_prop() { match { $u: User } return { $u.nickname } }",
485 false,
486 )])
487 .unwrap();
488 let report = check(®, &test_catalog());
489 assert!(report.has_breakages());
490 assert_eq!(report.breakages[0].query, "bad_prop");
491 }
492
493 #[test]
494 fn check_collects_every_breakage_not_fail_fast() {
495 let reg = QueryRegistry::from_specs(vec![
496 spec("a", "query a() { match { $w: Widget } return { $w.x } }", false),
497 spec("b", "query b() { match { $g: Gadget } return { $g.y } }", false),
498 spec(
499 "ok",
500 "query ok() { match { $u: User } return { $u.name } }",
501 false,
502 ),
503 ])
504 .unwrap();
505 let report = check(®, &test_catalog());
506 assert_eq!(report.breakages.len(), 2, "both bad queries reported: {:?}", report);
507 }
508
509 #[test]
510 fn vector_param_on_exposed_query_warns() {
511 let reg = QueryRegistry::from_specs(vec![spec(
512 "vec_search",
513 "query vec_search($q: Vector(4)) { match { $u: User } return { $u.name } \
514 order { nearest($u.embedding, $q) } limit 3 }",
515 true, )])
517 .unwrap();
518 let report = check(®, &test_catalog());
519 assert!(!report.has_breakages(), "valid query: {:?}", report);
520 assert_eq!(report.warnings.len(), 1);
521 assert_eq!(report.warnings[0].query, "vec_search");
522 }
523
524 #[test]
525 fn vector_param_on_unexposed_query_is_silent() {
526 let reg = QueryRegistry::from_specs(vec![spec(
527 "vec_search",
528 "query vec_search($q: Vector(4)) { match { $u: User } return { $u.name } \
529 order { nearest($u.embedding, $q) } limit 3 }",
530 false, )])
532 .unwrap();
533 let report = check(®, &test_catalog());
534 assert!(report.is_clean(), "unexpected: {:?}", report);
535 }
536
537 #[test]
538 fn non_vector_param_on_exposed_query_does_not_warn() {
539 let reg = QueryRegistry::from_specs(vec![spec(
544 "search",
545 "query search($name: String) { match { $u: User { name: $name } } return { $u.name } }",
546 true,
547 )])
548 .unwrap();
549 let report = check(®, &test_catalog());
550 assert!(report.is_clean(), "no breakage or warning expected: {:?}", report);
551 }
552
553 #[test]
556 fn catalog_entry_projects_every_param_kind() {
557 use crate::api::{self, ParamKind};
558 let reg = QueryRegistry::from_specs(vec![spec_tool(
559 "all_types",
560 "query all_types($s: String, $i: I32, $big: I64, $u: U64, $f: F64, $b: Bool, \
561 $d: Date, $dt: DateTime, $blob: Blob, $opt: String?, $list: [I32], $vec: Vector(4)) \
562 { match { $x: User } return { $x.name } }",
563 true,
564 "all",
565 )])
566 .unwrap();
567 let entry = api::query_catalog_entry(reg.lookup("all_types").unwrap());
568 assert_eq!(entry.name, "all_types");
569 assert_eq!(entry.tool_name, "all");
570 assert!(!entry.mutation);
571
572 let by: std::collections::HashMap<_, _> =
573 entry.params.iter().map(|p| (p.name.as_str(), p)).collect();
574 assert_eq!(by["s"].kind, ParamKind::String);
575 assert_eq!(by["i"].kind, ParamKind::Int);
576 assert_eq!(by["big"].kind, ParamKind::BigInt, "I64 → bigint (string on the wire)");
577 assert_eq!(by["u"].kind, ParamKind::BigInt, "U64 → bigint");
578 assert_eq!(by["f"].kind, ParamKind::Float);
579 assert_eq!(by["b"].kind, ParamKind::Bool);
580 assert_eq!(by["d"].kind, ParamKind::Date);
581 assert_eq!(by["dt"].kind, ParamKind::DateTime);
582 assert_eq!(by["blob"].kind, ParamKind::Blob);
583 assert!(!by["s"].nullable);
584 assert!(by["opt"].nullable, "String? → nullable");
585 assert_eq!(by["list"].kind, ParamKind::List);
586 assert_eq!(by["list"].item_kind, Some(ParamKind::Int), "[I32] → list of int");
587 assert_eq!(by["vec"].kind, ParamKind::Vector);
588 assert_eq!(by["vec"].vector_dim, Some(4));
589 }
590
591 #[test]
592 fn catalog_entry_flags_mutation_and_empty_params() {
593 use crate::api;
594 let reg = QueryRegistry::from_specs(vec![spec(
595 "add_user",
596 "query add_user($name: String) { insert User { name: $name } }",
597 true,
598 )])
599 .unwrap();
600 let entry = api::query_catalog_entry(reg.lookup("add_user").unwrap());
601 assert!(entry.mutation, "insert body → mutation flag");
602
603 let reg2 = QueryRegistry::from_specs(vec![spec(
604 "no_params",
605 "query no_params() { match { $u: User } return { $u.name } }",
606 true,
607 )])
608 .unwrap();
609 let entry2 = api::query_catalog_entry(reg2.lookup("no_params").unwrap());
610 assert!(entry2.params.is_empty(), "no declared params → empty list");
611 }
612
613}