mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-12 01:45:14 +02:00
feat(MR-656): inline query strings in CLI and HTTP server
CLI: - Add -e / --query-string <STRING> to omnigraph read and omnigraph change - Exactly one of --query, --query-string, --alias is required (3-way XOR) - Empty --query-string is rejected with a clear error HTTP: - New POST /query (read-only, clean field names: query/name/params/branch/snapshot) - Mutations on /query are rejected with 400 -- use POST /change instead - ChangeRequest fields polished: query (alias query_source), name (alias query_name) - POST /read and POST /change remain byte-compatible for existing clients Tests: - cli.rs: -e happy-path on read/change, mutex error vs --query, empty -e rejected - system_local.rs: inline -e read and -e change exercise the local flow - system_remote.rs: inline -e read/change over HTTP plus direct /query 200/400 - server.rs: /query 200, /query 400 on mutation, /change legacy field alias - openapi.rs: new /query path, QueryRequest schema, ChangeRequest field-name polish Docs: cli.md (-e examples), cli-reference.md (read/change rows), server.md (/query) Co-Authored-By: Ragnor Comerford <ragnor.comerford@gmail.com>
This commit is contained in:
parent
aadfa11ecb
commit
4152d9d5dc
14 changed files with 708 additions and 75 deletions
|
|
@ -170,10 +170,13 @@ enum Command {
|
|||
target: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
#[arg(long, conflicts_with_all = ["query", "query_string"])]
|
||||
alias: Option<String>,
|
||||
#[arg(long)]
|
||||
#[arg(long, conflicts_with_all = ["alias", "query_string"])]
|
||||
query: Option<PathBuf>,
|
||||
/// Inline GQ source — alternative to `--query <path>` and `--alias <name>`.
|
||||
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])]
|
||||
query_string: Option<String>,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[command(flatten)]
|
||||
|
|
@ -200,10 +203,13 @@ enum Command {
|
|||
target: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
#[arg(long, conflicts_with_all = ["query", "query_string"])]
|
||||
alias: Option<String>,
|
||||
#[arg(long)]
|
||||
#[arg(long, conflicts_with_all = ["alias", "query_string"])]
|
||||
query: Option<PathBuf>,
|
||||
/// Inline GQ source — alternative to `--query <path>` and `--alias <name>`.
|
||||
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])]
|
||||
query_string: Option<String>,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[command(flatten)]
|
||||
|
|
@ -906,7 +912,9 @@ fn resolve_query_path(
|
|||
.map(PathBuf::from)
|
||||
.or_else(|| alias_query.map(PathBuf::from))
|
||||
.ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!("exactly one of --query or --alias must be provided")
|
||||
color_eyre::eyre::eyre!(
|
||||
"exactly one of --query, --query-string, or --alias must be provided"
|
||||
)
|
||||
})
|
||||
.and_then(|query_path| config.resolve_query_path(&query_path))
|
||||
}
|
||||
|
|
@ -914,8 +922,15 @@ fn resolve_query_path(
|
|||
fn resolve_query_source(
|
||||
config: &OmnigraphConfig,
|
||||
explicit_query: Option<&PathBuf>,
|
||||
inline_query: Option<&str>,
|
||||
alias_query: Option<&str>,
|
||||
) -> Result<String> {
|
||||
if let Some(inline) = inline_query {
|
||||
if inline.trim().is_empty() {
|
||||
bail!("--query-string must not be empty");
|
||||
}
|
||||
return Ok(inline.to_string());
|
||||
}
|
||||
Ok(fs::read_to_string(resolve_query_path(
|
||||
config,
|
||||
explicit_query,
|
||||
|
|
@ -1629,8 +1644,8 @@ async fn execute_change_remote(
|
|||
Method::POST,
|
||||
remote_url(uri, "/change"),
|
||||
Some(serde_json::to_value(ChangeRequest {
|
||||
query_source: query_source.to_string(),
|
||||
query_name: query_name.map(ToOwned::to_owned),
|
||||
query: query_source.to_string(),
|
||||
name: query_name.map(ToOwned::to_owned),
|
||||
params: params_json.cloned(),
|
||||
branch: Some(branch.to_string()),
|
||||
})?),
|
||||
|
|
@ -2249,6 +2264,7 @@ async fn main() -> Result<()> {
|
|||
config,
|
||||
alias,
|
||||
query,
|
||||
query_string,
|
||||
name,
|
||||
params,
|
||||
branch,
|
||||
|
|
@ -2257,8 +2273,8 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
alias_args,
|
||||
} => {
|
||||
if alias.is_some() == query.is_some() {
|
||||
bail!("exactly one of --alias or --query must be provided");
|
||||
if alias.is_none() && query.is_none() && query_string.is_none() {
|
||||
bail!("exactly one of --query, --query-string, or --alias must be provided");
|
||||
}
|
||||
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
|
|
@ -2281,6 +2297,7 @@ async fn main() -> Result<()> {
|
|||
let query_source = resolve_query_source(
|
||||
&config,
|
||||
query.as_ref(),
|
||||
query_string.as_deref(),
|
||||
alias_config.map(|a| a.query.as_str()),
|
||||
)?;
|
||||
let params_json = merged_params_json(
|
||||
|
|
@ -2334,14 +2351,15 @@ async fn main() -> Result<()> {
|
|||
config,
|
||||
alias,
|
||||
query,
|
||||
query_string,
|
||||
name,
|
||||
params,
|
||||
branch,
|
||||
json,
|
||||
alias_args,
|
||||
} => {
|
||||
if alias.is_some() == query.is_some() {
|
||||
bail!("exactly one of --alias or --query must be provided");
|
||||
if alias.is_none() && query.is_none() && query_string.is_none() {
|
||||
bail!("exactly one of --query, --query-string, or --alias must be provided");
|
||||
}
|
||||
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
|
|
@ -2364,6 +2382,7 @@ async fn main() -> Result<()> {
|
|||
let query_source = resolve_query_source(
|
||||
&config,
|
||||
query.as_ref(),
|
||||
query_string.as_deref(),
|
||||
alias_config.map(|a| a.query.as_str()),
|
||||
)?;
|
||||
let params_json = merged_params_json(
|
||||
|
|
|
|||
|
|
@ -1422,6 +1422,102 @@ fn read_requires_name_for_multi_query_files() {
|
|||
assert!(stderr.contains("multiple queries"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_supports_inline_query_string() {
|
||||
let temp = tempdir().unwrap();
|
||||
let repo = repo_path(temp.path());
|
||||
init_repo(&repo);
|
||||
load_fixture(&repo);
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg(&repo)
|
||||
.arg("-e")
|
||||
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Alice"}"#)
|
||||
.arg("--json"),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(payload["query_name"], "find");
|
||||
assert_eq!(payload["row_count"], 1);
|
||||
assert_eq!(payload["rows"][0]["p.name"], "Alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_supports_inline_query_string() {
|
||||
let temp = tempdir().unwrap();
|
||||
let repo = repo_path(temp.path());
|
||||
init_repo(&repo);
|
||||
load_fixture(&repo);
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("change")
|
||||
.arg(&repo)
|
||||
.arg("--query-string")
|
||||
.arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Inline","age":42}"#)
|
||||
.arg("--json"),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(payload["query_name"], "add");
|
||||
assert_eq!(payload["affected_nodes"], 1);
|
||||
|
||||
let verify = output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg(&repo)
|
||||
.arg("-e")
|
||||
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Inline"}"#)
|
||||
.arg("--json"),
|
||||
);
|
||||
let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap();
|
||||
assert_eq!(verify_payload["row_count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_rejects_query_string_combined_with_query() {
|
||||
let temp = tempdir().unwrap();
|
||||
let repo = repo_path(temp.path());
|
||||
init_repo(&repo);
|
||||
load_fixture(&repo);
|
||||
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg(&repo)
|
||||
.arg("--query")
|
||||
.arg(fixture("test.gq"))
|
||||
.arg("-e")
|
||||
.arg("query whatever() { match { $p: Person } return { $p.name } }"),
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.contains("cannot be used") || stderr.contains("conflict"),
|
||||
"expected clap conflict error, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_rejects_empty_query_string() {
|
||||
let temp = tempdir().unwrap();
|
||||
let repo = repo_path(temp.path());
|
||||
init_repo(&repo);
|
||||
load_fixture(&repo);
|
||||
|
||||
let output = output_failure(cli().arg("read").arg(&repo).arg("-e").arg(""));
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.contains("must not be empty"),
|
||||
"expected empty-string rejection, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_create_json_outputs_source_and_name() {
|
||||
let temp = tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -246,6 +246,37 @@ fn local_cli_end_to_end_init_load_read_change_read_flow() {
|
|||
));
|
||||
assert_eq!(read_after["row_count"], 1);
|
||||
assert_eq!(read_after["rows"][0]["p.name"], "Eve");
|
||||
|
||||
// Inline-source variants of the same read/change flow (CLI `-e` /
|
||||
// `--query-string`). Confirms that file-less invocations reach the
|
||||
// engine identically, including param binding and `branch=main` defaults.
|
||||
let inline_change = parse_stdout_json(&output_success(
|
||||
cli()
|
||||
.arg("change")
|
||||
.arg(repo.path())
|
||||
.arg("-e")
|
||||
.arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Inline","age":42}"#)
|
||||
.arg("--json"),
|
||||
));
|
||||
assert_eq!(inline_change["branch"], "main");
|
||||
assert_eq!(inline_change["query_name"], "add");
|
||||
assert_eq!(inline_change["affected_nodes"], 1);
|
||||
|
||||
let inline_read = parse_stdout_json(&output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg(repo.path())
|
||||
.arg("--query-string")
|
||||
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Inline"}"#)
|
||||
.arg("--json"),
|
||||
));
|
||||
assert_eq!(inline_read["row_count"], 1);
|
||||
assert_eq!(inline_read["rows"][0]["p.name"], "Inline");
|
||||
assert_eq!(inline_read["rows"][0]["p.age"], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -192,6 +192,67 @@ query insert_person($name: String, $age: I32) {
|
|||
assert_eq!(local_verify["row_count"], 1);
|
||||
assert_eq!(local_verify["rows"][0]["p.name"], "Mina");
|
||||
|
||||
// CLI `-e` over the HTTP transport (--config points at remote server).
|
||||
// Confirms inline source survives the remote-execution path identically
|
||||
// to file-based queries, and exercises `POST /query` end-to-end via the
|
||||
// change-then-read round trip we just established.
|
||||
let inline_remote_read = parse_stdout_json(&output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.arg("-e")
|
||||
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Mina"}"#)
|
||||
.arg("--json"),
|
||||
));
|
||||
assert_eq!(inline_remote_read["row_count"], 1);
|
||||
assert_eq!(inline_remote_read["rows"][0]["p.name"], "Mina");
|
||||
|
||||
let inline_remote_change = parse_stdout_json(&output_success(
|
||||
cli()
|
||||
.arg("change")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.arg("--query-string")
|
||||
.arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Inline","age":42}"#)
|
||||
.arg("--json"),
|
||||
));
|
||||
assert_eq!(inline_remote_change["affected_nodes"], 1);
|
||||
|
||||
// `POST /query` happy path directly: a hand-rolled HTTP body using the
|
||||
// new clean field names.
|
||||
let http_query = client
|
||||
.post(format!("{}/query", server.base_url))
|
||||
.json(&json!({
|
||||
"branch": "main",
|
||||
"query": "query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }",
|
||||
"params": { "name": "Inline" }
|
||||
}))
|
||||
.send()
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json::<serde_json::Value>()
|
||||
.unwrap();
|
||||
assert_eq!(http_query["row_count"], 1);
|
||||
assert_eq!(http_query["rows"][0]["p.name"], "Inline");
|
||||
|
||||
// `POST /query` rejects mutations with 400.
|
||||
let http_query_mutation = client
|
||||
.post(format!("{}/query", server.base_url))
|
||||
.json(&json!({
|
||||
"branch": "main",
|
||||
"query": "query bad($name: String, $age: I32) { insert Person { name: $name, age: $age } }",
|
||||
"params": { "name": "Nope", "age": 1 }
|
||||
}))
|
||||
.send()
|
||||
.unwrap();
|
||||
assert_eq!(http_query_mutation.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
|
||||
// `run publish` / `run list` removed. Direct-to-target writes
|
||||
// already landed via the change call above; the commit graph is now
|
||||
// the audit surface (verified separately by `commit list`).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue