mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-09 01:35:18 +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>
* feat(MR-656): rename read/change to query/mutate with deprecation signals
HTTP server:
- Add POST /mutate as canonical write endpoint (pairs with POST /query).
- Mark POST /read and POST /change as deprecated. Three-channel signal:
* OpenAPI: `deprecated: true` on the operation (every codegen flags
the generated SDK method).
* RFC 9745: response `Deprecation: true` header on every response.
* RFC 8288: response `Link: </successor>; rel="successor-version"`
pointing at /query and /mutate respectively.
- Share business logic across /mutate and /change via run_mutate(); the
/change wrapper is the only place that adds the deprecation headers.
- ChangeRequest field aliases (query_source/query_name) preserved.
- AliasCommand serde now accepts `query`/`mutate` alongside `read`/`change`.
CLI:
- Promote `omnigraph query` / `omnigraph mutate` to top-level canonical
subcommands (clap visible_alias keeps `omnigraph read` / `omnigraph
change` working forever).
- Promote `omnigraph lint` / `omnigraph check` to top-level (was nested
under `omnigraph query lint`, which is now a deprecated argv shim that
rewrites to the canonical form).
- Argv-level preprocessing prints a one-line deprecation warning to
stderr when any legacy spelling is used. Canonical names are silent.
Tests:
- Server: /mutate works, /change emits Deprecation+Link headers, /read
emits Deprecation+Link headers, /query carries no deprecation signal.
- OpenAPI: /read and /change flagged deprecated; /query and /mutate not.
- CLI: canonical `lint` matches deprecated `query lint` / `query check`
output; `read` / `change` print deprecation warnings.
Docs:
- cli.md: new canonical examples; "Deprecated names" migration table.
- cli-reference.md: top-level table updated; aliases.<name>.command
accepts both legacy and canonical spellings.
- server.md: endpoint inventory shows /query and /mutate as canonical
and /read and /change as deprecated; dedicated section explains the
three-channel deprecation signal.
- og-cheet-sheet.md: use new `omnigraph lint` / `omnigraph check`.
- openapi.json regenerated.
Migration is purely cosmetic — every deprecated form continues to work
indefinitely; only the spelling changes.
Co-Authored-By: Ragnor Comerford <ragnor.comerford@gmail.com>
* fix(MR-656): address Devin Review findings on /query and /change
Two issues raised by Devin Review on PR #110:
1. `POST /query` mutation-rejection error pointed at the deprecated
`/change` endpoint instead of the canonical `/mutate`. Fixed in
three places: the runtime error message in `server_query`, the
utoipa 400-response description, and the handler doc comment. The
`QueryRequest` schema docstrings in `api.rs` got the same update so
the openapi.json bodies match. Server and openapi tests updated.
2. `execute_change_remote` serialized `ChangeRequest` directly, which
emits the new canonical field names `query` / `name` on the wire.
`#[serde(alias = "query_source")]` only affects deserialization, so
a newer CLI talking to an older server would have its `/change`
POST body fail with "missing field: query_source". Fixed by
extracting a `legacy_change_request_body` helper that hand-rolls
the JSON with the legacy keys (`query_source` / `query_name`), the
same byte-stable contract `execute_read_remote` already uses
against `/read`. Added two unit tests on the helper to lock the
wire shape in.
Co-Authored-By: Ragnor Comerford <ragnor.comerford@gmail.com>
* docs(dev): RFC 001 — inline + stored queries, envelope, MCP
Tracked artifact consolidating the design across MR-656 (this branch),
MR-976 (Phase 1 envelope hardening parent, with MR-977/978/979/980
sub-issues), and MR-969 (stored queries + MCP).
Sections:
* Two paths, one engine — inline `/query` + `/mutate` (this PR) coexist
with stored `/queries/{name}` (MR-969). Same `run_query` / `run_mutate`
backend (the fold-in landed in the previous commit).
* Request envelope ("before") — Idempotency-Key, If-Match, X-Deadline,
X-Trace-Id, expect, dry_run, fields. Phase 1 ships the load-bearing
subset on `/mutate`.
* Response envelope ("after") — audit_id, snapshot_id, commit_id, stats,
warnings. Closes the provenance loop today's `ChangeOutput` leaves
open.
* `.gq` pragmas — `@description`, `@returns`, `@mcp`. Source-of-truth
for the stored-query agent contract; no separate YAML registry.
* Multi-graph MCP — per-graph `/graphs/{id}/mcp/tools` + `/mcp/invoke`.
Token binds to one graph by default; cross-graph agents loop.
* Cedar split — `read`/`change` for inline, `invoke_query` for stored.
Operators deny ad-hoc for agent groups while keeping curated tool
list open.
* Rejected alternatives — per-env override files, compiled bundles,
tool-name prefixing across graphs, body-field graph dispatch.
Index entry added under "Active Implementation Plans" so future agents
land on the RFC before touching queries / mutations / envelope code.
`scripts/check-agents-md.sh` clean (35 links, 34 docs).
* docs(server): clarify why run_query lacks AppState parameter
run_mutate takes state for workload admission; run_query doesn't because
reads aren't admission-gated today. Mark the asymmetry as intentional and
flag the two future events that would grow the signature: Phase 1's
`expect: { max_rows_scanned: N }` budget (MR-976) or per-actor admission
extending to stored-read invocations (MR-969). Prevents the natural
"make these symmetrical" follow-up.
* refactor(server): run_query / run_mutate take &ResolvedActor
Replace `Option<Extension<ResolvedActor>>` in the helpers with
`Option<&ResolvedActor>`. Saves MR-969's stored-query handler from
wrapping a bare actor in axum's `Extension(...)` before calling.
Handler signatures (`server_query`, `server_read`, `server_mutate`,
`server_change`) keep `Option<Extension<ResolvedActor>>` because that
is what axum injects, and unwrap at the call site with
`actor.as_ref().map(|Extension(actor)| actor)`.
Net: -13/+10 LOC, 89/0 server tests pass.
* docs(releases): v0.6.0 — describe inline + canonical-named queries (MR-656)
Extend the v0.6.0 release notes to cover the third piece of work landing
alongside the graph terminology rename and multi-graph server mode:
canonical-named `POST /query` and `POST /mutate` endpoints, the CLI's
new `-e/--query-string` flag, the top-level promotion of `lint` /
`check`, and the three-channel deprecation signal on `/read` and
`/change` (OpenAPI `deprecated: true` + RFC 9745 + RFC 8288).
Additions:
* Top blurb: "Two pieces" -> "Three pieces" with a bullet describing
the rename + inline flow.
* Breaking Changes: new "Query / mutation rename" subsection covering
the `ChangeRequest` field rename (with the back-compat serde aliases
and the CLI's `legacy_change_request_body` byte-stable wire helper)
and the `omnigraph query lint` -> `omnigraph lint` move.
* New: 5 bullets — the two endpoints, the CLI subcommands, the `-e`
flag, the deprecation signal channels, the widened `aliases.<name>.command`
vocabulary.
* User Impact: one bullet making explicit that the rename is cosmetic
on the client side and migration is voluntary.
* Documentation: pointers to the updated `server.md` / `cli.md` /
`cli-reference.md` and the new `docs/dev/rfc-001-queries-envelope-mcp.md`.
+15/-1 lines. `./scripts/check-agents-md.sh` clean.
* refactor(cli): demote `check` from visible_alias to deprecation shim
`omnigraph check` was a clap `visible_alias` on `lint`, advertised in
`--help` as an equivalent canonical name. Per MR-981 §6 (long-form
flags as canonical, short forms as visible aliases), visible aliases
on subcommand names hurt agent CX: agents emit either spelling
depending on training-data drift, and there's no length signal
pointing at the canonical name.
Changes:
* Remove `#[command(visible_alias = "check")]` from the `Lint` variant.
`omnigraph --help` now shows only `lint`.
* Add bare `check` to `rewrite_deprecated_argv` so `omnigraph check
<args>` still works — it rewrites to `omnigraph lint <args>` and
emits a one-line stderr deprecation warning, matching the existing
pattern for `read` / `change` / `query lint` / `query check`.
* Fix the nested `query check` shim to substitute `check` -> `lint` in
the rewritten argv (previously it relied on `check` being a
visible_alias to reach the `Lint` variant).
* New test `deprecated_check_top_level_rewrites_to_lint` covers: bare
`check` produces identical stdout to `lint`, emits the deprecation
warning, and `check` does NOT appear as an alias in `omnigraph
--help`.
* Release notes updated to reflect the deprecation-shim treatment and
cross-reference MR-981 §6 reasoning.
Cargo / Go users typing `check` still work indefinitely; one stderr
nudge per invocation teaches the canonical name. Agents see only
`lint` in `--help --json` so they emit one canonical form.
67/0 omnigraph-cli tests pass; 39 workspace test suites green.
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Ragnor Comerford <ragnor.comerford@gmail.com>
Co-authored-by: Ragnor Comerford <hello@ragnor.co>
2378 lines
68 KiB
Rust
2378 lines
68 KiB
Rust
use std::fs;
|
|
|
|
use lance::index::DatasetIndexExt;
|
|
use omnigraph::db::{Omnigraph, ReadTarget};
|
|
use serde_json::Value;
|
|
use tempfile::tempdir;
|
|
|
|
mod support;
|
|
|
|
use support::*;
|
|
|
|
const POLICY_YAML: &str = r#"
|
|
version: 1
|
|
groups:
|
|
team: [act-andrew, act-bruno]
|
|
admins: [act-andrew]
|
|
protected_branches: [main]
|
|
rules:
|
|
- id: team-read
|
|
allow:
|
|
actors: { group: team }
|
|
actions: [read]
|
|
branch_scope: any
|
|
- id: team-write
|
|
allow:
|
|
actors: { group: team }
|
|
actions: [change]
|
|
branch_scope: unprotected
|
|
- id: admins-promote
|
|
allow:
|
|
actors: { group: admins }
|
|
actions: [branch_merge]
|
|
target_branch_scope: protected
|
|
"#;
|
|
|
|
const POLICY_TESTS_YAML: &str = r#"
|
|
version: 1
|
|
cases:
|
|
- id: allow-feature-write
|
|
actor: act-andrew
|
|
action: change
|
|
branch: feature
|
|
expect: allow
|
|
- id: deny-main-write
|
|
actor: act-bruno
|
|
action: change
|
|
branch: main
|
|
expect: deny
|
|
"#;
|
|
|
|
fn manifest_dataset_version(graph: &std::path::Path) -> u64 {
|
|
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
|
Omnigraph::open(graph.to_string_lossy().as_ref())
|
|
.await
|
|
.unwrap()
|
|
.snapshot_of(ReadTarget::branch("main"))
|
|
.await
|
|
.unwrap()
|
|
.version()
|
|
})
|
|
}
|
|
|
|
fn write_policy_config_fixture(root: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
|
|
let config = root.join("omnigraph.yaml");
|
|
let policy = root.join("policy.yaml");
|
|
fs::write(
|
|
&config,
|
|
r#"
|
|
project:
|
|
name: policy-test-graph
|
|
policy:
|
|
file: ./policy.yaml
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(&policy, POLICY_YAML).unwrap();
|
|
fs::write(root.join("policy.tests.yaml"), POLICY_TESTS_YAML).unwrap();
|
|
(config, policy)
|
|
}
|
|
|
|
#[test]
|
|
fn version_command_prints_current_cli_version() {
|
|
let output = output_success(cli().arg("version"));
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert_eq!(
|
|
stdout.trim(),
|
|
format!("omnigraph {}", env!("CARGO_PKG_VERSION"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn short_version_flag_prints_current_cli_version() {
|
|
let output = output_success(cli().arg("-v"));
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert_eq!(
|
|
stdout.trim(),
|
|
format!("omnigraph {}", env!("CARGO_PKG_VERSION"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn long_version_flag_prints_current_cli_version() {
|
|
let output = output_success(cli().arg("--version"));
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert_eq!(
|
|
stdout.trim(),
|
|
format!("omnigraph {}", env!("CARGO_PKG_VERSION"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn embed_seed_fills_missing_and_preserves_existing_vectors_by_default() {
|
|
let temp = tempdir().unwrap();
|
|
let seed = write_seed_fixture(temp.path());
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.env("OMNIGRAPH_EMBEDDINGS_MOCK", "1")
|
|
.arg("embed")
|
|
.arg("--seed")
|
|
.arg(&seed)
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["mode"], "fill_missing");
|
|
assert_eq!(payload["embedded_rows"], 1);
|
|
assert_eq!(payload["selected_rows"], 2);
|
|
|
|
let embedded = read_embedded_rows(temp.path().join("build/seed.embedded.jsonl"));
|
|
assert_eq!(
|
|
embedded[0]["data"]["embedding"].as_array().unwrap().len(),
|
|
4
|
|
);
|
|
assert_eq!(
|
|
embedded[1]["data"]["embedding"],
|
|
serde_json::json!([0.1, 0.2])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn embed_clean_removes_selected_embeddings() {
|
|
let temp = tempdir().unwrap();
|
|
let seed = write_seed_fixture(temp.path());
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("embed")
|
|
.arg("--seed")
|
|
.arg(&seed)
|
|
.arg("--clean")
|
|
.arg("--select")
|
|
.arg("Decision:slug=dec-beta")
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["mode"], "clean");
|
|
assert_eq!(payload["cleaned_rows"], 1);
|
|
|
|
let embedded = read_embedded_rows(temp.path().join("build/seed.embedded.jsonl"));
|
|
assert!(embedded[0]["data"].get("embedding").is_none());
|
|
assert!(embedded[1]["data"].get("embedding").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn embed_select_reembeds_only_matching_rows() {
|
|
let temp = tempdir().unwrap();
|
|
let seed = write_seed_fixture(temp.path());
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.env("OMNIGRAPH_EMBEDDINGS_MOCK", "1")
|
|
.arg("embed")
|
|
.arg("--seed")
|
|
.arg(&seed)
|
|
.arg("--select")
|
|
.arg("Decision:slug=dec-beta")
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["mode"], "reembed_selected");
|
|
assert_eq!(payload["embedded_rows"], 1);
|
|
assert_eq!(payload["selected_rows"], 1);
|
|
|
|
let embedded = read_embedded_rows(temp.path().join("build/seed.embedded.jsonl"));
|
|
assert!(embedded[0]["data"].get("embedding").is_none());
|
|
assert_ne!(
|
|
embedded[1]["data"]["embedding"],
|
|
serde_json::json!([0.1, 0.2])
|
|
);
|
|
assert_eq!(
|
|
embedded[1]["data"]["embedding"].as_array().unwrap().len(),
|
|
4
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn embed_seed_preserves_non_entity_rows() {
|
|
let temp = tempdir().unwrap();
|
|
let seed = write_seed_fixture_with_edge(temp.path());
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.env("OMNIGRAPH_EMBEDDINGS_MOCK", "1")
|
|
.arg("embed")
|
|
.arg("--seed")
|
|
.arg(&seed)
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["rows"], 3);
|
|
assert_eq!(payload["embedded_rows"], 1);
|
|
|
|
let embedded = read_embedded_rows(temp.path().join("build/seed.embedded.jsonl"));
|
|
assert_eq!(embedded.len(), 3);
|
|
assert_eq!(embedded[2]["edge"], "Triggered");
|
|
assert_eq!(embedded[2]["from"], "sig-alpha");
|
|
assert_eq!(embedded[2]["to"], "dec-alpha");
|
|
}
|
|
|
|
#[test]
|
|
fn init_creates_graph_successfully_on_missing_local_directory() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let schema = fixture("test.pg");
|
|
|
|
let output = output_success(cli().arg("init").arg("--schema").arg(&schema).arg(&graph));
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.contains("initialized"));
|
|
assert!(graph.join("_schema.pg").exists());
|
|
assert!(graph.join("__manifest").exists());
|
|
assert!(temp.path().join("omnigraph.yaml").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn schema_plan_json_reports_supported_additive_change() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let schema_path = temp.path().join("next.pg");
|
|
init_graph(&graph);
|
|
|
|
let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace(
|
|
" age: I32?\n}",
|
|
" age: I32?\n nickname: String?\n}",
|
|
);
|
|
fs::write(&schema_path, next_schema).unwrap();
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("plan")
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json")
|
|
.arg(&graph),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
|
|
assert_eq!(payload["supported"], true);
|
|
assert_eq!(payload["step_count"], 1);
|
|
assert_eq!(payload["steps"][0]["kind"], "add_property");
|
|
assert_eq!(payload["steps"][0]["type_kind"], "node");
|
|
assert_eq!(payload["steps"][0]["type_name"], "Person");
|
|
assert_eq!(payload["steps"][0]["property_name"], "nickname");
|
|
}
|
|
|
|
#[test]
|
|
fn schema_plan_json_reports_unsupported_type_change() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let schema_path = temp.path().join("breaking.pg");
|
|
init_graph(&graph);
|
|
|
|
let breaking_schema = fs::read_to_string(fixture("test.pg"))
|
|
.unwrap()
|
|
.replace("age: I32?", "age: I64?");
|
|
fs::write(&schema_path, breaking_schema).unwrap();
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("plan")
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json")
|
|
.arg(&graph),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
|
|
assert_eq!(payload["supported"], false);
|
|
assert!(payload["steps"].as_array().unwrap().iter().any(|step| {
|
|
step["kind"] == "unsupported_change"
|
|
&& step["entity"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("Person.age")
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn schema_apply_json_applies_supported_migration() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let schema_path = temp.path().join("next.pg");
|
|
init_graph(&graph);
|
|
|
|
let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace(
|
|
" age: I32?\n}",
|
|
" age: I32?\n nickname: String?\n}",
|
|
);
|
|
fs::write(&schema_path, next_schema).unwrap();
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("apply")
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json")
|
|
.arg(&graph),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
|
|
assert_eq!(payload["supported"], true);
|
|
assert_eq!(payload["applied"], true);
|
|
assert_eq!(payload["step_count"], 1);
|
|
|
|
let db = tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(Omnigraph::open(graph.to_string_lossy().as_ref()))
|
|
.unwrap();
|
|
assert!(
|
|
db.catalog().node_types["Person"]
|
|
.properties
|
|
.contains_key("nickname")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn schema_apply_human_reports_noop() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let schema_path = fixture("test.pg");
|
|
init_graph(&graph);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("apply")
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg(&graph),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.contains("applied: no"));
|
|
assert!(stdout.contains("no schema changes"));
|
|
}
|
|
|
|
#[test]
|
|
fn schema_apply_json_renames_type_and_updates_snapshot() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let schema_path = temp.path().join("rename.pg");
|
|
init_graph(&graph);
|
|
|
|
let renamed_schema = fs::read_to_string(fixture("test.pg"))
|
|
.unwrap()
|
|
.replace("node Person {\n", "node Human @rename_from(\"Person\") {\n")
|
|
.replace("edge Knows: Person -> Person", "edge Knows: Human -> Human")
|
|
.replace(
|
|
"edge WorksAt: Person -> Company",
|
|
"edge WorksAt: Human -> Company",
|
|
);
|
|
fs::write(&schema_path, renamed_schema).unwrap();
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("apply")
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json")
|
|
.arg(&graph),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
let db = tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(Omnigraph::open(graph.to_string_lossy().as_ref()))
|
|
.unwrap();
|
|
let snapshot = tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(db.snapshot_of(ReadTarget::branch("main")))
|
|
.unwrap();
|
|
assert!(snapshot.entry("node:Human").is_some());
|
|
assert!(snapshot.entry("node:Person").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn schema_apply_json_renames_property_and_updates_catalog() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let schema_path = temp.path().join("rename-property.pg");
|
|
init_graph(&graph);
|
|
|
|
let renamed_schema = fs::read_to_string(fixture("test.pg"))
|
|
.unwrap()
|
|
.replace("age: I32?", "years: I32? @rename_from(\"age\")");
|
|
fs::write(&schema_path, renamed_schema).unwrap();
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("apply")
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json")
|
|
.arg(&graph),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
let db = tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(Omnigraph::open(graph.to_string_lossy().as_ref()))
|
|
.unwrap();
|
|
let person = &db.catalog().node_types["Person"];
|
|
assert!(person.properties.contains_key("years"));
|
|
assert!(!person.properties.contains_key("age"));
|
|
}
|
|
|
|
#[test]
|
|
fn schema_apply_json_adds_index_for_existing_property() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let schema_path = temp.path().join("index.pg");
|
|
init_graph(&graph);
|
|
|
|
let before_index_count = tokio::runtime::Runtime::new().unwrap().block_on(async {
|
|
let db = Omnigraph::open(graph.to_string_lossy().as_ref())
|
|
.await
|
|
.unwrap();
|
|
let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap();
|
|
let dataset = snapshot.open("node:Person").await.unwrap();
|
|
dataset.load_indices().await.unwrap().len()
|
|
});
|
|
|
|
let indexed_schema = fs::read_to_string(fixture("test.pg"))
|
|
.unwrap()
|
|
.replace("name: String @key", "name: String @key @index");
|
|
fs::write(&schema_path, indexed_schema).unwrap();
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("apply")
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json")
|
|
.arg(&graph),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
let after_index_count = tokio::runtime::Runtime::new().unwrap().block_on(async {
|
|
let db = Omnigraph::open(graph.to_string_lossy().as_ref())
|
|
.await
|
|
.unwrap();
|
|
let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap();
|
|
let dataset = snapshot.open("node:Person").await.unwrap();
|
|
dataset.load_indices().await.unwrap().len()
|
|
});
|
|
assert!(after_index_count > before_index_count);
|
|
}
|
|
|
|
#[test]
|
|
fn schema_apply_rejects_unsupported_plan() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let schema_path = temp.path().join("breaking.pg");
|
|
init_graph(&graph);
|
|
|
|
let breaking_schema = fs::read_to_string(fixture("test.pg"))
|
|
.unwrap()
|
|
.replace("age: I32?", "age: I64?");
|
|
fs::write(&schema_path, breaking_schema).unwrap();
|
|
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("apply")
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg(&graph),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(stderr.contains("changing property type"));
|
|
}
|
|
|
|
#[test]
|
|
fn schema_apply_rejects_when_non_main_branch_exists() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let schema_path = temp.path().join("next.pg");
|
|
init_graph(&graph);
|
|
output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("feature"),
|
|
);
|
|
|
|
let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace(
|
|
" age: I32?\n}",
|
|
" age: I32?\n nickname: String?\n}",
|
|
);
|
|
fs::write(&schema_path, next_schema).unwrap();
|
|
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("apply")
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg(&graph),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(stderr.contains("schema apply requires a graph with only main"));
|
|
}
|
|
|
|
#[test]
|
|
fn query_lint_json_with_schema_reports_warnings() {
|
|
let temp = tempdir().unwrap();
|
|
let schema_path = temp.path().join("schema.pg");
|
|
let query_path = temp.path().join("queries.gq");
|
|
write_file(
|
|
&schema_path,
|
|
r#"
|
|
node Policy {
|
|
slug: String @key
|
|
name: String?
|
|
effectiveTo: DateTime?
|
|
}
|
|
"#,
|
|
);
|
|
write_query_file(
|
|
&query_path,
|
|
r#"
|
|
query update_policy($slug: String, $name: String) {
|
|
update Policy set { name: $name } where slug = $slug
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("query")
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
|
|
assert_eq!(payload["status"], "ok");
|
|
assert_eq!(payload["schema_source"]["kind"], "file");
|
|
assert_eq!(payload["queries_processed"], 1);
|
|
assert_eq!(payload["warnings"], 1);
|
|
assert_eq!(payload["findings"][0]["code"], "L201");
|
|
assert_eq!(
|
|
payload["findings"][0]["message"],
|
|
"Policy.effectiveTo exists in schema but no update query sets it"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn query_check_alias_matches_lint_output() {
|
|
let temp = tempdir().unwrap();
|
|
let schema_path = temp.path().join("schema.pg");
|
|
let query_path = temp.path().join("queries.gq");
|
|
write_file(
|
|
&schema_path,
|
|
r#"
|
|
node Person {
|
|
name: String
|
|
}
|
|
"#,
|
|
);
|
|
write_query_file(
|
|
&query_path,
|
|
r#"
|
|
query list_people() {
|
|
match { $p: Person }
|
|
return { $p.name }
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let lint_output = output_success(
|
|
cli()
|
|
.arg("query")
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json"),
|
|
);
|
|
let check_output = output_success(
|
|
cli()
|
|
.arg("query")
|
|
.arg("check")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json"),
|
|
);
|
|
|
|
assert_eq!(stdout_string(&lint_output), stdout_string(&check_output));
|
|
}
|
|
|
|
/// `omnigraph lint` is the canonical top-level lint command after the
|
|
/// query/mutate rename. `omnigraph query lint` and `omnigraph query check`
|
|
/// are kept as deprecated argv shims (warning + rewrite). All three must
|
|
/// produce identical stdout output.
|
|
#[test]
|
|
fn lint_top_level_matches_deprecated_query_lint_output() {
|
|
let temp = tempdir().unwrap();
|
|
let schema_path = temp.path().join("schema.pg");
|
|
let query_path = temp.path().join("queries.gq");
|
|
write_file(
|
|
&schema_path,
|
|
r#"
|
|
node Person {
|
|
name: String
|
|
}
|
|
"#,
|
|
);
|
|
write_query_file(
|
|
&query_path,
|
|
r#"
|
|
query list_people() {
|
|
match { $p: Person }
|
|
return { $p.name }
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let canonical = output_success(
|
|
cli()
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json"),
|
|
);
|
|
let deprecated_lint = output_success(
|
|
cli()
|
|
.arg("query")
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json"),
|
|
);
|
|
let deprecated_check = output_success(
|
|
cli()
|
|
.arg("query")
|
|
.arg("check")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json"),
|
|
);
|
|
|
|
assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_lint));
|
|
assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_check));
|
|
|
|
// Canonical form must NOT emit the deprecation warning.
|
|
let canonical_stderr = String::from_utf8(canonical.stderr).unwrap();
|
|
assert!(
|
|
!canonical_stderr.contains("deprecated"),
|
|
"`omnigraph lint` is canonical and must not warn; got stderr: {canonical_stderr}"
|
|
);
|
|
|
|
// Deprecated forms MUST emit the one-line warning, pointing at the
|
|
// new top-level `omnigraph lint`.
|
|
let lint_stderr = String::from_utf8(deprecated_lint.stderr).unwrap();
|
|
assert!(
|
|
lint_stderr.contains("`omnigraph query lint` is deprecated")
|
|
&& lint_stderr.contains("`omnigraph lint`"),
|
|
"expected deprecation warning pointing at `omnigraph lint`; got: {lint_stderr}"
|
|
);
|
|
let check_stderr = String::from_utf8(deprecated_check.stderr).unwrap();
|
|
assert!(
|
|
check_stderr.contains("`omnigraph query check` is deprecated")
|
|
&& check_stderr.contains("`omnigraph lint`"),
|
|
"expected deprecation warning pointing at `omnigraph lint`; got: {check_stderr}"
|
|
);
|
|
}
|
|
|
|
/// Bare `omnigraph check` is NOT a clap `visible_alias` on `lint` (MR-981 §6:
|
|
/// visible aliases give agents two canonical names to emit interchangeably).
|
|
/// It's an argv-level shim: rewrites to `omnigraph lint`, prints a one-line
|
|
/// stderr deprecation warning, and produces identical stdout to the canonical
|
|
/// invocation. Cargo/Go users typing `check` keep working; help text shows
|
|
/// only `lint`.
|
|
#[test]
|
|
fn deprecated_check_top_level_rewrites_to_lint() {
|
|
let temp = tempdir().unwrap();
|
|
let schema_path = temp.path().join("schema.pg");
|
|
let query_path = temp.path().join("queries.gq");
|
|
write_file(
|
|
&schema_path,
|
|
r#"
|
|
node Person {
|
|
name: String
|
|
}
|
|
"#,
|
|
);
|
|
write_query_file(
|
|
&query_path,
|
|
r#"
|
|
query list_people() {
|
|
match { $p: Person }
|
|
return { $p.name }
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let canonical = output_success(
|
|
cli()
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json"),
|
|
);
|
|
let deprecated_check = output_success(
|
|
cli()
|
|
.arg("check")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json"),
|
|
);
|
|
|
|
assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_check));
|
|
|
|
let check_stderr = String::from_utf8(deprecated_check.stderr).unwrap();
|
|
assert!(
|
|
check_stderr.contains("`omnigraph check` is deprecated")
|
|
&& check_stderr.contains("`omnigraph lint`"),
|
|
"expected `omnigraph check` deprecation warning pointing at `omnigraph lint`; got: {check_stderr}"
|
|
);
|
|
|
|
// `check` must NOT appear in the canonical `omnigraph --help` output —
|
|
// agents copy the surface from help text and would otherwise emit both
|
|
// names interchangeably.
|
|
let help = cli().arg("--help").output().unwrap();
|
|
let stdout = String::from_utf8(help.stdout).unwrap();
|
|
let check_aliased = stdout
|
|
.lines()
|
|
.any(|line| line.trim_start().starts_with("lint") && line.contains("check"));
|
|
assert!(
|
|
!check_aliased,
|
|
"`check` must not be advertised as a visible alias of `lint`; help output: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// `omnigraph read` and `omnigraph change` are kept as visible clap
|
|
/// aliases for the new canonical `query` / `mutate` subcommands, plus an
|
|
/// argv-level deprecation warning. The warning is emitted to stderr; the
|
|
/// command otherwise behaves identically to the canonical form.
|
|
#[test]
|
|
fn deprecated_read_and_change_subcommands_emit_warnings() {
|
|
// Both subcommands require `--query`/`--query-string`/`--alias`, so
|
|
// invoking them with no args will exit non-zero. That's fine --
|
|
// we only care that the deprecation warning is printed before the
|
|
// argument-required error.
|
|
let output = cli().arg("read").output().unwrap();
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(
|
|
stderr.contains("`omnigraph read` is deprecated")
|
|
&& stderr.contains("`omnigraph query`"),
|
|
"expected `omnigraph read` deprecation warning; got: {stderr}"
|
|
);
|
|
|
|
let output = cli().arg("change").output().unwrap();
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(
|
|
stderr.contains("`omnigraph change` is deprecated")
|
|
&& stderr.contains("`omnigraph mutate`"),
|
|
"expected `omnigraph change` deprecation warning; got: {stderr}"
|
|
);
|
|
|
|
// Sanity check the inverse: the canonical names must NOT print the
|
|
// deprecation banner.
|
|
let output = cli().arg("query").arg("--help").output().unwrap();
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(
|
|
!stderr.contains("deprecated"),
|
|
"`omnigraph query` is canonical and must not warn; got: {stderr}"
|
|
);
|
|
let output = cli().arg("mutate").arg("--help").output().unwrap();
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(
|
|
!stderr.contains("deprecated"),
|
|
"`omnigraph mutate` is canonical and must not warn; got: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn query_lint_can_use_local_graph_via_positional_uri() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let query_path = temp.path().join("queries.gq");
|
|
init_graph(&graph);
|
|
write_query_file(
|
|
&query_path,
|
|
r#"
|
|
query list_people() {
|
|
match { $p: Person }
|
|
return { $p.name }
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("query")
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--json")
|
|
.arg(&graph),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
|
|
assert_eq!(payload["status"], "ok");
|
|
assert_eq!(payload["schema_source"]["kind"], "graph");
|
|
assert_eq!(
|
|
payload["schema_source"]["uri"].as_str(),
|
|
Some(graph.to_string_lossy().as_ref())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn query_lint_can_resolve_graph_and_query_from_config() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
init_graph(&graph);
|
|
write_query_file(
|
|
&temp.path().join("queries.gq"),
|
|
r#"
|
|
query list_people() {
|
|
match { $p: Person }
|
|
return { $p.name }
|
|
}
|
|
"#,
|
|
);
|
|
write_config(&config_path, &local_yaml_config(&graph));
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("query")
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg("queries.gq")
|
|
.arg("--config")
|
|
.arg(&config_path)
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
|
|
assert_eq!(payload["status"], "ok");
|
|
assert_eq!(payload["schema_source"]["kind"], "graph");
|
|
assert_eq!(
|
|
payload["schema_source"]["uri"].as_str(),
|
|
Some(graph.to_string_lossy().as_ref())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn query_lint_rejects_http_targets_without_schema() {
|
|
let temp = tempdir().unwrap();
|
|
let query_path = temp.path().join("queries.gq");
|
|
write_query_file(
|
|
&query_path,
|
|
r#"
|
|
query list_people() {
|
|
match { $p: Person }
|
|
return { $p.name }
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("query")
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("http://127.0.0.1:8080"),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("query lint is only supported against local graph URIs in this milestone")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn query_lint_requires_schema_or_resolvable_graph_target() {
|
|
let temp = tempdir().unwrap();
|
|
let query_path = temp.path().join("queries.gq");
|
|
write_query_file(
|
|
&query_path,
|
|
r#"
|
|
query list_people() {
|
|
match { $p: Person }
|
|
return { $p.name }
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("query")
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg(&query_path),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("query lint requires --schema <schema.pg> or a resolvable graph target")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn query_lint_human_output_reports_warnings() {
|
|
let temp = tempdir().unwrap();
|
|
let schema_path = temp.path().join("schema.pg");
|
|
let query_path = temp.path().join("queries.gq");
|
|
write_file(
|
|
&schema_path,
|
|
r#"
|
|
node Policy {
|
|
slug: String @key
|
|
name: String?
|
|
effectiveTo: DateTime?
|
|
}
|
|
"#,
|
|
);
|
|
write_query_file(
|
|
&query_path,
|
|
r#"
|
|
query update_policy($slug: String, $name: String) {
|
|
update Policy set { name: $name } where slug = $slug
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("query")
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.contains("OK query `update_policy` (mutation)"));
|
|
assert!(
|
|
stdout.contains("WARN Policy.effectiveTo exists in schema but no update query sets it")
|
|
);
|
|
assert!(stdout.contains(
|
|
"INFO Lint complete: 1 queries processed (0 error(s), 1 warning(s), 0 info item(s))"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn query_lint_human_output_reports_strict_validation_errors() {
|
|
let temp = tempdir().unwrap();
|
|
let schema_path = temp.path().join("schema.pg");
|
|
let query_path = temp.path().join("queries.gq");
|
|
write_file(
|
|
&schema_path,
|
|
r#"
|
|
node Policy {
|
|
slug: String @key
|
|
name: String?
|
|
}
|
|
"#,
|
|
);
|
|
write_query_file(
|
|
&query_path,
|
|
r#"
|
|
query bad_update($slug: String) {
|
|
update Policy set { priority_level: "high" } where slug = $slug
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("query")
|
|
.arg("lint")
|
|
.arg("--query")
|
|
.arg(&query_path)
|
|
.arg("--schema")
|
|
.arg(&schema_path),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.contains("ERROR query `bad_update`:"));
|
|
assert!(stdout.contains("Policy"));
|
|
assert!(stdout.contains(
|
|
"INFO Lint complete: 1 queries processed (1 error(s), 0 warning(s), 0 info item(s))"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn load_json_outputs_summary_for_main_branch() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
let data = fixture("test.jsonl");
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("load")
|
|
.arg("--data")
|
|
.arg(&data)
|
|
.arg("--json")
|
|
.arg(&graph),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
|
|
assert_eq!(payload["branch"], "main");
|
|
assert_eq!(payload["mode"], "overwrite");
|
|
assert_eq!(payload["nodes_loaded"], 6);
|
|
assert_eq!(payload["edges_loaded"], 5);
|
|
assert_eq!(payload["node_types_loaded"], 2);
|
|
assert_eq!(payload["edge_types_loaded"], 2);
|
|
}
|
|
|
|
#[test]
|
|
fn load_into_feature_branch_with_merge_mode_succeeds() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
|
|
output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("feature"),
|
|
);
|
|
|
|
let feature_data = temp.path().join("feature.jsonl");
|
|
write_jsonl(
|
|
&feature_data,
|
|
r#"{"type":"Person","data":{"name":"Alice","age":31}}"#,
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("load")
|
|
.arg("--data")
|
|
.arg(&feature_data)
|
|
.arg("--branch")
|
|
.arg("feature")
|
|
.arg("--mode")
|
|
.arg("merge")
|
|
.arg(&graph),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.contains("branch feature"));
|
|
assert!(stdout.contains("with merge"));
|
|
assert!(stdout.contains("1 nodes across 1 node types"));
|
|
}
|
|
|
|
#[test]
|
|
fn read_json_outputs_rows_for_named_query() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
let queries = fixture("test.gq");
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg(&graph)
|
|
.arg("--query")
|
|
.arg(&queries)
|
|
.arg("--name")
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Alice"}"#)
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
|
|
assert_eq!(payload["query_name"], "get_person");
|
|
assert_eq!(payload["target"]["branch"], "main");
|
|
assert_eq!(payload["row_count"], 1);
|
|
assert_eq!(payload["rows"][0]["p.name"], "Alice");
|
|
}
|
|
|
|
#[test]
|
|
fn export_jsonl_outputs_source_rows_for_selected_branch_and_type() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
|
|
output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("feature"),
|
|
);
|
|
|
|
let feature_data = temp.path().join("feature-export.jsonl");
|
|
write_jsonl(
|
|
&feature_data,
|
|
r#"{"type":"Person","data":{"name":"Eve","age":29}}"#,
|
|
);
|
|
output_success(
|
|
cli()
|
|
.arg("load")
|
|
.arg("--data")
|
|
.arg(&feature_data)
|
|
.arg("--branch")
|
|
.arg("feature")
|
|
.arg("--mode")
|
|
.arg("append")
|
|
.arg(&graph),
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("export")
|
|
.arg(&graph)
|
|
.arg("--branch")
|
|
.arg("feature")
|
|
.arg("--type")
|
|
.arg("Person")
|
|
.arg("--jsonl"),
|
|
);
|
|
let rows = stdout_string(&output)
|
|
.lines()
|
|
.map(|line| serde_json::from_str::<Value>(line).unwrap())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(rows.len(), 5);
|
|
assert!(rows.iter().all(|row| row["type"] == "Person"));
|
|
assert!(rows.iter().all(|row| row.get("edge").is_none()));
|
|
assert!(
|
|
rows.iter()
|
|
.any(|row| row["data"]["name"].as_str() == Some("Eve"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn policy_validate_accepts_valid_policy_file() {
|
|
let temp = tempdir().unwrap();
|
|
let (config, _) = write_policy_config_fixture(temp.path());
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("policy")
|
|
.arg("validate")
|
|
.arg("--config")
|
|
.arg(&config),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.contains("policy valid:"));
|
|
assert!(stdout.contains("policy.yaml"));
|
|
assert!(stdout.contains("[2 actors]"));
|
|
}
|
|
|
|
#[test]
|
|
fn policy_validate_fails_for_invalid_policy_file() {
|
|
let temp = tempdir().unwrap();
|
|
let config = temp.path().join("omnigraph.yaml");
|
|
let policy = temp.path().join("policy.yaml");
|
|
fs::write(
|
|
&config,
|
|
r#"
|
|
project:
|
|
name: policy-test-graph
|
|
policy:
|
|
file: ./policy.yaml
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
&policy,
|
|
r#"
|
|
version: 1
|
|
groups:
|
|
team: [act-andrew]
|
|
rules:
|
|
- id: duplicate
|
|
allow:
|
|
actors: { group: team }
|
|
actions: [read]
|
|
branch_scope: any
|
|
- id: duplicate
|
|
allow:
|
|
actors: { group: team }
|
|
actions: [export]
|
|
branch_scope: any
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("policy")
|
|
.arg("validate")
|
|
.arg("--config")
|
|
.arg(&config),
|
|
);
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.contains("duplicate policy rule id"));
|
|
}
|
|
|
|
#[test]
|
|
fn policy_test_runs_declarative_cases() {
|
|
let temp = tempdir().unwrap();
|
|
let (config, _) = write_policy_config_fixture(temp.path());
|
|
|
|
let output = output_success(cli().arg("policy").arg("test").arg("--config").arg(&config));
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.contains("policy tests passed: 2 cases"));
|
|
}
|
|
|
|
#[test]
|
|
fn policy_explain_reports_decision_and_matched_rule() {
|
|
let temp = tempdir().unwrap();
|
|
let (config, _) = write_policy_config_fixture(temp.path());
|
|
|
|
let allow = output_success(
|
|
cli()
|
|
.arg("policy")
|
|
.arg("explain")
|
|
.arg("--config")
|
|
.arg(&config)
|
|
.arg("--actor")
|
|
.arg("act-andrew")
|
|
.arg("--action")
|
|
.arg("change")
|
|
.arg("--branch")
|
|
.arg("feature"),
|
|
);
|
|
let allow_stdout = stdout_string(&allow);
|
|
assert!(allow_stdout.contains("decision: allow"));
|
|
assert!(allow_stdout.contains("matched_rule: team-write"));
|
|
|
|
let deny = output_success(
|
|
cli()
|
|
.arg("policy")
|
|
.arg("explain")
|
|
.arg("--config")
|
|
.arg(&config)
|
|
.arg("--actor")
|
|
.arg("act-bruno")
|
|
.arg("--action")
|
|
.arg("change")
|
|
.arg("--branch")
|
|
.arg("main"),
|
|
);
|
|
let deny_stdout = stdout_string(&deny);
|
|
assert!(deny_stdout.contains("decision: deny"));
|
|
assert!(deny_stdout.contains("message: policy denied action 'change' on branch 'main'"));
|
|
}
|
|
|
|
#[test]
|
|
fn read_can_resolve_uri_from_config() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let config = temp.path().join("omnigraph.yaml");
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
write_config(&config, &local_yaml_config(&graph));
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--config")
|
|
.arg(&config)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("--name")
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Alice"}"#)
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["row_count"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn read_alias_from_yaml_config_runs_with_kv_output() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let config = temp.path().join("omnigraph.yaml");
|
|
let query = temp.path().join("aliases.gq");
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
write_query_file(
|
|
&query,
|
|
&std::fs::read_to_string(fixture("test.gq")).unwrap(),
|
|
);
|
|
write_config(
|
|
&config,
|
|
&format!(
|
|
"{}aliases:\n owner:\n command: read\n query: aliases.gq\n name: get_person\n args: [name]\n format: kv\n",
|
|
local_yaml_config(&graph)
|
|
),
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--config")
|
|
.arg(&config)
|
|
.arg("--alias")
|
|
.arg("owner")
|
|
.arg("Alice"),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.contains("row 1"));
|
|
assert!(stdout.contains("p.name: Alice"));
|
|
}
|
|
|
|
#[test]
|
|
fn read_alias_uses_alias_target_without_cli_default_and_accepts_url_like_arg() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let config = temp.path().join("omnigraph.yaml");
|
|
let query = temp.path().join("aliases.gq");
|
|
let data = temp.path().join("url-like.jsonl");
|
|
init_graph(&graph);
|
|
write_jsonl(
|
|
&data,
|
|
r#"{"type":"Person","data":{"name":"https://example.com","age":30}}"#,
|
|
);
|
|
output_success(cli().arg("load").arg("--data").arg(&data).arg(&graph));
|
|
write_query_file(
|
|
&query,
|
|
&std::fs::read_to_string(fixture("test.gq")).unwrap(),
|
|
);
|
|
write_config(
|
|
&config,
|
|
&format!(
|
|
"graphs:\n local:\n uri: '{}'\nquery:\n roots:\n - .\npolicy: {{}}\naliases:\n owner:\n command: read\n query: aliases.gq\n name: get_person\n args: [name]\n graph: local\n format: kv\n",
|
|
graph.to_string_lossy()
|
|
),
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--config")
|
|
.arg(&config)
|
|
.arg("--alias")
|
|
.arg("owner")
|
|
.arg("https://example.com"),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.contains("row 1"));
|
|
assert!(stdout.contains("p.name: https://example.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn change_alias_from_yaml_config_persists_changes() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let config = temp.path().join("omnigraph.yaml");
|
|
let query = temp.path().join("mutations.gq");
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
write_query_file(
|
|
&query,
|
|
r#"
|
|
query insert_person($name: String, $age: I32) {
|
|
insert Person { name: $name, age: $age }
|
|
}
|
|
"#,
|
|
);
|
|
write_config(
|
|
&config,
|
|
&format!(
|
|
"{}aliases:\n add_person:\n command: change\n query: mutations.gq\n name: insert_person\n args: [name, age]\n",
|
|
local_yaml_config(&graph)
|
|
),
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("change")
|
|
.arg("--config")
|
|
.arg(&config)
|
|
.arg("--alias")
|
|
.arg("add_person")
|
|
.arg("Eve")
|
|
.arg("29")
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["affected_nodes"], 1);
|
|
|
|
let verify = output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg(&graph)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("--name")
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Eve"}"#)
|
|
.arg("--json"),
|
|
);
|
|
let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap();
|
|
assert_eq!(verify_payload["row_count"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn read_csv_format_outputs_header_and_row_values() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg(&graph)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("--name")
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Alice"}"#)
|
|
.arg("--format")
|
|
.arg("csv"),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.lines().next().unwrap().contains("p.name"));
|
|
assert!(stdout.contains("Alice"));
|
|
}
|
|
|
|
#[test]
|
|
fn read_jsonl_format_outputs_metadata_header_first() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg(&graph)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("--name")
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Alice"}"#)
|
|
.arg("--format")
|
|
.arg("jsonl"),
|
|
);
|
|
let stdout = stdout_string(&output);
|
|
let mut lines = stdout.lines();
|
|
assert!(lines.next().unwrap().contains("\"kind\":\"metadata\""));
|
|
assert!(lines.next().unwrap().contains("\"p.name\":\"Alice\""));
|
|
}
|
|
|
|
#[test]
|
|
fn change_json_outputs_affected_counts_and_persists() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
let mutation_file = temp.path().join("mutations.gq");
|
|
write_query_file(
|
|
&mutation_file,
|
|
r#"
|
|
query insert_person($name: String, $age: I32) {
|
|
insert Person { name: $name, age: $age }
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("change")
|
|
.arg(&graph)
|
|
.arg("--query")
|
|
.arg(&mutation_file)
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Eve","age":29}"#)
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["branch"], "main");
|
|
assert_eq!(payload["query_name"], "insert_person");
|
|
assert_eq!(payload["affected_nodes"], 1);
|
|
assert_eq!(payload["affected_edges"], 0);
|
|
|
|
let verify = output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg(&graph)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("--name")
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Eve"}"#)
|
|
.arg("--json"),
|
|
);
|
|
let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap();
|
|
assert_eq!(verify_payload["row_count"], 1);
|
|
assert_eq!(verify_payload["rows"][0]["p.name"], "Eve");
|
|
}
|
|
|
|
#[test]
|
|
fn change_can_resolve_uri_and_branch_from_config() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let config = temp.path().join("omnigraph.yaml");
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
write_config(&config, &local_yaml_config(&graph));
|
|
let mutation_file = temp.path().join("config-mutations.gq");
|
|
write_query_file(
|
|
&mutation_file,
|
|
r#"
|
|
query insert_person($name: String, $age: I32) {
|
|
insert Person { name: $name, age: $age }
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("change")
|
|
.arg("--config")
|
|
.arg(&config)
|
|
.arg("--query")
|
|
.arg(&mutation_file)
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Mia","age":30}"#)
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["branch"], "main");
|
|
assert_eq!(payload["affected_nodes"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn read_requires_name_for_multi_query_files() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("read")
|
|
.arg(&graph)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq")),
|
|
);
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.contains("multiple queries"));
|
|
}
|
|
|
|
#[test]
|
|
fn read_supports_inline_query_string() {
|
|
let temp = tempdir().unwrap();
|
|
let repo = graph_path(temp.path());
|
|
init_graph(&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 = graph_path(temp.path());
|
|
init_graph(&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 = graph_path(temp.path());
|
|
init_graph(&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 = graph_path(temp.path());
|
|
init_graph(&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();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("feature")
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
|
|
assert_eq!(payload["from"], "main");
|
|
assert_eq!(payload["name"], "feature");
|
|
assert_eq!(payload["uri"], graph.to_string_lossy().as_ref());
|
|
}
|
|
|
|
#[test]
|
|
fn branch_list_outputs_sorted_branches() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
|
|
output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("zeta"),
|
|
);
|
|
output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("alpha"),
|
|
);
|
|
|
|
let output = output_success(cli().arg("branch").arg("list").arg("--uri").arg(&graph));
|
|
let stdout = stdout_string(&output);
|
|
let lines = stdout
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(lines, vec!["alpha", "main", "zeta"]);
|
|
}
|
|
|
|
#[test]
|
|
fn branch_delete_json_outputs_name_and_removes_branch() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
|
|
output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("feature"),
|
|
);
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("delete")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("feature")
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["name"], "feature");
|
|
assert_eq!(payload["uri"], graph.to_string_lossy().as_ref());
|
|
|
|
let listed = output_success(cli().arg("branch").arg("list").arg("--uri").arg(&graph));
|
|
let stdout = stdout_string(&listed);
|
|
let lines = stdout
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(lines, vec!["main"]);
|
|
}
|
|
|
|
#[test]
|
|
fn branch_delete_rejects_main() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("delete")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("main"),
|
|
);
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.contains("cannot delete branch 'main'"));
|
|
}
|
|
|
|
#[test]
|
|
fn branch_merge_defaults_target_to_main() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
|
|
output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("feature"),
|
|
);
|
|
|
|
let feature_data = temp.path().join("feature.jsonl");
|
|
write_jsonl(
|
|
&feature_data,
|
|
r#"{"type":"Person","data":{"name":"Eve","age":29}}"#,
|
|
);
|
|
output_success(
|
|
cli()
|
|
.arg("load")
|
|
.arg("--data")
|
|
.arg(&feature_data)
|
|
.arg("--branch")
|
|
.arg("feature")
|
|
.arg("--mode")
|
|
.arg("append")
|
|
.arg(&graph),
|
|
);
|
|
|
|
let merge_output = output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("merge")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("feature")
|
|
.arg("--json"),
|
|
);
|
|
let merge_payload: Value = serde_json::from_slice(&merge_output.stdout).unwrap();
|
|
assert_eq!(merge_payload["source"], "feature");
|
|
assert_eq!(merge_payload["target"], "main");
|
|
assert_eq!(merge_payload["outcome"], "fast_forward");
|
|
|
|
let snapshot_output = output_success(
|
|
cli()
|
|
.arg("snapshot")
|
|
.arg(&graph)
|
|
.arg("--branch")
|
|
.arg("main")
|
|
.arg("--json"),
|
|
);
|
|
let snapshot: Value = serde_json::from_slice(&snapshot_output.stdout).unwrap();
|
|
let person_row_count = snapshot["tables"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|table| table["table_key"] == "node:Person")
|
|
.unwrap()["row_count"]
|
|
.as_u64()
|
|
.unwrap();
|
|
assert_eq!(person_row_count, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn branch_merge_supports_explicit_target() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
|
|
output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("feature"),
|
|
);
|
|
output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("experiment"),
|
|
);
|
|
|
|
let feature_data = temp.path().join("feature-explicit.jsonl");
|
|
write_jsonl(
|
|
&feature_data,
|
|
r#"{"type":"Person","data":{"name":"Frank","age":41}}"#,
|
|
);
|
|
output_success(
|
|
cli()
|
|
.arg("load")
|
|
.arg("--data")
|
|
.arg(&feature_data)
|
|
.arg("--branch")
|
|
.arg("feature")
|
|
.arg("--mode")
|
|
.arg("append")
|
|
.arg(&graph),
|
|
);
|
|
|
|
let merge_output = output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("merge")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("feature")
|
|
.arg("--into")
|
|
.arg("experiment")
|
|
.arg("--json"),
|
|
);
|
|
let merge_payload: Value = serde_json::from_slice(&merge_output.stdout).unwrap();
|
|
assert_eq!(merge_payload["target"], "experiment");
|
|
assert_eq!(merge_payload["outcome"], "fast_forward");
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_json_returns_manifest_version_and_tables() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
|
|
let output = output_success(cli().arg("snapshot").arg(&graph).arg("--json"));
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
|
|
assert_eq!(payload["branch"], "main");
|
|
assert_eq!(
|
|
payload["manifest_version"].as_u64().unwrap(),
|
|
manifest_dataset_version(&graph)
|
|
);
|
|
assert!(payload["tables"].as_array().unwrap().len() >= 4);
|
|
}
|
|
|
|
fn write_seed_fixture(root: &std::path::Path) -> std::path::PathBuf {
|
|
fs::create_dir_all(root.join("data")).unwrap();
|
|
fs::create_dir_all(root.join("build")).unwrap();
|
|
let raw_seed = root.join("data/seed.jsonl");
|
|
let seed = root.join("seed.yaml");
|
|
|
|
fs::write(
|
|
&raw_seed,
|
|
concat!(
|
|
"{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-alpha\",\"intent\":\"Alpha ship\"}}\n",
|
|
"{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-beta\",\"intent\":\"Beta ship\",\"embedding\":[0.1,0.2]}}\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
fs::write(
|
|
&seed,
|
|
concat!(
|
|
"graph:\n",
|
|
" slug: mr-context-graph\n",
|
|
"sources:\n",
|
|
" raw_seed: ./data/seed.jsonl\n",
|
|
"artifacts:\n",
|
|
" embedded_seed: ./build/seed.embedded.jsonl\n",
|
|
"embeddings:\n",
|
|
" model: gemini-embedding-2-preview\n",
|
|
" dimension: 4\n",
|
|
" types:\n",
|
|
" Decision:\n",
|
|
" target: embedding\n",
|
|
" fields: [slug, intent]\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
seed
|
|
}
|
|
|
|
fn write_seed_fixture_with_edge(root: &std::path::Path) -> std::path::PathBuf {
|
|
let seed = write_seed_fixture(root);
|
|
let raw_seed = root.join("data/seed.jsonl");
|
|
fs::write(
|
|
&raw_seed,
|
|
concat!(
|
|
"{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-alpha\",\"intent\":\"Alpha ship\"}}\n",
|
|
"{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-beta\",\"intent\":\"Beta ship\",\"embedding\":[0.1,0.2]}}\n",
|
|
"{\"edge\":\"Triggered\",\"from\":\"sig-alpha\",\"to\":\"dec-alpha\"}\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
seed
|
|
}
|
|
|
|
fn read_embedded_rows(path: std::path::PathBuf) -> Vec<Value> {
|
|
fs::read_to_string(path)
|
|
.unwrap()
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.map(|line| serde_json::from_str(line).unwrap())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_can_resolve_uri_from_config() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let config = temp.path().join("omnigraph.yaml");
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
write_config(&config, &local_yaml_config(&graph));
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("snapshot")
|
|
.arg("--config")
|
|
.arg(&config)
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["branch"], "main");
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_human_output_includes_branch_and_table_summaries() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
|
|
let output = output_success(cli().arg("snapshot").arg(&graph));
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(stdout.contains("branch: main"));
|
|
assert!(stdout.contains("manifest_version:"));
|
|
assert!(stdout.contains("node:Person v"));
|
|
assert!(stdout.contains("edge:Knows v"));
|
|
}
|
|
|
|
#[test]
|
|
fn commit_show_accepts_long_uri_flag() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
|
|
let list = output_success(cli().arg("commit").arg("list").arg(&graph).arg("--json"));
|
|
let list_payload: Value = serde_json::from_slice(&list.stdout).unwrap();
|
|
let commit_id = list_payload["commits"][0]["graph_commit_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("commit")
|
|
.arg("show")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg(&commit_id)
|
|
.arg("--json"),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
|
|
assert_eq!(payload["graph_commit_id"], commit_id);
|
|
assert!(payload["manifest_version"].as_u64().unwrap() >= 1);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_fails_for_missing_graph() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
|
|
let output = output_failure(cli().arg("snapshot").arg(&graph));
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(
|
|
stderr.contains("_schema.pg")
|
|
|| stderr.contains("No such file")
|
|
|| stderr.contains("not found")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_fails_for_missing_schema_or_data_file() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let missing_schema = temp.path().join("missing.pg");
|
|
let missing_data = temp.path().join("missing.jsonl");
|
|
|
|
let init_output = output_failure(
|
|
cli()
|
|
.arg("init")
|
|
.arg("--schema")
|
|
.arg(&missing_schema)
|
|
.arg(&graph),
|
|
);
|
|
assert!(
|
|
String::from_utf8(init_output.stderr)
|
|
.unwrap()
|
|
.contains("No such file")
|
|
);
|
|
|
|
init_graph(&graph);
|
|
let load_output = output_failure(
|
|
cli()
|
|
.arg("load")
|
|
.arg("--data")
|
|
.arg(&missing_data)
|
|
.arg(&graph),
|
|
);
|
|
assert!(
|
|
String::from_utf8(load_output.stderr)
|
|
.unwrap()
|
|
.contains("No such file")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_fails_for_invalid_merge_requests() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
|
|
let missing_branch = output_failure(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("merge")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("missing"),
|
|
);
|
|
let missing_branch_stderr = String::from_utf8(missing_branch.stderr).unwrap();
|
|
assert!(
|
|
missing_branch_stderr.contains("missing")
|
|
|| missing_branch_stderr.contains("head commit")
|
|
|| missing_branch_stderr.contains("not found")
|
|
);
|
|
|
|
let same_branch = output_failure(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("merge")
|
|
.arg("--uri")
|
|
.arg(&graph)
|
|
.arg("main")
|
|
.arg("--into")
|
|
.arg("main"),
|
|
);
|
|
assert!(
|
|
String::from_utf8(same_branch.stderr)
|
|
.unwrap()
|
|
.contains("distinct source and target")
|
|
);
|
|
}
|
|
|
|
// `omnigraph run list/show/publish/abort` subcommands removed
|
|
// alongside the run state machine. Direct-to-target writes leave nothing
|
|
// for these CLIs to manage. Audit history is now visible via
|
|
// `omnigraph commit list` reading the commit graph.
|
|
|
|
// ─── MR-694 PR B: --allow-data-loss flag end-to-end ──────────────────────
|
|
//
|
|
// The schema-lint chassis v1.2 (PR #100) shipped the `--allow-data-loss`
|
|
// flag at the CLI layer; the SDK suite verifies promotion to Hard mode
|
|
// via `apply_schema_with_options(.., SchemaApplyOptions { allow_data_loss })`.
|
|
// These CLI tests close the integration gap so a future change that
|
|
// drops the flag wiring in `main.rs` turns red.
|
|
|
|
#[test]
|
|
fn schema_apply_allow_data_loss_flag_promotes_drops_to_hard() {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let schema_path = temp.path().join("drop-age.pg");
|
|
init_graph(&graph);
|
|
|
|
// Drop the nullable `age` column.
|
|
let next_schema = fs::read_to_string(fixture("test.pg"))
|
|
.unwrap()
|
|
.replace(" age: I32?\n", "");
|
|
fs::write(&schema_path, next_schema).unwrap();
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("apply")
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--allow-data-loss")
|
|
.arg("--json")
|
|
.arg(&graph),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
let drop_step = payload["steps"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|s| s["kind"] == "drop_property")
|
|
.expect("plan should include a drop_property step");
|
|
assert_eq!(
|
|
drop_step["mode"], "hard",
|
|
"--allow-data-loss should promote Soft → Hard; full step: {drop_step}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn schema_apply_without_allow_data_loss_keeps_soft_drops() {
|
|
// Symmetric to the above: same schema change without the flag →
|
|
// drops stay Soft. Pins default semantics against accidental Hard
|
|
// promotion if a future refactor changes the option threading.
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
let schema_path = temp.path().join("drop-age-soft.pg");
|
|
init_graph(&graph);
|
|
|
|
let next_schema = fs::read_to_string(fixture("test.pg"))
|
|
.unwrap()
|
|
.replace(" age: I32?\n", "");
|
|
fs::write(&schema_path, next_schema).unwrap();
|
|
|
|
let output = output_success(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("apply")
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json")
|
|
.arg(&graph),
|
|
);
|
|
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
let drop_step = payload["steps"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|s| s["kind"] == "drop_property")
|
|
.expect("plan should include a drop_property step");
|
|
assert_eq!(
|
|
drop_step["mode"], "soft",
|
|
"no flag should leave drops Soft; full step: {drop_step}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn schema_plan_parity_cli_and_sdk() {
|
|
// Same .pg through `Omnigraph::plan_schema_with_options` (SDK) and
|
|
// `omnigraph schema plan --json` (CLI). Asserts the steps array is
|
|
// byte-identical after JSON round-trip. HTTP doesn't expose a
|
|
// separate /schema/plan route — that side of parity is covered by
|
|
// the HTTP soft/hard drop tests, which exercise apply with
|
|
// identical fixtures.
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
let schema_path = temp.path().join("plan-parity.pg");
|
|
let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace(
|
|
" age: I32?\n}",
|
|
" age: I32?\n nickname: String?\n}",
|
|
);
|
|
fs::write(&schema_path, &next_schema).unwrap();
|
|
|
|
// CLI side.
|
|
let cli_output = output_success(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("plan")
|
|
.arg("--schema")
|
|
.arg(&schema_path)
|
|
.arg("--json")
|
|
.arg(&graph),
|
|
);
|
|
let cli_payload: Value = serde_json::from_slice(&cli_output.stdout).unwrap();
|
|
|
|
// SDK side: open graph, call plan_schema.
|
|
let plan = tokio::runtime::Runtime::new().unwrap().block_on(async {
|
|
let db = Omnigraph::open(graph.to_string_lossy().as_ref())
|
|
.await
|
|
.unwrap();
|
|
db.plan_schema(&next_schema).await.unwrap()
|
|
});
|
|
let sdk_steps = serde_json::to_value(&plan.steps).unwrap();
|
|
|
|
assert_eq!(
|
|
cli_payload["steps"], sdk_steps,
|
|
"CLI plan steps must match SDK plan steps for identical input",
|
|
);
|
|
assert_eq!(cli_payload["supported"], plan.supported);
|
|
}
|
|
|
|
// ─── MR-668 PR 8 — omnigraph graphs subcommand ─────────────────────────────
|
|
|
|
/// `omnigraph graphs --help` lists only the read-only `list`
|
|
/// subcommand. Runtime add (`create`) and remove (`delete`) are
|
|
/// deferred — operators add/remove graphs by editing `omnigraph.yaml`
|
|
/// and restarting. This test pins the deferral against accidental
|
|
/// re-introduction.
|
|
#[test]
|
|
fn graphs_subcommand_help_lists_list_only() {
|
|
let output = output_success(cli().arg("graphs").arg("--help"));
|
|
let stdout = stdout_string(&output);
|
|
assert!(
|
|
stdout.contains("list"),
|
|
"expected `list` subcommand in help output:\n{stdout}"
|
|
);
|
|
let lowered = stdout.to_lowercase();
|
|
assert!(
|
|
!lowered.contains("create a new graph"),
|
|
"graph create should not be in v0.6.0 help; got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
!lowered.contains("delete a graph"),
|
|
"graph delete should not be in v0.6.0 help; got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
/// `omnigraph graphs list` against a local URI errors with a clear
|
|
/// message — the CLI only operates against remote multi-graph servers.
|
|
#[test]
|
|
fn graphs_list_against_local_uri_errors_with_remote_only_message() {
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("graphs")
|
|
.arg("list")
|
|
.arg("--uri")
|
|
.arg("/tmp/local"),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
|
assert!(
|
|
stderr.contains("remote multi-graph server URL"),
|
|
"expected 'remote multi-graph server URL' rejection in stderr; got:\n{stderr}"
|
|
);
|
|
}
|