feat(cli)!: query/mutate invoke stored queries by name; server kind-assert (RFC-011 D3) (#247)

omnigraph query <name> / mutate <name> invoke a stored query by name from the served catalog (served-only). The verb asserts kind via a new expect_mutation on POST /queries/{name} (400 on mismatch). -e/--query + --store is the ad-hoc lane; the positional selects within the source (replacing --name). The bare positional graph URI, --uri, and --name are removed from query/mutate.
This commit is contained in:
Andrew Altshuler 2026-06-15 16:52:58 +03:00 committed by GitHub
parent 1bc0ea6b51
commit 9ef5f90991
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 331 additions and 120 deletions

View file

@ -92,20 +92,18 @@ pub(crate) enum Command {
/// when used. Pairs with `omnigraph mutate` on the write side.
#[command(visible_alias = "read")]
Query {
/// Graph URI
#[arg(long)]
uri: Option<String>,
#[arg(hide = true)]
legacy_uri: Option<String>,
/// Query name. With no `--query`/`-e`, the stored query to invoke from
/// the catalog (served — addressed via --server/--profile). With
/// `--query`/`-e`, selects which query in that ad-hoc source to run.
name: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
/// Ad-hoc query file (a `.gq` you're authoring / break-glass).
#[arg(long, conflicts_with = "query_string")]
query: Option<PathBuf>,
/// Inline GQ source — alternative to `--query <path>`.
/// Inline ad-hoc GQ source — alternative to `--query <path>`.
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")]
query_string: Option<String>,
#[arg(long)]
name: Option<String>,
#[command(flatten)]
params: ParamsArgs,
#[arg(long, conflicts_with = "snapshot")]
@ -124,20 +122,18 @@ pub(crate) enum Command {
/// warning when used. Pairs with `omnigraph query` on the read side.
#[command(visible_alias = "change")]
Mutate {
/// Graph URI
#[arg(long)]
uri: Option<String>,
#[arg(hide = true)]
legacy_uri: Option<String>,
/// Query name. With no `--query`/`-e`, the stored mutation to invoke
/// from the catalog (served — addressed via --server/--profile). With
/// `--query`/`-e`, selects which query in that ad-hoc source to run.
name: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
/// Ad-hoc mutation file (a `.gq` you're authoring / break-glass).
#[arg(long, conflicts_with = "query_string")]
query: Option<PathBuf>,
/// Inline GQ source — alternative to `--query <path>`.
/// Inline ad-hoc GQ source — alternative to `--query <path>`.
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")]
query_string: Option<String>,
#[arg(long)]
name: Option<String>,
#[command(flatten)]
params: ParamsArgs,
#[arg(long)]

View file

@ -29,7 +29,8 @@ use omnigraph::db::{Omnigraph, ReadTarget};
use omnigraph_api_types::{
BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput,
BranchMergeOutput, BranchMergeRequest, ChangeOutput, CommitListOutput, CommitOutput,
ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest, ReadOutput,
ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest,
InvokeStoredQueryRequest, ReadOutput,
ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, commit_output,
ingest_output, read_output, schema_apply_output, snapshot_payload,
};
@ -563,6 +564,50 @@ impl GraphClient {
}
}
/// `invoke_named` — run a stored query **by catalog name** (RFC-011 D3).
/// Served-only: the catalog is server-owned, so a `--store` (embedded)
/// scope has nothing to resolve the name against. `expect_mutation` carries
/// the verb's asserted kind; the server rejects a mismatch (400) before
/// running, so the response is exactly the expected envelope — the caller
/// deserializes it as the concrete `T` (`ReadOutput` for `query`,
/// `ChangeOutput` for `mutate`), sidestepping the untagged wire enum.
pub(crate) async fn invoke_named<T: serde::de::DeserializeOwned>(
&self,
name: &str,
expect_mutation: bool,
params_json: Option<&Value>,
branch: Option<String>,
snapshot: Option<String>,
) -> Result<T> {
match self {
GraphClient::Remote {
http,
base_url,
token,
} => {
let body = InvokeStoredQueryRequest {
params: params_json.cloned(),
branch,
snapshot,
expect_mutation: Some(expect_mutation),
};
remote_json(
http,
Method::POST,
remote_url(base_url, &["queries", name], &[])?,
Some(serde_json::to_value(body)?),
token.as_deref(),
)
.await
}
GraphClient::Embedded { .. } => bail!(
"by-name invocation needs a server (the stored-query catalog is \
server-owned); use -e '<gq>' or --query <file> for an ad-hoc query \
against --store, or address a server with --server / --profile"
),
}
}
pub(crate) async fn branch_create_from(
&self,
from: &str,

View file

@ -578,75 +578,96 @@ async fn main() -> Result<()> {
.await?;
}
Command::Query {
uri,
legacy_uri,
name,
config,
query,
query_string,
name,
params,
branch,
snapshot,
format,
json,
} => {
if query.is_none() && query_string.is_none() {
bail!("provide a query: --query <file> or -e '<inline gq>'");
}
let config = load_cli_config(config.as_ref())?;
let client = client::GraphClient::resolve(
&config,
cli.server.as_deref(),
cli.graph.as_deref(),
uri.or(legacy_uri),
None,
cli.profile.as_deref(),
cli.store.as_deref(),
)
.await?;
let query_source =
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
let params_json = load_params_json(&params)?;
let target = resolve_read_target(&config, branch, snapshot, None)?;
let output = client
.query(target, &query_source, name.as_deref(), params_json.as_ref())
.await?;
let output: ReadOutput = if query.is_some() || query_string.is_some() {
// Ad-hoc lane: run the source; the positional `name` selects
// within it when it holds more than one query.
let query_source =
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
client
.query(target, &query_source, name.as_deref(), params_json.as_ref())
.await?
} else {
// Catalog lane (served-only): invoke the stored query by name.
let Some(name) = name else {
bail!(
"provide a query name to invoke from the catalog, or -e '<gq>' / \
--query <file> for an ad-hoc query"
);
};
let (branch, snapshot) = match &target {
ReadTarget::Branch(b) => (Some(b.clone()), None),
ReadTarget::Snapshot(s) => (None, Some(s.as_str().to_string())),
};
client
.invoke_named(&name, false, params_json.as_ref(), branch, snapshot)
.await?
};
let format = resolve_read_format(&config, format, json, None);
print_read_output(&output, format, &config)?;
}
Command::Mutate {
uri,
legacy_uri,
name,
config,
query,
query_string,
name,
params,
branch,
json,
} => {
if query.is_none() && query_string.is_none() {
bail!("provide a mutation query: --query <file> or -e '<inline gq>'");
}
let config = load_cli_config(config.as_ref())?;
let client = client::GraphClient::resolve_with_policy(
&config,
cli.server.as_deref(),
cli.graph.as_deref(),
uri.or(legacy_uri),
None,
cli.as_actor.as_deref(),
cli.profile.as_deref(),
cli.store.as_deref(),
)
.await?;
let query_source =
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
let params_json = load_params_json(&params)?;
let branch = resolve_branch(&config, branch, None, "main");
let output = client
.mutate(&branch, &query_source, name.as_deref(), params_json.as_ref())
.await?;
let output: ChangeOutput = if query.is_some() || query_string.is_some() {
// Ad-hoc lane: run the source; positional `name` selects within it.
let query_source =
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
client
.mutate(&branch, &query_source, name.as_deref(), params_json.as_ref())
.await?
} else {
// Catalog lane (served-only): invoke the stored mutation by name.
let Some(name) = name else {
bail!(
"provide a mutation name to invoke from the catalog, or -e '<gq>' / \
--query <file> for an ad-hoc mutation"
);
};
client
.invoke_named(&name, true, params_json.as_ref(), Some(branch), None)
.await?
};
if json {
print_json(&output)?;
} else {