From a8f98d4ddce2d2b0ec021681ae256eacd345ad79 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Sat, 30 May 2026 20:54:20 +0200 Subject: [PATCH] Add `omnigraph queries validate` and `queries list` CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `queries validate` type-checks the stored-query registry against the live schema offline — it opens the selected graph, runs the same check() the server runs at boot, prints breakages/warnings (human or --json), and exits non-zero on any breakage — so an operator can catch a query broken by a schema change without restarting the server. `queries list` prints each registered query's name, MCP exposure, and typed params. Named `validate` (not `check`) to avoid overlap with the existing `omnigraph lint` — `query check`/`query lint` are already deprecated argv-shims to `lint`. Registry entries resolve like the server: a named graph uses its per-graph `queries:`; otherwise the top-level one. - Queries subcommand group; reuses QueryRegistry::load + check from omnigraph-server; local-only (needs the schema), mirrors lint - tests: clean registry exits 0, broken query exits non-zero + names it, list shows the query and its typed params Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/omnigraph-cli/src/main.rs | 233 ++++++++++++++++++++++++++++++ crates/omnigraph-cli/tests/cli.rs | 61 ++++++++ 2 files changed, 294 insertions(+) diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index b7e3041..bb4906e 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -24,6 +24,7 @@ use omnigraph_server::api::{ SnapshotTableOutput, commit_output, ingest_output, read_output, schema_apply_output, snapshot_payload, }; +use omnigraph_server::queries::{QueryRegistry, check}; use omnigraph_server::{ AliasCommand, OmnigraphConfig, PolicyAction, PolicyDecision, PolicyEngine, PolicyRequest, PolicyTestConfig, ReadOutputFormat, load_config, @@ -153,6 +154,11 @@ enum Command { #[arg(long)] json: bool, }, + /// Operate on the server-side stored-query registry (`queries:`). + Queries { + #[command(subcommand)] + command: QueriesCommand, + }, /// Show graph snapshot Snapshot { /// Graph URI @@ -502,6 +508,35 @@ enum PolicyCommand { }, } +#[derive(Debug, Subcommand)] +enum QueriesCommand { + /// Type-check the stored-query registry against the live schema. + /// + /// Distinct from `omnigraph lint` (which lints one `.gq` file): + /// this validates the whole `queries:` registry — opening the graph + /// to read its schema and confirming every stored query still + /// type-checks. Exits non-zero on any breakage. + Validate { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, + }, + /// List the registered stored queries (name, MCP exposure, params). + List { + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, + }, +} + #[derive(Debug, Args, Clone)] struct ParamsArgs { #[arg(long, conflicts_with = "params_file")] @@ -1609,6 +1644,187 @@ async fn execute_query_lint( )) } +#[derive(serde::Serialize)] +struct QueriesIssue { + query: String, + message: String, +} + +#[derive(serde::Serialize)] +struct QueriesValidateOutput { + ok: bool, + breakages: Vec, + warnings: Vec, +} + +#[derive(serde::Serialize)] +struct QueriesParam { + name: String, + #[serde(rename = "type")] + type_name: String, + nullable: bool, +} + +#[derive(serde::Serialize)] +struct QueriesListItem { + name: String, + mcp_expose: bool, + tool_name: Option, + mutation: bool, + params: Vec, +} + +#[derive(serde::Serialize)] +struct QueriesListOutput { + queries: Vec, +} + +/// Resolve the stored-query registry entries for the selected graph, +/// mirroring the server: a named graph in `graphs:` uses its per-graph +/// `queries:`; otherwise the top-level `queries:` (single-graph mode). +fn registry_entries<'a>( + config: &'a OmnigraphConfig, + target: Option<&str>, +) -> &'a std::collections::BTreeMap { + match target.or_else(|| config.cli_graph_name()) { + Some(name) if config.graphs.contains_key(name) => config + .target_query_entries(name) + .unwrap_or_else(|| config.query_entries()), + _ => config.query_entries(), + } +} + +fn load_registry_or_report(config: &OmnigraphConfig, target: Option<&str>) -> Result { + QueryRegistry::load(config, registry_entries(config, target)).map_err(|errors| { + color_eyre::eyre::eyre!( + "stored-query registry failed to load:\n {}", + errors + .iter() + .map(|e| e.to_string()) + .collect::>() + .join("\n ") + ) + }) +} + +async fn execute_queries_validate( + uri: Option, + target: Option, + config_path: Option<&PathBuf>, + json: bool, +) -> Result<()> { + let config = load_cli_config(config_path)?; + let registry = load_registry_or_report(&config, target.as_deref())?; + let uri = resolve_local_uri(&config, uri, target.as_deref(), "queries validate")?; + let db = Omnigraph::open(&uri).await?; + let report = check(®istry, &db.catalog()); + + let output = QueriesValidateOutput { + ok: !report.has_breakages(), + breakages: report + .breakages + .iter() + .map(|b| QueriesIssue { + query: b.query.clone(), + message: b.message.clone(), + }) + .collect(), + warnings: report + .warnings + .iter() + .map(|w| QueriesIssue { + query: w.query.clone(), + message: w.message.clone(), + }) + .collect(), + }; + + if json { + print_json(&output)?; + } else { + if output.breakages.is_empty() { + println!( + "OK {} stored quer{} type-check against the schema", + registry.len(), + if registry.len() == 1 { "y" } else { "ies" } + ); + } + for issue in &output.breakages { + println!("ERROR query '{}': {}", issue.query, issue.message); + } + for issue in &output.warnings { + println!("WARN query '{}': {}", issue.query, issue.message); + } + } + + if report.has_breakages() { + io::stdout().flush()?; + std::process::exit(1); + } + Ok(()) +} + +fn execute_queries_list( + target: Option, + config_path: Option<&PathBuf>, + json: bool, +) -> Result<()> { + let config = load_cli_config(config_path)?; + let registry = load_registry_or_report(&config, target.as_deref())?; + + let output = QueriesListOutput { + queries: registry + .iter() + .map(|q| QueriesListItem { + name: q.name.clone(), + mcp_expose: q.expose, + tool_name: q.tool_name.clone(), + mutation: q.is_mutation(), + params: q + .decl + .params + .iter() + .map(|p| QueriesParam { + name: p.name.clone(), + type_name: p.type_name.clone(), + nullable: p.nullable, + }) + .collect(), + }) + .collect(), + }; + + if json { + print_json(&output)?; + } else if output.queries.is_empty() { + println!("(no stored queries registered)"); + } else { + for q in &output.queries { + let kind = if q.mutation { "mutation" } else { "read" }; + let params = q + .params + .iter() + .map(|p| { + format!( + "${}: {}{}", + p.name, + p.type_name, + if p.nullable { "?" } else { "" } + ) + }) + .collect::>() + .join(", "); + let mcp = if q.mcp_expose { + format!(" [mcp: {}]", q.tool_name.as_deref().unwrap_or(&q.name)) + } else { + String::new() + }; + println!("{kind} {}({params}){mcp}", q.name); + } + } + Ok(()) +} + async fn execute_read( uri: &str, query_source: &str, @@ -2331,6 +2547,23 @@ async fn main() -> Result<()> { .await?; finish_query_lint(&output, json)?; } + Command::Queries { command } => match command { + QueriesCommand::Validate { + uri, + target, + config, + json, + } => { + execute_queries_validate(uri, target, config.as_ref(), json).await?; + } + QueriesCommand::List { + target, + config, + json, + } => { + execute_queries_list(target, config.as_ref(), json)?; + } + }, Command::Snapshot { uri, target, diff --git a/crates/omnigraph-cli/tests/cli.rs b/crates/omnigraph-cli/tests/cli.rs index 6e5de37..1e61fe5 100644 --- a/crates/omnigraph-cli/tests/cli.rs +++ b/crates/omnigraph-cli/tests/cli.rs @@ -2376,3 +2376,64 @@ fn graphs_list_against_local_uri_errors_with_remote_only_message() { "expected 'remote multi-graph server URL' rejection in stderr; got:\n{stderr}" ); } + +fn queries_test_config(graph_uri: &str, entry: &str, gq_file: &str) -> String { + format!( + "graphs:\n local:\n uri: '{}'\n queries:\n {entry}:\n file: ./{gq_file}\n\ + cli:\n graph: local\npolicy: {{}}\n", + graph_uri.replace('\'', "''") + ) +} + +#[test] +fn queries_validate_exits_zero_on_clean_registry() { + let graph = SystemGraph::loaded(); + graph.write_query( + "find_person.gq", + "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", + ); + let config = graph.write_config( + "omnigraph.yaml", + &queries_test_config(&graph.path().to_string_lossy(), "find_person", "find_person.gq"), + ); + let output = output_success(cli().arg("queries").arg("validate").arg("--config").arg(&config)); + let stdout = stdout_string(&output); + assert!(stdout.contains("OK"), "stdout:\n{stdout}"); +} + +#[test] +fn queries_validate_exits_nonzero_on_type_broken_query() { + let graph = SystemGraph::loaded(); + // `Widget` is not in the fixture schema. + graph.write_query("ghost.gq", "query ghost() { match { $w: Widget } return { $w.name } }"); + let config = graph.write_config( + "omnigraph.yaml", + &queries_test_config(&graph.path().to_string_lossy(), "ghost", "ghost.gq"), + ); + let output = output_failure(cli().arg("queries").arg("validate").arg("--config").arg(&config)); + let stdout = stdout_string(&output); + assert!( + stdout.contains("ghost"), + "validation should name the broken query; stdout:\n{stdout}" + ); +} + +#[test] +fn queries_list_prints_registered_query() { + let graph = SystemGraph::loaded(); + graph.write_query( + "find_person.gq", + "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", + ); + let config = graph.write_config( + "omnigraph.yaml", + &queries_test_config(&graph.path().to_string_lossy(), "find_person", "find_person.gq"), + ); + let output = output_success(cli().arg("queries").arg("list").arg("--config").arg(&config)); + let stdout = stdout_string(&output); + assert!(stdout.contains("find_person"), "stdout:\n{stdout}"); + assert!( + stdout.contains("$name: String"), + "list should show typed params; stdout:\n{stdout}" + ); +}